spexcode 0.5.0 → 0.5.2

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 (32) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/spec-cli/src/claude-headless.ts +105 -16
  4. package/spec-cli/src/cli.ts +13 -2
  5. package/spec-cli/src/codex-headless.ts +13 -0
  6. package/spec-cli/src/guide.ts +8 -7
  7. package/spec-cli/src/harness.ts +53 -5
  8. package/spec-cli/src/host.ts +1 -0
  9. package/spec-cli/src/opencode-headless.ts +119 -5
  10. package/spec-cli/src/opencode.ts +8 -4
  11. package/spec-cli/src/pi-headless.ts +1 -0
  12. package/spec-cli/src/pty-bridge.ts +31 -4
  13. package/spec-cli/src/pty-helper.mjs +16 -6
  14. package/spec-cli/src/pty-native-helper.mjs +22 -0
  15. package/spec-cli/src/session-timeline.ts +29 -21
  16. package/spec-cli/src/sessions.ts +72 -32
  17. package/spec-cli/templates/spexcode.json +1 -0
  18. package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
  19. package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
  20. package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
  21. package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
  22. package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
  23. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
  24. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
  25. package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
  26. package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
  27. package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
  28. package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
  29. package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
  30. package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
  31. package/spec-dashboard/dist/index.html +2 -2
  32. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
@@ -20,6 +20,7 @@ type Subscription = {
20
20
  bridge?: Bridge
21
21
  lingerTimer?: ReturnType<typeof setTimeout>
22
22
  restoreTimer?: ReturnType<typeof setTimeout>
23
+ startupError?: string
23
24
  }
24
25
 
25
26
  type Bridge = {
@@ -37,6 +38,7 @@ type Bridge = {
37
38
  refreshRunning: boolean
38
39
  refreshOffset?: number
39
40
  deliveryTimer?: ReturnType<typeof setTimeout>
41
+ startupError?: string
40
42
  }
41
43
 
42
44
  const subscribers = new Map<string, Map<Viewer, Subscription>>()
@@ -103,6 +105,8 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
103
105
  const ready = line.match(/^READY (\d+)$/)
104
106
  if (ready) {
105
107
  bridge.ptyPid = Number(ready[1])
108
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
109
+ if (subscription) subscription.startupError = undefined
106
110
  if (bridge.delivery === 'initial') {
107
111
  armDeliveryBoundary(bridge)
108
112
  queueRefresh(bridge)
@@ -117,11 +121,28 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
117
121
  queueRefresh(bridge)
118
122
  }
119
123
  } else if (line) {
124
+ const failed = line.match(/^ERROR (.+)$/)
125
+ if (failed) reportStartupError(bridge, failed[1])
120
126
  console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
121
127
  }
122
128
  }
123
129
  }
124
130
 
131
+ function reportStartupError(bridge: Bridge, detail: string): void {
132
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
133
+ if (!subscription || subscription.bridge !== bridge) return
134
+ const message = boundedStartupError(detail)
135
+ bridge.startupError = message
136
+ if (!message || subscription.startupError === message) return
137
+ subscription.startupError = message
138
+ deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'))
139
+ }
140
+
141
+ function boundedStartupError(detail: unknown): string {
142
+ return (detail instanceof Error ? detail.message : String(detail))
143
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start'
144
+ }
145
+
125
146
  function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
126
147
  if (subscription.bridge) return { bridge: subscription.bridge, created: false }
127
148
  let proc: ChildProcessWithoutNullStreams | undefined
@@ -130,8 +151,13 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
130
151
  stdio: ['pipe', 'pipe', 'pipe'],
131
152
  env: process.env,
132
153
  })
