spexcode 0.5.8 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,225 +1,81 @@
1
- // @@@ mentions - the two universal in-text reference primitives ([[mentions]]): `[[node]]` (a TOPIC — a spec
2
- // node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
3
- // them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
4
- // own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
5
- // sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s bounded create owner — storage and delivery stay
6
- // separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
7
-
8
- // ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
9
- // `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
10
- // letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
11
- // MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
12
- // Only `new` may carry `:<launcher>` ([[launcher-select]]); keeping that choice in the token makes the same
13
- // prose work from the CLI and dashboard, with no composer-only field. Both forms are deduped first-seen.
14
- const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)(?::([\p{L}\p{N}_.-]+))?/gu
1
+ // @@@ mentions - the two passive in-text references: `[[node]]` names a topic and
2
+ // `@session` names a retained session. The grammar is shared by every composer; neither
3
+ // reference is control. Sending to a session, launching one, and inheriting one remain
4
+ // explicit `spex session send`, `spex session new`, and `/distill <id>` actions.
5
+ const SESSION_RE = /(?:^|\s)@([\p{L}\p{N}_-]+(?::[\p{L}\p{N}_.-]+)?)/gu
15
6
  const NODE_RE = /\[\[([^\]\s]+)\]\]/g
16
7
 
17
8
  const uniq = (xs: string[]): string[] => [...new Set(xs)]
18
9
 
19
- // ── CLI sigil tolerance ───────────────────────────────────────────────────────────────────────────────
20
- // In FREE TEXT the sigils are required they are what marks a reference apart from prose. In a CLI
21
- // ARGUMENT the whole token IS the reference, so the sigil is optional: `spex session review @graph` ≡
22
- // `spex session review graph`, `spex eval add [[cli-surface]]` ≡ `spex eval add cli-surface`. One shared strip,
23
- // applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
24
- // dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
10
+ // In free text a sigil separates a reference from prose. A CLI argument is already a
11
+ // reference, so it tolerates either sigil without widening its normal matching grammar.
25
12
  export function stripRefSigil(token: string): string {
26
13
  const wrapped = /^\[\[(.*)\]\]$/.exec(token)
27
14
  if (wrapped) return wrapped[1]
28
15
  return token.startsWith('@') ? token.slice(1) : token
29
16
  }
30
17
 
31
- export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
32
- const actors: string[] = []
18
+ export function parseMentions(text: string): { sessions: string[]; nodes: string[] } {
19
+ const sessions: string[] = []
33
20
  const nodes: string[] = []
34
- for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1] === 'new' && m[2] ? `new:${m[2]}` : m[1])
21
+ for (const m of text.matchAll(SESSION_RE)) sessions.push(m[1])
35
22
  for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
36
- return { actors: uniq(actors), nodes: uniq(nodes) }
37
- }
38
-
39
- // ── resolve actors against live sessions (pure) ───────────────────────────────────────────────────────
40
- // A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
41
- export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
42
- export type Resolved =
43
- | { token: string; kind: 'new'; launcher?: string }
44
- | { token: string; kind: 'session'; session: ActorSession }
45
- | { token: string; kind: 'unresolved' }
46
-
47
- // Resolve each actor token to a `new` sentinel (optionally carrying its explicit launcher), an ONLINE
48
- // session (never a dead one — you don't summon a closed agent; `@new` acts on its behalf), or unresolved.
49
- // Match order: new/new:<launcher> → exact id → id prefix → name/title (case-insensitive) → name/title prefix.
50
- // First online match wins.
51
- export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
52
- const online = sessions.filter((s) => s.liveness === 'online')
53
- return tokens.map((token): Resolved => {
54
- const fresh = /^new(?::(.+))?$/.exec(token)
55
- if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
56
- const t = token.toLowerCase()
57
- const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
58
- const hit =
59
- online.find((s) => s.id === token) ||
60
- online.find((s) => s.id.startsWith(token)) ||
61
- online.find((s) => label(s) === t) ||
62
- online.find((s) => label(s).startsWith(t) && t.length >= 2)
63
- return hit ? { token, kind: 'session', session: hit } : { token, kind: 'unresolved' }
64
- })
23
+ return { sessions: uniq(sessions), nodes: uniq(nodes) }
65
24
  }
66
25
 
