spexcode 0.5.0 → 0.5.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.
package/README.md CHANGED
@@ -82,7 +82,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
82
82
  ```sh
83
83
  npm i -g spexcode # installs the `spex` command
84
84
  cd your-repo
85
- spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/, installs hooks, materializes the agent contracts
85
+ spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/, installs hooks, materializes the agent contracts
86
86
  ```
87
87
 
88
88
  That's the whole adoption. The example lists all the built-in harnesses — remove the ones you don't
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "@hono/node-server": "^1.13.7",
40
40
  "@hono/node-ws": "^1.0.4",
41
41
  "hono": "^4.6.14",
42
- "node-pty": "^1.1.0",
42
+ "node-pty": "1.2.0-beta.14",
43
43
  "tsx": "^4.19.2"
44
44
  },
45
45
  "devDependencies": {
@@ -0,0 +1,13 @@
1
+ import { codexLaunchCommand } from './harness.js'
2
+
3
+ // Codex headless keeps the existing app-server bootstrap and backend-owned thread/start + first turn, but
4
+ // deliberately omits the final interactive `--remote … resume` attach. The shared app-server remains the
5
+ // durable home for the thread; follow-up delivery uses codexHarness' existing JSON-RPC transport.
6
+ export function codexHeadlessLaunchCommand(
7
+ id: string,
8
+ codexCmd = 'codex',
9
+ serverCmd?: string,
10
+ dir?: string,
11
+ ): string {
12
+ return codexLaunchCommand(id, codexCmd, serverCmd, dir, false)
13
+ }
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
14
14
  startup commands.)
15
15
 
16
16
  2. Adopt a repo
17
- cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/ + git hooks (additive, never overwrites)
17
+ cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
18
18
  --harness is required and has no default — the explicit choice of which harness(es) materialize
19
19
  delivers into. The example lists every built-in; drop the ones you don't use (any one id or
20
20
  comma-separated subset is valid).
@@ -333,18 +333,19 @@ harness the adopter SELECTED (--harness), from the template pool
333
333
  "claude" → { "harness": "claude", "cmd": "claude" }
334
334
  "claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
335
335
  "codex" → { "harness": "codex", "cmd": "codex" }
336
+ "codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
336
337
  "opencode" → { "harness": "opencode", "cmd": "opencode" }
337
338
  "opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
338
339
  "pi" → { "harness": "pi", "cmd": "pi" }
339
340
  "pi-headless" → { "harness": "pi-headless", "cmd": "pi" }
340
- The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` is the one
341
- deliberate seed exception: its terminal-free run requires \`opencode --auto\`; interactive \`opencode\` stays
342
- plain. Other automatic-permission commands are NEVER clean-init defaults: define and select one explicitly
343
- only when that access is intended. To run workers under an auth
341
+ The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` and
342
+ \`codex-headless\` are deliberate seed exceptions: their terminal-free runs require \`opencode --auto\` and
343
+ \`codex --yolo\`; interactive profiles stay plain. Other automatic-permission commands are NEVER clean-init
344
+ defaults: define and select one explicitly only when that access is intended. To run workers under an auth
344
345
  wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
345
346
  override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
346
347
  permission variants. Shape:
347
- "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "opencode-headless" | "pi-headless",
348
+ "launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "codex-headless" | "opencode-headless" | "pi-headless",
348
349
  "cmd": "<launch command>" } }
349
350
  \`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
350
351
  in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
@@ -453,7 +454,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
453
454
  preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
454
455
  'default'; seed-time only, read by init.ts).
455
456
  harnesses which harness targets \`spex materialize\` delivers into — native ids
456
- ("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"opencode-headless"|"pi-headless") or a
457
+ ("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"codex-headless"|"opencode-headless"|"pi-headless") or a
457
458
  { "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
458
459
  stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
459
460
  git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
@@ -10,6 +10,7 @@ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlash
10
10
  import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
11
11
  import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
12
12
  import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
13
+ import { codexHeadlessLaunchCommand } from './codex-headless.js'
13
14
  import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
14
15
  import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
15
16
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
@@ -27,7 +28,7 @@ import { git } from './git.js'
27
28
  // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
28
29
  // (materialize writes every harness's artifacts).
29
30
 
30
- export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
31
+ export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
31
32
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
32
33
  // the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
33
34
  // the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
@@ -47,6 +48,9 @@ export interface Harness {
47
48
  // whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
48
49
  // adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
49
50
  readonly headless: boolean
51
+ // whether the launch command intentionally exits after its first turn instead of owning a resident process.
52
+ // One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
53
+ readonly launchOneShot?: boolean
50
54
  // whether this harness persists a native event stream that the console may expose as an optional
51
55
  // full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
52
56
  readonly messageStream: boolean
@@ -446,7 +450,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
446
450
  bypassProbe.set(binary, ok)
447
451
  return ok
448
452
  }
449
- export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
453
+ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
450
454
  const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
451
455
  // The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
452
456
  // the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
@@ -516,11 +520,17 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
516
520
  // rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
517
521
  `if [ "$1" = "--resume" ]; then`,
518
522
  ` tid=$2`,
523
+ ...(attachTui ? [] : [
524
+ // A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
525
+ // a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
526
+ `elif [ "$#" -eq 0 ]; then`,
527
+ ` exit 0`,
528
+ ]),
519
529
  `else`,
520
530
  ` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