133
- } catch {
154
+ } catch (error) {
134
155
  try { proc?.kill() } catch { /* spawn did not complete */ }
156
+ const detail = boundedStartupError(error)
157
+ if (subscription.startupError !== detail) {
158
+ subscription.startupError = detail
159
+ try { viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8')) } catch { /* socket closed */ }
160
+ }
135
161
  return { bridge: null, created: false }
136
162
  }
137
163
  const bridge: Bridge = {
@@ -142,18 +168,19 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
142
168
  proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
143
169
  proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
144
170
  let reaped = false
145
- const gone = () => {
171
+ const gone = (detail?: string) => {
146
172
  if (reaped) return
147
173
  reaped = true
148
174
  const current = currentSubscription(id, viewer)
149
175
  if (current?.bridge !== bridge) return
176
+ if (!bridge.ptyPid && !bridge.startupError) reportStartupError(bridge, detail || 'helper exited before native PTY startup')
150
177
  current.bridge = undefined
151
178
  clearDelivery(bridge)
152
179
  try { bridge.proc.kill() } catch { /* already gone */ }
153
180
  scheduleRestore(id, viewer, current)
154
181
  }
155
- proc.on('exit', gone)
156
- proc.on('error', gone)
182
+ proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`))
183
+ proc.on('error', (error) => gone(`helper process failed: ${error.message}`))
157
184
  return { bridge, created: true }
158
185
  }
159
186
 
@@ -1,5 +1,6 @@
1
1
  import * as pty from 'node-pty'
2
2
  import { execFileSync } from 'node:child_process'
3
+ import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs'
3
4
 
4
5
  const [id, colsArg, rowsArg] = process.argv.slice(2)
5
6
  const cols = Number(colsArg)
@@ -40,12 +41,21 @@ try {
40
41
  }
41
42
  } catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
42
43
 
43
- const terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
44
- name: 'xterm-256color',
45
- cols,
46
- rows,
47
- env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
48
- })
44
+ let terminal
45
+ try {
46
+ ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native))
47
+ terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
48
+ name: 'xterm-256color',
49
+ cols,
50
+ rows,
51
+ env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
52
+ })
53
+ } catch (error) {
54
+ const detail = (error instanceof Error ? error.message : String(error))
55
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500)
56
+ process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`)
57
+ process.exit(1)
58
+ }
49
59
 
50
60
  terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
51
61
  terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
@@ -0,0 +1,22 @@
1
+ import { chmodSync, statSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ const require = createRequire(import.meta.url)
6
+
7
+ export function nodePtySpawnHelperPath(nativeModule) {
8
+ const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename
9
+ if (!nativeAddon) throw new Error('cannot locate node-pty loaded native addon')
10
+ return join(dirname(nativeAddon), 'spawn-helper')
11
+ }
12
+
13
+ export function ensureExecutableIfPresent(path) {
14
+ let mode
15
+ try {
16
+ mode = statSync(path).mode & 0o777
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') return
19
+ throw error
20
+ }
21
+ if ((mode & 0o111) !== 0o111) chmodSync(path, mode | 0o111)
22
+ }
@@ -8,23 +8,23 @@ import type { Lifecycle, Proposal } from './sessions.js'
8
8
  // [[mobile-ui]]) renders instead of a live pane: without the terminal, the declaration notes ARE the agent's
9
9
  // replies, and the timeline is the whole conversation.
10
10
  //
11
- // WHY an observer, not writer instrumentation: the lifecycle has a writer the TS layer never sees — the
12
- // mark-active hook value-replaces status/proposal/note in session.json with pure-shell sed ([[state]]).
13
- // Instrumenting every writer would always miss that one, so the recorder OBSERVES the store instead: one
14
- // fs.watch on the sessions root (debounced) plus a slow reconcile tick (the fs.watch is best-effort, same
15
- // stance as [[graph-stream]]'s source 1), and on each tick it diffs every governed record's
16
- // (status, proposal, note) against the last seen and appends what moved. One mechanism covers every writer
17
- // by construction. Granularity is the debounce window — a flap faster than ~100ms can collapse, exactly like
18
- // the board itself.
11
+ // A declaration note is conversation content, so TS lifecycle writes append moved state at the same write
12
+ // boundary instead of asking a later sample of mutable session.json to reconstruct it. The observer remains
13
+ // because the lifecycle also has a writer the TS layer never sees: the mark-active hook value-replaces
14
+ // status/proposal/note with pure-shell sed ([[state]]). One fs.watch on the sessions root (debounced) plus a
15
+ // slow reconcile tick repairs those external writes. Direct append + observation may duplicate one move;
16
+ // readTimeline folds adjacent duplicates without making history mutable.
19
17
  //
20
- // The recorder runs ONLY in the serve process (superviseTimeline is called from index.ts) so exactly one
21
- // process appends; timestamps are observation times, honest to within the debounce. Only the AUTHORED axis
22
- // is recorded liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
- // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
- // the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
25
- // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED delivery (all prompt deliveries flow
26
- // through it: dashboard/phone input, `spex session send`, the merge dispatch); `from` is the sending
27
- // session's id, null = a human surface.
18
+ // The observer runs ONLY in the serve process (superviseTimeline is called from index.ts); lifecycle writers
19
+ // and confirmed senders append from whichever process owns that write. Direct events use the write time;
20
+ // observed shell events use an observation time honest to within the debounce. Only the AUTHORED axis is
21
+ // recorded liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived per
22
+ // probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from the
23
+ // board row. The timeline lives and dies with the session record (close sweeps the store dir), like
24
+ // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
25
+ // input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
26
+ // composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
27
+ // null = a human surface.
28
28
 
29
29
  export type TimelineEvent =
30
30
  | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
@@ -39,6 +39,13 @@ function append(id: string, ev: TimelineEvent): void {
39
39
  } catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
40
40
  }
41
41
 
42
+ // Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
43
+ // synchronously before returning, so a later write cannot erase an intermediate declaration note from the
44
+ // conversation. The serve observer calls the same sink for shell-authored state.
45
+ export function recordStatus(id: string, status: Lifecycle, proposal: Proposal | null, note: string | null): void {
46
+ append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
47
+ }
48
+
42
49
  function readEvents(id: string): TimelineEvent[] {
43
50
  try {
44
51
  const p = timelinePath(id)
@@ -89,7 +96,7 @@ function scan(): void {
89
96
  if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
90
97
  }
91
98
  lastSeen.set(id, fp)
92
- append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
99
+ recordStatus(id, status, proposal, note)
93
100
  } catch { /* one bad record must not stall the sweep */ }
94
101
  }
95
102
  const live = new Set(ids)
@@ -134,7 +141,8 @@ export function lastHumanSendVia(id: string): 'note' | null {
134
141
 
135
142
  // record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
136
143
  // caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
137
- // `replyVia` marks that the hint rode along so a surface can badge it.
144
+ // `replyVia` is the effective channel chosen by the shared prompt seam, whether explicit or derived from the
145
+ // target adapter, so the durable history records where the reply was actually readable.
138
146
  export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
139
147
  try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
140
148
  append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
@@ -142,9 +150,9 @@ export function recordSent(id: string, text: string, from: string | null, replyV
142
150
 
143
151
  // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
144
152
  // status event carrying its composed display word. null = no such session (the route 404s).
145
- // Adjacent status lines with identical (status, proposal, note) fold into their first: TWO serve processes
146
- // observing one store (a throwaway worktree/eval serve beside the live one) each keep their own lastSeen,
147
- // so a single record move can append twice — cross-process write locking isn't worth buying, so the log
153
+ // Adjacent status lines with identical (status, proposal, note) fold into their first: a direct writer and
154
+ // observer, or TWO serve processes observing one store (a throwaway worktree/eval serve beside the live
155
+ // one), can append a single record move twice. Cross-process write locking isn't worth buying, so the log
148
156
  // stays best-effort append-only and the read is where duplicates die, same stance as the board.
149
157
  export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
150
158
  let raw: ReturnType<typeof readAliasedRawRecord>
@@ -10,7 +10,7 @@ import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs
10
10
  import { defaultHarness, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
11
11
  import { materialize } from './materialize.js'
12
12
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
13
- import { recordSent, lastHumanSendVia } from './session-timeline.js'
13
+ import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
14
14
  import { stripRefSigil } from './mentions.js'
15
15
 
16
16
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
@@ -294,6 +294,8 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
294
294
  // pure-shell hot-path hook (mark-active) relies on: it value-replaces `"status"`/`"proposal"`/`"note"` with a
295
295
  // single sed and never needs jq on the user's box. So do NOT switch to conditional keys or a compact dump.
296
296
  function writeRecord(rec: SessRec): void {
297
+ let previous: SessRec | null = null
298
+ try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
297
299
  const obj = {
298
300
  session_id: rec.session,
299
301
  governed: rec.governed,
@@ -320,6 +322,13 @@ function writeRecord(rec: SessRec): void {
320
322
  }
321
323
  mkdirSync(sessionStoreDir(rec.session), { recursive: true })
322
324
  writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
325
+ // session.json is only the CURRENT projection. Persist each moved lifecycle value before this writer
326
+ // returns, so a later write cannot erase a declaration note between observer samples. New-record genesis
327
+ // stays with superviseTimeline; metadata-only writes do not manufacture status events.
328
+ if (rec.governed && previous && (previous.status !== rec.status
329
+ || previous.proposal !== rec.proposal || previous.note !== rec.note)) {
330
+ recordStatus(rec.session, rec.status, rec.proposal, rec.note)
331
+ }
323
332
  }
324
333
 
325
334
  // @@@ fail-loud enumeration - the worktree set is the board's EXISTENCE truth, so a failed enumeration must
@@ -892,17 +901,18 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
892
901
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
893
902
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
894
903
  }
895
- // @@@ withNoteReplyHint - the TERMINAL-FREE sender's insert, withSenderHint's sibling: a phone (or any
896
- // no-terminal surface, [[mobile-ui]]) cannot read the pane, so the only text that ever reaches its human is
897
- // the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
898
- // next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
899
- // Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
900
- // surface desktop included, later can opt in with the same flag. The notice declares itself
904
+ // @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
905
+ // terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
906
+ // insert tells the agent exactly that, so its next stop carries the complete answer in `--note` instead of
907
+ // prose that dies in an unseen output stream. The declaration is TRANSPORT, not task work: a raw prompt's
908
+ // "no tools" / "only print" constraint does not cancel it. composeSessionPrompt is the only production caller
909
+ // deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
910
+ // default. The notice declares itself
901
911
  // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
902
912
  // note-replied a few times keeps note-replying from context inertia long after the human is back at a
903
913
  // terminal — the sticky-note failure this pair exists to prevent.
904
914
  export const withNoteReplyHint = (text: string): string =>
905
- `${text}\n\n— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") the note is the only text that reaches them. This notice is PER-MESSAGE: a later message arriving WITHOUT it means the sender is back at a terminal and reads your normal output again.`
915
+ `${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For a simple answer awaiting the next message, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`
906
916
  // @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
907
917
  // note→terminal transition (a human send with no note flag whose previous human send carried one —
908
918
  // [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
@@ -996,6 +1006,30 @@ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpe
996
1006
  const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
997
1007
  return composeCommandPrompt(raw, [preset], specs)
998
1008
  }
1009
+
1010
+ type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
1011
+ type SessionPromptOptions = {
1012
+ from?: string
1013
+ replyVia?: 'note'
1014
+ loadedSpecs?: CommandSpec[]
1015
+ suffix?: string
1016
+ }
1017
+ export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
1018
+
1019
+ // @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
1020
+ // exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
1021
+ // merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
1022
+ // request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
1023
+ // note/terminal inserts, so clients never own the policy or duplicate the phrase.
1024
+ export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
1025
+ const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
1026
+ const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
1027
+ const h = harnessById(target.harness || defaultHarness.id)
1028
+ const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
1029
+ const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1030
+ : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
1031
+ return { text, ...(replyVia ? { replyVia } : {}) }
1032
+ }
999
1033
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
1000
1034
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
1001
1035
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
@@ -1057,7 +1091,10 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1057
1091
  // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
1058
1092
  // only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
1059
1093
  // attempts, then give up).
1060
- writeFileSync(file, [
1094
+ // A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
1095
+ // app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
1096
+ // retry loop is a runtime capability rather than a harness-id branch.
1097
+ const launchBody = harness.launchOneShot ? [born, ''] : [
1061
1098
  `for __spex_try in 1 2 3; do`,
1062
1099
  ` __spex_t0=$SECONDS`,
1063
1100
  ` ${born}`,
@@ -1068,7 +1105,8 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1068
1105
  `done`,
1069
1106
  `exit $__spex_rc`,
1070
1107
  ``,
1071
- ].join('\n'))
1108
+ ]
1109
+ writeFileSync(file, launchBody.join('\n'))
1072
1110
  return file
1073
1111
  }
1074
1112
  async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
@@ -1286,19 +1324,25 @@ export async function newSession(prompt: string, parent: string | null = null, l
1286
1324
  const chosen = resolveLauncher(lname)
1287
1325
  const h = harnessById(chosen.harness)
1288
1326
  const pinned = h.baseCmd(chosen.cmd)
1289
- // Resolve a command preset at the shared backend prompt boundary, before any worktree exists. The RAW prompt remains the
1290
- // identity + originating-prompt source; only `launchPrompt` is expanded for the agent. This preserves the
1291
- // no-target rule even when the plugin body itself contains `[[links]]`.
1292
1327
  const rawPrompt = prompt
1293
1328
  // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1294
1329
  // plugin prose is payload only and can never invent scope.
1295
1330
  const ref = nodeFromPrompt(rawPrompt)
1296
1331
  const launchSpecs = ref ? await loadSpecs() : null
1297
- let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
1298
1332
  const title = ref ? null : titleFromPrompt(rawPrompt)
1299
1333
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1300
1334
  const branch = `node/${slug}`
1301
1335
  const path = join(mainRoot(), '.worktrees', slug)
1336
+ // Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
1337
+ // preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
1338
+ const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
1339
+ const suffix = spec
1340
+ ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1341
+ : undefined
1342
+ const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
1343
+ loadedSpecs: launchSpecs ?? undefined,
1344
+ suffix,
1345
+ })).text
1302
1346
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1303
1347
  // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
