spexcode 0.5.9 → 0.6.1

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 (58) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +69 -70
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/delivery-queue.ts +107 -0
  8. package/spec-cli/src/doctor.ts +2 -1
  9. package/spec-cli/src/file-write.ts +22 -0
  10. package/spec-cli/src/git.ts +30 -8
  11. package/spec-cli/src/graphStream.ts +79 -11
  12. package/spec-cli/src/harness.ts +12 -10
  13. package/spec-cli/src/help.ts +9 -14
  14. package/spec-cli/src/index.ts +10 -2
  15. package/spec-cli/src/init.ts +8 -17
  16. package/spec-cli/src/layout.ts +35 -7
  17. package/spec-cli/src/materialize.ts +150 -107
  18. package/spec-cli/src/plugin-harness.ts +19 -8
  19. package/spec-cli/src/process-identity.ts +13 -0
  20. package/spec-cli/src/reviews.ts +55 -11
  21. package/spec-cli/src/session-cursors.ts +11 -17
  22. package/spec-cli/src/session-follow.ts +6 -6
  23. package/spec-cli/src/sessions.ts +224 -38
  24. package/spec-cli/src/uninstall.ts +2 -1
  25. package/spec-cli/templates/hooks/post-checkout +3 -0
  26. package/spec-cli/templates/hooks/post-merge +1 -0
  27. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  28. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
  29. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
  30. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  31. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  32. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  33. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  34. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  35. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  36. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  37. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  38. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  39. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  40. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  41. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  42. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  43. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  44. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  45. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  46. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  47. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  48. package/spec-dashboard/dist/index.html +2 -2
  49. package/spec-dashboard/src/reviewFilters.js +5 -0
  50. package/spec-dashboard/src/session.js +5 -1
  51. package/spec-eval/src/cli.ts +7 -8
  52. package/spec-eval/src/evaltab.ts +25 -5
  53. package/spec-eval/src/freshness.ts +52 -8
  54. package/spec-eval/src/scenariofresh.ts +58 -8
  55. package/spec-eval/src/scenarios.ts +60 -12
  56. package/spec-eval/src/sessioneval.ts +122 -30
  57. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  58. package/spec-eval/src/matrix.ts +0 -693
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { listSessions } from './sessions.js'
3
3
  import { getBoard, getBoardForForgeRevision } from './graphCache.js'
4
- import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
4
+ import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
5
5
  import { evalTimeline } from '../../spec-eval/src/evaltab.js'
6
6
  import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
7
7
  import { issueStores as issueStoresForReview } from './issues.js'
@@ -217,15 +217,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
217
217
 
218
218
  const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
219
219
 
220
- function evalNeighbor(item: any): EvalNeighbor {
220
+ function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
221
221
  return {
222
222
  node: String(item.node),
223
223
  scenario: String(item.scenario),
224
- state: String(item.state ?? evalReviewState(item)),
224
+ state: stateOf(item),
225
225
  }
226
226
  }
227
227
 
