spexcode 0.4.1 → 0.5.0

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 +110 -18
  7. package/spec-cli/src/claude-headless.ts +271 -0
  8. package/spec-cli/src/cli.ts +112 -38
  9. package/spec-cli/src/client.ts +42 -11
  10. package/spec-cli/src/doctor.ts +174 -36
  11. package/spec-cli/src/gateway-auth.ts +194 -0
  12. package/spec-cli/src/gateway-hub.ts +360 -0
  13. package/spec-cli/src/gateway.ts +27 -19
  14. package/spec-cli/src/git.ts +12 -5
  15. package/spec-cli/src/graph.ts +54 -43
  16. package/spec-cli/src/graphStream.ts +265 -34
  17. package/spec-cli/src/guide.ts +115 -56
  18. package/spec-cli/src/harness-select.ts +20 -6
  19. package/spec-cli/src/harness.ts +295 -39
  20. package/spec-cli/src/help.ts +68 -28
  21. package/spec-cli/src/host.ts +610 -0
  22. package/spec-cli/src/identity-presets.d.ts +13 -0
  23. package/spec-cli/src/identity-presets.js +128 -0
  24. package/spec-cli/src/index.ts +158 -87
  25. package/spec-cli/src/init.ts +53 -25
  26. package/spec-cli/src/layout.ts +6 -5
  27. package/spec-cli/src/lint.ts +25 -121
  28. package/spec-cli/src/login-page.ts +12 -5
  29. package/spec-cli/src/materialize.ts +39 -13
  30. package/spec-cli/src/mentions.ts +16 -11
  31. package/spec-cli/src/message-stream.ts +147 -0
  32. package/spec-cli/src/opencode-headless.ts +95 -0
  33. package/spec-cli/src/opencode.ts +145 -0
  34. package/spec-cli/src/pi-harness.ts +124 -0
  35. package/spec-cli/src/pi-headless.ts +195 -0
  36. package/spec-cli/src/project-identity.ts +73 -0
  37. package/spec-cli/src/pty-bridge.ts +321 -521
  38. package/spec-cli/src/pty-helper.mjs +86 -0
  39. package/spec-cli/src/reaper.ts +16 -8
  40. package/spec-cli/src/reviewSnapshot.ts +23 -0
  41. package/spec-cli/src/reviews.ts +347 -0
  42. package/spec-cli/src/session-timeline.ts +14 -0
  43. package/spec-cli/src/sessions.ts +211 -75
  44. package/spec-cli/src/shim-runtime.ts +169 -0
  45. package/spec-cli/src/slash-commands.ts +76 -0
  46. package/spec-cli/src/source-files.ts +79 -0
  47. package/spec-cli/src/specs.ts +12 -7
  48. package/spec-cli/src/supervise.ts +37 -15
  49. package/spec-cli/src/tree.ts +8 -8
  50. package/spec-cli/src/uninstall.ts +56 -23
  51. package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
  52. package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
  53. package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
  54. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  55. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
  56. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
  58. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  59. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  60. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  61. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  62. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  63. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  64. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  65. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  66. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  67. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  68. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  69. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
  70. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
  71. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  72. package/spec-cli/templates/spec/project/spec.md +6 -5
  73. package/spec-cli/templates/spexcode.json +10 -2
  74. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +27 -0
  75. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +2 -0
  76. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +1 -0
  77. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +2 -0
  78. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +1 -0
  79. package/spec-dashboard/dist/assets/PageScroll-B_dKCuXx.js +1 -0
  80. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +1 -0
  81. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  82. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +39 -0
  83. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +13 -0
  84. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +1 -0
  85. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +1 -0
  86. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  87. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +41 -0
  88. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +1 -0
  89. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  90. package/spec-dashboard/dist/index.html +10 -10
  91. package/spec-dashboard/src/reviewFilters.js +279 -0
  92. package/spec-dashboard/src/reviewQuery.js +174 -0
  93. package/spec-dashboard/src/session.js +167 -0
  94. package/spec-eval/src/cli.ts +49 -28
  95. package/spec-eval/src/humanok.ts +1 -1
  96. package/spec-eval/src/matrix.ts +693 -0
  97. package/spec-eval/src/sessioneval.ts +726 -66
  98. package/spec-cli/src/migrate-table.ts +0 -408
  99. package/spec-cli/src/migrate.ts +0 -386
  100. package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
  108. package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
  109. package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
  110. package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
  111. package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
