Skip to content

SessionService

Defined in: src/service.ts:862

new SessionService(deps): SessionService

Defined in: src/service.ts:863

ServiceDeps

SessionService

advanceToExecutionOnPr(id): boolean

Defined in: src/service.ts:2153

Auto-advance a manually-driven (or otherwise un-released) planning session into execution once a PR appears. When the operator reviews the plan then steers the agent instead of clicking Go, the agent writes code and opens a PR while planPhase is still “planning” — leaving the plan-gate badge latched and making autopilot stand down (autopilot.ts eligible() suppresses planning sessions). This method detects that case: called when a PR is observed for a still-planning session, it flips planPhase → “executing” so the plan gate yields and autopilot stops standing down.

Critically, it does NOT send PLAN_GO_STEER — the agent already executed; steering it to “implement the approved plan” would be wrong (the plan may not even be approved). Its caller mirrors autopilot’s hasPr non-”none” semantics (open/merged/closed) so the two don’t drift.

Returns true when a real transition occurred (planPhase was “planning” and is now “executing”), false when the session is unknown or not in the planning phase (idempotent no-op).

string

boolean


archive(id, reapKeys?): Promise<number>

Defined in: src/service.ts:2483

Close a session: optionally terminate selected leftovers first, then stop the agent, remove the worktree, and archive the row. reapKeys are leftover keys the operator chose to kill; we re-detect and intersect by key so a stale/forged client selection can never make us kill an arbitrary pid. Returns the number of leftovers actually reaped (the intersection), so bulk callers can report a count that reflects what was killed rather than what was requested.

string

string[]

Promise<number>


archiveMany(ids): Promise<{ cleared: string[]; leftovers: number; }>

Defined in: src/service.ts:2545

Bulk-close sessions (“clear all merged”). Each session’s leftover subprocesses are auto-detected and reaped before its teardown — unlike the single-session close (which asks per-process), bulk clear terminates them all so a landed session can’t leave a dev server orphaned. Returns the ids actually archived (missing ones are skipped) and the total leftovers terminated — counted from what archive actually reaped, so the number never overstates. The caller must restrict ids to a safe set (e.g. merged-only) — this archives what it’s given.

One session’s teardown failing (e.g. worktree.remove throwing) must not abort the rest, so each is isolated: a failed id is skipped and left out of cleared, so the caller emits archived events for exactly the rows that really went away.

string[]

Promise<{ cleared: string[]; leftovers: number; }>


branchExists(repoPath, branch): boolean

Defined in: src/service.ts:1865

Whether a local branch already exists — the server’s pre-flight check before a rename.

string

string

boolean


broadcast(ids, text): object

Defined in: src/service.ts:2026

Fan a steer out to many sessions (human-style). Skips unknown ids and dead panes. Lists herdr’s live agents ONCE up front rather than per id, so a wide fan-out doesn’t spawn one blocking herdr agent list per target.

string[]

string

object

sent: number

total: number


clearMerging(id): void

Defined in: src/service.ts:2293

Clear one session’s merge-train mark. No-op (no event) when not marked.

string

void


clearMergingForTrain(trainId, now?): void

Defined in: src/service.ts:2342

The train session was archived (run complete). Clear any of its members still marked (unchanged), then drive the offer. If a merge was already credited → emit + finalize now. Else DEFER: mark the entry archived and fast-poll each still-tracked member via refreshPr so a poller-gated merge surfaces within seconds and routes through resolveMerging → late-credit emit. A deferred entry whose late credit never arrives is reclaimed by sweepStaleMerging with no emit, MERGE_STALE_MS after this archive — the await window starts HERE, so it is independent of how long the run itself took (a slow/long run is never reclaimed mid-flight; only the post-archive wait is bounded). now injectable.

string

number = ...

void


create(input): Promise<Session>

Defined in: src/service.ts:1389

CreateSessionInput

Promise<Session>


haltAll(): object

Defined in: src/service.ts:2051

Fleet-wide emergency stop: interrupt every live, actively-working agent at once. Sends a single ESC — the Claude Code interrupt key — to each pane whose herdr agent reports working, halting the current turn WITHOUT clearing its input or quitting it (a lone ESC, no bracketed paste, no trailing CR — the opposite of a steer). Idle / blocked / done agents, dead panes, archived sessions and the ephemeral usage probe are all left untouched: only ACTIVE sessions are matched against the live agent set (so the probe — never a stored session — and archived rows fall out), and of those only the ones reporting working are hit. Auto-spawned (drain) sessions are included BY DESIGN — a misfiring autopilot is exactly what this stops. Lists herdr’s agents ONCE up front (like broadcast) so a wide fan-out makes a single agent list call, not one per target. Emits halt:done {halted} so every connected operator sees the reach.