228
- export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario: string, want = 5) {
228
+ // `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
229
+ // was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
230
+ // states cover the selected row and its window — which is exactly why the state is looked up rather than
231
+ // carried: a row this response does not render contributes its POSITION and nothing else.
232
+ export function boundedEvalNeighbors(
233
+ sequence: { node: string; scenario: string }[],
234
+ node: string,
235
+ scenario: string,
236
+ stateOf: (row: { node: string; scenario: string }) => string,
237
+ want = 5,
238
+ ) {
239
+ const items = sequence
229
240
  const key = `${node}\0${scenario}`
230
241
  const index = items.findIndex((item) => evalItemKey(item) === key)
231
242
  if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
@@ -235,8 +246,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
235
246
  const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
236
247
  const prevN = Math.min(before, take - nextN)
237
248
  return {
238
- prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
239
- next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
249
+ prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
250
+ next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
240
251
  total: items.length,
241
252
  index,
242
253
  order: 'default' as const,
@@ -248,12 +259,22 @@ export function projectEvalDetail(
248
259
  historySource: ReviewItem[],
249
260
  node: string,
250
261
  scenario: string,
251
- metadata: { scope?: string | null; summary?: SessionEvals['summary']; evalRevision?: SessionEvals['evalRevision'] } = {},
262
+ metadata: {
263
+ scope?: string | null
264
+ summary?: SessionEvals['summary']
265
+ evalRevision?: SessionEvals['evalRevision']
266
+ // the scope's whole measured sequence, when `items` deliberately holds only the rendered window
267
+ sequence?: { node: string; scenario: string }[]
268
+ } = {},
252
269
  ): EvalDetailReview {
253
270
  const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
254
271
  const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
255
272
  const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
256
- const neighbors = boundedEvalNeighbors(results, node, scenario)
273
+ const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
274
+ const sequence = metadata.sequence
275
+ ?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
276
+ const neighbors = boundedEvalNeighbors(sequence, node, scenario,
277
+ (row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
257
278
  const scope = metadata.scope ?? null
258
279
  return {
259
280
  scope,
@@ -266,15 +287,38 @@ export function projectEvalDetail(
266
287
  }
267
288
  }
268
289
 
290
+ // the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
291
+ // and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
292
+ // actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
293
+ export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
294
+ return order.filter((row) => row.ts)
295
+ .sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
296
+ || a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
297
+ .map((row) => ({ node: row.node, scenario: row.scenario }))
298
+ }
299
+
300
+ // the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
301
+ // contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
302
+ export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
303
+ const sequence = measuredSequence(order)
304
+ const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
305
+ if (index < 0) return [node]
306
+ return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
307
+ }
308
+
269
309
  export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
270
310
  if (scope) {
271
- const model = await buildSessionEvals(scope)
311
+ // A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
312
+ // So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
313
+ // a build that finds a full cached model ignores the pick and answers from it instead.
314
+ const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
272
315
  if (!model) return null
273
316
  const sourceNode = model.nodes.find((candidate) => candidate.id === node)
274
317
  return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
275
318
  scope,
276
319
  summary: model.summary,
277
320
  evalRevision: model.evalRevision,
321
+ ...(model.order ? { sequence: measuredSequence(model.order) } : {}),
278
322
  })
279
323
  }
280
324
  await getBoard()
@@ -335,13 +379,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
335
379
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
336
380
  return {
337
381
  scope,
338
- gates: model.gates,
382
+ gates: model.gates ?? [],
339
383
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
340
384
  summary: model.summary,
341
385
  evalRevision: model.evalRevision,
342
386
  impact: model.impact,
343
387
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
344
- domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
388
+ domain: 'evals', scope, items, gates: model.gates ?? [], summary: model.summary,
345
389
  evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
346
390
  }),
347
391
  }
@@ -4,10 +4,13 @@ import { sessionArtifactPath, sessionStoreDir } from './layout.js'
4
4
  import type { TimelineEvent } from './session-timeline.js'
5
5
 
6
6
  // @@@ session-cursors - a reader's durable place in a log. One `cursors.json` per session in its global store
7
- // dir: `inbox` is its place in its OWN timeline, `follows` one entry per followed session. A position is an
8
- // event INDEX into timeline.ndjson (lines already consumed), so it is also the index of the next unread event.
7
+ // dir: `follows` holds one entry per followed session, including the reader's own id when it watches its own
8
+ // log. A position is an event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
9
+ // the next unread event. A position is NOT a work list: what a session still owes its agent is a debt, and it
10
+ // lives in its own queue ([[delivery-queue]]). Binding both to one counter is what made a session's own status
11
+ // lines get consumed as though they were mail, and made "anything outstanding?" a scan of all history.
9
12
 
10
- export type Cursors = { version: 1; inbox: number; follows: Record<string, number> }
13
+ export type Cursors = { version: 1; follows: Record<string, number> }
11
14
 
12
15
  const cursorsPath = (id: string): string => sessionArtifactPath(id, 'cursors.json')
13
16
  const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
@@ -16,7 +19,7 @@ const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v)
16
19
  // message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
17
20
  // store dir is gone are dropped here — expiry is this read, and the next write persists it.
18
21
  export function readCursors(id: string): Cursors {
19
- let raw: { inbox?: unknown; follows?: unknown } | null = null
22
+ let raw: { follows?: unknown } | null = null
20
23
  try { raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8')) } catch { /* no cursors yet */ }
21
24
  const follows: Record<string, number> = {}
22
25
  const stored = raw?.follows
@@ -26,11 +29,11 @@ export function readCursors(id: string): Cursors {
26
29
  follows[target] = at(pos)
27
30
  }
28
31
  }
29
- return { version: 1, inbox: at(raw?.inbox), follows }
32
+ return { version: 1, follows }
30
33
  }
