jackherd 1.12.3 → 1.12.5

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,15 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.12.5
6
+
7
+ - **A lane that stands still is news.** Since 1.12.4 an unchanged heartbeat stays silent, so a working lane whose phase, ticks and subagents do not move would go unnoticed. `jh watch --stream` now reports such a lane once, after three heartbeat intervals without a move (45 minutes at the default 15), as its own line `lane <name> stalled <n> min`, and stays silent until the lane moves again. The orchestrate skill tells the coordinator to read that lane, instead of comparing counts across heartbeats.
8
+
9
+ ## 1.12.4
10
+
11
+ - **A quiet stream when nothing changed.** `jh watch --stream` sends a heartbeat only when its state (lanes, their progress, what waits on you, the main checkout) changed since the last one it sent, so a coordinator's chat no longer fills with identical heartbeats over hours. The events a heartbeat brings (parallel tasks, a lane without a task signal, an unlinked Artifact) come as before.
12
+ - **A quiet restart.** A restart prints its `armed: …` line only when the lanes, their states or the board differ from the last start, or when it replaced a running watcher. The orchestrate skill checks the start by the Monitor call that started the stream, and the coordinator restarts an expired stream without a word to the user.
13
+
5
14
  ## 1.12.3
6
15
 
7
16
  - **One read for every herd of a board.** A running watcher (`jh watch --stream`) reads the board, its plans and the lanes once and publishes the read; each herd of that board, the lane herds included, takes it, woken by the file, instead of reading everything itself every few seconds. It writes the sprint page as well, so no herd builds it. Without a running watcher, after a read older than a minute, or with a herd that runs other code than the watcher, a herd reads for itself as before. Measured with all lanes idle: a herd that follows the watcher takes about 1 % CPU, the watcher about 4-5 %. One herd plus its watcher costs a little more than in 1.12.2 (~5.6 % against ~4.3 %); from two herds on one watcher on, the whole costs less.
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.12.3/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.12.5/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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.3/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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,25 +15,25 @@ 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.12.3/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.12.5/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.12.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.">
21
+ <img src="https://unpkg.com/jackherd@1.12.5/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.12.3/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.12.5/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.12.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>
26
+ <p><img src="https://unpkg.com/jackherd@1.12.5/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.12.3/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.12.3/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.12.3/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.12.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.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.12.5/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.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.12.3/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.3/assets/icons/space.svg"></p>
36
+ <p><img src="https://unpkg.com/jackherd@1.12.5/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg"></p>
37
37
 
38
38
  ## Quickstart
39
39
 
@@ -69,22 +69,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
69
69
  /jh-orchestrate docs/plans/board-<name>/board.md
