jackherd 1.12.2 → 1.12.4

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +16 -16
  3. package/dist/plan-format.d.ts +23 -11
  4. package/dist/plan-format.js +6 -3
  5. package/herdr-plugin/herdr-plugin.toml +1 -1
  6. package/install.sh +1 -1
  7. package/kit/workflow/skills/orchestrate/SKILL.md +7 -7
  8. package/package.json +1 -1
  9. package/src/context/session-log.ts +4 -6
  10. package/src/plan-format/phases.ts +22 -6
  11. package/src/plan-format/progress.ts +5 -8
  12. package/src/plans/command.ts +10 -3
  13. package/src/plans/current-task.ts +3 -7
  14. package/src/plans/focus-overlay.ts +80 -0
  15. package/src/plans/git.ts +6 -11
  16. package/src/plans/herd-state.ts +201 -0
  17. package/src/plans/lane-reason.ts +1 -1
  18. package/src/plans/lane-summary.ts +6 -2
  19. package/src/plans/layout-phase.ts +1 -3
  20. package/src/plans/layout-summary.ts +1 -1
  21. package/src/plans/layout.ts +32 -31
  22. package/src/plans/link-chips.ts +7 -16
  23. package/src/plans/live.ts +215 -267
  24. package/src/plans/markdown-text.ts +4 -0
  25. package/src/plans/page-sync.ts +39 -0
  26. package/src/plans/phase-source.ts +5 -2
  27. package/src/plans/reader.ts +217 -0
  28. package/src/plans/refresh-schedule.ts +53 -0
  29. package/src/plans/snapshot.ts +32 -31
  30. package/src/plans/status-pace.ts +7 -4
  31. package/src/plans/subagents-read.ts +28 -12
  32. package/src/plans/supervisor.ts +1 -1
  33. package/src/plans/view.ts +9 -101
  34. package/src/plans/waiting-click.ts +27 -17
  35. package/src/plans/worked-phases.ts +1 -1
  36. package/src/plans/worktree-stamp.ts +12 -9
  37. package/src/plans/your-turn-rows.ts +33 -32
  38. package/src/plans/your-turn.ts +6 -4
  39. package/src/shared/bounded-memo.ts +19 -0
  40. package/src/shared/stat-cache.ts +2 -1
  41. package/src/terminal/lifetime.ts +52 -0
  42. package/src/watch/command.ts +21 -10
  43. package/src/watch/herd-publisher.ts +84 -0
  44. package/src/watch/lane-report.ts +17 -1
  45. package/src/watch/source-entries.ts +7 -0
  46. package/src/watch/source-statuses.ts +2 -2
  47. package/src/watch/sources.ts +5 -2
  48. package/src/watch/stream-output.ts +6 -0
  49. package/src/watch/watch-events.ts +8 -0
  50. package/src/watch/watcher-lock.ts +5 -2
  51. package/src/plans/next-rows.ts +0 -12
@@ -0,0 +1,39 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { errorText } from './error-text'
3
+ import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting } from './plan-chips'
4
+ import type { Snapshot, Source } from './snapshot'
5
+ import { writeSprintPageInSteps } from './sprint-export'
6
+ import { boardTexts } from './texts'
7
+
8
+ /** The sprint page last written, a write still running, and why the last one failed. */
9
+ export type PageState = { page: KnownPage | null; writing: boolean; error: string | null }
10
+
11
+ export const initialPageState = (): PageState => ({ page: null, writing: false, error: null })
12
+
13
+ /**
14
+ * Writes the sprint page, so a plan chip's link points at a file that exists. Only when the plans `read` holds changed
15
+ * or the file is gone: building it takes seconds, with the event loop blocked for all of them. `whole` gives the whole
16
+ * board to build it from, `read` itself unless it shows one lane. True when it wrote or failed, so the owner draws again.
17
+ */
18
+ export const syncSprintPage = async (
19
+ state: PageState,
20
+ source: Source,
21
+ read: Snapshot,
22
+ whole: () => Promise<Snapshot>,
23
+ ): Promise<boolean> => {
24
+ const key = pageKey(read)
25
+ if (state.writing || !pageNeedsWriting(state.page, key, state.page !== null && existsSync(state.page.path))) {
26
+ return false
27
+ }
28
+ state.writing = true
29
+ try {
30
+ const written = await writeSprintPageInSteps(source, await whole())
31
+ state.error = 'error' in written ? boardTexts(source.settings.board.term).pageNotWritten(written.error) : null
32
+ state.page = pageAfterWrite(state.page, written, key)
33
+ } catch (error) {
34
+ state.error = boardTexts(source.settings.board.term).pageNotWritten(errorText(error))
35
+ } finally {
36
+ state.writing = false
37
+ }
38
+ return true
39
+ }
@@ -1,4 +1,4 @@
1
- import type { PlanPhase } from '../plan-format'
1
+ import { PHASE_WORK, type PlanPhase, phaseWorkFields } from '../plan-format'
2
2
 