Throws (→ HTTP 500 → the UI surfaces halt_failed + Retry) when herdr can’t even be listed: a swallowed failure would emit a success-looking halt:done {halted:0}, indistinguishable from “nothing was working” — a silent no-op at the worst moment.

object

halted: number


leftovers(id): Leftover[]

Defined in: src/service.ts:2450

Leftover subprocesses/proxies that would survive this session’s close; [] when none.

string

Leftover[]


liveTrainPrs(): number[]

Defined in: src/service.ts:2219

The PR numbers currently scoped by any live merge train (the union of every live train’s prNumbers), deduped + sorted. Empty when no train is live. Cheap + sync — reads only the in-memory #liveTrains map, no forge round-trip. The Herd Rundown folds this in as the train’s queued set.

number[]


markTrainMember(trainId, sessionId, prNumber, now?): void

Defined in: src/service.ts:2260

Stamp one session as a member of a live train and register it with the completion tracker. Idempotent and guarded: a no-op when the session IS the train, when the train is no longer live (a session:git after archive must not resurrect a member), when the session is unknown, or when already marked. The tracker entry is lazily created on the FIRST marked member, so a train whose PRs never open creates no entry. now injectable.

string

string

number

number = ...

void


reconcileTrainMarks(trainId?, now?): void

Defined in: src/service.ts:2202

Server-derived participant marking: for each live train (the one trainId, or all when omitted), mark every ACTIVE same-repo session whose live PR is OPEN and whose number is in the train’s scoped queue. Reads the live PR snapshot via deps.prSnapshot (empty when unwired). The train session itself is never marked, and an already-marked session is left alone (idempotent — markTrainMember re-guards). Drives both the launch-time reconcile and the per-session:git one (a PR that opens later is picked up the next time this runs). now injectable.

string

number = ...

void


registerTrain(trainId, repoPath, prNumbers, now?): void

Defined in: src/service.ts:2179

string

string

number[]

number = ...

void


relaunch(originalId, issueRef?, overrides?): Promise<Session>

Defined in: src/service.ts:1537

Spawn a fresh replacement for an existing task, carrying its prompt and all per-task settings — including the spawn-baked ones that can’t be changed after the fact (model, repoPath, baseBranch, planGateEnabled) plus the runtime toggles (autopilot, auto-merge). Owns ONLY the spawn + override copy: it emits no events, never archives the original, and does not resolve forge/issues — those are the route handler’s job. Always auto: false (relaunch is an explicit operator action).

overrides is an optional bag applied over the original (absent field keeps the original’s value; a present one — incl. explicit null — replaces it), letting a caller relaunch into a DIFFERENT repo while carrying prompt/model/base-branch forward. Image handling forks on whether overrides are present:

  • quick relaunch (overrides == null) → the original’s uploads are auto-carried (copied into staging), byte-for-byte the original spawn.
  • relaunch WITH overrides → overrides.images is used VERBATIM and the original’s uploads are NOT auto-carried. The composer is the single source of truth here: it seeds the carried originals (via stageRelaunchImages) into the override list and the operator edits that list, so re-merging server-side would double the images.

On the quick-relaunch branch, the original’s uploaded images are COPIED (not moved) into staging and passed to create, which lands them in the new worktree — so a spawn failure here leaves the original’s images intact on disk (the originals are reclaimed only when the original’s worktree is torn down by the route, after a successful spawn). On any error AFTER create, the just-created session is best-effort torn down and the error rethrown, so no orphaned new session leaks.

string

IssueRef

RelaunchOverrides

Promise<Session>


releasePlanGate(id): boolean

Defined in: src/service.ts:2127

The “Go” gate: release an APPROVED planning session into autonomous execution. Strict — only transitions when the session is in the planning phase AND its plan gate is approved (the reviewer signed off). Flips planPhase → “executing”, steers the agent to implement the approved plan, and emits session:plangate so clients update. Returns false (no-op) when the session is unknown, not planning, or not yet approved. Used by the /go route (interactive) and by PlanGateService for an auto session’s auto-release on approval.

string

boolean


rename(id, slug, opts): Session | null

Defined in: src/service.ts:1905

string

string

boolean

Session | null


reply(id, text): boolean