70
70
  ```
71
71
 
72
- 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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.3/assets/icons/space.svg">
72
+ 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. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg">
73
73
 
74
74
  ## Follow anything outside GitHub
75
75
 
76
76
  Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
77
77
 
78
- <img src="https://unpkg.com/jackherd@1.12.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
+ <img src="https://unpkg.com/jackherd@1.12.5/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.">
79
79
 
80
- The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.3/assets/icons/space.svg">
80
+ The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.5/assets/icons/space.svg">
81
81
 
82
82
  ## In detail
83
83
 
84
84
  <details>
85
85
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
86
86
 
87
- <img src="https://unpkg.com/jackherd@1.12.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
+ <img src="https://unpkg.com/jackherd@1.12.5/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.">
88
88
 
89
89
  | On his own | After your approval | Never |
90
90
  | --- | --- | --- |
@@ -117,7 +117,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
117
117
 
118
118
  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.
119
119
 
120
- <img src="https://unpkg.com/jackherd@1.12.3/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
120
+ <img src="https://unpkg.com/jackherd@1.12.5/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
121
121
 
122
122
  | Skill | Does |
123
123
  | --- | --- |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.12.3"
3
+ version = "1.12.5"
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.12.3}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.5}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -192,16 +192,14 @@ watcher finds you by the default name of § 0, or by `--coordinator <name>`. In
192
192
  arrives as one message, and the watcher keeps running.
193
193
 
194
194
  The heartbeat (default 15 minutes, due after that long without a report) is one line,
195
- `=== heartbeat <time> · <lane> <status> · …`. Each working lane carries its current phase and its ticks
196
- (`<lane> working: P12 7/12`) at every heartbeat, and the subagents running in its worktree
197
- (`P12 7/12, 3 subagents`). A lane whose count stays put with no subagent running is
198
- one to read yourself. `lane added: <name>` and `lane removed: <name>` mark a change of the found lanes.
195
+ `=== heartbeat <time> · <lane> <status> · …`, sent only on a change. Each working lane carries its current phase, its ticks
196
+ and the subagents in its worktree (`<lane> working: P12 7/12, 3 subagents`). A working lane whose line stands
197
+ still three heartbeat intervals comes once as `lane <name> stalled <n> min`. `lane added: <name>` and `lane removed: <name>` mark a change.
199
198
  An agent is watched from the state it first shows; one that exits fires as `no longer listed`.
200
199
  Deploy runs are reported only when the config sets `watch.deploy`.
201
200
 
202
201
  Something outside GitHub (a ticket, an issue, an error, a status page) the watcher reports only as
203
- a configured source (`watch.sources`). When the user asks you to follow such an item ("follow X",
204
- "tell me when X answers"), run `/follow`.
202
+ a configured source (`watch.sources`). When the user asks you to follow such an item, run `/follow`.
205
203
 
206
204
  A restart is safe: the watcher keeps what it saw between runs. A stop during a report re-delivers that
207
205
  report's events on the next arm; vet a repeated event against `gh pr view` or the run before acting
@@ -210,20 +208,20 @@ twice.
210
208
  - **The stream runs under the Monitor tool.** A Bash call never ends with it, not even in the
211
209
  background, so its reports go unseen; the kit's `watch-guard` hook refuses such a call where the
212
210
  project installed it, and this rule holds where it did not.
213
- - **Check the start.** The first line, `armed: …`, arrives as a Monitor event. When it arrives as
214
- the output of a Bash call instead, the start went wrong: stop that task (`TaskStop`) and start the
215
- stream under the Monitor tool.
211
+ - **Check the start.** A start under the Monitor tool is right; its first line, `armed: …`, comes
212
+ only when lanes, states or board changed. A start as a Bash call went wrong: stop that task
213
+ (`TaskStop`) and start it under the Monitor tool.
216
214
  - **Only when the session has no Monitor tool**, even after `ToolSearch`, the watcher runs without
217
215
  `--stream` as its own Bash call with `run_in_background: true`. It returns after one report and is
218
- started again after every wake-up. Tell the user in one sentence that this session watches without a stream
219
- because it has no Monitor tool, unless the provider has none (above). A watcher sent off with a trailing `&` finishes unseen.
216
+ started again after every wake-up. Tell the user in one sentence that this session watches without a stream,
217
+ unless the provider has none (above). A watcher sent off with a trailing `&` finishes unseen.
220
218
  - **A wake-up is done when every watched lane has a started next action or a named reason to wait**
221
219
  (`waiting for merge #<n>`, `waiting for mockup approval`, `plan finished, close the lane?`).
222
220
  - **Start the stream again only when it ended:** the monitor expired (its notice after 30 minutes),
223
221
  `stopped by SIGTERM` from outside, or `watcher stopped: <reason>`. Start it before answering the
224
222
  user. A watcher with named lanes is restarted with every lane still running.
225
223
 
226
- **Herd for the user.** The watcher wakes you; the herd shows the user where every lane stands. The
224
+ **Herd for the user.** The herd shows the user where every lane stands. The
227
225
  watcher keeps the herd panes beside your pane and beside each lane of the board and prints a line per
228
226
  view it started. On `herd --ensure failed, still watching: <reason>`, fix the cause and run
229
227
  `bunx jackherd herd --ensure` from your pane. How the herd picks a lane's phases is in