31
34
 
32
- // Written whole and atomically, one field per line — the same shape as the session record, so the mark-active
33
- // hook can read its inbox position with an exact whole-line match in pure shell.
35
+ // Written whole and atomically, one field per line — the same shape as the session record, so a position stays
36
+ // readable by an exact whole-line match where a value regex would not be.
34
37
  function writeCursors(id: string, cursors: Cursors): void {
35
38
  const dir = sessionStoreDir(id)
36
39
  mkdirSync(dir, { recursive: true })
@@ -39,22 +42,13 @@ function writeCursors(id: string, cursors: Cursors): void {
39
42
  renameSync(tmp, cursorsPath(id))
40
43
  }
41
44
 
42
- export const inboxCursor = (id: string): number => readCursors(id).inbox
43
-
44
- // A reader that has shown everything up to `to`. Monotonic: a stale read can leave the position too low
45
- // (a message shown twice), never too high (a message lost).
46
- export function advanceInbox(id: string, to: number): void {
47
- const cursors = readCursors(id)
48
- if (to <= cursors.inbox) return
49
- writeCursors(id, { ...cursors, inbox: to })
50
- }
51
-
52
45
  export const followCursor = (id: string, target: string): number | null => {
53
46
  const stored = readCursors(id).follows[target]
54
47
  return stored === undefined ? null : stored
55
48
  }
56
49
 
57
50
  // Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
51
+ // Monotonic: a stale read can leave a position too low (an event read twice), never too high (one lost).
58
52
  export function advanceFollow(id: string, target: string, to: number): void {
59
53
  const cursors = readCursors(id)
60
54
  const stored = cursors.follows[target]
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { sessionStoreDir } from './layout.js'
3
- import { advanceFollow, followCursor, inboxCursor, unreadSince } from './session-cursors.js'
3
+ import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
4
4
  import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
5
5
  import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
6
6
 
@@ -158,12 +158,12 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
158
158
  state.delete(id)
159
159
  emit(`${tag}[spex] closed · removed [id ${id}]`)
160
160
  }
161
- // THE INBOXthe follower's own log, read past the cursor `cursors.json` already holds and NEVER advanced
162
- // here: the turn-boundary mark-active hook is the inbox's one reader ([[session-timeline]]), and advancing
163
- // behind its back would wake this process on a message the agent is then never shown. Re-read every tick, so
164
- // a line that hook has since injected stops counting as unread and cannot wake a later wait twice.
161
+ // THE FOLLOWER'S OWN LOG watched exactly like any other target, on its own entry in `cursors.json`. It is
162
+ // a WATCH, not a delivery: a message reaches the agent as a prompt through the adapter ([[delivery-queue]]),
163
+ // so this position only decides what THIS process has already reported and can never make an agent miss
164
+ // mail. Never advanced here in take mode — the waiter stops on the event and the next wait resumes on it.
165
165
  if (self && existsSync(sessionStoreDir(self))) {
166
- const mine = unreadSince(timelineEvents(self), inboxCursor(self))
166
+ const mine = unreadSince(timelineEvents(self), followCursor(self, self) ?? 0)
167
167
  for (let k = 0; k < mine.events.length; k++) {
168
168
  const e = mine.events[k]
169
169
  if (e.kind !== 'sent') continue
@@ -12,6 +12,7 @@ import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentity
12
12
  import { materialize } from './materialize.js'
13
13
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
14
14
  import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
15
+ import { drain, enqueue, owesDelivery } from './delivery-queue.js'
15
16
  import { stripRefSigil } from './mentions.js'
16
17
  import { shQuote } from './sh.js'
17
18
  import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
@@ -20,6 +21,7 @@ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGener
20
21
 
21
22
  const pexec = promisify(execFile)
22
23
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
24
+ const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
23
25
  const HARNESS = defaultHarness
24
26
  const COLS = 120, ROWS = 32
25
27
  const DEFAULT_MAX_ACTIVE = 8
@@ -71,7 +73,7 @@ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', noth
71
73
 
72
74
  export type Session = {
73
75
  id: string; node: string | null; branch: string | null; path: string
74
- label: string; headline: string // the DERIVED display strings ([[session-label]]) the only names surfaces read
76
+ label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
75
77
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
76
78
  parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
77
79
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
@@ -110,18 +112,26 @@ function removeLaunchFile(id: string): void {
110
112
 
111
113
  // One line, bounded — the launch prompt's shape when it enters a compact headline.
112
114
  export const HEADLINE_PREVIEW_COLUMNS = 60
115
+ function isBareUrl(text: string): boolean {
116
+ return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
117
+ }
113
118
  function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
114
- const first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
119
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
120
+ const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
115
121
  return first.length > n ? first.slice(0, n - 1) + '…' : first
116
122
  }
117
123
 
118
124
  export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
119
125
  r.name || r.node || r.title || r.branch || r.id
120
- export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
121
- r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
126
+ export const deriveTitle = (r: { name?: string | null; activity?: string | null; note?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
127
+ r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id
128
+ // Compatibility for package consumers that still import the old name.
129
+ export const deriveHeadline = deriveTitle
122
130
 
123
131
  export const sessionLabel = (s: Session): string => s.label
124
- export const sessionHeadline = (s: Session): string => s.headline
132
+ export const sessionTitle = (s: Session): string => s.title
133
+ // Compatibility for older callers; all visible surfaces now resolve through `title`.
134
+ export const sessionHeadline = sessionTitle
125
135
 
126
136
  // @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
127
137
  // HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
@@ -448,7 +458,113 @@ function writeRecord(rec: SessRec): void {
448
458
  if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
449
459
  || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
450
460
  recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
461
+ scheduleWatchNotifications(rec)
462
+ }
463
+ }
464
+
465
+ type WatchEntry = { watcher: string; createdAt: string }
466
+ export type SessionWatch = { target: string; createdAt: string }
467
+ const watchPath = (target: string) => sessionArtifactPath(target, 'watchers.json')
468
+
469
+ function readWatchEntries(target: string): WatchEntry[] {
470
+ try {
471
+ const raw = JSON.parse(readFileSync(watchPath(target), 'utf8')) as unknown
472
+ if (!Array.isArray(raw)) return []
473
+ const seen = new Set<string>()
474
+ return raw.flatMap((entry): WatchEntry[] => {
475
+ if (!entry || typeof entry !== 'object') return []
476
+ const watcher = (entry as WatchEntry).watcher
477
+ const createdAt = (entry as WatchEntry).createdAt
478
+ if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher)) return []
479
+ seen.add(watcher)
480
+ return [{ watcher, createdAt }]
481
+ })
482
+ } catch { return [] }
483
+ }
484
+
485
+ function writeWatchEntries(target: string, entries: WatchEntry[]): void {
486
+ const path = watchPath(target)
487
+ if (!entries.length) { try { unlinkSync(path) } catch { /* already absent */ }; return }
488
+ const dir = sessionStoreDir(target)
489
+ mkdirSync(dir, { recursive: true })
490
+ const tmp = join(dir, `.watchers.json.${process.pid}.tmp`)
491
+ writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n')
492
+ renameSync(tmp, path)
493
+ }
494
+
495
+ function managedWatchRecord(id: string): SessRec {
496
+ const rec = readRecord(id)
497
+ if (!rec?.governed) throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`)
498
+ return rec
499
+ }
500
+
501
+ function watchMessage(target: SessRec): string {
502
+ const status = target.status === 'awaiting'
503
+ ? PROPOSAL_STATUS[target.proposal ?? 'nothing']
504
+ : target.status === 'active' ? 'working' : target.status
505
+ const note = target.note ? ` — ${target.note}` : ''
506
+ return `[spex watch] ${target.session} is ${status}${note}`
507
+ }
508
+
509
+ function scheduleWatchNotifications(target: SessRec): void {
510
+ const watchers = readWatchEntries(target.session).map((entry) => entry.watcher)
511
+ if (!watchers.length) return
512
+ queueMicrotask(() => {
513
+ for (const watcher of watchers) {
514
+ void sendText(watcher, watchMessage(target), target.session).then((result) => {
515
+ if (!result.ok) console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`)
516
+ })
517
+ }
518
+ })
519
+ }
520
+
521
+ export async function subscribeSessionWatch(watcher: string, targets: string[]): Promise<{ watched: string[] }> {
522
+ managedWatchRecord(watcher)
523
+ const watched: string[] = []
524
+ for (const target of [...new Set(targets)]) {
525
+ if (target === watcher) throw new ResourceConflict('a session cannot watch itself')
526
+ const targetRecord = managedWatchRecord(target)
527
+ withRecordLockSync(target, () => {
528
+ const entries = readWatchEntries(target)
529
+ if (!entries.some((entry) => entry.watcher === watcher)) {
530
+ writeWatchEntries(target, [...entries, { watcher, createdAt: new Date().toISOString() }])
531
+ }
532
+ })
533
+ const delivered = await sendText(watcher, watchMessage(targetRecord), target)
534
+ if (!delivered.ok) throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`)
535
+ watched.push(target)
536
+ }
537
+ return { watched }
538
+ }
539
+
540
+ export function listSessionWatches(watcher: string): SessionWatch[] {
541
+ managedWatchRecord(watcher)
542
+ const watches: SessionWatch[] = []
543
+ for (const target of listSessionIds()) {
544
+ const entries = readWatchEntries(target)
545
+ const active = entries.filter((entry) => {
546
+ try { return !!readRecord(entry.watcher)?.governed } catch { return false }
547
+ })
548
+ if (active.length !== entries.length) writeWatchEntries(target, active)
549
+ for (const entry of active) if (entry.watcher === watcher) watches.push({ target, createdAt: entry.createdAt })
550
+ }
551
+ return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target))
552
+ }
553
+
554
+ export function cancelSessionWatch(watcher: string, targets: string[]): number {
555
+ managedWatchRecord(watcher)
556
+ let cancelled = 0
557
+ for (const target of [...new Set(targets)]) {
558
+ withRecordLockSync(target, () => {
559
+ const entries = readWatchEntries(target)
560
+ const kept = entries.filter((entry) => entry.watcher !== watcher)
561
+ if (kept.length !== entries.length) {
562
+ writeWatchEntries(target, kept)
563
+ cancelled++
564
+ }
565
+ })
451
566
  }
567
+ return cancelled
452
568
  }
453
569
 
454
570
  // Share one liveness snapshot rather than spawning tmux for every displayed session.
@@ -662,10 +778,26 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
662
778
  return { path: rec.worktreePath, branch: rec.branch, rec }
663
779
  }
664
780
 
781
+ // @@@ identity WITHOUT the gates - reviewPayload answers two different questions at once: who is this
782
+ // session (a store read, free) and how does its branch stand against main (ahead count, dirty scan, a
783
+ // merge-tree conflict probe — 646 ms and 8 git children on a far-diverged branch). A consumer that renders
784
+ // no gates strip should not buy the second one. The record already holds the identity half.
785
+ export type ReviewIdentity = { id: string; node: string | null; branch: string | null; label: string }
786
+ export function reviewIdentity(id: string): ReviewIdentity | null {
787
+ const rec = readRecord(id)
788
+ if (!rec) return null
789
+ return {
790
+ id,
791
+ node: rec.node,
792
+ branch: rec.branch,
793
+ label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
794
+ }
795
+ }
796
+
665
797
  function corruptSession(id: string, entry: { path: string; error: string }): Session {
666
798
  const label = `${id.slice(0, 8)} (unreadable record)`
667
799
  return {
668
- id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
800
+ id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
669
801
  parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
670
802
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
671
803
  note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
@@ -680,9 +812,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
680
812
  const showActivity = lv === 'online'
681
813
  const act = showActivity ? activity : null
682
814
  const pp = prompt ? oneLinePreview(prompt) : null
683
- const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
815
+ const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp }
684
816
  const harness = harnessById(rec.harness || defaultHarness.id)
685
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
817
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
686
818
  }
687
819
 
688
820
  export async function renameSession(id: string, name: string): Promise<boolean> {
@@ -1223,6 +1355,28 @@ export function superviseQueue(intervalMs = 3000): void {
1223
1355
  void tick()
1224
1356
  }
1225
1357
 
1358
+ let supervisingDelivery = false
1359
+ // @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
1360
+ // covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
1361
+ // pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
1362
+ // the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
1363
+ // it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
1364
+ // concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
1365
+ export function superviseDelivery(intervalMs = 2000): void {
1366
+ if (supervisingDelivery) return
1367
+ supervisingDelivery = true
1368
+ const tick = async () => {
1369
+ try {
1370
+ for (const id of listSessionIds()) {
1371
+ if (!owesDelivery(id)) continue
1372
+ try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
1373
+ }
1374
+ } catch { /* transient store read; next tick retries */ }
1375
+ setTimeout(tick, intervalMs).unref()
1376
+ }
1377
+ void tick()
1378
+ }
1379
+
1226
1380
  type TurnFailureObserverState = {
1227
1381
  fingerprint: string
1228
1382
  subscription: FailureSubscription | null
@@ -1646,6 +1800,23 @@ type SessionCandidateReceiptRead =
1646
1800
  const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
1647
1801
  const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
1648
1802
  const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
1803
+ // The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
1804
+ // registering a session candidate. The receipt is written before `git worktree add` and retired only
1805
+ // after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
1806
+ export function pendingSessionCreateWorktreePaths(): Set<string> {
1807
+ const paths = new Set<string>()
1808
+ let entries: import('node:fs').Dirent[]
1809
+ try { entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true }) }
1810
+ catch { return paths }
1811
+ for (const entry of entries) {
1812
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue
1813
+ try {
1814
+ const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8')) as Partial<SessionCandidateReceipt>
1815
+ if (typeof value.path === 'string' && value.path && typeof value.stage === 'string') paths.add(resolve(value.path))
1816
+ } catch { /* an in-flight atomic replace is not a candidate path */ }
1817
+ }
1818
+ return paths
1819
+ }
1649
1820
  function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
1650
1821
  const path = sessionCandidateReceiptPath(id)
1651
1822
  if (!existsSync(path)) return { kind: 'absent' }
@@ -1834,7 +2005,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1834
2005
  const resourceLock = sessionCandidateLockId(path, branch)
1835
2006
  return await withRecordLock(resourceLock, async () => {
1836
2007
  throwIfCreateAborted(signal, phase)
2008
+ traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
1837
2009
  let before = await sessionCandidateState(root, path, branch, signal)
2010
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
1838
2011
  let storePresent = existsSync(sessionStoreDir(id))
1839
2012
  const durable = readSessionCandidateReceipt(id)
1840
2013
  if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
@@ -1866,7 +2039,12 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1866
2039
  let published = false
1867
2040
  try {
1868
2041
  gitMutationStarted = true
1869
- const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()]))
2042
+ traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
2043
+ const added = await withGitAbortSignal(signal, () => gitTry(
2044
+ ['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()],
2045
+ { extraEnv: DEFER_FOOTPRINT_REFRESH },
2046
+ ))
2047
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
1870
2048
  if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
1871
2049
  if (!added.ok || !existsSync(path)) {
1872
2050
  throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
@@ -1874,7 +2052,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
1874
2052
  candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
1875
2053
  writeSessionCandidateReceipt(id, candidateReceipt)
1876
2054
  traceSessionCreate(id, requestDigest, phase, 'finish')
2055
+ traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
1877
2056
  seedWorktreeHostState(root, path)
2057
+ traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
1878
2058
 
1879
2059
  let rec: SessRec = {
1880
2060
  session: id, governed: true, worktreePath: path, branch,
@@ -3092,12 +3272,11 @@ export function formatTable(sessions: Session[], color = true): string {
3092
3272
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3093
3273
  }
3094
3274
 
3095
- // @@@ sendText - THE APPEND IS THE DELIVERY ([[dispatch]]). The message lands in the target's durable log
3096
- // under its record lock, and success is decided there; only then is the harness adapter poked with the same
3097
- // text, so a live agent sees it in its current turn instead of at its next turn boundary. The poke is
3098
- // best-effort losing it, having it refused, or replaying it costs nothing, because the line is already the
3099
- // message's copy and the turn-boundary reader picks up whatever the poke did not show. What stays LOUD is only
3100
- // what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3275
+ // @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
3276
+ // message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
3277
+ // sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
3278
+ // separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
3279
+ // genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3101
3280
  // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3102
3281
  // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3103
3282
  // (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
@@ -3105,37 +3284,44 @@ export async function sendText(id: string, text: string, from?: string, opts: {
3105
3284
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3106
3285
  const rec = readRecord(id)
3107
3286
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3287
+ // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3288
+ // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3289
+ // a message that was already accepted.
3108
3290
  const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3109
- let sent: { mid: string }
3110
3291
  try {
3111
- // The lock covers the append alone. Codex's native turn can synchronously run hooks that write this same
3112
- // record, so holding it across the adapter poke below would deadlock the app-server's confirmation.
3113
- sent = await withRecordLock(id, async () => appendSent(id, text, from ?? null, prompt.replyVia))
3292
+ await withRecordLock(id, async () => {
3293
+ const appended = appendSent(id, text, from ?? null, prompt.replyVia)
3294
+ enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
3295
+ })
3114
3296
  } catch (error) {
3115
3297
  return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
3116
3298
  }
3117
- const h = harnessById(rec.harness || defaultHarness.id)
3118
- // Awaited, not fire-and-forget: `spex session send` is a short-lived process that would exit before an
3119
- // unawaited poke ever reached the socket, costing every CLI send its same-turn arrival. Its result never
3120
- // advances the inbox: a write cannot prove the target parsed it, so only the target's reader consumes the
3121
- // durable line.
3122
- await pokeAdapter(h, rec, prompt.text, sent.mid)
3299
+ // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
3300
+ // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
3301
+ // the text in a live agent's current turn instead of up to one tick later.
3302
+ await drainSession(id)
3123
3303
  return { ok: true }
3124
3304
  }
3125
3305
 
3126
- // The courtesy kick. Carries `mid` as the adapter's native message marker, so an adapter that replays or
3127
- // duplicates it stays harmless. Never throws and never reports: a poke has no outcome the caller can act on.
3128
- async function pokeAdapter(h: Harness, rec: SessRec, text: string, mid: string): Promise<void> {
3129
- // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3130
- // prompt its channel confirms (claude's sessions panel), checkable only from the pane. It no longer refuses
3131
- // the send — the message is already delivered — it only skips a kick known to be swallowed.
3132
- if (h.deliveryBlockedBy) {
3133
- try {
3134
- if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return
3135
- } catch { /* no pane to consult let the poke itself decide */ }
3136
- }
3137
- try { await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid }, text) }
3138
- catch { /* the unread timeline line remains the delivery */ }
3306
+ // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
3307
+ // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
3308
+ // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
3309
+ export async function drainSession(id: string): Promise<void> {
3310
+ if (!owesDelivery(id)) return
3311
+ const rec = readRecord(id)
3312
+ if (!rec) return
3313
+ const h = harnessById(rec.harness || defaultHarness.id)
3314
+ await drain(id, async (msg) => {
3315
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3316
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
3317
+ // rather than a skip the message stays owed and the sweep hands it over once the pane leaves that state.
3318
+ if (h.deliveryBlockedBy) {
3319
+ try {
3320
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
3321
+ } catch { /* no pane to consult — let the insert itself decide */ }
3322
+ }
3323
+ return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
3324
+ })
3139
3325
  }
3140
3326
 
3141
3327
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
@@ -7,6 +7,7 @@ import { runtimeRoot, readConfig, mainCheckout } from './layout.js'
7
7
  import { resolveHarnessTargets } from './harness-select.js'
8
8
  import { loadSkillConfig, loadAgentConfig } from './specs.js'
9
9
  import { dematerialize } from './materialize.js'
10
+ import { gitBinary } from './git.js'
10
11
 
11
12
  // the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
12
13
  const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex', '.zcode'] as const
@@ -71,7 +72,7 @@ function pluginLedgerHosts(store: string): string[] {
71
72
  // resolve the repo's shared git hooks dir (the common dir's hooks/), or null when <dir> isn't a git repo.
72
73
  function hooksDir(proj: string): string | null {
73
74
  try {
74
- const common = execFileSync('git', ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
75
+ const common = execFileSync(gitBinary(process.env), ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
75
76
  encoding: 'utf8',
76
77
  stdio: ['ignore', 'pipe', 'ignore'],
77
78
  }).trim()