spexcode 0.4.0 → 0.4.3

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 (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -2,14 +2,15 @@ import { execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
5
- import { join, dirname, relative, isAbsolute } from 'node:path'
5
+ import { join, dirname, relative, isAbsolute, resolve, sep } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { seedWorktreeHostState } from './worktree-sources.js'
8
8
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
9
- import { loadSpecs } from './specs.js'
10
- import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
9
+ import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs.js'
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
14
  import { stripRefSigil } from './mentions.js'
14
15
 
15
16
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
@@ -43,9 +44,9 @@ import { stripRefSigil } from './mentions.js'
43
44
  // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
44
45
  // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
45
46
  //
46
- // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
47
- // SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
48
- // launcher ([[launcher-select]]), not an env var.
47
+ // Launch rules: private `tmux -L <label>` socket + the session's pinned named-launcher command. The launcher
48
+ // preserves its harness's normal permission model unless the user explicitly configured an automatic-permission
49
+ // command. SPEXCODE_TMUX overrides the tmux socket for tests; no env var rewrites the launcher.
49
50
 
50
51
  const pexec = promisify(execFile)
51
52
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -86,7 +87,7 @@ const rvEnv = (id: string, harness = HARNESS) => {
86
87
  // start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
87
88
  // process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
88
89
  // rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
89
- // (claude) — codex has no such daemon, so it's omitted there.
90
+ // (claude/pi/opencode) — codex has no such daemon, so it is omitted there.
90
91
  const parts = [`SPEXCODE_SESSION_ID=${id}`]
91
92
  if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
92
93
  for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
@@ -228,9 +229,34 @@ export type SessRec = {
228
229
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
229
230
  launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
230
231
  launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
232
+ launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
231
233
  }
232
234
  const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
233
235
  const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
236
+ export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
237
+
238
+ // @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
239
+ // That URL survives child hot reload/restart; PORT inside a supervised child is private and ephemeral, so it
240
+ // is only the fallback for a directly-run server with no injected API URL. Credentials/query/fragment are
241
+ // not authority and may contain secrets, so they are stripped before the value reaches session.json.
242
+ export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
243
+ const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
244
+ const url = new URL(raw)
245
+ url.username = ''
246
+ url.password = ''
247
+ url.search = ''
248
+ url.hash = ''
249
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/'
250
+ return url.toString().replace(/\/$/, '')
251
+ }
252
+
253
+ export function rawLifecycleStatus(rec: Pick<SessRec, 'status' | 'launchOwner'>): string {
254
+ return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status
255
+ }
256
+
257
+ export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, authority = backendLaunchAuthority()): boolean {
258
+ return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority)
259
+ }
234
260
 
235
261
  // typed read of a session's record from the global store (null if it has none — a self-launched session that
236
262
  // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
@@ -241,8 +267,13 @@ function readRecord(id: string): SessRec | null {
241
267
  if (!raw) return null
242
268
  return fromRaw(raw)
243
269
  }
244
- function fromRaw(raw: RawRecord): SessRec {
245
- const status = LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
270
+ // the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
271
+ // claude, absent pin null) are unit-auditable without a store on disk.
272
+ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
273
+ const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
274
+ const status = ownedQueue ? 'queued' : LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
275
+ const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
276
+ if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
246
277
  const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
247
278
  const sk = raw.sortkey
248
279
  const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
@@ -254,6 +285,7 @@ function fromRaw(raw: RawRecord): SessRec {
254
285
  harnessSessionId: raw.harness_session_id || null,
255
286
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
256
287
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
288
+ launchOwner: launchOwner || null,
257
289
  }
258
290
  }
259
291
  // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