@@ -248,17 +246,18 @@ On wake-up:
248
246
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
249
247
  | `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
250
248
  | `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
251
- | `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): the `for you` part of that line is the count only (`3 wait for you, see the herd`, in the language of the replies); the herd shows the items, and no reply of yours ends with a list of the rows of the board section |
249
+ | `heartbeat` | One line of status to the user; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): the `for you` part of that line is the count only (`3 wait for you, see the herd`, in the language of the replies); no reply of yours ends with a list of the rows of the board section |
252
250
  | `gh unavailable` | Tell the user once; herdr watching continues |
253
251
  | `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
254
252
  | `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
253
+ | `lane <name> stalled <n> min` | Read its screen: running subagents or moving output are a reason to wait; a stopped lane gets its next step |
255
254
  | `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (**Approvals**, below); the duty line names the question's mark and labels |
256
255
  | `coordinator warn` / `coordinator alert` | § 8, for yourself |
257
256
  | `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane and needs no row. One the lane did not announce goes to the user |
258
257
  | `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
259
258
  | `kit updated <old> → <new>` | The kit was updated under the running watcher, which ended. After the `jh update` call has finished (with `(source changed)` there is none: the kit's source moved under a watcher run from it), read the change of this skill the next line names (`orchestrate skill: § … changed · diff <path>`; the diff shows the kit's placeholders, not your values), and the change of each reference a line names (`orchestrate reference <name> changed · diff <path>`, or `new · read <path>`), so the new text is the one you follow; read this skill file again (`Read` on the file you were given, no session restart) only when the line says `diff unavailable`; `orchestrate skill unchanged` needs no reading. Then start the stream again |
260
259
  | an event line ending in `· read <path>` | A rare event (`feedback`, `preview`, `source`, `parallel`, an update, …): read the file it names, mostly [`references/events.md`](references/events.md), and handle the event as it says there; read its `parallel tasks` section also before you switch `watch.parallelTasks.auto` on |
261
- | monitor expired | Start the stream again |
260
+ | monitor expired | Start the stream again, without a word to the user |
262
261
  | `superseded by <pid>` / `stopped by SIGTERM` | Nothing for `superseded`: the newer watcher runs. After `stopped by SIGTERM` from outside, start it again |
263
262
  | `watcher stopped: <reason>` | Tell the user, fix the cause (an unreadable state file, say), then start it again |
