jackherd 1.23.1 → 1.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.23.3
6
+
7
+ - **The coordinator restarts a lane's dev server without a question.** The orchestrate skill (§ 3) and the pane-input denial now name the way: stop the server by its PID once `/proc/<pid>/cwd` shows the lane's worktree, then `herdr pane run` the same command in its pane; both pass the merge guard, keys typed into the pane ask the user. Run `jh sync` to get the skill text and the hook.
8
+
9
+ ## 1.23.2
10
+
11
+ - **After a reboot the coordinator is found without its name, and the herd comes back.** herdr keeps no name given with `herdr agent rename` over a restore, so `jh resume` found no coordinator and sent nothing. The watcher's resume record now also keeps the coordinator's agent session id and pane id, and the lanes' model columns; `jh resume` finds the coordinator by the session id (else the pane id) in the main checkout, sets the name again and logs it. Records of older versions work as before. Repositories are resumed at the same time, so one missing coordinator no longer holds up the others. herdr cannot tell which model a restored lane runs (it comes back without `--model`), so the log names each interrupted lane with its board model and the coordinator's line asks it to check those lanes against `lane-session.md`. `herd --ensure` leaves out a pane whose `process-info` fails (an old shell in a deleted directory) with one line, instead of failing for every lane.
12
+
5
13
  ## 1.23.1
6
14
 
7
15
  - **A lane with uncommitted work can rebase after a merge.** The orchestrate skill (§ 5 and § 6) let a lane work uncommitted until its PR was ready and also told every lane to rebase after each merge; Git rebases no dirty tree and `git stash` is shared between worktrees. Such a lane now commits locally with `-F` (amended in later rounds), rebases, and runs the install command; push and PR still wait for the PR approval. Run `jh sync` to get the skill text.
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p>
2
- <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.23.1/assets/branding/jackherd-logo-outline.svg" width="340">
2
+ <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.23.3/assets/branding/jackherd-logo-outline.svg" width="340">
3
3
  <br>
4
4
  </p>
5
5
 
6
- [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg">
6
+ [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,29 +15,29 @@ For developers who'd rather decide than supervise.
15
15
  curl -fsSL https://unpkg.com/jackherd/install.sh | sh
16
16
  ```
17
17
 
18
- Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg">
18
+ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.23.1/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
21
+ <img src="https://unpkg.com/jackherd@1.23.3/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
22
22
  <br>
23
- <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg">
23
+ <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
26
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
36
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
37
37
 
38
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
38
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
39
39
 
40
- <p><img src="https://unpkg.com/jackherd@1.23.1/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg"></p>
40
+ <p><img src="https://unpkg.com/jackherd@1.23.3/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg"></p>
41
41
 
42
42
  ## Quickstart
43
43
 
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
68
68
  /jh-orchestrate docs/plans/board-<name>/board.md
69
69
  ```
70
70
 
71
- Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg">
71
+ Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg">
72
72
 
73
73
  ## Follow anything you can query
74
74
 
75
75
  Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
76
76
 
77
- <img src="https://unpkg.com/jackherd@1.23.1/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
77
+ <img src="https://unpkg.com/jackherd@1.23.3/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
78
78
 
79
- The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.1/assets/icons/space.svg">
79
+ The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.23.3/assets/icons/space.svg">
80
80
 
81
81
  ## In detail
82
82
 
83
83
  <details>
84
84
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
85
85
 
86
- <img src="https://unpkg.com/jackherd@1.23.1/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
86
+ <img src="https://unpkg.com/jackherd@1.23.3/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
87
87
 
88
88
  | On his own | After your approval | Never |
89
89
  | --- | --- | --- |
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
118
118
 
119
119
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
120
120
 
121
- <img src="https://unpkg.com/jackherd@1.23.1/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
121
+ <img src="https://unpkg.com/jackherd@1.23.3/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
122
122
 
123
123
  | Skill | Does |
124
124
  | --- | --- |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.23.1"
3
+ version = "1.23.3"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.23.1}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.23.3}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -821,7 +821,7 @@ const paneInputSentence = (commands: string[]): string[] =>
821
821
  commands.length === 0
822
822
  ? []
823
823
  : [
824
- `${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own.`,
824
+ `${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own. To restart a dev server, stop it by its PID (the port names it, \`/proc/<pid>/cwd\` shows the lane's worktree), then \`herdr pane run\` the same command in its pane.`,
825
825
  ]
826
826
 
827
827
  /** Why an input into an agent asks: a key there can answer the dialog the agent has open, as if the user clicked. */
@@ -64,3 +64,7 @@ herdr pane run <new-pane> "PORT=<port> {{kit.command.dev}}"
64
64
  The new pane starts in the lane's worktree; check with `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) that the server runs
65
65
  there, and before stopping one by PID. Stop only a server you started; another's port
66
66
  goes to the user.
67
+
68
+ To restart it after a rebase, stop it by PID (found through the port) once `/proc/<pid>/cwd` (macOS: `lsof -p <pid>`) shows
69
+ the lane's worktree, then `herdr pane run <pane> "<the same command>"` in the same pane. Each step is a call of its own and
70
+ passes the pane guard without a question; keys typed into the pane ask the user.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.23.1",
3
+ "version": "1.23.3",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -68,7 +68,8 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
68
68
  '.claude/skills/orchestrate/references/stage-approval.md': 634,
69
69
  '.claude/skills/orchestrate/references/standing-approval.md': 458,
70
70
  // Core cut: § 2 steps 0 to 8 and § 3 dev server, moved out of the core.
71
- '.claude/skills/orchestrate/references/start-lane.md': 712,
71
+ // The dev server restart by PID and `herdr pane run`, the form the merge guard passes (+54).
72
+ '.claude/skills/orchestrate/references/start-lane.md': 766,
72
73
  // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