@@ -270,7 +302,10 @@ function writeRecord(rec: SessRec): void {
270
302
  title: rec.title ?? '',
271
303
  name: rec.name ?? '',
272
304
  parent: rec.parent ?? '',
273
- status: rec.status,
305
+ // A leased queue uses a raw token older drainers do not recognize as `queued`; current readers map it
306
+ // back to the unchanged public lifecycle. This version fence is what keeps an orphaned old backend from
307
+ // stealing the entry before it can even inspect the new launch_owner field.
308
+ status: rawLifecycleStatus(rec),
274
309
  proposal: rec.proposal ?? '',
275
310
  merges: rec.merges,
276
311
  note: rec.note ?? '',
@@ -280,6 +315,7 @@ function writeRecord(rec: SessRec): void {
280
315
  harness_session_id: rec.harnessSessionId ?? '',
281
316
  launcher: rec.launcher ?? '',
282
317
  launch_cmd: rec.launchCmd ?? '',
318
+ launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
283
319
  }
284
320
  mkdirSync(sessionStoreDir(rec.session), { recursive: true })
285
321
  writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
@@ -322,16 +358,7 @@ async function listWorktrees(): Promise<{ path: string; branch: string | null }[
322
358
  // wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
323
359
  // its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
324
360
  // its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
325
- async function procSnapshot(): Promise<ProcTable> {
326
- const t: ProcTable = new Map()
327
- let out = ''
328
- try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: TMUX_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' })) } catch { return t }
329
- for (const line of out.split('\n')) {
330
- const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
331
- if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
332
- }
333
- return t
334
- }
361
+ // (the whole-box ps snapshot itself — procSnapshot lives in harness.ts beside its tree-walk consumers.)
335
362
  // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
336
363
  // (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
337
364
  // ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
@@ -519,7 +546,7 @@ export function selfSummary(paneTitle: string): string | null {
519
546
  // instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
520
547
  // memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
521
548
  const launchedAt = new Map<string, number>()
522
- const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
549
+ export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
523
550
  // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
524
551
  const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
525
552
  // exit before readiness is retryable, but it is not proof of one specific cause
@@ -539,10 +566,10 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
539
566
  export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
540
567
  if (!rec.session) return 'offline'
541
568
  if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
542
- // ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND a live listener on its rendezvous socket; codex
543
- // = tmux up AND a codex-ish process live among the pane pid's descendants (not the bare shell a failed launch
544
- // dropped back to). The 'starting' grace stays here (a launcher concern): a just-launched agent whose
545
- // online-signal hasn't appeared yet reads 'starting' for the boot window, only past it 'offline'.
569
+ // Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
570
+ // codex proves its launch-registered pid (with the legacy descendant-tree fallback). The 'starting' grace
571
+ // stays here: a just-launched agent whose online signal has not appeared yet reads 'starting', only past it
572
+ // 'offline'.
546
573
  const h = harnessById(rec.harness || defaultHarness.id)
547
574
  if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
548
575
  // not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
@@ -863,6 +890,25 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
863
890
  const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
864
891
  return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
865
892
  }
893
+ // @@@ withNoteReplyHint - the TERMINAL-FREE sender's insert, withSenderHint's sibling: a phone (or any
894
+ // no-terminal surface, [[mobile-ui]]) cannot read the pane, so the only text that ever reaches its human is
895
+ // the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
896
+ // next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
897
+ // Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
898
+ // surface — desktop included, later — can opt in with the same flag. The notice declares itself
899
+ // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
900
+ // note-replied a few times keeps note-replying from context inertia long after the human is back at a
901
+ // terminal — the sticky-note failure this pair exists to prevent.
902
+ export const withNoteReplyHint = (text: string): string =>
903
+ `${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.`
904
+ // @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
905
+ // note→terminal transition (a human send with no note flag whose previous human send carried one —
906
+ // [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
907
+ // the agent once beats hoping it scopes the earlier per-message notice correctly (it demonstrably doesn't —
908
+ // codex especially). Not repeated on later terminal sends: once the transition send is recorded, the last
909
+ // human channel is no longer note, so ordinary conversation stays insert-free.
910
+ export const withTerminalReplyHint = (text: string): string =>
911
+ `${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`
866
912
  async function postJSON(path: string, body: unknown): Promise<void> {
867
913
  try {
868
914
  await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
@@ -896,10 +942,10 @@ export const isBackendUnreachable = (e: unknown): boolean =>
896
942
  export const slugify = (s: string | null) =>
897
943
  (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
898
944
 
899
- // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
900
- // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
901
- // actually left in the prompt (changed it, or deleted it for a node-agnostic prompt) is the truth. We read
902
- // the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
945
+ // @@@ node + title from the prompt - the spec node a session works on is the FIRST `[[<id>]]` topic
946
+ // reference in the raw prompt ([[mentions]]: `[[node]]` is a topic, `@` is an actor/session). The node the
947
+ // user actually left in the prompt is the truth: there is no focused-node/API/function argument that can
948
+ // grant a binding outside the task text. Changing or deleting the mention changes or removes the binding.
903
949
  // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
904
950
  // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
905
951
  // (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
@@ -907,7 +953,47 @@ export const slugify = (s: string | null) =>
907
953
  // id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
908
954
  // node-agnostic.
909
955
  const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
910
- const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
956
+ export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
957
+
958
+ type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
959
+ type CommandSpec = Pick<SpecLite, 'id' | 'path'>
960
+
961
+ // @@@ command invocation - turn the raw `/<preset> [[node]]… free text` into the ONE agent prompt.
962
+ // This is deliberately server-side: dashboard, phone, CLI, direct API, and the in-process fallback all call
963
+ // the launch/send boundary, so no client gets its own command interpreter. Launch keeps the RAW prompt for
964
+ // session identity/history; only the agent payload uses this expansion, preventing a plugin body's own
965
+ // [[links]] from becoming the session node. With no mention, a preset remains targetless.
966
+ export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
967
+ const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
968
+ if (!match) return raw
969
+ const preset = presets.find((p) => p.name === match[1])
970
+ if (!preset) return raw
971
+
972
+ const ids: string[] = []
973
+ const allMentions = new RegExp(MENTION.source, 'gu')
974
+ const free = match[2].replace(allMentions, (_, id: string) => { ids.push(id); return '' }).trim()
975
+ const targets = ids.length
976
+ ? ids.map((id) => {
977
+ const spec = specs.find((s) => s.id === id)
978
+ const path = spec?.path.replace(/^\.spec\//, '').replace(/\/spec\.md$/, '')
979
+ return path ? `- [[${id}]] — ${path}` : `- [[${id}]]`
980
+ }).join('\n')
981
+ : '(No target was mentioned. If the prompt names the scope, use it; otherwise ask the human to define the scope before proceeding — unless this task needs no scope, in which case proceed.)'
982
+ const body = preset.body.includes('{{targets}}')
983
+ ? preset.body.replace('{{targets}}', targets)
984
+ : ids.length ? `${preset.body}\n\n${targets}` : preset.body
985
+ return free ? `${body}\n\n${free}` : body
986
+ }
987
+
988
+ // Load only the one live preset named by the raw invocation. Both newSession and sendText call this seam, so
989
+ // launch and an existing session's inbox resolve identical plugin data with identical target semantics.
990
+ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
991
+ const commandName = raw.match(/^\/(\S+)/)?.[1]
992
+ const preset = commandName ? loadConfig().find((p) => p.name === commandName) : undefined
993
+ if (!preset) return raw
994
+ const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
995
+ return composeCommandPrompt(raw, [preset], specs)
996
+ }
911
997
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
912
998
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
913
999
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
@@ -1005,7 +1091,7 @@ function isOccupying(s: Session, snap: LiveSnap): boolean {
1005
1091
  if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
1006
1092
  const rec = readRecord(s.id)
1007
1093
  if (!rec) return false
1008
- return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online' // and only while the agent is genuinely live (its adapter's channel)
1094
+ return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online'
1009
1095
  }
1010
1096
  // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
1011
1097
  // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
@@ -1021,6 +1107,7 @@ let draining = false // re-entrancy guard: only one drain pass runs at a time
1021
1107
  async function startQueued(id: string): Promise<boolean> {
1022
1108
  const wt = await findWorktree(id)
1023
1109
  if (!wt) return false
1110
+ if (!canDrainQueued(wt.rec)) return false
1024
1111
  const launchPrompt = readLaunchFile(id)
1025
1112
  if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
1026
1113
  launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
@@ -1032,7 +1119,7 @@ async function startQueued(id: string): Promise<boolean> {
1032
1119
  launching.delete(id)
1033
1120
  return false // launch failed → stays `queued`, retried on the next drain tick
1034
1121
  }
1035
- writeRecord({ ...wt.rec, status: 'active', proposal: null })
1122
+ writeRecord({ ...wt.rec, status: 'active', proposal: null, launchOwner: null })
1036
1123
  removeLaunchFile(id) // consumed
1037
1124
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1038
1125
  // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
@@ -1059,7 +1146,12 @@ export async function drainQueue(): Promise<void> {
1059
1146
  if (snap.probeFailed) break
1060
1147
  const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
1061
1148
  if (occupied >= cap) break
1062
- const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
1149
+ const authority = backendLaunchAuthority()
1150
+ const next = sessions.find((s) => {
1151
+ if (s.status !== 'queued' || launching.has(s.id)) return false
1152
+ const rec = readRecord(s.id)
1153
+ return !!rec && canDrainQueued(rec, authority)
1154
+ })
1063
1155
  if (!next) break
1064
1156
  if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
1065
1157
  }
@@ -1116,6 +1208,29 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1116
1208
  }
1117
1209
  }
1118
1210
 
1211
+ type SessionCreateFn = (prompt: string, parent: string | null, launcher?: string) => Promise<Session>
1212
+ export type SessionCreateRequestResult =
1213
+ | { status: 201; session: Session }
1214
+ | { status: 400; error: string }
1215
+
1216
+ // The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
1217
+ // contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
1218
+ export async function sessionCreateRequest(body: unknown, create: SessionCreateFn = newSession): Promise<SessionCreateRequestResult> {
1219
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
1220
+ const input = body as Record<string, unknown>
1221
+ const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
1222
+ if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
1223
+ const prompt = typeof input.prompt === 'string' ? input.prompt : ''
1224
+ if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1225
+ const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1226
+ const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
1227
+ try {
1228
+ return { status: 201, session: await create(prompt, parent, launcher) }
1229
+ } catch (e) {
1230
+ return { status: 400, error: String((e as Error).message || e) }
1231
+ }
1232
+ }
1233
+
1119
1234
  // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
1120
1235
  // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1121
1236
  // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
@@ -1124,7 +1239,7 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1124
1239
  // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1125
1240
  // reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
1126
1241
  // handler calls newSession directly, so it never re-enters this path.
1127
- export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1242
+ export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1128
1243
  await assertProjectMatch('spex session new')
1129
1244
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1130
1245
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
@@ -1136,11 +1251,11 @@ export async function createSession(node: string | null, prompt: string, launche
1136
1251
  res = await fetch(`${await apiBase()}/api/sessions`, {
1137
1252
  method: 'POST',
1138
1253
  headers: { 'content-type': 'application/json' },
1139
- body: JSON.stringify({ node, prompt, parent, launcher }),
1254
+ body: JSON.stringify({ prompt, parent, launcher }),
1140
1255
  })
1141
1256
  } catch {
1142
1257
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1143
- return newSession(node, prompt, parent, launcher)
1258
+ return newSession(prompt, parent, launcher)
1144
1259
  }
1145
1260
  if (!res.ok) {
1146
1261
  const text = await res.text().catch(() => '')
@@ -1160,7 +1275,7 @@ export async function createSession(node: string | null, prompt: string, launche
1160
1275
  // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
1161
1276
  // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
1162
1277
  // programmatic surface, every other surface is prompt only).
1163
- export async function newSession(node: string | null, prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
1278
+ export async function newSession(prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
1164
1279
  const id = randomUUID()
1165
1280
  // a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
1166
1281
  // picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
@@ -1168,10 +1283,17 @@ export async function newSession(node: string | null, prompt: string, parent: st
1168
1283
  const lname = launcher ?? defaultLauncher(mainRoot())
1169
1284
  const chosen = resolveLauncher(lname)
1170
1285
  const h = harnessById(chosen.harness)
1171
- // node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
1172
- // none is node-agnostic and labeled by its first few words.
1173
- const ref = node || mentionedNode(prompt)
1174
- const title = ref ? null : titleFromPrompt(prompt)
1286
+ const pinned = h.baseCmd(chosen.cmd)
1287
+ // Resolve a command preset at the shared backend prompt boundary, before any worktree exists. The RAW prompt remains the
1288
+ // identity + originating-prompt source; only `launchPrompt` is expanded for the agent. This preserves the
1289
+ // no-target rule even when the plugin body itself contains `[[links]]`.
1290
+ const rawPrompt = prompt
1291
+ // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1292
+ // plugin prose is payload only and can never invent scope.
1293
+ const ref = nodeFromPrompt(rawPrompt)
1294
+ const launchSpecs = ref ? await loadSpecs() : null
1295
+ let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
1296
+ const title = ref ? null : titleFromPrompt(rawPrompt)
1175
1297
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1176
1298
  const branch = `node/${slug}`
1177
1299
  const path = join(mainRoot(), '.worktrees', slug)
@@ -1193,28 +1315,28 @@ export async function newSession(node: string | null, prompt: string, parent: st
1193
1315
  node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
1194
1316
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
1195
1317
  harness: h.id, harnessSessionId: null, launcher: chosen.name,
1196
- // PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
1318
+ // PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
1197
1319
  // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
1198
1320
  // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
1199
- launchCmd: h.baseCmd(chosen.cmd),
1321
+ launchCmd: pinned,
1322
+ launchOwner: backendLaunchAuthority(),
1200
1323
  }
1201
1324
  writeRecord(rec)
1202
- writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
1325
+ writePromptFile(id, rawPrompt) // capture the ORIGINATING prompt (the human/manager's ask), not expanded plugin prose
1203
1326
  // materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
1204
1327
  // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
1205
1328
  // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1206
1329
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1207
- // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
1330
+ // own memory load too.
1208
1331
  bootstrapMaterialize(rec)
1209
- let launchPrompt = prompt
1210
1332
  if (ref) {
1211
- // @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
1333
+ // @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
1212
1334
  // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
1213
1335
  // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
1214
1336
  // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
1215
1337
  // a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
1216
- const spec = (await loadSpecs()).find((n) => n.id === ref)
1217
- if (spec) launchPrompt = `${prompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1338
+ const spec = launchSpecs?.find((n) => n.id === ref)
1339
+ if (spec) launchPrompt = `${launchPrompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
1218
1340
  }
1219
1341
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1220
1342
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
@@ -1288,7 +1410,7 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
1288
1410
  // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
1289
1411
  // a refusal leaves the record wholly untouched.
1290
1412
  // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
1291
- export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
1413
+ export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
1292
1414
  const { force = false, guard = true } = opts
1293
1415
  const wt = await findWorktree(id)
1294
1416
  if (!wt) return { ok: false, error: `no such session ${id}` }
@@ -1303,7 +1425,7 @@ export async function resumeSession(id: string, opts: { force?: boolean; guard?:
1303
1425
  writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
1304
1426
  if (force || lv === 'offline') {
1305
1427
  await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
1306
- await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec)) // resume under the SAME persisted launcher ([[launcher-select]])
1428
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
1307
1429
  await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
1308
1430
  }
1309
1431
  return { ok: true }
@@ -1602,26 +1724,31 @@ const ANSI: Record<DisplayStatus, string> = {
1602
1724
  }
1603
1725
 
1604
1726
  // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
1605
- // selector matches a session iff it is the session's full id, an id-PREFIX, its node, or its branch. This is
1727
+ // selector matches a session iff it is the session's full id, an id-PREFIX, its node, its branch, or `.` for
1728
+ // the caller's own launched session. This is
1606
1729
  // the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
1607
1730
  // resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
1608
1731
  // review/merge/send/close act on".
1609
- export function matchesSelector(s: Session, q: string): boolean {
1732
+ export function matchesSelector(s: Session, q: string, own = ownSessionId(), cwd = process.cwd()): boolean {
1610
1733
  // a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
1611
1734
  // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
1612
1735
  // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
1613
1736
  // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
1614
1737
  // silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
1615
1738
  // the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
1739
+ const sessionPath = s.path ? resolve(s.path) : null
1740
+ const callerPath = resolve(cwd)
1741
+ const self = Boolean(own) && s.id === own
1742
+ || Boolean(sessionPath) && (callerPath === sessionPath || callerPath.startsWith(`${sessionPath}${sep}`))
1616
1743
  return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
1617
- .some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1744
+ .some((p) => p === '.' ? self : s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1618
1745
  }
1619
1746
 
1620
1747
  // no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
1621
- export function selectSessions(all: Session[], selectors: string[], statuses?: string[]): Session[] {
1748
+ export function selectSessions(all: Session[], selectors: string[], statuses?: string[], own = ownSessionId(), cwd = process.cwd()): Session[] {
1622
1749
  let out = all
1623
1750
  const sel = selectors.filter((x) => x && x !== '@all')
1624
- if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q)))
1751
+ if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q, own, cwd)))
1625
1752
  if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
1626
1753
  return out
1627
1754
  }
@@ -1633,11 +1760,11 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
1633
1760
  // precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
1634
1761
  // otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
1635
1762
  export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
1636
- export function resolveSession(selector: string, sessions: Session[]): Resolved {
1763
+ export function resolveSession(selector: string, sessions: Session[], own = ownSessionId(), cwd = process.cwd()): Resolved {
1637
1764
  // the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
1638
1765
  const exact = sessions.find((s) => s.id === stripRefSigil(selector))
1639
1766
  if (exact) return { ok: exact }
1640
- const hits = sessions.filter((s) => matchesSelector(s, selector))
1767
+ const hits = sessions.filter((s) => matchesSelector(s, selector, own, cwd))
1641
1768
  if (hits.length === 1) return { ok: hits[0] }
1642
1769
  return hits.length ? { ambiguous: hits } : { none: true }
1643
1770
  }
@@ -1843,21 +1970,41 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
1843
1970
  }
1844
1971
 
1845
1972
  // @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
1846
- // ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
1847
- // to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1973
+ // ([[harness-adapter]]) — claude the rendezvous control socket (atomic reply+repaint chunk: repaint-done proves
1974
+ // the daemon parsed it, a kicked connection resends, wall expiry on a live connection is busy-not-lost → ok),
1975
+ // codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
1848
1976
  // fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
1849
1977
  // ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
1850
1978
  // instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
1851
1979
  // harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
1852
- export async function sendText(id: string, text: string, from?: string): Promise<DispatchResult> {
1980
+ export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
1853
1981
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
1854
1982
  const rec = readRecord(id)
1855
1983
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
1856
1984
  const h = harnessById(rec.harness || defaultHarness.id)
1857
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, text)
1985
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
1986
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane — refuse loudly with
1987
+ // the recovery named instead of reporting a false success. A missing pane (window gone, probe failure) skips
1988
+ // the guard: the delivery channel itself is the authority on whether the agent is reachable.
1989
+ if (h.deliveryBlockedBy) {
1990
+ try {
1991
+ const blocked = h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', id], TMUX_PROBE_TIMEOUT_MS))
1992
+ if (blocked) return { ok: false, error: blocked }
1993
+ } catch { /* no pane to consult — let the delivery channel decide */ }
1994
+ }
1995
+ const prompt = await resolveCommandPrompt(text)
1996
+ // a terminal-free sender's dispatch carries the note-reply insert; a human send WITHOUT the flag whose
1997
+ // previous human send carried it is the note→terminal transition and gets the one-shot counter-insert
1998
+ // ([[session-timeline]]). Both appended here, beside the delivery, so every input surface shares the one
1999
+ // phrase pair and the timeline records the message WITHOUT it (the hint is transport, not conversation).
2000
+ const wrapped = opts.replyVia === 'note' ? withNoteReplyHint(prompt)
2001
+ : !from && lastHumanSendVia(id) === 'note' ? withTerminalReplyHint(prompt) : prompt
2002
+ const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, wrapped)
1858
2003
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
1859
2004
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
1860
2005
  if (r.ok && from) void recordComms(id, from)
2006
+ // the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
2007
+ if (r.ok) recordSent(id, text, from ?? null, opts.replyVia)
1861
2008
  return r
1862
2009
  }
1863
2010
 
@@ -1865,7 +2012,7 @@ export async function sendText(id: string, text: string, from?: string): Promise
1865
2012
  // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
1866
2013
  // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
1867
2014
  // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
1868
- // keystroke-navigation state its input box is replaced by the menu, so the dashboard's type mode forwards
2015
+ // keystroke-navigation state its input box is replaced by the menu, so the CLI raw-key fallback forwards
1869
2016
  // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
1870
2017
  // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
1871
2018
  // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`