1304
1348
  // artifacts; the ONE
@@ -1331,15 +1375,6 @@ export async function newSession(prompt: string, parent: string | null = null, l
1331
1375
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1332
1376
  // own memory load too.
1333
1377
  bootstrapMaterialize(rec)
1334
- if (ref) {
1335
- // @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
1336
- // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
1337
- // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
1338
- // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
1339
- // a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
1340
- const spec = launchSpecs?.find((n) => n.id === ref)
1341
- if (spec) launchPrompt = `${launchPrompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1342
- }
1343
1378
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1344
1379
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1345
1380
  const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
@@ -1455,6 +1490,17 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
1455
1490
  }
1456
1491
  export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
1457
1492
  export const markError = (sessionId?: string) => markState('error', { sessionId })
1493
+ // @@@ headless turn outcome - a harness turn is an ephemeral child, so its non-zero exit is the one external
1494
+ // runtime fact that must become visible on the durable board. Compare-and-set only an undeclared active record:
1495
+ // a zero exit is never routed here, and a declaration that landed before teardown is authoritative.
1496
+ export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
1497
+ if (exitCode === '0') return false
1498
+ const rec = readRecord(sessionId)
1499
+ if (!rec || rec.status !== 'active') return false
1500
+ const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
1501
+ writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
1502
+ return true
1503
+ }
1458
1504
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
1459
1505
  const id = sessionId || ownSessionId()
1460
1506
  if (!id || !harnessSessionId) return false
@@ -1996,19 +2042,13 @@ export async function sendText(id: string, text: string, from?: string, opts: {
1996
2042
  if (blocked) return { ok: false, error: blocked }
1997
2043
  } catch { /* no pane to consult — let the delivery channel decide */ }
1998
2044
  }
1999
- const prompt = await resolveCommandPrompt(text)
2000
- // a terminal-free sender's dispatch carries the note-reply insert; a human send WITHOUT the flag whose
2001
- // previous human send carried it is the note→terminal transition and gets the one-shot counter-insert
2002
- // ([[session-timeline]]). Both appended here, beside the delivery, so every input surface shares the one
2003
- // phrase pair and the timeline records the message WITHOUT it (the hint is transport, not conversation).
2004
- const wrapped = opts.replyVia === 'note' ? withNoteReplyHint(prompt)
2005
- : !from && lastHumanSendVia(id) === 'note' ? withTerminalReplyHint(prompt) : prompt
2006
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, wrapped)
2045
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
2046
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
2007
2047
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
2008
2048
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
2009
2049
  if (r.ok && from) void recordComms(id, from)
2010
2050
  // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
2011
- if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
2051
+ if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
2012
2052
  return r
2013
2053
  }
2014
2054
 
@@ -10,6 +10,7 @@
10
10
  "claude": { "harness": "claude", "cmd": "claude" },
11
11
  "claude-headless": { "harness": "claude-headless", "cmd": "claude" },
12
12
  "codex": { "harness": "codex", "cmd": "codex" },
13
+ "codex-headless": { "harness": "codex-headless", "cmd": "codex --yolo" },
13
14
  "opencode": { "harness": "opencode", "cmd": "opencode" },
14
15
  "opencode-headless": { "harness": "opencode-headless", "cmd": "opencode --auto" },
15
16
  "pi": { "harness": "pi", "cmd": "pi" },