67
- // Any spawn's parent = its originator ([[session-nesting]]): the `@new` worker nests under the session that
68
- // wrote the mention but ONLY when the author IS a real board session id. A dashboard 'human', a CLI
69
- // 'unknown', or a forge login resolves to no session null a top-level worker, never a phantom nest.
70
- // Exact id match only (lineage is provenance, not addressing — no prefix/name resolution), any liveness:
71
- // a parent that later closes is auto-promoted at read time by the derived tree.
72
- export function spawnParent(author: string, sessions: { id: string }[]): string | null {
73
- return sessions.some((s) => s.id === author) ? author : null
26
+ // The originator courtesy is the only automatic notification left in this module. It
27
+ // resolves explicit stored session ids against online rows; prose @ references never call it.
28
+ type Session = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
29
+
30
+ function resolveOnlineSession(token: string, sessions: Session[]): Session | null {
31
+ const online = sessions.filter((session) => session.liveness === 'online')
32
+ const text = token.toLowerCase()
33
+ const label = (session: Session) => (session.name || session.title || '').toLowerCase()
34
+ return online.find((session) => session.id === token)
35
+ || online.find((session) => session.id.startsWith(token))
36
+ || online.find((session) => label(session) === text)
37
+ || online.find((session) => label(session).startsWith(text) && text.length >= 2)
38
+ || null
74
39
  }
75
40
 
76
- // ── dispatch (integration) ─────────────────────────────────────────────────────────────────────────────
77
- export type DispatchOutcome = { token: string; result: 'sent' | 'spawned' | 'offline' | 'unresolved' | 'failed'; detail?: string; note?: string }
78
-
79
- // The prompt an @-mentioned actor receives: the comment verbatim + a pointer back to the thread. It is a
80
- // nudge to look, never a rigid command — what to do is up to the comment's words.
81
- function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
82
- const re = node ? ` (re: ${node})` : ''
83
- return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
84
- `Read the thread and act as the comment asks (often just a look): \`spex issue ls --all\` lists them; ` +
85
- `reply with \`spex issue reply ${threadId} --body -\`.`
86
- }
87
- // A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
88
- // landed, so the prompt leads with the status and a verify-on-main-first instruction.
89
- export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
90
- // Keep inherited scope inside the text the worker receives: the create transaction derives its node only from the
91
- // raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
92
- const on = node ? ` on node [[${node}]]` : ''
93
- const settled = status && status !== 'open'
94
- ? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
95
- `Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
96
- `instead of re-implementing.\n\n`
97
- : ''
98
- return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
99
- settled +
100
- `Read the thread (\`spex issue ls --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
101
- }
41
+ export type LoopIn = { originator: string }
42
+ export type LoopInPick =
43
+ | { kind: 'deliver'; originator: string; session: Session }
44
+ | { kind: 'none' }
102
45
 
103
- // Parse a committed issue post's text for `@` actors and deliver to each. Best-effort and LOUD: the thread is
104
- // already committed, so a failed dispatch never fails the post it is reported. sessions.ts is imported
105
- // lazily; with no actor mentions this returns [] without loading it or hitting the backend.
106
- export async function dispatchMentions(
107
- text: string,
108
- ctx: { threadId: string; node: string | null; author: string; status?: string | null },
109
- ): Promise<DispatchOutcome[]> {
110
- const { actors } = parseMentions(text)
111
- if (!actors.length) return []
112
- const { sendText, listSessions, sessionCreateRequest } = await import('./sessions.js')
113
- const sessions = await listSessions()
114
- const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
115
- const out: DispatchOutcome[] = []
116
- for (const r of resolved) {
117
- if (r.kind === 'unresolved') { out.push({ token: r.token, result: 'unresolved' }); continue }
118
- if (r.kind === 'new') {
119
- // the drain guard ([[mentions]]): @new on a settled thread still spawns (the summons may be a
120
- // deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
121
- const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
122
- try {
123
- const created = await sessionCreateRequest({
124
- prompt: newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status),
125
- parent: spawnParent(ctx.author, sessions),
126
- launcher: r.launcher,
127
- })
128
- if (created.status !== 201) throw new Error(`${created.code || 'session_create_failed'}: ${created.error}`)
129
- out.push({ token: r.token, result: 'spawned', detail: created.session.id, ...(settled ? { note: `thread ${settled}` } : {}) })
130
- } catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
131
- continue
132
- }
133
- const res = await sendText(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
134
- out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
135
- : { token: r.token, result: 'offline', detail: res.error })
46
+ // A committed reply can notify its originator as a courtesy. This is not a mention
47
+ // dispatch or assignment, and it never creates a worker or retries an offline session.
48
+ export function pickLoopIn(chain: (string | null)[], replier: string, sessions: Session[]): LoopInPick {
49
+ const seen = new Set<string>()
50
+ for (const originator of chain) {
51
+ if (!originator || originator === replier || seen.has(originator)) continue
52
+ seen.add(originator)
53
+ const session = resolveOnlineSession(originator, sessions)
54
+ if (session) return { kind: 'deliver', originator, session }
136
55
  }
137
- return out
56
+ return { kind: 'none' }
138
57
  }
139
58
 
140
- // ── implicit originator loop-in + the dispatch fallback chain ([[mentions]] / [[remark-substrate]] R3) ────
141
- // A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
142
- // candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
143
- // filer session, then the node's governing session, then nobody (it still surfaces on the board via the
144
- // teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex remark resolve`,
145
- // R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
146
- // online-resolution + one sendText), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
147
- // ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
148
- // explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
149
- // an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
150
- // needless escalation). Store-agnostic: a forge issue's author is a github login, resolves to no live session,
151
- // and the chain runs dry silently — exactly right.
152
- export type LoopIn = { originator: string } // the candidate we actually reached (a filer OR a governing-session fallback)
153
-
154
- // The courtesy prompt — framed as a heads-up, never a command (that is what an @-mention's mentionPrompt is).
155
59
  function originatorPrompt(threadId: string, node: string | null, replier: string, text: string): string {
156
60
  const re = node ? ` (re: ${node})` : ''
157
- return `A new reply landed on a thread you originated "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
158
- `This is a courtesy heads-up (you started this thread), not an assignment. Look if it concerns you; ` +
159
- `\`spex issue ls --all\` lists them, reply with \`spex issue reply ${threadId} --body -\`.`
61
+ return `A new reply landed on a thread you originated - "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
62
+ `This is a courtesy heads-up, not an assignment. Look if it concerns you; ` +
63
+ `\`spex issue ls --all\` lists threads and \`spex issue reply ${threadId} --body -\` replies.`
160
64
  }
161
65
 
162
- // The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
163
- // pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
164
- // link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
165
- // double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
166
- // the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendText around it.
167
- export type LoopInPick =
168
- | { kind: 'deliver'; originator: string; session: ActorSession }
169
- | { kind: 'reached' }
170
- | { kind: 'none' }
171
- export function pickLoopIn(
172
- chain: (string | null)[],
173
- replier: string,
174
- sessions: ActorSession[],
175
- alreadyDelivered?: Set<string>,
176
- ): LoopInPick {
177
- const seen = new Set<string>()
178
- const candidates = chain.filter((c): c is string => !!c && c !== replier && !seen.has(c) && (seen.add(c), true))
179
- for (const originator of candidates) {
180
- const [resolved] = resolveActors([originator], sessions)
181
- if (resolved.kind !== 'session') continue // offline / no live session → try the next fallback link
182
- if (alreadyDelivered?.has(resolved.session.id)) return { kind: 'reached' } // already an explicit @-target: stop
183
- return { kind: 'deliver', originator, session: resolved.session }
184
- }
185
- return { kind: 'none' }
186
- }
187
-
188
- // `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
189
- // offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
190
- // (resolve is a deliberate `spex remark resolve`), never spawns (only `@new` spawns).
191
66
  export async function notifyOriginator(
192
67
  chain: (string | null)[],
193
68
  replier: string,
194
69
  text: string,
195
- ctx: { threadId: string; node: string | null; alreadyDelivered?: Set<string> },
70
+ ctx: { threadId: string; node: string | null },
196
71
  ): Promise<LoopIn | null> {
197
- const seen = new Set<string>()
198
- if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
72
+ if (!chain.some((candidate) => candidate && candidate !== replier)) return null
199
73
  const { sendText, listSessions } = await import('./sessions.js')
200
- const pick = pickLoopIn(chain, replier, await listSessions() as unknown as ActorSession[], ctx.alreadyDelivered)
201
- if (pick.kind !== 'deliver') return null // reached via @ / nobody online → silent
202
- const res = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
203
- return res.ok ? { originator: pick.originator } : null // a failed send behaves like offline: silent
74
+ const pick = pickLoopIn(chain, replier, await listSessions() as unknown as Session[])
75
+ if (pick.kind !== 'deliver') return null
76
+ const result = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
77
+ return result.ok ? { originator: pick.originator } : null
204
78
  }
205
79
 
206
- // The set of session ids a dispatch ALREADY reached (sent or spawned) what the loop-in skips to avoid a
207
- // double-delivery to an originator who was also an explicit @-target.
208
- export function deliveredIds(outcomes: DispatchOutcome[]): Set<string> {
209
- return new Set(outcomes.filter((o) => (o.result === 'sent' || o.result === 'spawned') && o.detail).map((o) => o.detail!))
210
- }
211
-
212
- // A one-line human summary of what a dispatch did, for the CLI to echo after a post. The optional loop-in is
213
- // noted DISTINCT from the @-dispatch — a courtesy copy, not an assignment.
214
- export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null): string {
215
- const parts: string[] = []
216
- if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
217
- if (o.result === 'sent') return `${o.token}→sent`
218
- if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
219
- if (o.result === 'offline') return `${o.token}→offline (stored)`
220
- if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
221
- return `${o.token}→failed (${o.detail})`
222
- }).join(' · '))
223
- if (loopIn) parts.push(`↩ looped in originator @${loopIn.originator} (online)`)
224
- return parts.join(' · ')
225
- }
80
+ export const summarizeLoopIn = (loopIn?: LoopIn | null): string =>
81
+ loopIn ? `looped in originator @${loopIn.originator} (online)` : ''
@@ -131,7 +131,7 @@ function turnExited(rec: HarnessDeliveryRecord, outcome: Extract<TurnOutcome, {
131
131
  const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`
132
132
  return {
133
133
  ok: false,
134
- error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - prompt delivery FAILED`,
134
+ error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - immediate wake failed`,
135
135
  }
136
136
  }
137
137
 
@@ -173,7 +173,7 @@ export async function spawnOpenCodeHeadlessTurn(
173
173
  const outcome = readTurnOutcome(outcomePath)
174
174
  if (outcome?.state === 'exit') return turnExited(rec, outcome)
175
175
  if (outcome?.state === 'invalid') {
176
- return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
176
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
177
177
  }
178
178
  if (Date.now() >= deadline) break
179
179
  await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()))
@@ -181,19 +181,19 @@ export async function spawnOpenCodeHeadlessTurn(
181
181
 
182
182
  const first = readTurnOutcome(outcomePath)
183
183
  if (first?.state === 'exit') return turnExited(rec, first)
184
- if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - prompt delivery FAILED` }
185
- if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
184
+ if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - immediate wake failed` }
185
+ if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
186
186
  if (first.state === 'reporting') {
187
- return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - prompt delivery FAILED` }
187
+ return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - immediate wake failed` }
188
188
  }
189
189
  if (!pidAlive(first.pid)) {
190
- return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - prompt delivery FAILED` }
190
+ return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - immediate wake failed` }
191
191
  }
192
192
  await sleep(OUTCOME_POLL_MS)
193
193
  const settled = readTurnOutcome(outcomePath)
194
194
  if (settled?.state === 'exit') return turnExited(rec, settled)
195
195
  if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
196
- return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - prompt delivery FAILED` }
196
+ return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - immediate wake failed` }
197
197
  }
198
198
  return { ok: true }
199
199
  } catch (error) {
@@ -92,11 +92,8 @@ export const SpexcodePlugin = async (ctx) => {
92
92
  })()
93
93
  }
94
94
 
95
- // the rendezvous daemon, from the shared runtime: {type:"reply"} injects into the root session (a plugin
96
- // that adopted no session yet reply-rejects synchronously, BEFORE repaint-done, so the sender fails loud
97
- // instead of confirming a prompt that can never inject); {type:"repaint"} answers repaint-done in the same
98
- // synchronous parse pass — confirmation means PARSED, not processed. The injection itself (a whole model
99
- // turn: the SDK prompt call resolves only when the TURN ends) runs BEHIND the confirm.
95
+ // The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
96
+ // timeline line for its next turn boundary; the SDK injection remains host-owned.
100
97
  rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
101
98
 
102
99
  const toolPayload = (input, output) => {
@@ -8,7 +8,7 @@ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
8
8
  import { controlRequest, withTimeout } from './headless-controller.js'
9
9
  import { shQuote } from './sh.js'
10
10
 
11
- type ControlRequest = { type: 'deliver'; text: string }
11
+ type ControlRequest = { type: 'deliver'; text: string; mid?: string }
12
12
  type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
13
13
 
14
14
  const PKG = fileURLToPath(new URL('..', import.meta.url))
@@ -24,7 +24,7 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
24
24
  }
25
25
 
26
26
  export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
27
- controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
27
+ controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text, mid: rec.mid }, {
28
28
  name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
29
29
  rejected: 'pi-headless controller rejected the request',
30
30
  })
@@ -101,10 +101,10 @@ export class PiHeadlessController {
101
101
  if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
102
102
  if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
103
103
 
104
- // A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
105
- // shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
104
+ // A live extension listener is an in-flight pi turn. Only a proven absent listener may cold-wake a saved
105
+ // session; an inconclusive probe must not start a duplicate turn.
106
106
  const { deliverViaSocketOrWake } = await import('./harness.js')
107
- return deliverViaSocketOrWake(this.id, request.text, async () => {
107
+ return deliverViaSocketOrWake(this.id, request.text, request.mid, async () => {
108
108
  if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
109
109
  await this.spawnTurn(request.text, true)
110
110
  return { ok: true }
@@ -2,7 +2,6 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { promisify } from 'node:util'
4
4
  import { alive, withSessionInputLock } from './sessions.js'
5
- import { runSessionOperationSync } from './session-maintenance.js'
6
5
 
7
6
  const pexec = promisify(execFile)
8
7
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -363,10 +362,10 @@ const MAX_INPUT_BYTES = 64 * 1024
363
362
  export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
364
363
  const subscription = currentSubscription(id, viewer)
365
364
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
366
- return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
367
- sendControl(subscription.bridge!, { t: 'input', data })
368
- return true
369
- }) ?? false)
365
+ return withSessionInputLock(id, () => {
366
+ sendControl(subscription.bridge!, { t: 'input', data })
367
+ return true
368
+ }) ?? false
370
369
  }
371
370
 
372
371
  async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
@@ -9,6 +9,7 @@ export type ReviewEvalNode = {
9
9
  export type ReviewSnapshot = {
10
10
  issues: any[]
11
11
  evalNodes: ReviewEvalNode[]
12
+ forgeRevision: number
12
13
  }
13
14
 
14
15
  let current: ReviewSnapshot | null = null
@@ -1,11 +1,12 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { listSessions } from './sessions.js'
3
- import { getBoard } from './graphCache.js'
3
+ import { getBoard, getBoardForForgeRevision } from './graphCache.js'
4
4
  import { 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'
8
8
  import { hasReviewSnapshot, readReviewSnapshot } from './reviewSnapshot.js'
9
+ import { residentForgeRevision, residentForgeState } from '../../spec-forge/src/resident.js'
9
10
  // @ts-expect-error The dashboard module is deliberately plain JS so the browser and server execute the
10
11
  // exact same tokenizer/matcher. It is shipped beside the built dashboard by the root package manifest.
11
12
  import { EVAL_FILTER_KIND, evalFilterModel, evalReviewState, issueFilterModel, tokenFilterState } from '../../spec-dashboard/src/reviewFilters.js'
@@ -124,7 +125,11 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
124
125
  // The first request must wait for the first atomic publication. Once one exists, a graph refresh may be
125
126
  // rebuilding unrelated board/session state; the published review source remains a valid answer and its
126
127
  // revision/poll path will deliver the next generation without making this page join that flight.
128
+ residentForgeState()
127
129
  if (!hasReviewSnapshot()) await getBoard()
130
+ else if (readReviewSnapshot().forgeRevision < residentForgeRevision()) {
131
+ await getBoardForForgeRevision(residentForgeRevision())
132
+ }
128
133
  const sessions = await listSessions()
129
134
  const issues = readReviewSnapshot().issues.slice().sort(issueOrder)
130
135
  const text = String(query ?? '').trim() || ISSUE_QUERY_DEFAULT
@@ -0,0 +1,54 @@
1
+ // @@@ root-lru - ONE bounded root→key cache policy, for every layer that keeps immutable per-HEAD work warm.
2
+ // A leaf module on purpose: it imports nothing from the spec graph, the eval sidecar, or git, so both layers
3
+ // can depend on it without either owning the other ([[source-of-truth]]).
4
+ //
5
+ // The policy is reference-counted, not plain LRU, and the distinction is the whole point. Entries are keyed by
6
+ // something IMMUTABLE (a HEAD, or a ledger path + HEAD), so two checkouts sitting on the same commit must share
7
+ // one entry rather than build it twice. A root moving A→B therefore drops A only when NO other root still
8
+ // points at it; otherwise a sequence of successful rebuilds retains one whole index per commit until the slot
9
+ // bound finally evicts them. Bumping an unchanged root is a pure recency touch (delete + reinsert), which is
10
+ // what makes `roots` insertion-ordered enough for the eviction loop to mean "oldest root".
11
+ //
12
+ // This existed twice, verbatim in logic and even in name — `touchRoot` in git.ts (index/drift) and again in
13
+ // spec-eval's scenariofresh.ts (scenario chains), whose comment said it was "mirroring historyIndex/driftIndex
14
+ // in git.ts". Both authors knew; neither had anywhere to put it. Now they do.
15
+
16
+ // slot bound for one cache family. Every caller names its own env knob and default so operators can tune the
17
+ // families independently, but nobody gets to invent a different FLOOR or a bare literal — a cache whose bound
18
+ // is a magic number cannot be tuned in the field at all (scenariofresh's was a hardcoded 16).
19
+ // @@@ unparseable is not zero - the shape both copies used, `Math.max(4, Number(env || fallback))`, returns
20
+ // NaN for a mistyped value, and `size > NaN` is always false: one typo in an env var silently turned the
21
+ // bound OFF and let the cache grow without limit. A bound that fails open is worse than no bound, because
22
+ // nothing reports it. Anything that does not parse to a positive number falls back to the caller's default.
23
+ export const rootSlots = (env: string | undefined, fallback: number): number => {
24
+ const asked = Number(env)
25
+ return Math.max(4, Number.isFinite(asked) && asked > 0 ? asked : fallback)
26
+ }
27
+
28
+ // Record that `root` now wants `key`, evicting what no root wants any more and keeping `roots` within `slots`.
29
+ // `cache` is the caller's own keyed store; this owns only the ROOT→key bookkeeping and the eviction decision.
30
+ export function touchRoot(
31
+ roots: Map<string, string>,
32
+ cache: Map<string, unknown> & { delete(key: string): boolean },
33
+ root: string,
34
+ key: string,
35
+ slots: number,
36
+ ): void {
37
+ const previous = roots.get(root)
38
+ if (previous !== key) {
39
+ roots.set(root, key)
40
+ // the old key survives only while some OTHER root still names it — immutable entries are shared, so
41
+ // dropping one root's view must not throw away a sibling checkout's warm work.
42
+ if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
43
+ } else {
44
+ roots.delete(root)
45
+ roots.set(root, key) // recency bump: reinsertion is what makes the eviction loop below pick the oldest
46
+ }
47
+ while (roots.size > slots) {
48
+ const oldest = roots.keys().next().value as string | undefined
49
+ if (oldest === undefined) break
50
+ const oldKey = roots.get(oldest)
51
+ roots.delete(oldest)
52
+ if (oldKey && ![...roots.values()].includes(oldKey)) cache.delete(oldKey)
53
+ }
54
+ }
@@ -1,14 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
 
3
- // @@@ session-runtime guard ([[platform-support]]) - SpexCode's session orchestration rests on POSIX
4
- // primitives with no native-Windows analog: tmux (the durable detached PTY + capture-pane scrollback +
5
- // multi-client reattach fabric), hand-written bash launch scripts, and filesystem-path AF_UNIX control
6
- // sockets. The supported runtime is POSIX — Linux, macOS, or Windows *via WSL2* (a real Linux kernel where
7
- // tmux/bash/unix-sockets all work). This is the honest gate at the entry to the session runtime: detect the
8
- // load-bearing primitive (tmux) missing and print ONE actionable line naming the fix, instead of letting a
9
- // cryptic downstream ENOENT be the user's first signal. Read-only CLI (init/lint/board) never calls this,
10
- // so it still runs anywhere the launcher does — only the session-launch path (`spex serve`) is gated.
11
-
12
3
  // tmux presence is the primitive we actually depend on, so probe THAT rather than assuming by platform:
13
4
  // this also catches a bare POSIX box that simply hasn't installed tmux, not only native Windows.
14
5
  export function hasTmux(): boolean {
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { sessionArtifactPath, sessionStoreDir } from './layout.js'
4
+ import type { TimelineEvent } from './session-timeline.js'
5
+
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.
9
+
10
+ export type Cursors = { version: 1; inbox: number; follows: Record<string, number> }
11
+
12
+ const cursorsPath = (id: string): string => sessionArtifactPath(id, 'cursors.json')
13
+ const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
14
+
15
+ // Never throws: a missing, empty, or unparseable file reads as "nothing consumed", because re-showing a
16
+ // message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
17
+ // store dir is gone are dropped here — expiry is this read, and the next write persists it.
18
+ export function readCursors(id: string): Cursors {
19
+ let raw: { inbox?: unknown; follows?: unknown } | null = null
20
+ try { raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8')) } catch { /* no cursors yet */ }
21
+ const follows: Record<string, number> = {}
22
+ const stored = raw?.follows
23
+ if (stored && typeof stored === 'object') {
24
+ for (const [target, pos] of Object.entries(stored as Record<string, unknown>)) {
25
+ if (!target || !existsSync(sessionStoreDir(target))) continue
26
+ follows[target] = at(pos)
27
+ }
28
+ }
29
+ return { version: 1, inbox: at(raw?.inbox), follows }
30
+ }
31
+
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.
34
+ function writeCursors(id: string, cursors: Cursors): void {
35
+ const dir = sessionStoreDir(id)
36
+ mkdirSync(dir, { recursive: true })
37
+ const tmp = join(dir, `.cursors.json.${process.pid}.tmp`)
38
+ writeFileSync(tmp, JSON.stringify(cursors, null, 2) + '\n')
39
+ renameSync(tmp, cursorsPath(id))
40
+ }
41
+
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
+ export const followCursor = (id: string, target: string): number | null => {
53
+ const stored = readCursors(id).follows[target]
54
+ return stored === undefined ? null : stored
55
+ }
56
+
57
+ // Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
58
+ export function advanceFollow(id: string, target: string, to: number): void {
59
+ const cursors = readCursors(id)
60
+ const stored = cursors.follows[target]
61
+ if (stored !== undefined && to <= stored) return
62
+ writeCursors(id, { ...cursors, follows: { ...cursors.follows, [target]: Math.max(0, Math.floor(to)) } })
63
+ }
64
+
65
+ export const followedSessions = (id: string): string[] => Object.keys(readCursors(id).follows)
66
+
67
+ const sameStatus = (a: TimelineEvent, b: TimelineEvent): boolean =>
68
+ a.kind === 'status' && b.kind === 'status' && a.status === b.status
69
+ && (a.proposal ?? null) === (b.proposal ?? null) && (a.note ?? null) === (b.note ?? null)
70
+
71
+ // @@@ edges, not lines - what a reader has not yet seen, with repeated status lines dropped. X→X IS NOT A
72
+ // TRANSITION: the log is append-only and already holds runs of identical status lines from before the
73
+ // timeline observer was deleted (every stray `spex serve` process ran an fs.watch over the same store and
74
+ // re-recorded each real move, so ONE transition landed as up to 6 lines within ~200ms). Those bytes are
75
+ // history and stay. Any consumer that decides "did it move?" must therefore compare VALUES, never adjacency,
76
+ // and must compare against the last status the reader ALREADY saw — otherwise a duplicate straddling the
77
+ // cursor boundary reads as a fresh move on the very next tick. `next` is where the cursor goes after consuming
78
+ // the WHOLE slice: the full length, because a dropped duplicate is still consumed. `at[i]` is the absolute
79
+ // index of `events[i]`, which is what lets a reader that STOPS on one event ([[session-follow]]'s take-one
80
+ // wait) advance to exactly `at[i] + 1` and leave the lines behind it unread for the next reader.
81
+ export function unreadSince(events: TimelineEvent[], from: number): { events: TimelineEvent[]; at: number[]; next: number } {
82
+ const start = Math.min(Math.max(0, Math.floor(from)), events.length)
83
+ let prev: TimelineEvent | null = null
84
+ for (let i = start - 1; i >= 0; i--) if (events[i].kind === 'status') { prev = events[i]; break }
85
+ const out: TimelineEvent[] = []
86
+ const at: number[] = []
87
+ for (let i = start; i < events.length; i++) {
88
+ const e = events[i]
89
+ if (e.kind === 'status') {
90
+ if (prev && sameStatus(prev, e)) continue
91
+ prev = e
92
+ }
93
+ out.push(e)
94
+ at.push(i)
95
+ }
96
+ return { events: out, at, next: events.length }
97
+ }