@@ -2,15 +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 } from './session-timeline.js'
13
+ import { recordSent, 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
@@ -44,9 +44,9 @@ import { stripRefSigil } from './mentions.js'
44
44
  // auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
45
45
  // deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
46
46
  //
47
- // Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
48
- // SPEXCODE_TMUX overrides the tmux socket for tests; the launch COMMAND comes from the session's pinned
49
- // 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.
50
50
 
51
51
  const pexec = promisify(execFile)
52
52
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -87,7 +87,7 @@ const rvEnv = (id: string, harness = HARNESS) => {
87
87
  // start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
88
88
  // process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
89
89
  // rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
90
- // (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.
91
91
  const parts = [`SPEXCODE_SESSION_ID=${id}`]
92
92
  if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
93
93
  for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
@@ -117,6 +117,7 @@ export type Session = {
117
117
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
118
118
  parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
119
119
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
120
+ capabilities: { headless: boolean; messageStream: boolean } // stable adapter projection; console surfaces consume data, never harness ids
120
121
  launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
121
122
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
122
123
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
@@ -229,9 +230,34 @@ export type SessRec = {
229
230
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
230
231
  launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
231
232
  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
233
+ launchOwner: string | null // stable public-backend authority while queued; null for active/legacy records
232
234
  }
233
235
  const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
234
236
  const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
237
+ export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
238
+
239
+ // @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
240
+ // That URL survives child hot reload/restart; PORT inside a supervised child is private and ephemeral, so it
241
+ // is only the fallback for a directly-run server with no injected API URL. Credentials/query/fragment are
242
+ // not authority and may contain secrets, so they are stripped before the value reaches session.json.
243
+ export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
244
+ const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
245
+ const url = new URL(raw)
246
+ url.username = ''
247
+ url.password = ''
248
+ url.search = ''
249
+ url.hash = ''
250
+ url.pathname = url.pathname.replace(/\/+$/, '') || '/'
251
+ return url.toString().replace(/\/$/, '')
252
+ }
253
+
254
+ export function rawLifecycleStatus(rec: Pick<SessRec, 'status' | 'launchOwner'>): string {
255
+ return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status
256
+ }
257
+
258
+ export function canDrainQueued(rec: Pick<SessRec, 'status' | 'launchOwner'>, authority = backendLaunchAuthority()): boolean {
259
+ return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority)
260
+ }
235
261
 
236
262
  // typed read of a session's record from the global store (null if it has none — a self-launched session that
237
263
  // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
@@ -242,8 +268,13 @@ function readRecord(id: string): SessRec | null {
242
268
  if (!raw) return null
243
269
  return fromRaw(raw)
244
270
  }
245
- function fromRaw(raw: RawRecord): SessRec {
246
- const status = LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
271
+ // the loose on-disk fields validated into the typed shape. Exported so the old-record defaults (harness →
272
+ // claude, absent pin null) are unit-auditable without a store on disk.
273
+ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
274
+ const ownedQueue = raw.status === OWNED_QUEUE_RAW_STATUS
275
+ const status = ownedQueue ? 'queued' : LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
276
+ const launchOwner = ownedQueue ? raw.launch_owner?.trim() : null
277
+ if (ownedQueue && !launchOwner) throw new Error(`owned queue record '${raw.session_id}' has no launch_owner`)
247
278
  const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
248
279
  const sk = raw.sortkey
249
280
  const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
@@ -255,6 +286,7 @@ function fromRaw(raw: RawRecord): SessRec {
255
286
  harnessSessionId: raw.harness_session_id || null,
256
287
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
257
288
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
289
+ launchOwner: launchOwner || null,
258
290
  }
259
291
  }
260
292
  // @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
@@ -271,7 +303,10 @@ function writeRecord(rec: SessRec): void {
271
303
  title: rec.title ?? '',
272
304
  name: rec.name ?? '',
273
305
  parent: rec.parent ?? '',
274
- status: rec.status,
306
+ // A leased queue uses a raw token older drainers do not recognize as `queued`; current readers map it
307
+ // back to the unchanged public lifecycle. This version fence is what keeps an orphaned old backend from
308
+ // stealing the entry before it can even inspect the new launch_owner field.
309
+ status: rawLifecycleStatus(rec),
275
310
  proposal: rec.proposal ?? '',
276
311
  merges: rec.merges,
277
312
  note: rec.note ?? '',
@@ -281,6 +316,7 @@ function writeRecord(rec: SessRec): void {
281
316
  harness_session_id: rec.harnessSessionId ?? '',
282
317
  launcher: rec.launcher ?? '',
283
318
  launch_cmd: rec.launchCmd ?? '',
319
+ launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
284
320
  }
285
321
  mkdirSync(sessionStoreDir(rec.session), { recursive: true })
286
322
  writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
@@ -323,16 +359,7 @@ async function listWorktrees(): Promise<{ path: string; branch: string | null }[
323
359
  // wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
324
360
  // its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
325
361
  // its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
326
- async function procSnapshot(): Promise<ProcTable> {
327
- const t: ProcTable = new Map()
328
- let out = ''
329
- try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: TMUX_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' })) } catch { return t }
330
- for (const line of out.split('\n')) {
331
- const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
332
- if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
333
- }
334
- return t
335
- }
362
+ // (the whole-box ps snapshot itself — procSnapshot lives in harness.ts beside its tree-walk consumers.)
336
363
  // @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
337
364
  // (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
338
365
  // ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
@@ -520,7 +547,7 @@ export function selfSummary(paneTitle: string): string | null {
520
547
  // instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
521
548
  // memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
522
549
  const launchedAt = new Map<string, number>()
523
- const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
550
+ export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
524
551
  // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
525
552
  const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
526
553
  // exit before readiness is retryable, but it is not proof of one specific cause
@@ -539,13 +566,13 @@ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it
539
566
  // for the grace window; only past it (still not online) is it genuinely 'offline'.
540
567
  export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
541
568
  if (!rec.session) return 'offline'
542
- if (snap.probeFailed) return 'unknown' // the probe failed we can't tell, and MUST NOT guess offline
543
- // ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND a live listener on its rendezvous socket; codex
544
- // = tmux up AND a codex-ish process live among the pane pid's descendants (not the bare shell a failed launch
545
- // dropped back to). The 'starting' grace stays here (a launcher concern): a just-launched agent whose
546
- // 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'.
547
573
  const h = harnessById(rec.harness || defaultHarness.id)
548
574
  if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
575
+ if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
549
576
  // not provably online — but if this session's LISTENER probe couldn't conclude (timeout under load / EAGAIN
550
577
  // off a full-but-alive backlog), death is UNPROVEN: `unknown`, never a false `offline` a supervisor would
551
578
  // act on (issue #40 — a wedged-but-alive worker must not read as an actionable corpse).
@@ -585,7 +612,8 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
585
612
  const act = showActivity ? activity : null
586
613
  const pp = prompt ? promptPreview(prompt) : null
587
614
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
588
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: rec.harness, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
615
+ const harness = harnessById(rec.harness || defaultHarness.id)
616
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless, messageStream: harness.messageStream }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
589
617
  }
590
618
 
591
619
  // @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
@@ -869,9 +897,20 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
869
897
  // the declaration NOTE ([[session-timeline]]). This one-line insert tells the agent exactly that, so its
870
898
  // next stop carries the complete answer in `--note` instead of prose that dies in an unseen terminal.
871
899
  // Appended server-side (the input route passes replyVia:'note'), so the phrase lives in ONE place and any
872
- // surface — desktop included, later — can opt in with the same flag.
900
+ // surface — desktop included, later — can opt in with the same flag. The notice declares itself
901
+ // PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
902
+ // note-replied a few times keeps note-replying from context inertia long after the human is back at a
903
+ // terminal — the sticky-note failure this pair exists to prevent.
873
904
  export const withNoteReplyHint = (text: string): string =>
874
- `${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.`
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.`
906
+ // @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
907
+ // note→terminal transition (a human send with no note flag whose previous human send carried one —
908
+ // [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
909
+ // the agent once beats hoping it scopes the earlier per-message notice correctly (it demonstrably doesn't —
910
+ // codex especially). Not repeated on later terminal sends: once the transition send is recorded, the last
911
+ // human channel is no longer note, so ordinary conversation stays insert-free.
912
+ export const withTerminalReplyHint = (text: string): string =>
913
+ `${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).`
875
914
  async function postJSON(path: string, body: unknown): Promise<void> {
876
915
  try {
877
916
  await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
@@ -905,10 +944,10 @@ export const isBackendUnreachable = (e: unknown): boolean =>
905
944
  export const slugify = (s: string | null) =>
906
945
  (s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
907
946
 
908
- // @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
909
- // "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
910
- // actually left in the prompt (changed it, or deleted it for a node-agnostic prompt) is the truth. We read
911
- // the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
947
+ // @@@ node + title from the prompt - the spec node a session works on is the FIRST `[[<id>]]` topic
948
+ // reference in the raw prompt ([[mentions]]: `[[node]]` is a topic, `@` is an actor/session). The node the
949
+ // user actually left in the prompt is the truth: there is no focused-node/API/function argument that can
950
+ // grant a binding outside the task text. Changing or deleting the mention changes or removes the binding.
912
951
  // When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
913
952
  // The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
914
953
  // (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
@@ -916,7 +955,47 @@ export const slugify = (s: string | null) =>
916
955
  // id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
917
956
  // node-agnostic.
918
957
  const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
919
- const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
958
+ export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
959
+
960
+ type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
961
+ type CommandSpec = Pick<SpecLite, 'id' | 'path'>
962
+
963
+ // @@@ command invocation - turn the raw `/<preset> [[node]]… free text` into the ONE agent prompt.
964
+ // This is deliberately server-side: dashboard, phone, CLI, direct API, and the in-process fallback all call
965
+ // the launch/send boundary, so no client gets its own command interpreter. Launch keeps the RAW prompt for
966
+ // session identity/history; only the agent payload uses this expansion, preventing a plugin body's own
967
+ // [[links]] from becoming the session node. With no mention, a preset remains targetless.
968
+ export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
969
+ const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
970
+ if (!match) return raw
971
+ const preset = presets.find((p) => p.name === match[1])
972
+ if (!preset) return raw
973
+
974
+ const ids: string[] = []
975
+ const allMentions = new RegExp(MENTION.source, 'gu')
976
+ const free = match[2].replace(allMentions, (_, id: string) => { ids.push(id); return '' }).trim()
977
+ const targets = ids.length
978
+ ? ids.map((id) => {
979
+ const spec = specs.find((s) => s.id === id)
980
+ const path = spec?.path.replace(/^\.spec\//, '').replace(/\/spec\.md$/, '')
981
+ return path ? `- [[${id}]] — ${path}` : `- [[${id}]]`
982
+ }).join('\n')
983
+ : '(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.)'
984
+ const body = preset.body.includes('{{targets}}')
985
+ ? preset.body.replace('{{targets}}', targets)
986
+ : ids.length ? `${preset.body}\n\n${targets}` : preset.body
987
+ return free ? `${body}\n\n${free}` : body
988
+ }
989
+
990
+ // Load only the one live preset named by the raw invocation. Both newSession and sendText call this seam, so
991
+ // launch and an existing session's inbox resolve identical plugin data with identical target semantics.
992
+ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpec[]): Promise<string> {
993
+ const commandName = raw.match(/^\/(\S+)/)?.[1]
994
+ const preset = commandName ? loadConfig().find((p) => p.name === commandName) : undefined
995
+ if (!preset) return raw
996
+ const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
997
+ return composeCommandPrompt(raw, [preset], specs)
998
+ }
920
999
  // @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
921
1000
  // prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
922
1001
  // board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
@@ -1014,7 +1093,7 @@ function isOccupying(s: Session, snap: LiveSnap): boolean {
1014
1093
  if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
1015
1094
  const rec = readRecord(s.id)
1016
1095
  if (!rec) return false
1017
- 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)
1096
+ 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'
1018
1097
  }
1019
1098
  // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
1020
1099
  // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
@@ -1030,6 +1109,7 @@ let draining = false // re-entrancy guard: only one drain pass runs at a time
1030
1109
  async function startQueued(id: string): Promise<boolean> {
1031
1110
  const wt = await findWorktree(id)
1032
1111
  if (!wt) return false
1112
+ if (!canDrainQueued(wt.rec)) return false
1033
1113
  const launchPrompt = readLaunchFile(id)
1034
1114
  if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
1035
1115
  launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
@@ -1041,7 +1121,7 @@ async function startQueued(id: string): Promise<boolean> {
1041
1121
  launching.delete(id)
1042
1122
  return false // launch failed → stays `queued`, retried on the next drain tick
1043
1123
  }
1044
- writeRecord({ ...wt.rec, status: 'active', proposal: null })
1124
+ writeRecord({ ...wt.rec, status: 'active', proposal: null, launchOwner: null })
1045
1125
  removeLaunchFile(id) // consumed
1046
1126
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
1047
1127
  // wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
@@ -1068,7 +1148,12 @@ export async function drainQueue(): Promise<void> {
1068
1148
  if (snap.probeFailed) break
1069
1149
  const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
1070
1150
  if (occupied >= cap) break
1071
- const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
1151
+ const authority = backendLaunchAuthority()
1152
+ const next = sessions.find((s) => {
1153
+ if (s.status !== 'queued' || launching.has(s.id)) return false
1154
+ const rec = readRecord(s.id)
1155
+ return !!rec && canDrainQueued(rec, authority)
1156
+ })
1072
1157
  if (!next) break
1073
1158
  if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
1074
1159
  }
@@ -1125,6 +1210,29 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1125
1210
  }
1126
1211
  }
1127
1212
 
1213
+ type SessionCreateFn = (prompt: string, parent: string | null, launcher?: string) => Promise<Session>
1214
+ export type SessionCreateRequestResult =
1215
+ | { status: 201; session: Session }
1216
+ | { status: 400; error: string }
1217
+
1218
+ // The API create boundary accepts one small, closed object shape. Unknown fields fail through this generic
1219
+ // contract before any worktree is made; removed or misspelled inputs never disappear into defaults.
1220
+ export async function sessionCreateRequest(body: unknown, create: SessionCreateFn = newSession): Promise<SessionCreateRequestResult> {
1221
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'body must be a JSON object' }
1222
+ const input = body as Record<string, unknown>
1223
+ const unknown = Object.keys(input).filter((key) => !['prompt', 'parent', 'launcher'].includes(key)).sort()
1224
+ if (unknown.length) return { status: 400, error: `unknown session-create field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}` }
1225
+ const prompt = typeof input.prompt === 'string' ? input.prompt : ''
1226
+ if (!prompt.trim()) return { status: 400, error: 'empty prompt' }
1227
+ const launcher = typeof input.launcher === 'string' && input.launcher.trim() ? input.launcher.trim() : undefined
1228
+ const parent = typeof input.parent === 'string' && input.parent.trim() ? input.parent.trim() : null
1229
+ try {
1230
+ return { status: 201, session: await create(prompt, parent, launcher) }
1231
+ } catch (e) {
1232
+ return { status: 400, error: String((e as Error).message || e) }
1233
+ }
1234
+ }
1235
+
1128
1236
  // @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
1129
1237
  // BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
1130
1238
  // launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
@@ -1133,7 +1241,7 @@ export async function assertProjectMatch(verb: string): Promise<void> {
1133
1241
  // either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
1134
1242
  // reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
1135
1243
  // handler calls newSession directly, so it never re-enters this path.
1136
- export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1244
+ export async function createSession(prompt: string, launcher?: string): Promise<Session> {
1137
1245
  await assertProjectMatch('spex session new')
1138
1246
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
1139
1247
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
@@ -1145,11 +1253,11 @@ export async function createSession(node: string | null, prompt: string, launche
1145
1253
  res = await fetch(`${await apiBase()}/api/sessions`, {
1146
1254
  method: 'POST',
1147
1255
  headers: { 'content-type': 'application/json' },
1148
- body: JSON.stringify({ node, prompt, parent, launcher }),
1256
+ body: JSON.stringify({ prompt, parent, launcher }),
1149
1257
  })
1150
1258
  } catch {
1151
1259
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
1152
- return newSession(node, prompt, parent, launcher)
1260
+ return newSession(prompt, parent, launcher)
1153
1261
  }
1154
1262
  if (!res.ok) {
1155
1263
  const text = await res.text().catch(() => '')
@@ -1169,7 +1277,7 @@ export async function createSession(node: string | null, prompt: string, launche
1169
1277
  // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
1170
1278
  // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
1171
1279
  // programmatic surface, every other surface is prompt only).
1172
- export async function newSession(node: string | null, prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
1280
+ export async function newSession(prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
1173
1281
  const id = randomUUID()
1174
1282
  // a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
1175
1283
  // picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
@@ -1177,10 +1285,17 @@ export async function newSession(node: string | null, prompt: string, parent: st
1177
1285
  const lname = launcher ?? defaultLauncher(mainRoot())
1178
1286
  const chosen = resolveLauncher(lname)
1179
1287
  const h = harnessById(chosen.harness)
1180
- // node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
1181
- // none is node-agnostic and labeled by its first few words.
1182
- const ref = node || mentionedNode(prompt)
1183
- const title = ref ? null : titleFromPrompt(prompt)
1288
+ 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
+ const rawPrompt = prompt
1293
+ // node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
1294
+ // plugin prose is payload only and can never invent scope.
1295
+ const ref = nodeFromPrompt(rawPrompt)
1296
+ const launchSpecs = ref ? await loadSpecs() : null
1297
+ let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
1298
+ const title = ref ? null : titleFromPrompt(rawPrompt)
1184
1299
  const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
1185
1300
  const branch = `node/${slug}`
1186
1301
  const path = join(mainRoot(), '.worktrees', slug)
@@ -1202,35 +1317,38 @@ export async function newSession(node: string | null, prompt: string, parent: st
1202
1317
  node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
1203
1318
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
1204
1319
  harness: h.id, harnessSessionId: null, launcher: chosen.name,
1205
- // PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
1320
+ // PIN the resolved launch command NOW ([[launcher-select]] resume-launcher-pin) so every future
1206
1321
  // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
1207
1322
  // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
1208
- launchCmd: h.baseCmd(chosen.cmd),
1323
+ launchCmd: pinned,
1324
+ launchOwner: backendLaunchAuthority(),
1209
1325
  }
1210
1326
  writeRecord(rec)
1211
- writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
1327
+ writePromptFile(id, rawPrompt) // capture the ORIGINATING prompt (the human/manager's ask), not expanded plugin prose
1212
1328
  // materialize the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
1213
1329
  // shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
1214
1330
  // self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
1215
1331
  // --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
1216
- // own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
1332
+ // own memory load too.
1217
1333
  bootstrapMaterialize(rec)
1218
- let launchPrompt = prompt
1219
1334
  if (ref) {
1220
- // @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
1335
+ // @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
1221
1336
  // Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
1222
1337
  // it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
1223
1338
  // is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
1224
1339
  // a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
1225
- const spec = (await loadSpecs()).find((n) => n.id === ref)
1226
- if (spec) launchPrompt = `${prompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
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)}.`
1227
1342
  }
1228
1343
  writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
1229
1344
  await drainQueue() // launch now if under the cap, else leave it queued for a free slot
1230
1345
  const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
1231
- // queued no process yet (offline liveness); just-launched its socket is still booting (starting).
1346
+ // Every adapter answers from its own truth. Asking with no process facts distinguishes a record-backed
1347
+ // adapter (online immediately) from process-backed queued/booting adapters (offline/starting) without an
1348
+ // extra whole-box tmux snapshot on the creation hot path.
1349
+ const recordOnline = h.liveness(after, false, runtimeRoot()) === 'online'
1232
1350
  const queued = after.status === 'queued'
1233
- return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
1351
+ return toSession(after, queued ? 'queued' : 'working', recordOnline ? 'online' : queued ? 'offline' : 'starting')
1234
1352
  }
1235
1353
 
1236
1354
  // @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
@@ -1297,7 +1415,7 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
1297
1415
  // session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
1298
1416
  // a refusal leaves the record wholly untouched.
1299
1417
  // Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
1300
- export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
1418
+ export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
1301
1419
  const { force = false, guard = true } = opts
1302
1420
  const wt = await findWorktree(id)
1303
1421
  if (!wt) return { ok: false, error: `no such session ${id}` }
@@ -1312,7 +1430,7 @@ export async function resumeSession(id: string, opts: { force?: boolean; guard?:
1312
1430
  writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
1313
1431
  if (force || lv === 'offline') {
1314
1432
  await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
1315
- await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec)) // resume under the SAME persisted launcher ([[launcher-select]])
1433
+ await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
1316
1434
  await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
1317
1435
  }
1318
1436
  return { ok: true }
@@ -1532,14 +1650,13 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
1532
1650
 
1533
1651
  // @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
1534
1652
  // two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
1535
- // window reading `starting` instead of `offline`), and sweep its rendezvous socket. The socket lives in the OS
1536
- // tmpdir (NOT the worktree), so worktree removal alone would leave it behind closing many sessions over time
1537
- // would accumulate stale `spexcode-rv-*.sock` files; we unlink it here (force = no error if claude/OS already
1538
- // removed it). Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
1653
+ // window reading `starting` instead of `offline`), and ask the resolved adapter to sweep its ephemeral runtime
1654
+ // transport. Deliberately does NOT drainQueue the caller drains once, after it has settled the worktree.
1539
1655
  async function stopAgentProcess(id: string): Promise<void> {
1656
+ const rec = readRecord(id)
1540
1657
  await tmuxOk(['kill-session', '-t', id])
1541
1658
  launchedAt.delete(id)
1542
- try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
1659
+ harnessById(rec?.harness || defaultHarness.id).cleanupRuntime(rec ?? { session: id })
1543
1660
  }
1544
1661
 
1545
1662
  // @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
@@ -1611,26 +1728,31 @@ const ANSI: Record<DisplayStatus, string> = {
1611
1728
  }
1612
1729
 
1613
1730
  // @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
1614
- // selector matches a session iff it is the session's full id, an id-PREFIX, its node, or its branch. This is
1731
+ // selector matches a session iff it is the session's full id, an id-PREFIX, its node, its branch, or `.` for
1732
+ // the caller's own launched session. This is
1615
1733
  // the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
1616
1734
  // resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
1617
1735
  // review/merge/send/close act on".
1618
- export function matchesSelector(s: Session, q: string): boolean {
1736
+ export function matchesSelector(s: Session, q: string, own = ownSessionId(), cwd = process.cwd()): boolean {
1619
1737
  // a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
1620
1738
  // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
1621
1739
  // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
1622
1740
  // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
1623
1741
  // silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
1624
1742
  // the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
1743
+ const sessionPath = s.path ? resolve(s.path) : null
1744
+ const callerPath = resolve(cwd)
1745
+ const self = Boolean(own) && s.id === own
1746
+ || Boolean(sessionPath) && (callerPath === sessionPath || callerPath.startsWith(`${sessionPath}${sep}`))
1625
1747
  return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
1626
- .some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1748
+ .some((p) => p === '.' ? self : s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1627
1749
  }
1628
1750
 
1629
1751
  // no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
1630
- export function selectSessions(all: Session[], selectors: string[], statuses?: string[]): Session[] {
1752
+ export function selectSessions(all: Session[], selectors: string[], statuses?: string[], own = ownSessionId(), cwd = process.cwd()): Session[] {
1631
1753
  let out = all
1632
1754
  const sel = selectors.filter((x) => x && x !== '@all')
1633
- if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q)))
1755
+ if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q, own, cwd)))
1634
1756
  if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
1635
1757
  return out
1636
1758
  }
@@ -1642,11 +1764,11 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
1642
1764
  // precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
1643
1765
  // otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
1644
1766
  export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
1645
- export function resolveSession(selector: string, sessions: Session[]): Resolved {
1767
+ export function resolveSession(selector: string, sessions: Session[], own = ownSessionId(), cwd = process.cwd()): Resolved {
1646
1768
  // the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
1647
1769
  const exact = sessions.find((s) => s.id === stripRefSigil(selector))
1648
1770
  if (exact) return { ok: exact }
1649
- const hits = sessions.filter((s) => matchesSelector(s, selector))
1771
+ const hits = sessions.filter((s) => matchesSelector(s, selector, own, cwd))
1650
1772
  if (hits.length === 1) return { ok: hits[0] }
1651
1773
  return hits.length ? { ambiguous: hits } : { none: true }
1652
1774
  }
@@ -1874,10 +1996,14 @@ export async function sendText(id: string, text: string, from?: string, opts: {
1874
1996
  if (blocked) return { ok: false, error: blocked }
1875
1997
  } catch { /* no pane to consult — let the delivery channel decide */ }
1876
1998
  }
1877
- // a terminal-free sender's dispatch carries the note-reply insert ([[session-timeline]]); appended here,
1878
- // beside the delivery, so every input surface shares the one phrase and the timeline records the message
1879
- // WITHOUT it (the hint is transport, not conversation).
1880
- const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, opts.replyVia === 'note' ? withNoteReplyHint(text) : text)
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)
1881
2007
  // record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
1882
2008
  // send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
1883
2009
  if (r.ok && from) void recordComms(id, from)
@@ -1886,11 +2012,21 @@ export async function sendText(id: string, text: string, from?: string, opts: {
1886
2012
  return r
1887
2013
  }
1888
2014
 
2015
+ // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
2016
+ // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
2017
+ export async function interruptSession(id: string): Promise<DispatchResult> {
2018
+ const rec = readRecord(id)
2019
+ if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
2020
+ const h = harnessById(rec.harness || defaultHarness.id)
2021
+ if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
2022
+ return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
2023
+ }
2024
+
1889
2025
  // @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
1890
2026
  // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
1891
2027
  // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
1892
2028
  // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
1893
- // keystroke-navigation state its input box is replaced by the menu, so the dashboard's type mode forwards
2029
+ // keystroke-navigation state its input box is replaced by the menu, so the CLI raw-key fallback forwards
1894
2030
  // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
1895
2031
  // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
1896
2032
  // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`