3
3
  /** What a lane has done in its own worktree: commits ahead of the base ref (null when unknown) and changed files. */
4
4
  export type OwnWork = { ahead: number | null; dirty: number }
@@ -56,7 +56,10 @@ export const newerPhases = (base: readonly PlanPhase[], copy: readonly PlanPhase
56
56
  */
57
57
  const markTouched = (phase: PlanPhase, basePhase: PlanPhase | undefined): PlanPhase => {
58
58
  if (basePhase && sameTicks(phase, basePhase)) return phase
59
- return basePhase && tickCount(basePhase) > 0 ? { ...phase, touched: true, merged: true } : { ...phase, touched: true }
59
+ return {
60
+ ...phase,
61
+ ...phaseWorkFields(basePhase && tickCount(basePhase) > 0 ? PHASE_WORK.merged : PHASE_WORK.touched),
62
+ }
60
63
  }
61
64
 
62
65
  /**
@@ -0,0 +1,217 @@
1
+ import { type FSWatcher, watch } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { stampOf } from '../shared/stat-cache'
4
+ import type { LaneMemory } from './current-phase'
5
+ import { errorText } from './error-text'
6
+ import {
7
+ type FetchState,
8
+ fetchBase,
9
+ fetchNotice,
10
+ fetchPlanHeads,
11
+ goneLanePullRequests,
12
+ initialFetchState,
13
+ shouldFetch,
14
+ watchedFiles,
15
+ } from './fetch'
16
+ import { fetchHeadPath } from './git'
17
+ import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
18
+ import { recallLanes, rememberLanes } from './herd-memory'
19
+ import { planPullRequests } from './plan-heads'
20
+ import { newReadCache, type ReadCache } from './read-cache'
21
+ import { quietRoundsAfter, REFRESH_PACE, refreshDelay } from './refresh-pace'
22
+ import { createRefreshSchedule, type RefreshSchedule } from './refresh-schedule'
23
+ import { BASE_REMOTE, baseRefOf, CLOCK_LOCALE } from './settings'
24
+ import { singleFlight } from './single-flight'
25
+ import { loadSnapshot, type Snapshot, type Source } from './snapshot'
26
+ import { isOwnFetchWrite } from './watch-burst'
27
+
28
+ /** What one read gave: the snapshot, or why it failed. */
29
+ export type ReadOutcome = { read: Snapshot } | { error: string }
30
+
31
+ /** What the reader's owner does with a read; true when it changed what the owner shows, which keeps the pace up. */
32
+ export type OnRead = (outcome: ReadOutcome) => boolean
33
+
34
+ /** A running reader: what it knows besides the snapshot, a read asked for at once, and its end. */
35
+ export type Reader = {
36
+ /** The pull requests of the last GitHub read. */
37
+ pullRequests: () => PullRequestState
38
+ /** The footer line after a failed fetch of the base branch, null while the last one worked. */
39
+ fetchLine: () => string | null
40
+ /** Reads soon, bundled with other asks (`RefreshSchedule`). */
41
+ schedule: () => void
42
+ stop: () => void
43
+ }
44
+
45
+ /** The state of a running reader; only the functions below `startReader` change it. */
46
+ type ReaderState = {
47
+ source: Source
48
+ onRead: OnRead
49
+ pullRequests: PullRequestState
50
+ fetch: FetchState
51
+ watchers: Map<string, FSWatcher>
52
+ /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
53
+ fetchHead: string | null
54
+ /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
55
+ memories: Map<string, LaneMemory>
56
+ /** Session logs, plans and git answers kept from the last read while they did not change. */
57
+ cache: ReadCache
58
+ flight: (() => Promise<void>) | null
59
+ /** The reads in a row that changed nothing the owner shows; the next one waits `refreshDelay` of it. */
60
+ quietRounds: number
61
+ stopped: boolean
62
+ }
63
+
64
+ const PULL_REQUEST_REFRESH_MS = 60_000
65
+
66
+ /** Where the hooks write what agents report (`jh task`, open questions, sent files): a change there is read at once. */
67
+ const RECORDS_DIRECTORY = '.jackherd'
68
+
69
+ /** When `file` was last written (epoch milliseconds); 0 when it cannot be stat-ed, which no fetch of the herd claims. */
70
+ const modifiedAt = (file: string): number => stampOf(file)?.mtimeMs ?? 0
71
+
72
+ const syncWatchers = (watchers: Map<string, FSWatcher>, files: string[], onChange: (file: string) => void) => {
73
+ for (const [file, watcher] of watchers) {
74
+ if (files.includes(file)) continue
75
+ watcher.close()
76
+ watchers.delete(file)
77
+ }
78
+ for (const file of files.filter((candidate) => !watchers.has(candidate))) {
79
+ try {
80
+ const watcher = watch(file, (event) => {
81
+ if (event === 'rename') {
82
+ watcher.close()
83
+ watchers.delete(file)
84
+ }
85
+ onChange(file)
86
+ })
87
+ watchers.set(file, watcher)
88
+ } catch {
89
+ // A file that cannot be watched right now is retried on the next refresh, which also covers it meanwhile.
90
+ }
91
+ }
92
+ }
93
+
94
+ /** Watches the hooks' records below the checkout, so a reported task or question shows without waiting for the pace. */
95
+ const watchRecords = (root: string, onChange: () => void): FSWatcher | null => {
96
+ try {
97
+ return watch(join(root, RECORDS_DIRECTORY), { recursive: true }, onChange)
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ const readOnce = async (state: ReaderState, schedule: RefreshSchedule) => {
104
+ schedule.refreshStarted()
105
+ let outcome: ReadOutcome
106
+ try {
107
+ const read = await loadSnapshot(state.source, state.pullRequests, state.memories, state.cache)
108
+ rememberLanes(state.source.root, state.memories, read.documents.laneOrder)
109
+ if (!state.stopped) syncWatchers(state.watchers, watchedFiles(read.files, state.fetchHead), schedule.onFileChange)
110
+ outcome = { read }
111
+ } catch (error) {
112
+ outcome = { error: errorText(error) }
113
+ }
114
+ if (state.stopped) return
115
+ state.quietRounds = quietRoundsAfter(state.quietRounds, state.onRead(outcome))
116
+ }
117
+
118
+ /** A read never overlaps another: one asked for meanwhile runs once after the running one, so no older read wins. */
119
+ const refresh = (state: ReaderState, schedule: RefreshSchedule): Promise<void> => {
120
+ state.flight ??= singleFlight(() => readOnce(state, schedule))
121
+ return state.flight()
122
+ }
123
+
124
+ /**
125
+ * Reads the board, its plans and the lanes' state for `source` and hands each read to `onRead`: at once, then at the
126
+ * pace of `refreshDelay` (faster while reads change what the owner shows), at once when a watched plan file, this
127
+ * clone's `FETCH_HEAD` or a hook record changes, and after a fetch of the base branch. It reads GitHub's pull requests
128
+ * every minute and fetches the base branch when due, both as the herd always did. The herd runs one for itself, the
129
+ * watcher one for every herd of the board.
130
+ */
131
+ export const startReader = (source: Source, onRead: OnRead): Reader => {
132
+ /** The lanes' branches at the last read: a pull request of one of them that is gone asks for a fetch. */
133
+ const laneBranches = new Set<string>()
134
+ const state: ReaderState = {
135
+ source,
136
+ onRead: (outcome) => {
137
+ if ('read' in outcome) {
138
+ laneBranches.clear()
139
+ for (const { status } of outcome.read.views) if (status.branch) laneBranches.add(status.branch)
140
+ }
141
+ return onRead(outcome)
142
+ },
143
+ pullRequests: noPullRequests(),
144
+ fetch: initialFetchState(),
145
+ watchers: new Map(),
146
+ fetchHead: fetchHeadPath(source.root),
147
+ memories: recallLanes(source.root),
148
+ cache: newReadCache(),
149
+ flight: null,
150
+ quietRounds: 0,
151
+ stopped: false,
152
+ }
153
+ const timers: { poll: Timer | null } = { poll: null }
154
+ const schedule: RefreshSchedule = createRefreshSchedule({
155
+ now: Date.now,
156
+ setTimer: (run, delayMs) => void setTimeout(() => state.stopped || run(), delayMs),
157
+ refresh: () => void refresh(state, schedule),
158
+ isOwnFetchWrite: (file) => file === state.fetchHead && isOwnFetchWrite(modifiedAt(file), state.fetch),
159
+ })
160
+ const fetchBaseBranch = async () => {
161
+ state.fetch = { ...state.fetch, lastAttempt: Date.now(), running: true }
162
+ const failure = await fetchBase(source.root, BASE_REMOTE, source.settings.baseBranch)
163
+ const planHeads = planPullRequests([...state.pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
164
+ if (failure === null) await fetchPlanHeads(source.root, BASE_REMOTE, planHeads)
165
+ state.fetch = {
166
+ ...state.fetch,
167
+ running: false,
168
+ failure,
169
+ lastSuccess: failure === null ? new Date() : state.fetch.lastSuccess,
170
+ }
171
+ schedule.schedule()
172
+ }
173
+ /** Starts a fetch when one is due; true when it did, whose end then refreshes. */
174
+ const fetchWhenDue = (pullRequestGone: boolean): boolean => {
175
+ const due = shouldFetch({
176
+ now: Date.now(),
177
+ state: state.fetch,
178
+ intervalMinutes: source.settings.fetchIntervalMinutes,
179
+ pullRequestGone,
180
+ })
181
+ if (due) void fetchBaseBranch()
182
+ return due
183
+ }
184
+ const refreshPullRequests = async () => {
185
+ const previous = state.pullRequests
186
+ state.pullRequests = await fetchPullRequests(source.root)
187
+ if (state.stopped) return
188
+ const readable = previous.errors.length === 0 && state.pullRequests.errors.length === 0
189
+ const gone = goneLanePullRequests(previous.byBranch, state.pullRequests.byBranch, laneBranches)
190
+ if (!fetchWhenDue(readable && gone.length > 0)) schedule.schedule()
191
+ }
192
+ const poll = () => {
193
+ if (state.stopped) return
194
+ fetchWhenDue(false)
195
+ void refresh(state, schedule).finally(() => {
196
+ if (!state.stopped) timers.poll = setTimeout(poll, refreshDelay(state.quietRounds))
197
+ })
198
+ }
199
+ const records = watchRecords(source.root, () => schedule.schedule())
200
+ timers.poll = setTimeout(poll, REFRESH_PACE.baseMs)
201
+ const pullRequestTimer = setInterval(refreshPullRequests, PULL_REQUEST_REFRESH_MS)
202
+ void refresh(state, schedule)
203
+ void refreshPullRequests()
204
+ return {
205
+ pullRequests: () => state.pullRequests,
206
+ fetchLine: () => fetchNotice(state.fetch, baseRefOf(source.settings), CLOCK_LOCALE),
207
+ schedule: () => schedule.schedule(),
208
+ stop: () => {
209
+ state.stopped = true
210
+ if (timers.poll) clearTimeout(timers.poll)
211
+ clearInterval(pullRequestTimer)
212
+ records?.close()
213
+ for (const watcher of state.watchers.values()) watcher.close()
214
+ state.watchers.clear()
215
+ },
216
+ }
217
+ }
@@ -0,0 +1,53 @@
1
+ import { watchRefreshDelay } from './watch-burst'
2
+
3
+ export type RefreshScheduleDeps = {
4
+ /** The clock, epoch milliseconds. */
5
+ now: () => number
6
+ /** Runs `run` once after `delayMs`. */
7
+ setTimer: (run: () => void, delayMs: number) => void
8
+ /** Starts the refresh; it tells `refreshStarted` when it begins reading. */
9
+ refresh: () => void
10
+ /** Whether a write to `file` came from the herd's own fetch, whose end refreshes once on its own. */
11
+ isOwnFetchWrite: (file: string) => boolean
12
+ }
13
+
14
+ export type RefreshSchedule = {
15
+ /** A refresh asked for outside the pace, bundled by `watchRefreshDelay`: one already waiting covers the ask. */
16
+ schedule: () => void
17
+ /** A watched file changed; the herd's own fetch writing `FETCH_HEAD` asks nothing. */
18
+ onFileChange: (file: string) => void
19
+ /** A refresh began reading; the asks that follow wait for the end of its window. */
20
+ refreshStarted: () => void
21
+ }
22
+
23
+ export const createRefreshSchedule = ({
24
+ now,
25
+ setTimer,
26
+ refresh,
27
+ isOwnFetchWrite,
28
+ }: RefreshScheduleDeps): RefreshSchedule => {
29
+ let waiting = false
30
+ let lastRefreshAt: number | null = null
31
+
32
+ const schedule = () => {
33
+ if (waiting) return
34
+ waiting = true
35
+ setTimer(
36
+ () => {
37
+ waiting = false
38
+ refresh()
39
+ },
40
+ watchRefreshDelay(lastRefreshAt, now()),
41
+ )
42
+ }
43
+
44
+ return {
45
+ schedule,
46
+ onFileChange: (file) => {
47
+ if (!isOwnFetchWrite(file)) schedule()
48
+ },
49
+ refreshStarted: () => {
50
+ lastRefreshAt = now()
51
+ },
52
+ }
53
+ }
@@ -36,17 +36,7 @@ import {
36
36
  } from './current-phase'
37
37
  import { activityTextOf, taskSignalsOf } from './current-task'
38
38
  import { errorText } from './error-text'
39
- import {
40
- type BaseReader,
41
- type BranchStatusReader,
42
- baseReaderAt,
43
- branchStatusAt,
44
- gitStatusOf,
45
- lastChangesAt,
46
- refCommits,
47
- refExists,
48
- resolveCommit,
49
- } from './git'
39
+ import { type BaseReader, baseReaderAt, gitStatusOf, lastChangesAt, refCommits, refExists, resolveCommit } from './git'
50
40
  import type { PullRequestState } from './github'
51
41
  import { groomedDaysAgo } from './groomed'
52
42
  import {
@@ -77,7 +67,7 @@ import { readAllSignalsSliced } from './signals-read'
77
67
  import { createSliceGate, type SliceGate } from './slice-gate'
78
68
  import { withSourcePrefix } from './source-prefix'
79
69
  import type { HerdSource, SourceBlock } from './source-rows'
80
- import { type KeptStatus, logMark, pacedStatus } from './status-pace'
70
+ import { type KeptStatus, logMark, pacedStatus, type WorktreeStatus } from './status-pace'
81
71
  import type { SubagentActivity } from './subagents'
82
72
  import { readSubagentActivitySliced, subagentSourceOf } from './subagents-read'
83
73
  import { boardTexts } from './texts'
@@ -128,6 +118,8 @@ export type Snapshot = {
128
118
  planned: PlannedPlan[]
129
119
  files: string[]
130
120
  titles: Map<string, string | null>
121
+ /** The pane of each lane whose agent has one, by lane name: which of `titles` a lane's own herd sets. */
122
+ panes?: Record<string, string>
131
123
  errors: string[]
132
124
  /**
133
125
  * The watcher's health; absent when no lane has an open pull request and no source is marked `ticket`, so the
@@ -171,8 +163,10 @@ type LaneSources = {
171
163
  parsed?: PlanSources['parsed']
172
164
  /** The gate each lane passes before a piece of synchronous work, so the lanes take turns with keys and drawing. */
173
165
  gate: SliceGate
174
- /** Each worktree's last `git status`, kept between the herd's reads; absent for a single read, which asks git. */
166
+ /** Each worktree's last git answer, kept between the herd's reads; absent for a single read, which asks git. */
175
167
  statuses?: Map<string, KeptStatus>
168
+ /** The base ref's commit at this read, null when it does not resolve: a kept answer from another base is asked again. */
169
+ baseCommit: string | null
176
170
  /** When this read happens, epoch milliseconds. */
177
171
  now: number
178
172
  }
@@ -248,28 +242,27 @@ const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: Lane
248
242
  }
249
243
 
250
244
  /**
251
- * `git status` of a lane's worktree asked only when something there may have moved: its index or HEAD, its agent's
252
- * log or its subagents' work (`worktreeStamp`), or the kept answer is old; git every time without kept statuses.
245
+ * `git status` and the commits ahead of the base of a lane's worktree, asked only when something there may have moved:
246
+ * its index, HEAD or commits, the base's commit, its agent's log or its subagents' work (`worktreeStamp`), or the kept
247
+ * answer is old; git every time without kept statuses.
253
248
  */
254
- const pacedStatusReader = (sources: LaneSources, name: string, agent: LaneAgent | undefined): BranchStatusReader => {
255
- const { statuses, now } = sources
256
- if (!statuses) return branchStatusAt
257
- return (worktree, pass) => {
258
- const activity = `${logMark(agent ? sources.readLog(agent) : null)}|${sources.subagents.get(name)?.activity ?? ''}`
259
- const ask = { worktree, stamp: worktreeStamp(worktree, activity), now }
260
- return pacedStatus(statuses, ask, () => branchStatusAt(worktree, pass))
261
- }
249
+ const pacedGitStatus = (sources: LaneSources, name: string, agent: LaneAgent | undefined): Promise<WorktreeStatus> => {
250
+ const { source, statuses, now, baseCommit, gate } = sources
251
+ const worktree = worktreeOf(source.root, source.settings, name)
252
+ const read = () => gitStatusOf(worktree, baseRefOf(source.settings), gate.pass)
253
+ if (!statuses) return read()
254
+ const activity = [
255
+ baseCommit ?? '',
256
+ logMark(agent ? sources.readLog(agent) : null),
257
+ sources.subagents.get(name)?.activity ?? '',
258
+ ].join('|')
259
+ return pacedStatus(statuses, { worktree, stamp: worktreeStamp(worktree, activity), now }, read)
262
260
  }
263
261
 
264
262
  /** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
265
263
  const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
266
264
  const { source, pullRequests } = sources
267
- const gitStatus = await gitStatusOf(
268
- worktreeOf(source.root, source.settings, name),
269
- baseRefOf(source.settings),
270
- sources.gate.pass,
271
- pacedStatusReader(sources, name, agent),
272
- )
265
+ const gitStatus = await pacedGitStatus(sources, name, agent)
273
266
  await sources.gate.pass()
274
267
  const subagent = agent ? undefined : sources.subagents.get(name)
275
268
  return {
@@ -531,6 +524,7 @@ const laneSetOf = async (
531
524
  source: Source,
532
525
  context: {
533
526
  base: BaseReader
527
+ baseCommit: string | null
534
528
  board: BoardLanes
535
529
  sprintDirectory: string
536
530
  worktrees: string
@@ -540,7 +534,7 @@ const laneSetOf = async (
540
534
  pullRequests: PullRequestState,
541
535
  now: number,
542
536
  ): Promise<LaneSet> => {
543
- const { base, board, sprintDirectory, worktrees, cache, gate } = context
537
+ const { base, baseCommit, board, sprintDirectory, worktrees, cache, gate } = context
544
538
  await gate.pass()
545
539
  const { agents, errors } = await readAgents(source)
546
540
  const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
@@ -567,6 +561,7 @@ const laneSetOf = async (
567
561
  parsed: cache && { entries: cache.plans, used: new Set<string>() },
568
562
  gate,
569
563
  statuses: cache?.statuses,
564
+ baseCommit,
570
565
  now,
571
566
  },
572
567
  errors,
@@ -674,7 +669,12 @@ export const loadSnapshot = async (
674
669
  const worktrees = join(root, settings.worktreeDirectory)
675
670
  const now = Date.now()
676
671
  const liveBoard = board.liveMarkdown
677
- const laneSet = await laneSetOf(source, { base, board, sprintDirectory, worktrees, cache, gate }, pullRequests, now)
672
+ const laneSet = await laneSetOf(
673
+ source,
674
+ { base, baseCommit, board, sprintDirectory, worktrees, cache, gate },
675
+ pullRequests,
676
+ now,
677
+ )
678
678
  const results = await laneResultsOf(laneSet, fallbacks, now, memories)
679
679
  const { parsed } = laneSet.sources
680
680
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
@@ -738,6 +738,7 @@ export const loadSnapshot = async (
738
738
  pane ? [[pane, signalledTitle(view.summary?.signalled ?? null, shortTitle(view.progress))]] : [],
739
739
  ),
740
740
  ),
741
+ panes: Object.fromEntries(results.flatMap(({ view, pane }) => (pane ? [[view.progress.lane.name, pane]] : []))),
741
742
  errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
742
743
  watch: reading?.health,
743
744
  waiting,
@@ -3,8 +3,11 @@ import type { LaneStatus } from './layout'
3
3
  /** What `git status` tells the herd about a worktree. */
4
4
  export type BranchStatus = Pick<LaneStatus, 'branch' | 'dirty'>
5
5
 
6
- /** The last `git status` of a worktree, with the stamp of the worktree it was asked at and when (epoch milliseconds). */
7
- export type KeptStatus = { stamp: string; at: number; status: BranchStatus }
6
+ /** What git tells the herd about a worktree: `git status`, and the commits ahead of the base (`rev-list`). */
7
+ export type WorktreeStatus = Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>
8
+
9
+ /** The last git answer for a worktree, with the stamp of the worktree it was asked at and when (epoch milliseconds). */
10
+ export type KeptStatus = { stamp: string; at: number; status: WorktreeStatus }
8
11
 
9
12
  /** Where and when a status is asked: the worktree, its stamp (`worktreeStamp`) and the time. */
10
13
  export type StatusAsk = { worktree: string; stamp: string; now: number }
@@ -35,8 +38,8 @@ export const logMark = (lines: readonly string[] | null): string => {
35
38
  export const pacedStatus = async (
36
39
  kept: Map<string, KeptStatus>,
37
40
  ask: StatusAsk,
38
- read: () => Promise<BranchStatus>,
39
- ): Promise<BranchStatus> => {
41
+ read: () => Promise<WorktreeStatus>,
42
+ ): Promise<WorktreeStatus> => {
40
43
  const previous = kept.get(ask.worktree)
41
44
  if (isFresh(previous, ask)) return previous.status
42
45
  const status = await read()
@@ -2,6 +2,8 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defaultProjectsDirectory, followTailLines, projectDirectoryOf } from '../context/session-log'
4
4
  import { MS_PER_MINUTE } from '../plan-format/clock'
5
+ import { boundedMemo } from '../shared/bounded-memo'
6
+ import { stampOf } from '../shared/stat-cache'
5
7
  import { DEFAULT_SKILL_PREFIX } from '../sync/init'
6
8
  import { readApprovalRequests } from '../watch/approvals'
7
9
  import {
@@ -51,7 +53,7 @@ const directoriesOf = (path: string): string[] => {
51
53
  /** How many directories keep their listing: one `subagents` directory per session, a few hundred in a busy project. */
52
54
  const LISTED_DIRECTORIES = 2048
53
55
 
54
- const listings = new Map<string, { modifiedAt: number; names: string[] }>()
56
+ const listings = boundedMemo<string, { modifiedAt: number; names: string[] }>(LISTED_DIRECTORIES)
55
57
 
56
58
  /**
57
59
  * The entry names of `path`, listed again only when the directory changed since (a file added or removed moves its
@@ -62,10 +64,7 @@ const namesIn = (path: string): string[] => {
62
64
  const modifiedAt = statSync(path).mtimeMs
63
65
  const kept = listings.get(path)
64
66
  if (kept?.modifiedAt === modifiedAt) return kept.names
65
- const names = readdirSync(path)
66
- if (listings.size >= LISTED_DIRECTORIES) listings.clear()
67
- listings.set(path, { modifiedAt, names })
68
- return names
67
+ return listings.set(path, { modifiedAt, names: readdirSync(path) }).names
69
68
  } catch {
70
69
  return []
71
70
  }
@@ -90,9 +89,24 @@ const logsInSession = (directory: string, now: number): SubagentLog[] =>
90
89
  return id ? logOf(directory, name, id, now) : []
91
90
  })
92
91
 
93
- /** The `subagents` directory of every session in one project directory. */
94
- const sessionDirectoriesOf = (project: string): string[] =>
95
- directoriesOf(project).map((session) => join(project, session, 'subagents'))
92
+ /**
93
+ * How long a session's own log may be untouched before its subagents are no longer looked for: a subagent runs while
94
+ * its session does, and a day is far beyond any subagent's run. Without it, every read checked each log of every past
95
+ * session (about 2,000 in a busy project), when none of them could be running.
96
+ */
97
+ export const QUIET_SESSION_MS = 24 * 60 * MS_PER_MINUTE
98
+
99
+ /** Whether the session's own log was written within `QUIET_SESSION_MS`; a session without a readable log counts as live. */
100
+ const isSessionLive = (project: string, session: string, now: number): boolean => {
101
+ const stamp = stampOf(join(project, `${session}.jsonl`))
102
+ return stamp === null || now - stamp.mtimeMs <= QUIET_SESSION_MS
103
+ }
104
+
105
+ /** The `subagents` directory of every session in one project directory that may still run one. */
106
+ const sessionDirectoriesOf = (project: string, now: number): string[] =>
107
+ directoriesOf(project)
108
+ .filter((session) => isSessionLive(project, session, now))
109
+ .map((session) => join(project, session, 'subagents'))
96
110
 
97
111
  /**
98
112
  * The project directories Claude Code keeps for sessions started in the main checkout `root` and, when
@@ -105,8 +119,10 @@ const projectDirectoriesOf = (projects: string, root: string, worktreeDirectory?
105
119
  }
106
120
 
107
121
  /** The `subagents` directory of every session of the main checkout and its worktrees. */
108
- const allSessionDirectories = (projects: string, root: string, worktreeDirectory?: string): string[] =>
109
- projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) => sessionDirectoriesOf(join(projects, name)))
122
+ const allSessionDirectories = (projects: string, root: string, now: number, worktreeDirectory?: string): string[] =>
123
+ projectDirectoriesOf(projects, root, worktreeDirectory).flatMap((name) =>
124
+ sessionDirectoriesOf(join(projects, name), now),
125
+ )
110
126
 
111
127
  /**
112
128
  * The subagents with a recently written log in the sessions started in the main checkout `root` and its worktrees,
@@ -119,7 +135,7 @@ export const readSubagentLogs = (
119
135
  now: number,
120
136
  worktreeDirectory?: string,
121
137
  ): SubagentLog[] =>
122
- allSessionDirectories(projects, root, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
138
+ allSessionDirectories(projects, root, now, worktreeDirectory).flatMap((directory) => logsInSession(directory, now))
123
139
 
124
140
  /** The names the gate holds an open request under, as the watcher reads them. */
125
141
  const heldNames = (root: string, now: number): Set<string> =>
@@ -160,7 +176,7 @@ export const readSubagentActivitySliced = async (
160
176
  now: number = Date.now(),
161
177
  ): Promise<Map<string, SubagentActivity>> => {
162
178
  const logs: SubagentLog[] = []
163
- for (const directory of allSessionDirectories(source.projects, source.root, source.worktreeDirectory)) {
179
+ for (const directory of allSessionDirectories(source.projects, source.root, now, source.worktreeDirectory)) {
164
180
  await pass()
165
181
  logs.push(...logsInSession(directory, now))
166
182
  }
@@ -2,12 +2,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
2
2
  import { watch } from 'node:fs'
3
3
  import { dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { TERMINAL_OFF } from '../terminal/lifetime'
5
6
  import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
7
  import type { StartedKit } from '../version/started-kit'
7
8
  import { STOP_REQUEST, type Stopper, stopperOf } from './child-stop'
8
9
  import { HERD_COMMAND } from './herd-command'
9
10
  import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
10
- import { TERMINAL_OFF } from './view'
11
11
 
12
12
  const FORWARDED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const
13
13