521
531
  `fi`,
522
532
  `[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
523
- `exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`,
533
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
524
534
  ].join('\n')
525
535
  return `bash -lc ${shQuote(script)} spexcode-codex`
526
536
  }
@@ -1230,6 +1240,24 @@ export const codexHarness: Harness = {
1230
1240
  resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
1231
1241
  }
1232
1242
 
1243
+ // Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
1244
+ // while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
1245
+ // the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
1246
+ export const codexHeadlessHarness: Harness = {
1247
+ ...codexHarness,
1248
+ id: 'codex-headless',
1249
+ headless: true,
1250
+ launchOneShot: true,
1251
+ messageStream: false,
1252
+ launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
1253
+ // Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
1254
+ // thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
1255
+ liveness: () => 'online',
1256
+ // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
1257
+ // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
1258
+ resumeArg: () => '',
1259
+ }
1260
+
1233
1261
  // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
1234
1262
  // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
1235
1263
  // lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
@@ -1367,7 +1395,7 @@ export const opencodeHeadlessHarness: Harness = {
1367
1395
  }
1368
1396
 
1369
1397
  // every adapter — materialize iterates this to write each harness's artifacts in one pass.
1370
- export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
1398
+ export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
1371
1399
 
1372
1400
  // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1373
1401
  export const defaultHarness: Harness = claudeHarness
@@ -22,9 +22,10 @@ import type { Lifecycle, Proposal } from './sessions.js'
22
22
  // is recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
23
  // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
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.
25
+ // comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
26
+ // input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
27
+ // composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
28
+ // null = a human surface.
28
29
 
29
30
  export type TimelineEvent =
30
31
  | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
@@ -134,7 +135,8 @@ export function lastHumanSendVia(id: string): 'note' | null {
134
135
 
135
136
  // record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
136
137
  // 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.
138
+ // `replyVia` is the effective channel chosen by the shared prompt seam, whether explicit or derived from the
139
+ // target adapter, so the durable history records where the reply was actually readable.
138
140
  export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
139
141
  try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
140
142
  append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
@@ -892,12 +892,12 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
892
892
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
893
893
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
894
894
  }
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
895
+ // @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
896
+ // terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
897
+ // one-line insert tells the agent exactly that, so its next stop carries the complete answer in `--note`
898
+ // instead of prose that dies in an unseen output stream. composeSessionPrompt is the only production caller
899
+ // deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
900
+ // default. The notice declares itself
901
901
  // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
902
902
  // note-replied a few times keeps note-replying from context inertia long after the human is back at a
903
903
  // terminal — the sticky-note failure this pair exists to prevent.
@@ -996,6 +996,30 @@ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpe
996
996
  const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
997
997
  return composeCommandPrompt(raw, [preset], specs)
998
998
  }
999
+
1000
+ type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
1001
+ type SessionPromptOptions = {
1002
+ from?: string
1003
+ replyVia?: 'note'
1004
+ loadedSpecs?: CommandSpec[]
1005
+ suffix?: string
1006
+ }
1007
+ export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
1008
+
1009
+ // @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
1010
+ // exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
1011
+ // merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
1012
+ // request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
1013
+ // note/terminal inserts, so clients never own the policy or duplicate the phrase.
1014
+ export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
1015
+ const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
1016
+ const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
1017
+ const h = harnessById(target.harness || defaultHarness.id)
1018
+ const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
1019
+ const text = replyVia === 'note' ? withNoteReplyHint(prompt)
1020
+ : !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
1021
+ return { text, ...(replyVia ? { replyVia } : {}) }
1022
+ }
999
1023
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
1000
1024
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
1001
1025
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
@@ -1057,7 +1081,10 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1057
1081
  // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
1058
1082
  // only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
1059
1083
  // attempts, then give up).
1060
- writeFileSync(file, [
1084
+ // A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
1085
+ // app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
1086
+ // retry loop is a runtime capability rather than a harness-id branch.
1087
+ const launchBody = harness.launchOneShot ? [born, ''] : [
1061
1088
  `for __spex_try in 1 2 3; do`,
1062
1089
  ` __spex_t0=$SECONDS`,
1063
1090
  ` ${born}`,
@@ -1068,7 +1095,8 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
1068
1095
  `done`,
1069
1096
  `exit $__spex_rc`,
1070
1097
  ``,
1071
- ].join('\n'))
1098
+ ]
1099
+ writeFileSync(file, launchBody.join('\n'))
1072
1100
  return file
1073
1101
  }
1074
1102
  async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
@@ -1286,19 +1314,25 @@ export async function newSession(prompt: string, parent: string | null = null, l
1286
1314
  const chosen = resolveLauncher(lname)
1287
1315
  const h = harnessById(chosen.harness)
1288
1316
  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
1317
  const rawPrompt = prompt
1293
1318
  // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1294
1319
  // plugin prose is payload only and can never invent scope.
1295
1320
  const ref = nodeFromPrompt(rawPrompt)
1296
1321
  const launchSpecs = ref ? await loadSpecs() : null
1297
- let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
1298
1322
  const title = ref ? null : titleFromPrompt(rawPrompt)
1299
1323
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1300
1324
  const branch = `node/${slug}`
1301
1325
  const path = join(mainRoot(), '.worktrees', slug)
1326
+ // Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
1327
+ // preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
1328
+ const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
1329
+ const suffix = spec
1330
+ ? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1331
+ : undefined
1332
+ const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
1333
+ loadedSpecs: launchSpecs ?? undefined,
1334
+ suffix,
1335
+ })).text
1302
1336
  await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
1303
1337
  // the checkout delivers the tracked spec sources and the materialize below delivers the materialized
1304
1338
  // artifacts; the ONE
@@ -1331,15 +1365,6 @@ export async function newSession(prompt: string, parent: string | null = null, l
1331
1365
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1332
1366
  // own memory load too.
1333
1367
  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
1368
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1344
1369
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1345
1370
  const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
@@ -1996,19 +2021,13 @@ export async function sendText(id: string, text: string, from?: string, opts: {
1996
2021
  if (blocked) return { ok: false, error: blocked }
1997
2022
  } catch { /* no pane to consult — let the delivery channel decide */ }
1998
2023
  }
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)
2024
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
2025
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
2007
2026
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
2008
2027
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
2009
2028
  if (r.ok && from) void recordComms(id, from)
2010
2029
  // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
2011
- if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
2030
+ if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
2012
2031
  return r
2013
2032
  }
2014
2033
 
@@ -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" },