264
263
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.12.3",
3
+ "version": "1.12.5",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -87,7 +87,7 @@ import {
87
87
  import { type FeedbackItem } from './feedback-item'
88
88
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
89
89
  import { answered, deployTargetOf, type GithubRead, readGithub, targetText } from './github-read'
90
- import { currentPhaseCountsOf } from './heartbeat-progress'
90
+ import { currentPhaseCountsOf, type LaneNotes } from './heartbeat-progress'
91
91
  import { startHerdPublisher } from './herd-publisher'
92
92
  import { agentScreen, listAgents } from './herdr'
93
93
  import { endedByKitUpdate } from './kit-update'
@@ -99,7 +99,7 @@ import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress, watchedPla
99
99
  import {
100
100
  appointmentEventLine,
101
101
  approvalDutyLines,
102
- armLine,
102
+ armNotice,
103
103
  blockedDutyLines,
104
104
  coordinatorStatusLine,
105
105
  feedbackEventLines,
@@ -120,6 +120,7 @@ import {
120
120
  import { hasLaneHandover, laneReportPath, readLaneReport, reportMtime } from './lane-report-file'
121
121
  import { restNote } from './lane-rest'
122
122
  import { branchOf, fetchBaseWhenMoved, fetchPlanHeadsWhenMoved, readMainCheckout } from './lane-sources'
123
+ import { type LaneStalledEvent, laneStalledEvents, STALLED_HEARTBEATS, type StallState } from './lane-stalled'
123
124
  import {
124
125
  advanceBaseline,
125
126
  armBaseline,
@@ -172,7 +173,7 @@ import { sourceDryRun } from './source-dry-run-command'
172
173
  import { runSource, SOURCE_LIMITS } from './source-run'
173
174
  import { recordWatchedSources } from './source-tickets'
174
175
  import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
175
- import { streamHeartbeatLine } from './stream-output'
176
+ import { heartbeatStateOf, isHeartbeatNews, streamHeartbeatLine } from './stream-output'
176
177
  import {
177
178
  formatTaskParallelLine,
178
179
  TASK_PARALLEL_EVENT,
@@ -306,6 +307,8 @@ type Watching = {
306
307
  parallelAutoEnded: string | null
307
308
  /** Since when each lane works without a task signal and the phase last reported; persisted like the fingerprints. */
308
309
  noTaskSignals: NoSignalState
310
+ /** The working lanes' positions at the last heartbeat and since when they stand; persisted like the fingerprints. */
311
+ laneStalls: StallState
309
312
  /** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
310
313
  unlinkedArtifacts: UnlinkedState
311
314
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
@@ -360,6 +363,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
360
363
  taskParallels: watching.taskParallels,
361
364
  parallelAutoEnded: watching.parallelAutoEnded,
362
365
  noTaskSignals: watching.noTaskSignals,
366
+ laneStalls: watching.laneStalls,
363
367
  unlinkedArtifacts: watching.unlinkedArtifacts,
364
368
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
365
369
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
@@ -1160,12 +1164,29 @@ const subagentCountsOf = (watch: Watch, now: number): Record<string, number> =>
1160
1164
  }
1161
1165
  }
1162
1166
 
1167
+ /**
1168
+ * This heartbeat's `stalled` events: the working lanes whose position (the heartbeat's note) stood still for
1169
+ * `STALLED_HEARTBEATS` heartbeat intervals, each once per stretch; records the state in `watching`.
1170
+ */
1171
+ const laneStalledEventsOf = (watch: Watch, watching: Watching, notes: LaneNotes, now: number): LaneStalledEvent[] => {
1172
+ const working = new Set(workingLanes(watching))
1173
+ const readings = Object.fromEntries(
1174
+ watching.lanes.map((lane) => [lane, { working: working.has(lane), position: notes[lane] ?? '' }]),
1175
+ )
1176
+ const limitMinutes = watch.options.heartbeatMinutes * STALLED_HEARTBEATS
1177
+ const { events, state } = laneStalledEvents(watching.laneStalls, readings, now, limitMinutes)
1178
+ watching.laneStalls = state
1179
+ return events
1180
+ }
1181
+
1163
1182
  /**
1164
1183
  * The heartbeat of a stream: one line, a working lane with its phase count at every heartbeat and the subagents running
1165
- * in its worktree. The plans are read only when a lane works; a board that cannot be read leaves the lines without a
1166
- * count, and the next report names the failure.
1184
+ * in its worktree, printed only when its state changed since the last one sent; a lane whose position stood still for
1185
+ * `STALLED_HEARTBEATS` intervals comes as its own `stalled` event. The plans are read only when a lane works; a board that cannot be
1186
+ * read leaves the lines without a count, and the next report names the failure. Returns the state of the heartbeat it
1187
+ * printed, null when it printed none.
1167
1188
  */
1168
- const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<void> => {
1189
+ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: number): Promise<string | null> => {
1169
1190
  const working = workingLanes(watching)
1170
1191
  const progress = await workingProgressOf(watch, watching)
1171
1192
  const counts = currentPhaseCountsOf(progress)
@@ -1177,13 +1198,17 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
1177
1198
  }),
1178
1199
  )
1179
1200
  const standing = [waitingLineOf(watch), mainCheckoutLineOf(watch)].filter((line) => line !== null)
1180
- console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, standing))
1201
+ const line = streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes, rests, standing)
1202
+ const sent = isHeartbeatNews(watching.state.heartbeat, line) ? heartbeatStateOf(line) : null
1203
+ if (sent !== null) console.log(line)
1181
1204
  const events = [
1205
+ ...laneStalledEventsOf(watch, watching, notes, now),
1182
1206
  ...taskParallelEventsOf(watch, watching, now),
1183
1207
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1184
1208
  ...unlinkedArtifactEventsOf(watch, watching),
1185
1209
  ]
1186
1210
  for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
1211
+ return sent
1187
1212
  }