Defined in: src/service.ts:1938

Steer a session’s live PTY (human-style): deliver the text as a bracketed paste, then submit it with a carriage return.

The wrap is load-bearing for multi-line steers (e.g. a pasted-in critic review). herdr does NOT bracket-wrap injected text, and back-to-back sends coalesce into a single PTY read — so a multi-line blob with a trailing “\r” reaches Claude Code as one chunk, trips its paste heuristic, and the CR is swallowed as just another newline: message typed-but-unsent. (Single-line steers escaped this because no embedded “\n” trips the heuristic — which is why short steers worked and reviews didn’t.) Wrapping the text in the bracketed-paste markers (ESC[200~ … ESC[201~) gives an explicit paste-end, so the following CR is unambiguously Enter regardless of read boundaries — deterministic, no timing guesswork. Strip any stray paste markers from the payload first: a leaked end-marker would close the paste early (turning the rest into live keystrokes), and a leaked start-marker is benign but dropped for symmetry. Returns false when the session is unknown OR its pane is dead (claude exited / terminal reaped) — a live store row can still back a dead pane, which would make herdr.send throw. The up-front liveness check keeps reply an honest, non-throwing boolean for human steers, and hands the auto-address loop a clean “not delivered” instead of relying on it to catch the throw downstream.

string

string

boolean


resolveMerging(id, didMerge): void

Defined in: src/service.ts:2318

A queue member’s PR resolved (session:git merged/closed). Replaces the bare per-member clearMerging call: clears the UI mark exactly as before, then credits the completion tracker. Credit is keyed by #memberToTrain (NOT the session’s mergingTrainId, which archive may already have nulled).

A merge only counts toward the offer when the session is isolated — a non-isolated session works in the canonical clone, so its fast-forward would always report wrong_branch (mirrors the parent feature’s guard). We read isolated/trainId before clearing, since the mark must be cleared either way. Emits only via the LATE-CREDIT path: if the train already archived awaiting a merge, this credit completes it. A credit while the train is still live never emits — the offer fires on run completion, not first merge.

string

boolean

void


resume(id, opts?): Promise<Session | null>

Defined in: src/service.ts:1774

Bring a finished session back: spawn a fresh claude --resume <pinnedId> in its still-present worktree so the whole conversation is restored and steerable again. Re-points the session at the new herdr agent and flips it back to running.

Returns the updated session, or null when it can’t be resumed:

  • unknown id, or archived (its worktree was already removed), or
  • a pre-feature session with no pinned claude session id to resume. If the herdr agent is still live (a “done” session that’s merely idle at the prompt), there’s nothing to respawn — the current session is handed back so the caller just re-attaches, avoiding a duplicate claude process.

force overrides that re-use: it tears down whatever agent currently backs the worktree and spawns a fresh claude --resume regardless. This is the explicit “bring claude back” action (header / card-menu button) for the case the re-use path can’t see — claude exited but its herdr tab survived as a bare shell, so the agent still lists as live (idle) and a plain resume would only re-adopt the shell.

We force unconditionally rather than only on a detected husk because herdr ≥0.6 agent list exposes no command/liveness field, so a husk shell and an idle claude are indistinguishable here (see ui canResume). The tradeoff: if invoked on a genuinely-live idle claude it respawns one needlessly, resetting that pane’s terminal scrollback — but --resume restores the FULL conversation, so no work is lost, and the control is only surfaced/clicked when the user believes they’re stranded. Guaranteeing the husk case works (always respawn) beats preserving scrollback in the rare misclick-on-live-claude case.

string

boolean

Promise<Session | null>


retryHalted(ids, continueText): Promise<{ resumed: number; steered: number; total: number; }>

Defined in: src/service.ts:1990

Retry a set of usage-halted sessions. For each id:

  • If its pane is live (herdr still lists it) → steer with continueText so the agent can continue from where it stopped (live idle/blocked state).
  • Otherwise → resume(id) spawns a fresh claude --resume pane. On any success the haltReason flag is cleared so the UI badge disappears. The steer text is supplied by the caller (localized client-side) — the server stays i18n-agnostic, exactly like broadcast.

string[]

string

Promise<{ resumed: number; steered: number; total: number; }>


setReadyToMerge(id, ready): void

Defined in: src/service.ts:2104

Toggle the manual “ready to merge” flag (parked / done). Persists it and pushes the change live so every client patches the row without a refetch.

string

boolean

void


stageRelaunchImages(originalId): object[]

Defined in: src/service.ts:1618

Stage an original session’s uploaded images for a relaunch-WITH-overrides composer to seed. Copies (not moves) the original’s worktree uploads into the repo staging dir — like the quick-relaunch branch and New Task — and returns the staged path plus its basename for each, capped at MAX_IMAGES so the UI never seeds more chips than a spawn accepts. The copies are recoverable on disk; relaunch() then takes the (possibly operator-edited) list back verbatim, so there is no server-side re-merge.

Each open copies fresh into staging, so a cancelled open or a removed chip orphans its copies. Before staging, we reclaim staged uploads past the TTL (the same sweep the server runs at startup, over the shared staging dir) so repeated opens don’t accumulate.

string

object[]


startPreview(id, command): boolean

Defined in: src/service.ts:1948

Steer the agent for session id to start its dev server with command running in the background. The agent’s PTY is a live Claude Code session — we can’t spawn processes ourselves, so we deliver a directive asking the agent to do it. Returns false for an unknown id or a dead pane (same semantics as reply()).

string

string

boolean


stopPreview(id, signal?): object

Defined in: src/service.ts:1969

Stop the previewed dev server for session id by SIGNALLING its process to terminate. UNLIKE startPreview (which steers the agent to start its dev server — Shepherd can’t start it itself), this really signals the process, because Shepherd can find the worktree process listening on the dev port.

killed is a signals-SENT count, NOT a death confirmation (a process may ignore the signal or take time to exit). This method does NOT release the preview listener — teardown happens via the poller sweep when the port stops listening (that port-gone event is the only real “RAM-freed” signal). Idle-stop passes “SIGTERM” then escalates to “SIGKILL”; force-stop passes “SIGKILL”.

Returns:

  • { result: “not_found”, killed: 0 } — unknown id, or deps not wired.
  • { result: “not_bound”, killed: 0 } — no live preview for this session.
  • { result: “stopped”, killed } — signal dispatched to killed process(es).

string

Signals = "SIGTERM"

object

killed: number

result: "stopped" | "not_bound" | "not_found"


sweepEgressTmp(): void

Defined in: src/service.ts:2527

Startup reconcile: remove any orphaned shepherd-egress/<id> temp dir whose id is not a currently live (non-archived) session — bounds unbounded growth from teardown removals missed across a crash/restart. Best-effort (never throws). Call once at server boot. A live session’s dir (incl. its dns.log) is preserved.

void


sweepStaleMerging(now?): void

Defined in: src/service.ts:2387

Liveness-based reclaim: marks live with their train, never on a flat age. Runs three ordered phases each call (order matters — A feeds B and C): A. Deregister crashed/gone live trains. A train whose session is missing or archived, or whose last activity (max(registeredAt, updatedAt)) is past TRAIN_TRACKER_MAX_MS, is dropped from #liveTrains. Last-activity-keyed so a slow-but-alive train (still touching its row) is never ceiled mid-run. B. Clear member marks whose train is no longer live — a mark is released exactly when its train stops running (cleanly via clearMergingForTrain, or via A’s deregistration here in the same sweep). No standalone age TTL. C. Reclaim #trainOffers entries, both no-emit (fail-safe no-offer): - AWAITING (archived, awaiting a late credit): once the post-archive window (MERGE_STALE_MS, #426) lapses — preserved exactly. - LIVE-CRASH ORPHAN (awaitingSince null, train no longer live): A having just dropped a crashed train, its still-live entry is reclaimed here. The awaitingSince-null gate keeps a cleanly-archived entry on the AWAITING path. now injectable for tests.

number = ...

void


syncWorktreeBranch(id): string | null

Defined in: src/service.ts:1886

Reconcile a session’s stored branch with the one actually checked out in its worktree. An agent that runs git checkout -b / git branch -m renames the branch out from under us, so the stored branch goes stale and PR detection (which queries gh pr list --head <branch>) silently misses the opened PR. Called by the PR poller on a “no PR found” miss. When the live branch differs, adopt it (re-point branch) — that alone is what restores PR recognition. Returns the adopted branch (so the poller can re-query), or null when nothing changed / it can’t be determined.

The display name follows only when it still trivially mirrors the old branch (i.e. was auto-derived). A name that already diverged is a chosen name — a manual rename or an LLM refine — and outranks a raw branch slug, the same precedence refineNameInBackground enforces. When it does follow, it’s de-duped through uniqueName like the other automatic rename paths so it can’t clash with a sibling’s tab label.

string

string | null