73
74
  '.claude/skills/orchestrate/references/waiting.md': 216,
74
75
  '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
@@ -19,14 +19,14 @@ import {
19
19
  splitPaneOf,
20
20
  viewCommandOf,
21
21
  withAgentPanes,
22
+ withForeground,
22
23
  } from './plan-panes'
23
24
  import type { PlansSettings } from './settings'
24
25
 
25
- const panesWithForeground = (agentPanes: readonly string[]): PaneState[] =>
26
- withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes).map((each) => ({
27
- ...each,
28
- foreground: foregroundOf(runHerdr(['pane', 'process-info', '--pane', each.pane])),
29
- }))
26
+ const panesWithForeground = (agentPanes: readonly string[]): { panes: PaneState[]; notes: string[] } =>
27
+ withForeground(withAgentPanes(paneListOf(runHerdr(['pane', 'list'])), agentPanes), (pane) =>
28
+ foregroundOf(runHerdr(['pane', 'process-info', '--pane', pane])),
29
+ )
30
30
 
31
31
  /** The board's lanes that have a worktree, closed ones left out. */
32
32
  const lanesOf = (root: string, settings: PlansSettings, boardFile: string) =>
@@ -72,7 +72,7 @@ export const ensurePlanPaneRun = (
72
72
  ): { lines: string[]; exitCode: EnsureExit } => {
73
73
  const agents = runHerdr(['agent', 'list'])
74
74
  const listed = parseAgentList(agents)
75
- const panes = panesWithForeground(agentPanesOf(agents))
75
+ const { panes, notes: skipped } = panesWithForeground(agentPanesOf(agents))
76
76
  const coordinatorName = resolveCoordinatorName(
77
77
  basename(root),
78
78
  listed.map((agent) => agent.name),
@@ -91,7 +91,7 @@ export const ensurePlanPaneRun = (
91
91
  command: (lane) => viewCommandOf(process.argv, lane),
92
92
  missingCoordinator: missingCoordinatorNote(panes, root, coordinatorName),
93
93
  })
94
- return { lines: [...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
94
+ return { lines: [...skipped, ...actions.map(apply), ...notes], exitCode: ensureExitOf(panes, coordinator) }
95
95
  }
96
96
 
97
97
  /** The lines of `ensurePlanPaneRun`, for a caller that brings the panes back without an exit code (the watcher). */
@@ -1,4 +1,5 @@
1
1
  import { basename } from 'node:path'
2
+ import { errorText } from './error-text'
2
3
  import { HERD_COMMAND, HERD_COMMAND_WORDS } from './herd-command'
3
4
 
4
5
  /**
@@ -83,6 +84,27 @@ export const withAgentPanes = <Pane extends { pane: string; agent: boolean }>(
83
84
  agentPanes: readonly string[],
84
85
  ): Pane[] => panes.map((each) => (agentPanes.includes(each.pane) ? { ...each, agent: true } : each))
85
86
 
87
+ /**
88
+ * The panes with their foreground from `readForeground`; a pane it throws for is left out with one note. herdr lists
89
+ * panes it cannot answer for (old shells whose directory is gone answer `pane_not_found`), and one of them must not
90
+ * keep the herd from the others.
91
+ */
92
+ export const withForeground = (
93
+ panes: readonly Omit<PaneState, 'foreground'>[],
94
+ readForeground: (pane: string) => string[][],
95
+ ): { panes: PaneState[]; notes: string[] } => {
96
+ const kept: PaneState[] = []
97
+ const notes: string[] = []
98
+ for (const each of panes) {
99
+ try {
100
+ kept.push({ ...each, foreground: readForeground(each.pane) })
101
+ } catch (error) {
102
+ notes.push(`${each.pane}: no process info, left out (${errorText(error)})`)
103
+ }
104
+ }
105
+ return { panes: kept, notes }
106
+ }
107
+
86
108
  const isArgv = (value: unknown): value is string[] =>
87
109
  Array.isArray(value) && value.every((arg) => typeof arg === 'string')
88
110
 
@@ -4,7 +4,17 @@ import { errorText } from '../plans/error-text'
4
4
  import type { PlanLanguage } from '../vocabulary'
5
5
  import { type ResumeDecision, resumeDecision } from './decision'
6
6
  import { type ResumeRecord, resumedRecord } from './record'
7
- import { type AgentPane, type Streak, steadyTarget, TARGET_MISS, type TargetMissed, targetFor } from './targets'
7
+ import {
8
+ type AgentIdentity,
9
+ type AgentPane,
10
+ type Streak,
11
+ steadyTarget,
12
+ TARGET_MISS,
13
+ TARGET_VIA,
14
+ type TargetMissed,
15
+ type TargetVia,
16
+ targetFor,
17
+ } from './targets'
8
18
  import { resumeTexts } from './texts'
9
19
 
10
20
  type Limits = { attempts: number; intervalMs: number }
@@ -34,6 +44,8 @@ export type ResumePorts = {
34
44
  settingsOf: (root: string) => { language: PlanLanguage; worktreeDirectory: string }
35
45
  /** herdr's agent panes; throws with herdr's error when it does not answer. */
36
46
  agentPanes: () => AgentPane[]
47
+ /** Gives the agent in `pane` the herdr name `name` again (`herdr agent rename`); throws with herdr's error. */
48
+ rename: (pane: string, name: string) => void
37
49
  send: SendPorts
38
50
  sleep: (ms: number) => Promise<void>
39
51
  log: (root: string, message: string) => void
@@ -42,7 +54,7 @@ export type ResumePorts = {
42
54
 
43
55
  type Resume = Extract<ResumeDecision, { kind: 'resume' }>
44
56
 
45
- type Recipient = { name: string; directory: string }
57
+ type Recipient = { name: string; directory: string; identity?: AgentIdentity }
46
58
 
47
59
  type Search = { found: Map<Recipient, string>; missed: Map<Recipient, TargetMissed> }
48
60
 
@@ -78,20 +90,23 @@ const awaitTargets = async (
78
90
  root: string,
79
91
  recipients: readonly Recipient[],
80
92
  limits: Limits,
81
- onFound: (target: string) => Promise<void>,
93
+ onFound: (target: string, via: TargetVia) => Promise<void>,
82
94
  ): Promise<Search> => {
83
95
  const search: Search = { found: new Map(), missed: new Map() }
84
96
  const streaks = new Map<Recipient, Streak>()
85
97
  for (let attempt = 1; search.found.size < recipients.length; attempt++) {
86
98
  const panes = pollPanes(ports, root)
87
99
  for (const recipient of recipients.filter((each) => !search.found.has(each))) {
88
- const steady = steadyTarget(targetFor(panes, recipient.name, recipient.directory), streaks.get(recipient) ?? null)
100
+ const steady = steadyTarget(
101
+ targetFor(panes, recipient.name, recipient.directory, recipient.identity),
102
+ streaks.get(recipient) ?? null,
103
+ )
89
104
  streaks.set(recipient, steady.streak)
90
105
  if (steady.target.kind === 'none') search.missed.set(recipient, steady.target)
91
106
  else {
92
107
  search.found.set(recipient, steady.target.target)
93
108
  search.missed.delete(recipient)
94
- await onFound(steady.target.target)
109
+ await onFound(steady.target.target, steady.target.via)
95
110
  }
96
111
  }
97
112
  if (search.found.size === recipients.length || attempt >= limits.attempts) break
@@ -131,10 +146,27 @@ const sendTo = async (ports: ResumePorts, root: string, target: string, text: st
131
146
  ports.log(root, (await sendLanePrompt(ports.send, target, text)).line)
132
147
  }
133
148
 
149
+ /** herdr does not keep a name given by `herdr agent rename` over a restore; a coordinator found without it gets it back. */
150
+ const renameCoordinator = async (ports: ResumePorts, decision: Resume, pane: string, via: TargetVia) => {
151
+ if (via !== TARGET_VIA.session && via !== TARGET_VIA.pane) return
152
+ try {
153
+ ports.rename(pane, decision.coordinator)
154
+ ports.log(decision.root, `coordinator ${decision.coordinator} found by its ${via} in ${pane}, name set again`)
155
+ } catch (error) {
156
+ ports.log(decision.root, `coordinator found by its ${via} in ${pane}, rename failed: ${errorText(error)}`)
157
+ }
158
+ }
159
+
134
160
  /** The coordinator's target once it is back in the main checkout; null, logged, when it is not within the window. */
135
161
  const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<string | null> => {
136
- const recipient = { name: decision.coordinator, directory: decision.root }
137
- const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, async () => {})
162
+ const recipient: Recipient = {
163
+ name: decision.coordinator,
164
+ directory: decision.root,
165
+ ...(decision.coordinatorIdentity ? { identity: decision.coordinatorIdentity } : {}),
166
+ }
167
+ const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, (pane, via) =>
168
+ renameCoordinator(ports, decision, pane, via),
169
+ )
138
170
  reportMisses(ports, decision.root, search.missed, (name) => `no coordinator ${name}`)
139
171
  return search.found.get(recipient) ?? null
140
172
  }
@@ -162,7 +194,10 @@ const resumeRepository = async (ports: ResumePorts, path: string, record: Resume
162
194
  if (decision.unsafe.length > 0) ports.log(decision.root, `left out, no lane names: ${decision.unsafe.join(', ')}`)
163
195
  const coordinator = await awaitCoordinator(ports, decision)
164
196
  if (coordinator === null || !marked(ports, path, record)) return
165
- const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked)
197
+ const modelCheck = Object.keys(decision.laneModels)
198
+ for (const [name, model] of Object.entries(decision.laneModels))
199
+ ports.log(decision.root, `${name}: restored without --model, board model ${model}; the coordinator checks it`)
200
+ const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked, modelCheck)
166
201
  await sendTo(ports, decision.root, coordinator, line)
167
202
  await resumeLanes(ports, decision, worktreeDirectory, texts.laneText)
168
203
  }
@@ -178,19 +213,24 @@ const resumeEntry = async (ports: ResumePorts, path: string, record: ResumeRecor
178
213
  else await resumeRepository(ports, path, record, decision)
179
214
  }
180
215
 
181
- /** Every repository with a record, one after the other; a failure in one is logged and the next goes on. Always 0. */
216
+ const resumeSafely = async (ports: ResumePorts, path: string, record: ResumeRecord | null) => {
217
+ try {
218
+ if (record === null) ports.log(path, 'damaged record, read as none')
219
+ else await resumeEntry(ports, path, record)
220
+ } catch (error) {
221
+ ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Every repository with a record at once, so one whose coordinator does not come back holds up no other; a failure in
227
+ * one is logged and the others go on. Every log line starts with its repository. Always 0.
228
+ */
182
229
  export const resumeAll = async (ports: ResumePorts): Promise<number> => {
183
230
  try {
184
231
  const entries = ports.records()
185
232
  if (entries.length === 0) ports.log('-', 'no resume record')
186
- for (const { path, record } of entries) {
187
- try {
188
- if (record === null) ports.log(path, 'damaged record, read as none')
189
- else await resumeEntry(ports, path, record)
190
- } catch (error) {
191
- ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
192
- }
193
- }
233
+ await Promise.all(entries.map(({ path, record }) => resumeSafely(ports, path, record)))
194
234
  } catch (error) {
195
235
  ports.log('-', `cannot read the records: ${errorText(error)}`)
196
236
  }
@@ -1,7 +1,7 @@
1
1
  import { isAbsolute, relative, sep } from 'node:path'
2
2
  import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
3
3
  import { SAFE_LANE_NAME } from '../watch/lane-name'
4
- import type { ResumeRecord } from './record'
4
+ import type { CoordinatorIdentity, ResumeRecord } from './record'
5
5
 
6
6
  /** Why `jh resume` leaves a repository alone. */
7
7
  export const RESUME_SKIP = {
@@ -45,6 +45,10 @@ export type ResumeDecision =
45
45
  interrupted: string[]
46
46
  /** The lanes that waited for an answer; only named to the coordinator. */
47
47
  blocked: string[]
48
+ /** What finds the coordinator without its name; null for a record of an older version. */
49
+ coordinatorIdentity: CoordinatorIdentity | null
50
+ /** The interrupted lanes with a model column on record, each with that column made printable; only logged. */
51
+ laneModels: Record<string, string>
48
52
  /** Lane names that are no single file name: never sent to, only logged. */
49
53
  unsafe: string[]
50
54
  }
@@ -65,6 +69,23 @@ const isSafeName = (name: string): boolean => SAFE_LANE_NAME.test(name) && !name
65
69
  const lanesWith = (lanes: Record<string, string>, status: AgentStatus): string[] =>
66
70
  Object.entries(lanes).flatMap(([name, laneStatus]) => (laneStatus === status && isSafeName(name) ? [name] : []))
67
71
 
72
+ const MODEL_TEXT_LIMIT = 60
73
+
74
+ /** A board cell for the log: printable characters only, so a column cannot break a log line. */
75
+ const printable = (column: string): string =>
76
+ column
77
+ .replace(/[^\p{L}\p{N}._/ ,+|-]/gu, '?')
78
+ .trim()
79
+ .slice(0, MODEL_TEXT_LIMIT)
80
+
81
+ const modelsOf = (record: ResumeRecord, lanes: readonly string[]): Record<string, string> =>
82
+ Object.fromEntries(
83
+ lanes.flatMap((name) => {
84
+ const column = record.laneModels?.[name]
85
+ return column === undefined ? [] : [[name, printable(column)]]
86
+ }),
87
+ )
88
+
68
89
  const skip = (reason: ResumeSkip): ResumeDecision => ({ kind: 'none', reason })
69
90
 
70
91
  /** The reason the record itself gives against a restart, checked before what the system says now. */
@@ -90,12 +111,15 @@ export const resumeDecision = ({ record, now, watcherRunning, boardExists }: Res
90
111
  const board = boardInside(record.root, record.board)
91
112
  if (board === null) return skip(RESUME_SKIP.unsafeBoard)
92
113
  if (!boardExists) return skip(RESUME_SKIP.boardMissing)
114
+ const interrupted = lanesWith(record.lanes, AGENT_STATUS.working)
93
115
  return {
94
116
  kind: 'resume',
95
117
  root: record.root,
96
118
  board,
97
119
  coordinator: record.coordinator,
98
- interrupted: lanesWith(record.lanes, AGENT_STATUS.working),
120
+ coordinatorIdentity: record.coordinatorIdentity ?? null,
121
+ laneModels: modelsOf(record, interrupted),
122
+ interrupted,
99
123
  blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
100
124
  unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
101
125
  }
@@ -1,6 +1,15 @@
1
1
  import { errorText } from '../plans/error-text'
2
- import type { LaneSnapshot } from '../watch/lane-watch'
3
- import { followedRecord, type RecordStart, type ResumeRecord, runningRecord, stoppedRecord } from './record'
2
+ import type { HerdrAgent, LaneSnapshot } from '../watch/lane-watch'
3
+ import {
4
+ type CoordinatorIdentity,
5
+ followedRecord,
6
+ type Observed,
7
+ type RecordStart,
8
+ type ResumeRecord,
9
+ runningRecord,
10
+ stoppedRecord,
11
+ } from './record'
12
+ import { sessionValueOf } from './targets'
4
13
 
5
14
  /** The one record file of a repository; `read` gives null for a missing or damaged one. */
6
15
  export type RecordStore = {
@@ -15,13 +24,31 @@ export type KeeperPorts = {
15
24
 
16
25
  /** What the watcher calls: `follow` at every herdr poll, `stop` once on a clean end. */
17
26
  export type RecordKeeper = {
18
- follow: (lanes: LaneSnapshot, now: number) => void
27
+ follow: (lanes: LaneSnapshot, now: number, observed?: Observed) => void
19
28
  stop: (now: number) => void
20
29
  }
21
30
 
22
31
  export const laneStatusesOf = (snapshot: LaneSnapshot): Record<string, string> =>
23
32
  Object.fromEntries(Object.entries(snapshot).map(([name, lane]) => [name, lane.status]))
24
33
 
34
+ /**
35
+ * What identifies the coordinator without its name: the agent herdr lists under `name`, else the unnamed agent in the
36
+ * watcher's own pane (as `coordinatorTargetOf` finds it). Null when neither is listed.
37
+ */
38
+ export const coordinatorIdentityOf = (
39
+ agents: readonly HerdrAgent[],
40
+ name: string,
41
+ ownPane: string | undefined,
42
+ ): CoordinatorIdentity | null => {
43
+ const agent =
44
+ agents.find((each) => each.name === name) ?? agents.find((each) => each.pane_id === ownPane && !each.name)
45
+ return agent ? { pane: agent.pane_id, session: sessionValueOf(agent.agent_session) } : null
46
+ }
47
+
48
+ /** Lane → the model column of its board row, from the watcher's last model readings. */
49
+ export const laneModelColumnsOf = (readings: Record<string, { column: string }>): Record<string, string> =>
50
+ Object.fromEntries(Object.entries(readings).map(([lane, { column }]) => [lane, column]))
51
+
25
52
  /**
26
53
  * Keeps the resume record of the watcher in `start.pid` beside its loop: running at the start, saved when a lane status
27
54
  * changes or `RESUME_REFRESH_MS` is up, stamped as stopped at a clean end unless a successor holds the file by then (a
@@ -48,8 +75,8 @@ export const keepRecord = (start: RecordStart, now: number, ports: KeeperPorts):
48
75
  let current = runningRecord(start, now)
49
76
  let unsaved = !attempt(() => ports.store.write(current))
50
77
  return {
51
- follow: (lanes, at) => {
52
- const next = followedRecord(current, laneStatusesOf(lanes), at)
78
+ follow: (lanes, at, observed) => {
79
+ const next = followedRecord(current, laneStatusesOf(lanes), at, observed)
53
80
  if (next === current && !unsaved) return
54
81
  current = next
55
82
  unsaved = !attempt(() => ports.store.write(next))
@@ -88,6 +88,9 @@ export const systemResumePorts = (home: string = homedir()): ResumePorts => {
88
88
  return { language: responseLanguage, worktreeDirectory }
89
89
  },
90
90
  agentPanes: () => resumablePanesOf(herdr(['agent', 'list']), herdr(['pane', 'list'])),
91
+ rename: (pane, name) => {
92
+ herdr(['agent', 'rename', pane, name])
93
+ },
91
94
  send: herdrSendPorts(),
92
95
  sleep: (ms) => Bun.sleep(ms),
93
96
  log,
@@ -9,6 +9,9 @@ export const RESUME_RECORD_VERSION = 1
9
9
  /** Below `~/.jackherd/`: the user's own, outside every repository an agent writes to. */
10
10
  const RESUME_DIRECTORY = 'resume'
11
11
 
12
+ /** The herdr pane and the agent session id of the coordinator; either may be unknown. */
13
+ export type CoordinatorIdentity = { pane: string | null; session: string | null }
14
+
12
15
  /**
13
16
  * What the watcher keeps about one repository so `jh resume` can tell a crash from a stop: a record without `stoppedAt`
14
17
  * was left by a watcher that never ended cleanly. `lanes` holds each lane's last `agent_status`, `resumedAt` the one
@@ -21,10 +24,18 @@ export type ResumeRecord = {
21
24
  board: string
22
25
  /** The coordinator's herdr name. */
23
26
  coordinator: string
27
+ /**
28
+ * What finds the coordinator when herdr restores its pane without the name (`herdr agent rename` does not survive a
29
+ * restore, the session id and the pane id do). Absent in records of older versions and until the watcher has seen
30
+ * the coordinator.
31
+ */
32
+ coordinatorIdentity?: CoordinatorIdentity
24
33
  pid: number
25
34
  startedAt: number
26
35
  savedAt: number
27
36
  lanes: Record<string, string>
37
+ /** Lane → the model column of its board row as the watcher last read it; absent in older records. */
38
+ laneModels?: Record<string, string>
28
39
  stoppedAt: number | null
29
40
  resumedAt: number | null
30
41
  }
@@ -41,6 +52,11 @@ const isAbsolutePath = (value: unknown): boolean => typeof value === 'string' &&
41
52
  const isLaneStatuses = (value: unknown): boolean =>
42
53
  isRecord(value) && Object.values(value).every((status) => typeof status === 'string')
43
54
 
55
+ const isStringOrNull = (value: unknown): boolean => value === null || typeof value === 'string'
56
+
57
+ const isCoordinatorIdentity = (value: unknown): boolean =>
58
+ isRecord(value) && isStringOrNull(value.pane) && isStringOrNull(value.session)
59
+
44
60
  export const isResumeRecord = (value: unknown): value is ResumeRecord =>
45
61
  isRecord(value) &&
46
62
  value.version === RESUME_RECORD_VERSION &&
@@ -52,6 +68,8 @@ export const isResumeRecord = (value: unknown): value is ResumeRecord =>
52
68
  isTime(value.startedAt) &&
53
69
  isTime(value.savedAt) &&
54
70
  isLaneStatuses(value.lanes) &&
71
+ (value.coordinatorIdentity === undefined || isCoordinatorIdentity(value.coordinatorIdentity)) &&
72
+ (value.laneModels === undefined || isLaneStatuses(value.laneModels)) &&
55
73
  'stoppedAt' in value &&
56
74
  isTimeOrNull(value.stoppedAt) &&
57
75
  'resumedAt' in value &&
@@ -94,6 +112,26 @@ const sameStatuses = (left: Record<string, string>, right: Record<string, string
94
112
  export const withLanes = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
95
113
  sameStatuses(record.lanes, lanes) ? record : { ...record, lanes: { ...lanes }, savedAt: now }
96
114
 
115
+ const sameIdentity = (left: CoordinatorIdentity | undefined, right: CoordinatorIdentity): boolean =>
116
+ left?.pane === right.pane && left?.session === right.session
117
+
118
+ /** The record with the coordinator's identity; the same object when it is unchanged or unknown (null keeps the stored one). */
119
+ export const withIdentity = (record: ResumeRecord, identity: CoordinatorIdentity | null, now: number): ResumeRecord =>
120
+ identity === null ||
121
+ (identity.pane === null && identity.session === null) ||
122
+ sameIdentity(record.coordinatorIdentity, identity)
123
+ ? record
124
+ : { ...record, coordinatorIdentity: identity, savedAt: now }
125
+
126
+ /** The record with the lanes' model columns; the same object when unchanged, and a lane without a column keeps its stored one. */
127
+ export const withLaneModels = (record: ResumeRecord, laneModels: Record<string, string>, now: number): ResumeRecord => {
128
+ const merged = { ...record.laneModels, ...laneModels }
129
+ return sameStatuses(record.laneModels ?? {}, merged) ? record : { ...record, laneModels: merged, savedAt: now }
130
+ }
131
+
132
+ /** What the watcher saw at one poll besides the lane statuses; both parts may be unknown. */
133
+ export type Observed = { coordinator?: CoordinatorIdentity | null; laneModels?: Record<string, string> }
134
+
97
135
  /**
98
136
  * How often the watcher saves its record although no lane changed: once an hour, so a watcher that rests longer than
99
137
  * `RESUME_MAX_AGE_MS` of `decision.ts` without a change still leaves a record young enough to resume after a crash.
@@ -105,8 +143,20 @@ export const refreshedRecord = (record: ResumeRecord, now: number): ResumeRecord
105
143
  now - record.savedAt < RESUME_REFRESH_MS ? record : { ...record, savedAt: now }
106
144
 
107
145
  /** The record after one herdr poll: new statuses, else a new save time once due; the same object when neither. */
108
- export const followedRecord = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
109
- refreshedRecord(withLanes(record, lanes, now), now)
146
+ export const followedRecord = (
147
+ record: ResumeRecord,
148
+ lanes: Record<string, string>,
149
+ now: number,
150
+ observed: Observed = {},
151
+ ): ResumeRecord =>
152
+ refreshedRecord(
153
+ withLaneModels(
154
+ withIdentity(withLanes(record, lanes, now), observed.coordinator ?? null, now),
155
+ observed.laneModels ?? {},
156
+ now,
157
+ ),
158
+ now,
159
+ )
110
160
 
111
161
  /** The record stamped as stopped cleanly; null when it names another process, whose record a successor keeps. */
112
162
  export const stoppedRecord = (record: ResumeRecord, ownPid: number, now: number): ResumeRecord | null =>
@@ -2,7 +2,15 @@ import { isRecord } from '../shared/is-record'
2
2
  import { parseJsonOrNull } from '../shared/parse-json'
3
3
 
4
4
  /** One pane an agent runs in, from herdr's lists; `name` is null while herdr names none. */
5
- export type AgentPane = { pane: string; name: string | null; cwd: string | null }
5
+ export type AgentPane = { pane: string; name: string | null; cwd: string | null; session: string | null }
6
+
7
+ /** What finds an agent whose name herdr lost: its session id (preferred) and the pane it ran in. */
8
+ export type AgentIdentity = { pane: string | null; session: string | null }
9
+
10
+ /** How a target was found: by the name, by the session id or pane id of the record, or as the one unnamed pane in the directory. */
11
+ export const TARGET_VIA = { name: 'name', session: 'session', pane: 'pane', directory: 'directory' } as const
12
+
13
+ export type TargetVia = (typeof TARGET_VIA)[keyof typeof TARGET_VIA]
6
14
 
7
15
  export const TARGET_MISS = { missing: 'missing', ambiguous: 'ambiguous', elsewhere: 'elsewhere' } as const
8
16
 
@@ -12,7 +20,7 @@ export type TargetMiss = (typeof TARGET_MISS)[keyof typeof TARGET_MISS]
12
20
  export type TargetMissed = { kind: 'none'; reason: TargetMiss; panes: string[] }
13
21
 
14
22
  /** Where a message goes: the agent's name or its pane id, `byName` when herdr named it. */
15
- export type Target = { kind: 'found'; target: string; byName: boolean } | TargetMissed
23
+ export type Target = { kind: 'found'; target: string; byName: boolean; via: TargetVia } | TargetMissed
16
24
 
17
25
  /** The one unnamed pane found in the directory so far, and in how many polls in a row. */
18
26
  export type Streak = { pane: string; polls: number } | null
@@ -34,6 +42,12 @@ const entriesOf = (json: string, key: 'agents' | 'panes'): Record<string, unknow
34
42
 
35
43
  const stringOrNull = (value: unknown): string | null => (typeof value === 'string' ? value : null)
36
44
 
45
+ /** The session id of herdr's `agent_session` (`{ value }`); null for none. */
46
+ export const sessionValueOf = (agentSession: unknown): string | null => {
47
+ const value = isRecord(agentSession) ? agentSession.value : undefined
48
+ return typeof value === 'string' && value !== '' ? value : null
49
+ }
50
+
37
51
  /**
38
52
  * The agent panes of `herdr agent list` and `herdr pane list`, each once; none from an answer that cannot be read.
39
53
  * Right after a restart the pane list names no agent for a few seconds while the agent list already does, and the
@@ -42,33 +56,69 @@ const stringOrNull = (value: unknown): string | null => (typeof value === 'strin
42
56
  */
43
57
  export const resumablePanesOf = (agentList: string, paneList: string): AgentPane[] => {
44
58
  const found = new Map<string, AgentPane>()
45
- for (const { pane_id, name, cwd } of entriesOf(agentList, 'agents'))
59
+ for (const { pane_id, name, cwd, agent_session } of entriesOf(agentList, 'agents'))
46
60
  if (typeof pane_id === 'string')
47
- found.set(pane_id, { pane: pane_id, name: stringOrNull(name), cwd: stringOrNull(cwd) })
61
+ found.set(pane_id, {
62
+ pane: pane_id,
63
+ name: stringOrNull(name),
64
+ cwd: stringOrNull(cwd),
65
+ session: sessionValueOf(agent_session),
66
+ })
48
67
  for (const { pane_id, agent, cwd } of entriesOf(paneList, 'panes')) {
49
68
  if (typeof pane_id !== 'string' || (typeof agent !== 'string' && !found.has(pane_id))) continue
50
69
  const known = found.get(pane_id)
51
- found.set(pane_id, { pane: pane_id, name: known?.name ?? null, cwd: known?.cwd ?? stringOrNull(cwd) })
70
+ found.set(pane_id, {
71
+ pane: pane_id,
72
+ name: known?.name ?? null,
73
+ cwd: known?.cwd ?? stringOrNull(cwd),
74
+ session: known?.session ?? null,
75
+ })
52
76
  }
53
77
  return [...found.values()]
54
78
  }
55
79
 
56
80
  const missed = (reason: TargetMiss, panes: string[]): TargetMissed => ({ kind: 'none', reason, panes })
57
81
 
82
+ const isUnnamedIn = (pane: AgentPane, directory: string): boolean => pane.name === null && pane.cwd === directory
83
+
84
+ /**
85
+ * The unnamed pane in `directory` that the record's identity points at: the one with its session id, else (without a
86
+ * session match) the one with its pane id. A session id names one conversation, so it needs no second look; a pane id
87
+ * may have passed to another agent, so the caller lets it stay alone for a few polls (`steadyTarget`).
88
+ */
89
+ const identifiedTarget = (panes: readonly AgentPane[], identity: AgentIdentity, directory: string): Target | null => {
90
+ const bySession = identity.session === null ? undefined : panes.find((pane) => pane.session === identity.session)
91
+ if (bySession && isUnnamedIn(bySession, directory))
92
+ return { kind: 'found', target: bySession.pane, byName: false, via: TARGET_VIA.session }
93
+ const byPane = identity.pane === null ? undefined : panes.find((pane) => pane.pane === identity.pane)
94
+ return byPane && !bySession && isUnnamedIn(byPane, directory)
95
+ ? { kind: 'found', target: byPane.pane, byName: false, via: TARGET_VIA.pane }
96
+ : null
97
+ }
98
+
58
99
  /**
59
100
  * The agent to send to: the one named `name` when it runs in `directory` (the main checkout for the coordinator, the
60
101
  * worktree for a lane) or herdr names no directory; the name in another directory belongs to another repository's
61
102
  * agent. Without the name, the one unnamed agent pane in `directory`: an agent with another name there is another
62
- * agent, a helper of the lane for one, and two unnamed panes are never guessed between.
103
+ * agent, a helper of the lane for one, and two unnamed panes are never guessed between, unless the record's
104
+ * `identity` (session id, pane id) points at one of them.
63
105
  */
64
- export const targetFor = (panes: readonly AgentPane[], name: string, directory: string): Target => {
106
+ export const targetFor = (
107
+ panes: readonly AgentPane[],
108
+ name: string,
109
+ directory: string,
110
+ identity?: AgentIdentity,
111
+ ): Target => {
65
112
  const named = panes.find((pane) => pane.name === name)
66
113
  if (named)
67
114
  return named.cwd === null || named.cwd === directory
68
- ? { kind: 'found', target: name, byName: true }
115
+ ? { kind: 'found', target: name, byName: true, via: TARGET_VIA.name }
69
116
  : missed(TARGET_MISS.elsewhere, [named.pane])
117
+ const identified = identity ? identifiedTarget(panes, identity, directory) : null
118
+ if (identified) return identified
70
119
  const candidates = panes.filter((pane) => pane.name === null && pane.cwd === directory).map(({ pane }) => pane)
71
- if (candidates.length === 1) return { kind: 'found', target: candidates[0] as string, byName: false }
120
+ if (candidates.length === 1)
121
+ return { kind: 'found', target: candidates[0] as string, byName: false, via: TARGET_VIA.directory }
72
122
  return missed(candidates.length === 0 ? TARGET_MISS.missing : TARGET_MISS.ambiguous, candidates)
73
123
  }
74
124
 
@@ -77,7 +127,7 @@ export const targetFor = (panes: readonly AgentPane[], name: string, directory:
77
127
  * alone for `STEADY_POLLS` polls in a row; until then it counts as missing. Returns the streak for the next poll.
78
128
  */
79
129
  export const steadyTarget = (target: Target, streak: Streak): { target: Target; streak: Streak } => {
80
- if (target.kind === 'none' || target.byName) return { target, streak: null }
130
+ if (target.kind === 'none' || target.byName || target.via === TARGET_VIA.session) return { target, streak: null }
81
131
  const polls = streak?.pane === target.target ? streak.polls + 1 : 1
82
132
  const next = { pane: target.target, polls }
83
133
  return { target: polls >= STEADY_POLLS ? target : missed(TARGET_MISS.missing, []), streak: next }
@@ -3,7 +3,12 @@ import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
3
3
  /** The fixed words `jh resume` sends; nothing else from outside reaches a session but checked lane names and the board. */
4
4
  export type ResumeTexts = {
5
5
  /** One line, no break: herdr would send a break as an enter and split the command. */
6
- coordinatorLine: (board: string, interrupted: readonly string[], blocked: readonly string[]) => string
6
+ coordinatorLine: (
7
+ board: string,
8
+ interrupted: readonly string[],
9
+ blocked: readonly string[],
10
+ modelCheck?: readonly string[],
11
+ ) => string
7
12
  /** One line, no break: the hand-over script refuses one. The first input of the chat after a clear. */
8
13
  clearLine: (board: string) => string
9
14
  laneText: string
@@ -11,18 +16,29 @@ export type ResumeTexts = {
11
16
 
12
17
  const listed = (lanes: readonly string[], none: string): string => (lanes.length > 0 ? lanes.join(', ') : none)
13
18
 
19
+ const withModelCheck = (line: string, note: string, lanes: readonly string[]): string =>
20
+ lanes.length > 0 ? `${line} ${note}: ${lanes.join(', ')}.` : line
21
+
14
22
  const TEXTS: Record<PlanLanguage, ResumeTexts> = {
15
23
  [PLAN_LANGUAGE.de]: {
16
- coordinatorLine: (board, interrupted, blocked) =>
17
- `/orchestrate ${board} Neustart nach Absturz: Watcher starten (§ 4), Wecker mit den Terminen abgleichen; fortgesetzt: ${listed(interrupted, 'keine')}; vor dem Absturz blockiert: ${listed(blocked, 'keine')}.`,
24
+ coordinatorLine: (board, interrupted, blocked, modelCheck = []) =>
25
+ withModelCheck(
26
+ `/orchestrate ${board} Neustart nach Absturz: Watcher starten (§ 4), Wecker mit den Terminen abgleichen; fortgesetzt: ${listed(interrupted, 'keine')}; vor dem Absturz blockiert: ${listed(blocked, 'keine')}.`,
27
+ 'Diese Spuren kamen ohne --model zurück, Modell gegen die Board-Zeile prüfen und nach lane-session.md umstellen',
28
+ modelCheck,
29
+ ),
18
30
  clearLine: (board) =>
19
31
  `/orchestrate ${board} Aufgeräumt: zuerst jh resume --coordinator --board ${board}, dann Watcher starten (§ 4).`,
20
32
  laneText:
21
33
  'Der Rechner ist abgestürzt und herdr wurde neu gestartet. Dein letzter Befehl wurde unterbrochen: prüfe, wie weit er kam, und setze ihn fort.',
22
34
  },
23
35
  [PLAN_LANGUAGE.en]: {
24
- coordinatorLine: (board, interrupted, blocked) =>
25
- `/orchestrate ${board} Restart after a crash: start the watcher (§ 4), match the timers against the appointments; resumed: ${listed(interrupted, 'none')}; blocked before the crash: ${listed(blocked, 'none')}.`,
36
+ coordinatorLine: (board, interrupted, blocked, modelCheck = []) =>
37
+ withModelCheck(
38
+ `/orchestrate ${board} Restart after a crash: start the watcher (§ 4), match the timers against the appointments; resumed: ${listed(interrupted, 'none')}; blocked before the crash: ${listed(blocked, 'none')}.`,
39
+ 'These lanes came back without --model, check the model against the board row and switch it as lane-session.md says',
40
+ modelCheck,
41
+ ),
26
42
  clearLine: (board) =>
27
43
  `/orchestrate ${board} Cleared: first jh resume --coordinator --board ${board}, then start the watcher (§ 4).`,
28
44
  laneText:
@@ -52,8 +52,9 @@ import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '.
52
52
  import { readRunningSubagents, subagentSourceOf } from '../plans/subagents-read'
53
53
  import { boardTexts } from '../plans/texts'
54
54
  import { readWaitingRecords, waitingItemsOf } from '../plans/waiting-records'
55
- import { keepRecord } from '../resume/keeper'
55
+ import { coordinatorIdentityOf, keepRecord, laneModelColumnsOf } from '../resume/keeper'
56
56
  import { systemRecordStore } from '../resume/ports'
57
+ import type { Observed } from '../resume/record'
57
58
  import { defaultUpdateCheck } from '../sync/if-changed'
58
59
  import { DEFAULT_SKILL_PREFIX } from '../sync/init'
59
60
  import { herdrLaneStatusSource } from '../terminal/herdr'
@@ -1597,7 +1598,15 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
1597
1598
  Date.now(),
1598
1599
  { store: systemRecordStore(watch.root), log: console.log },
1599
1600
  )
1600
- resumeRecord.follow(armed, Date.now())
1601
+ const observed = (): Observed => ({
1602
+ coordinator: coordinatorIdentityOf(
1603
+ watching.agents,
1604
+ coordinatorNameFor(watch, watching.agents),
1605
+ process.env.HERDR_PANE_ID,
1606
+ ),
1607
+ laneModels: laneModelColumnsOf(watching.laneModelReadings),
1608
+ })
1609
+ resumeRecord.follow(armed, Date.now(), observed())
1601
1610
  process.on('SIGTERM', () => {
1602
1611
  resumeRecord.stop(Date.now())
1603
1612
  saveState(watch, watching)
@@ -1629,7 +1638,7 @@ const watchLanes = async (watch: Watch): Promise<boolean> => {
1629
1638
  while (await pollOnce(watch, watching)) {
1630
1639
  await Bun.sleep(POLL_MS)
1631
1640
  pollLanes(watch, watching)
1632
- resumeRecord.follow(watching.current, Date.now())
1641
+ resumeRecord.follow(watching.current, Date.now(), observed())
1633
1642
  await announceUpdate?.()
1634
1643
  if (endedByKitUpdate(kit, effects)) return true
1635
1644
  }
@@ -1,6 +1,12 @@
1
1
  import { AGENT_STATUS } from '../terminal/types'
2
2
 
3
- export type HerdrAgent = { name?: string; pane_id: string; agent_status: string; state_change_seq?: number }
3
+ export type HerdrAgent = {
4
+ name?: string
5
+ pane_id: string
6
+ agent_status: string
7
+ agent_session?: unknown
8
+ state_change_seq?: number
9
+ }
4
10
 
5
11
  /** The state a report gives a lane whose agent left the herdr listing. */
6
12
  export const LANE_GONE = 'no longer listed'