1188
1213
 
1189
1214
  /**
@@ -1236,8 +1261,8 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
1236
1261
  })
1237
1262
  } else {
1238
1263
  if (heartbeat) {
1239
- await printStreamHeartbeat(watch, watching, now)
1240
- next = recordedOf(watch, watching)
1264
+ const sent = await printStreamHeartbeat(watch, watching, now)
1265
+ next = { ...recordedOf(watch, watching), ...(sent !== null && { heartbeat: sent }) }
1241
1266
  }
1242
1267
  if (due) writePrivateFile(watch.files.state, JSON.stringify(next))
1243
1268
  watching.baseline = advanceBaseline(watching.baseline, watching.current)
@@ -1280,6 +1305,7 @@ const initialWatching = (
1280
1305
  taskParallels: start.state.taskParallels,
1281
1306
  parallelAutoEnded: start.state.parallelAutoEnded,
1282
1307
  noTaskSignals: start.state.noTaskSignals,
1308
+ laneStalls: start.state.laneStalls,
1283
1309
  unlinkedArtifacts: start.state.unlinkedArtifacts,
1284
1310
  approvalRequests: null,
1285
1311
  appointments: null,
@@ -1300,10 +1326,11 @@ const watchLanes = async (watch: Watch) => {
1300
1326
  const sprintLanes = watch.options.lanes ? [] : sprintLaneNames(watch, [])
1301
1327
  const lanes = lanesFor(watch, agents, sprintLanes)
1302
1328
  const armed = toSnapshot(agents, lanes)
1303
- console.log(armLine(lanes, armed, takeOver, relative(watch.root, watch.sprintFile)))
1329
+ const arm = armNotice(state.armed, lanes, armed, takeOver, relative(watch.root, watch.sprintFile))
1330
+ if (arm.line !== null) console.log(arm.line)
1304
1331
  if (watch.boardNotice) console.log(watch.boardNotice)
1305
1332
 
1306
- const watching = initialWatching(watch, { lanes, sprintLanes, state, armed, agents })
1333
+ const watching = initialWatching(watch, { lanes, sprintLanes, state: { ...state, armed: arm.state }, armed, agents })
1307
1334
  keepViews(watch, watching, { kind: ENSURE_TRIGGER.start })
1308
1335
  process.on('SIGTERM', () => {
1309
1336
  saveState(watch, watching)
@@ -11,6 +11,7 @@ import { type FeedbackEvent, firstLine } from './feedback'
11
11
  import type { LaneFillEvent } from './lane-fill'
12
12
  import type { LaneModelEvent } from './lane-model'
13
13
  import type { LaneParallelEvent } from './lane-parallel'
14
+ import { laneStalledLine } from './lane-stalled'
14
15
  import { AGENT_STATUS, type LaneSnapshot, NO_AGENT } from './lane-watch'
15
16
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
16
17
  import { SOURCE_FIELD } from './source-fields'
@@ -25,7 +26,7 @@ import {
25
26
  WATCH_EVENT,
26
27
  type WatchEvent,
27
28
  } from './watch-events'
28
- import type { TakeOver } from './watcher-lock'
29
+ import { NO_TAKE_OVER, type TakeOver } from './watcher-lock'
29
30
 
30
31
  export type LaneReport = {
31
32
  lane: string
@@ -150,6 +151,19 @@ export const otherPullRequestsLine = (
150
151
  return `${texts.otherPullRequests}: ${[...named, ...(more > 0 ? [`+${more}`] : [])].join(' · ')}`
151
152
  }
152
153
 
154
+ /** The header line of an event about one lane's agent (fill, model, parallel phases, task signal, standing still); null for any other. */
155
+ const laneEventLine = (event: WatchEvent): string | null => {
156
+ if (event.kind === WATCH_EVENT.laneFill)
157
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
158
+ if (event.kind === WATCH_EVENT.laneModel)
159
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
160
+ if (event.kind === WATCH_EVENT.laneParallel)
161
+ return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
162
+ if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
163
+ if (event.kind === WATCH_EVENT.laneStalled) return `=== ${terminalSafe(laneStalledLine(event))}`
164
+ return null
165
+ }
166
+
153
167
  /**
154
168
  * The header line of one event: `=== merged #<number> (lane)`, `=== checks failed #<number> (lane|branch <name>): <checks> (<k> tests: <names>)`,
155
169
  * the checks and tests only when the event carries them,
@@ -167,14 +181,9 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
167
181
  if (event.kind === WATCH_EVENT.ghUnavailable) return `=== ${event.kind}: ${terminalSafe(event.reason)}`
168
182
  if (event.kind === WATCH_EVENT.preview)
169
183
  return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.url)} · ${terminalSafe(event.headCommit.slice(0, SHORT_SHA_LENGTH))}`
170
- if (event.kind === WATCH_EVENT.laneFill)
171
- return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.level} ${contextLabel(event.share)}`
172
- if (event.kind === WATCH_EVENT.laneModel)
173
- return `=== ${event.kind} ${terminalSafe(event.lane)} ${terminalSafe(event.model)} · board ${terminalSafe(event.column)}`
174
- if (event.kind === WATCH_EVENT.laneParallel)
175
- return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
184
+ const laneLine = laneEventLine(event)
185
+ if (laneLine !== null) return laneLine
176
186
  if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
177
- if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
178
187
  if (event.kind === WATCH_EVENT.unlinkedArtifact) return `=== ${unlinkedArtifactLine(event)}`
179
188
  if (event.kind === WATCH_EVENT.approval)
180
189
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
@@ -363,3 +372,19 @@ export const armLine = (lanes: readonly string[], armed: LaneSnapshot, takeOver:
363
372
  'no lanes'
364
373
  return `armed: ${states}${takeOverNote(takeOver)} board ${terminalSafe(board)}`
365
374
  }
375
+
376
+ /**
377
+ * The `armed:` line a start prints, null when it would repeat the last start's (`previous`), and the line to remember:
378
+ * the one without a take-over note, so a start that replaced a watcher speaks and the one after it compares as usual.
379
+ */
380
+ export const armNotice = (
381
+ previous: string | null,
382
+ lanes: readonly string[],
383
+ armed: LaneSnapshot,
384
+ takeOver: TakeOver,
385
+ board: string,
386
+ ): { line: string | null; state: string } => {
387
+ const state = armLine(lanes, armed, NO_TAKE_OVER, board)
388
+ const line = armLine(lanes, armed, takeOver, board)
389
+ return { line: line === previous ? null : line, state }
390
+ }
@@ -0,0 +1,59 @@
1
+ import { MS_PER_MINUTE } from '../plan-format/clock'
2
+ import { isRecord } from '../shared/is-record'
3
+
4
+ export const LANE_STALLED_EVENT = 'stalled'
5
+
6
+ /** How many heartbeat intervals a working lane's position stands still before it is reported. */
7
+ export const STALLED_HEARTBEATS = 3
8
+
9
+ /** What a heartbeat read of a lane: whether its agent works, and its position as the heartbeat shows it (`P12 7/12, 3 subagents`, `''` without one). */
10
+ export type StallReading = { working: boolean; position: string }
11
+
12
+ /** Per working lane: the position last read, since when it stands, and whether that stretch was reported. */
13
+ export type StallState = Record<string, { position: string; since: number; reported: boolean }>
14
+
15
+ export type LaneStalledEvent = { kind: typeof LANE_STALLED_EVENT; lane: string; minutes: number }
16
+
17
+ type LaneEntry = StallState[string]
18
+
19
+ const nextEntry = (previous: LaneEntry | undefined, position: string, now: number): LaneEntry =>
20
+ previous?.position === position ? previous : { position, since: now, reported: false }
21
+
22
+ const standingMinutes = (entry: LaneEntry, now: number): number => Math.floor((now - entry.since) / MS_PER_MINUTE)
23
+
24
+ /**
25
+ * The working lanes whose position (phase, ticks, subagents) stood still for `limitMinutes` or longer, one event per
26
+ * stretch: a moved position starts the clock again and may be reported again, a lane that stops working or has no
27
+ * reading drops its clock.
28
+ */
29
+ export const laneStalledEvents = (
30
+ previous: StallState,
31
+ readings: Readonly<Record<string, StallReading>>,
32
+ now: number,
33
+ limitMinutes: number,
34
+ ): { events: LaneStalledEvent[]; state: StallState } => {
35
+ const events: LaneStalledEvent[] = []
36
+ const state: StallState = {}
37
+ for (const [lane, { working, position }] of Object.entries(readings)) {
38
+ if (!working) continue
39
+ const entry = nextEntry(previous[lane], position, now)
40
+ const minutes = standingMinutes(entry, now)
41
+ const due = !entry.reported && minutes >= limitMinutes
42
+ if (due) events.push({ kind: LANE_STALLED_EVENT, lane, minutes })
43
+ state[lane] = due ? { ...entry, reported: true } : entry
44
+ }
45
+ return { events, state }
46
+ }
47
+
48
+ /** The line the coordinator reads: `lane <name> stalled <n> min`. */
49
+ export const laneStalledLine = ({ lane, minutes }: LaneStalledEvent): string =>
50
+ `lane ${lane} ${LANE_STALLED_EVENT} ${minutes} min`
51
+
52
+ const isEntry = (value: unknown): boolean =>
53
+ isRecord(value) &&
54
+ typeof value.position === 'string' &&
55
+ typeof value.since === 'number' &&
56
+ typeof value.reported === 'boolean'
57
+
58
+ export const isStallState = (value: unknown): value is StallState =>
59
+ isRecord(value) && Object.values(value).every(isEntry)
@@ -35,3 +35,9 @@ export const streamHeartbeatLine = (
35
35
  ...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
36
36
  ].join(SEPARATOR)
37
37
  }
38
+
39
+ /** A heartbeat line without its head and time: what two heartbeats are compared by. */
40
+ export const heartbeatStateOf = (line: string): string => line.slice(line.indexOf(SEPARATOR) + SEPARATOR.length)
41
+
42
+ /** Whether a heartbeat brings news against the state of the last one sent (`heartbeatStateOf`); the first one always does. */
43
+ export const isHeartbeatNews = (previous: string | null, line: string): boolean => previous !== heartbeatStateOf(line)
@@ -14,6 +14,7 @@ import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackSt
14
14
  import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
15
15
  import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels } from './lane-model'
16
16
  import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
17
+ import { isStallState, LANE_STALLED_EVENT, type LaneStalledEvent, type StallState } from './lane-stalled'
17
18
  import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
18
19
  import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
19
20
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
@@ -57,6 +58,7 @@ export const WATCH_EVENT = {
57
58
  laneParallel: LANE_PARALLEL_EVENT,
58
59
  taskParallel: TASK_PARALLEL_EVENT,
59
60
  noTaskSignal: NO_TASK_SIGNAL_EVENT,
61
+ laneStalled: LANE_STALLED_EVENT,
60
62
  unlinkedArtifact: UNLINKED_ARTIFACT_EVENT,
61
63
  approval: APPROVAL_EVENT,
62
64
  source: SOURCE_EVENT,
@@ -116,6 +118,8 @@ export type WatchState = {
116
118
  parallelAutoEnded: string | null
117
119
  /** Per watched lane: since when it works without a task signal and the phase last reported as `no task signal`. */
118
120
  noTaskSignals: NoSignalState
121
+ /** Per working lane: its position at the last heartbeat, since when it stands and whether that was reported as `stalled`. */
122
+ laneStalls: StallState
119
123
  /** Per open artifact send no plan or board links: in its grace or already reported as `unlinked artifact`. */
120
124
  unlinkedArtifacts: UnlinkedState
121
125
  /** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
@@ -128,6 +132,10 @@ export type WatchState = {
128
132
  reportQuestions: Record<string, string>
129
133
  /** The lane lines of the status block last printed; null before the first report. */
130
134
  statusLines: string[] | null
135
+ /** The state of the last heartbeat a stream sent (`heartbeatStateOf`); null before the first one. */
136
+ heartbeat: string | null
137
+ /** The `armed:` line of the last start; a start that would print the same line stays silent. Null before the first. */
138
+ armed: string | null
131
139
  /** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
132
140
  appointments: AppointmentState
133
141
  }
@@ -175,6 +183,7 @@ export type WatchEvent =
175
183
  | LaneParallelEvent
176
184
  | TaskParallelEvent
177
185
  | NoTaskSignalEvent
186
+ | LaneStalledEvent
178
187
  | UnlinkedArtifactEvent
179
188
  | ApprovalEvent
180
189
  | SourceEvent
@@ -213,12 +222,15 @@ export const emptyWatchState = (): WatchState => ({
213
222
  taskParallels: {},
214
223
  parallelAutoEnded: null,
215
224
  noTaskSignals: {},
225
+ laneStalls: {},
216
226
  unlinkedArtifacts: {},
217
227
  approvals: [],
218
228
  sources: emptySourceState(),
219
229
  reportMtimes: {},
220
230
  reportQuestions: {},
221
231
  statusLines: null,
232
+ heartbeat: null,
233
+ armed: null,
222
234
  appointments: emptyAppointmentState(),
223
235
  })
224
236
 
@@ -263,6 +275,7 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
263
275
  isTaskParallelFingerprints(value.taskParallels) &&
264
276
  (value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
265
277
  isNoSignalState(value.noTaskSignals) &&
278
+ isStallState(value.laneStalls) &&
266
279
  isUnlinkedState(value.unlinkedArtifacts) &&
267
280
  Array.isArray(value.approvals) &&
268
281
  value.approvals.every((id) => typeof id === 'string') &&
@@ -271,6 +284,8 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
271
284
  everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
272
285
  (value.statusLines === null ||
273
286
  (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
287
+ (value.heartbeat === null || typeof value.heartbeat === 'string') &&
288
+ (value.armed === null || typeof value.armed === 'string') &&
274
289
  isAppointmentState(value.appointments)
275
290
 
276
291
  /**
@@ -18,6 +18,9 @@ export type CommandLineOf = (pid: number) => string | null
18
18
  /** What taking the lock did to a watcher that held it: stopped it, or found it still running after the wait. */
19
19
  export type TakeOver = { replaced: number | null; stillRunning: number | null }
20
20
 
21
+ /** A start that found no watcher to take over from. */
22
+ export const NO_TAKE_OVER: TakeOver = { replaced: null, stillRunning: null }
23
+
21
24
  /** The CLI's entry by source path or bin link (`jh`, and `jackherd` for `bunx jackherd`); `agent-kit` until 2.0.0, so a watcher of the old CLI is replaced. */
22
25
  const CLI_ENTRIES = ['cli.ts', CLI_NAME, PACKAGE_NAME, 'agent-kit']
23
26
  const WATCH_COMMAND = 'watch'
@@ -165,11 +168,11 @@ export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = reade
165
168
  export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
166
169
  const previous = supersedeTarget(readPrivateText(lockFile), process.pid, commandLineOf)
167
170
  writePrivateFile(lockFile, String(process.pid))
168
- if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
171
+ if (previous === null || !isAlive(previous)) return NO_TAKE_OVER
169
172
  try {
170
173
  process.kill(previous, 'SIGTERM')
171
174
  } catch {
172
- return { replaced: null, stillRunning: null }
175
+ return NO_TAKE_OVER
173
176
  }
174
177
  const deadline = Date.now() + STOP_WAIT_MS
175
178
  while (isAlive(previous) && Date.now() < deadline) await Bun.sleep(STOP_POLL_MS)