spexcode 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -6,9 +6,10 @@ import { join, dirname, relative, isAbsolute } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
8
8
  import { loadSpecs } from './specs.js'
9
- import { defaultHarness, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
9
+ import { defaultHarness, defaultLauncher, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
10
10
  import { materialize } from './materialize.js'
11
11
  import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
12
+ import { stripRefSigil } from './mentions.js'
12
13
 
13
14
  // @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
14
15
  // SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
@@ -45,10 +46,9 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
45
46
  // SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
46
47
 
47
48
  const pexec = promisify(execFile)
48
- const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
49
- // the harness the dashboard/CLI launcher drives. ALL harness-specific launch facts (the agent command, the
50
- // session-id flag, the hook shim, the session env var) come from this adapter — the launcher never names
51
- // Claude. Resolved once here ([[harness-adapter]]); a future codex launcher flips defaultHarness, nothing else.
49
+ export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
50
+ // the legacy/default harness for helpers and old records. New sessions derive their harness from the selected
51
+ // launcher; all harness-specific launch facts still come from the adapter.
52
52
  const HARNESS = defaultHarness
53
53
  const COLS = 120, ROWS = 32
54
54
  // @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
@@ -114,7 +114,7 @@ export type Session = {
114
114
  raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
115
115
  parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
116
116
  harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
117
- launcher: string | null // the named launcher profile this session launched under ([[launcher-select]]); null an unnamed/zero-config launch. Surfaced so a row can badge WHICH launcher (glyph + name) actually brought the worker up
117
+ launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
118
118
  lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
119
119
  prompt: string | null; promptPreview: string | null; created: number; activity: string | null
120
120
  sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
@@ -224,7 +224,7 @@ export type SessRec = {
224
224
  parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
225
225
  status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
226
226
  sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
227
- launcher: string | null // the named launcher profile this session launches under ([[launcher-select]]); null the unnamed global default (an old record, or a zero-config launch)
227
+ launcher: string | null // the launcher profile this session launches under ([[launcher-select]]); null only for old records predating launchers
228
228
  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
229
229
  }
230
230
  const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
@@ -250,7 +250,7 @@ function fromRaw(raw: RawRecord): SessRec {
250
250
  status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
251
251
  harness: raw.harness || 'claude', // records written before the harness field default to claude
252
252
  harnessSessionId: raw.harness_session_id || null,
253
- launcher: raw.launcher || null, // records written before launchers → null → the unnamed global resolution
253
+ launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
254
254
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
255
255
  }
256
256
  }
@@ -371,7 +371,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
371
371
  async function paneTitles(): Promise<Map<string, string>> {
372
372
  const m = new Map<string, string>()
373
373
  let out = ''
374
- try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}']) } catch { return m }
374
+ try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS) } catch { return m }
375
375
  for (const line of out.split('\n')) {
376
376
  const tab = line.indexOf('\t'); if (tab < 0) continue
377
377
  const id = line.slice(0, tab), title = line.slice(tab + 1)
@@ -437,8 +437,8 @@ export function selfSummary(paneTitle: string): string | null {
437
437
  const launchedAt = new Map<string, number>()
438
438
  const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
439
439
  // window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
440
- const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this — the
441
- // launcher daemon-not-ready race fails in ~8s; a real session runs far longer
440
+ const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
441
+ // exit before readiness is retryable, but it is not proof of one specific cause
442
442
 
443
443
  // @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
444
444
  // lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
@@ -678,11 +678,80 @@ export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[]
678
678
  return { nodes, edges }
679
679
  }
680
680
 
681
+ // @@@ apiBase resolution - WHICH backend a client verb talks to, resolved ONCE per process with the
682
+ // source kept as a discriminant ([[remote-client]]). The core thesis: a FLAG is the only signal that is
683
+ // provably deliberate — an env var cannot tell "I exported this on the command" from "I inherited this
684
+ // from the backend that launched my shell", and that ambiguity is exactly the misroute bug (a shell
685
+ // carrying project A's SPEXCODE_API_URL silently drives every bare `spex` in project B at A's backend).
686
+ // The ladder:
687
+ // 1. explicit `--api <url>` (`--port <n>` = localhost sugar) — always wins, any verb
688
+ // 2a. WORKER (SPEXCODE_SESSION_ID present): env SPEXCODE_API_URL — the backend-injected lifeline; a
689
+ // worker's state writes must NEVER gamble on cwd discovery, so the env is not demotable by (2b)
690
+ // 2b. HUMAN (no session id): the cwd project's RECORDED live backend (`spex serve` writes it, we
691
+ // /health-probe before trusting — a dead record is ignored, never followed)
692
+ // 3. the other side as fallback (human with no live record → env; worker with no env → record)
693
+ // 4. default http://127.0.0.1:$PORT||8787
694
+ export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
695
+ export type ApiBaseInfo = { url: string; source: ApiBaseSource }
696
+ const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
697
+ // the explicit routing flag, read from THIS process's argv (never the environment — that's the point).
698
+ // `--port` doubles as a BIND port for serve/dashboard, so the sugar is skipped for those verbs.
699
+ function explicitApiFlag(): string | null {
700
+ const argv = process.argv
701
+ const ai = argv.indexOf('--api')
702
+ if (ai >= 0) {
703
+ const v = argv[ai + 1]
704
+ if (!v || v.startsWith('--')) throw usageError('--api expects a URL (e.g. --api http://127.0.0.1:8901)')
705
+ const withScheme = v.includes('://') ? v : `http://${v}`
706
+ try { new URL(withScheme) } catch { throw usageError(`--api: not a URL: ${v}`) }
707
+ return withScheme.replace(/\/+$/, '')
708
+ }
709
+ if (argv[2] === 'serve' || argv[2] === 'dashboard') return null // their --port is a bind port, not routing
710
+ const pi = argv.indexOf('--port')
711
+ if (pi >= 0) {
712
+ const v = argv[pi + 1]
713
+ if (!v || !Number.isInteger(Number(v))) throw usageError('--port expects an integer (localhost sugar for --api http://127.0.0.1:<n>)')
714
+ return `http://127.0.0.1:${v}`
715
+ }
716
+ return null
717
+ }
718
+ // the cwd project's recorded backend ({url,pid}, written by `spex serve` at bind time into the per-project
719
+ // runtime tier), trusted only after a live /health probe — a stale record must never swallow a command.
720
+ async function liveRecordUrl(): Promise<string | null> {
721
+ let file: string
722
+ try { file = join(runtimeRoot(), 'backend.json') } catch { return null } // cwd not in a git repo → nothing to discover
723
+ let url = ''
724
+ try { const rec = JSON.parse(readFileSync(file, 'utf8')); if (typeof rec?.url === 'string') url = rec.url.trim() } catch { return null }
725
+ if (!url) return null
726
+ const ctrl = new AbortController()
727
+ const t = setTimeout(() => ctrl.abort(), 600)
728
+ try { return (await fetch(`${url}/health`, { signal: ctrl.signal })).ok ? url : null }
729
+ catch { return null }
730
+ finally { clearTimeout(t) }
731
+ }
732
+ async function resolveApiBase(): Promise<ApiBaseInfo> {
733
+ const flag = explicitApiFlag()
734
+ if (flag) return { url: flag, source: 'flag' }
735
+ const env = process.env.SPEXCODE_API_URL?.trim() || null
736
+ if (process.env.SPEXCODE_SESSION_ID?.trim()) {
737
+ if (env) return { url: env, source: 'worker-env' }
738
+ const rec = await liveRecordUrl()
739
+ if (rec) return { url: rec, source: 'record' }
740
+ } else {
741
+ const rec = await liveRecordUrl()
742
+ if (rec) return { url: rec, source: 'record' }
743
+ if (env) return { url: env, source: 'env-fallback' }
744
+ }
745
+ return { url: `http://127.0.0.1:${process.env.PORT || 8787}`, source: 'default' }
746
+ }
747
+ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
748
+ export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
749
+ export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
750
+
681
751
  // @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
682
752
  // REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
683
753
  // deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
684
754
  // streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
685
- export const apiBase = () => process.env.SPEXCODE_API_URL || `http://127.0.0.1:${process.env.PORT || 8787}`
686
755
  // the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
687
756
  // Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
688
757
  // re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
@@ -708,7 +777,7 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
708
777
  }
709
778
  async function postJSON(path: string, body: unknown): Promise<void> {
710
779
  try {
711
- await fetch(`${apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
780
+ await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
712
781
  } catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
713
782
  }
714
783
  export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
@@ -769,28 +838,28 @@ export function launcherCmd(rec: SessRec): string | undefined {
769
838
  if (rec.launchCmd) return rec.launchCmd
770
839
  return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
771
840
  }
772
- function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
841
+ export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
773
842
  const file = join(storeDir(id), 'launch.sh')
774
843
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
775
844
  // createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
776
845
  // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
777
846
  // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
778
- // ambient default so resume reuses the same auth, else undefined the unnamed global resolution.
847
+ // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
779
848
  const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
780
- // Bounded relaunch on a FAST exit: the agent launcher (e.g. the reclaude daemon) can lose a startup
781
- // race ("daemon did not become ready") and exit within seconds before the rendezvous socket ever
782
- // appears a fast exit is the race, not real work, so retry a few times. Once the agent has run past
783
- // LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal session end
784
- // and is NEVER retried the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this retry
785
- // window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This only
786
- // closes the startup race — it adds no fallback and never masks a genuinely dead agent (3 attempts, then give up).
849
+ // Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
850
+ // ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
851
+ // has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
852
+ // session end and is NEVER retried the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this
853
+ // retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
854
+ // only closes startup unready failures it adds no fallback and never masks a genuinely dead agent (3
855
+ // attempts, then give up).
787
856
  writeFileSync(file, [
788
857
  `for __spex_try in 1 2 3; do`,
789
858
  ` __spex_t0=$SECONDS`,
790
859
  ` ${invocation}`,
791
860
  ` __spex_rc=$?`,
792
861
  ` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
793
- ` printf '[spex launch] attempt %s exited in %ss (rc=%s) likely a launcher daemon race; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
862
+ ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
794
863
  ` sleep 2`,
795
864
  `done`,
796
865
  `exit $__spex_rc`,
@@ -897,30 +966,38 @@ export function superviseQueue(intervalMs = 3000): void {
897
966
  void tick()
898
967
  }
899
968
 
900
- // @@@ assertProjectMatch - the launch is PROJECT-BOUND, but routing is by URL. `spex new`'s intent is "create a
901
- // session for the project my cwd is in", yet `apiBase()` is SPEXCODE_API_URL||PORT||8787 a pure URL carrying
902
- // no project identity. The backend it answers builds the worktree under ITS OWN mainRoot, so a stale inherited
903
- // SPEXCODE_API_URL (e.g. pointing at another repo's backend) silently lands the session in the WRONG repo. Read/
904
- // control verbs deliberately point anywhere (viewer-points-anywhere, see remote-client); only the mutating
905
- // launch is bound to the caller's project. So before launching, compare the caller's repo root to the backend's
906
- // served root and FAIL LOUD on a provable, same-host mismatch — never a silent misroute. The guard fires only on
907
- // a positive mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local
908
- // path (a genuinely remote backend) all fall through to allow, so legit remote dispatch is untouched.
909
- async function assertProjectMatch(): Promise<void> {
969
+ // @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
970
+ // "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
971
+ // the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
972
+ // another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
973
+ // point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
974
+ // rename/rawkey/reopen/exit) is bound to the caller's project. So before writing, compare the caller's
975
+ // repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
976
+ // misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
977
+ // cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
978
+ // mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
979
+ // (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
980
+ export async function assertProjectMatch(verb: string): Promise<void> {
981
+ const { url, source } = await apiBaseInfo()
982
+ if (source === 'flag') return // explicitly routed — the caller named the target
910
983
  let localMain: string
911
984
  try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
912
985
  let served: string | null = null
913
986
  try {
914
- const r = await fetch(`${apiBase()}/api/layout`)
987
+ const r = await fetch(`${url}/api/layout`)
915
988
  if (r.ok) served = (await r.json() as { main?: string }).main ?? null
916
- } catch { return } // backend unreachable → the POST surfaces it (fail-loud there)
989
+ } catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
917
990
  if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
918
991
  let backendMain: string
919
992
  try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
920
- if (backendMain !== localMain)
921
- throw new Error(
922
- `spex new: refusing — cwd is in ${localMain} but the backend at ${apiBase()} serves ${backendMain}.\n` +
923
- `Start this project's backend: cd ${localMain} && spex serve (or point SPEXCODE_API_URL at it)`)
993
+ if (backendMain !== localMain) {
994
+ const e = new Error(
995
+ `${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
996
+ `Name the target explicitly (--api <url> / --port <n>) to write cross-project on purpose,\n` +
997
+ `or run this project's own backend: cd ${localMain} && spex serve. (Reads stay unguarded.)`)
998
+ e.name = 'GuardError'
999
+ throw e
1000
+ }
924
1001
  }
925
1002
 
926
1003
  // @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
@@ -930,8 +1007,8 @@ async function assertProjectMatch(): Promise<void> {
930
1007
  // the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
931
1008
  // launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
932
1009
  // warning) — the backend's own POST handler calls newSession directly, so it never re-enters this path.
933
- export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id, launcher?: string): Promise<Session> {
934
- await assertProjectMatch()
1010
+ export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
1011
+ await assertProjectMatch('spex new')
935
1012
  // @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
936
1013
  // via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
937
1014
  // backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
@@ -939,16 +1016,23 @@ export async function createSession(node: string | null, prompt: string, harness
939
1016
  const parent = ownSessionId()
940
1017
  let res: Response
941
1018
  try {
942
- res = await fetch(`${apiBase()}/api/sessions`, {
1019
+ res = await fetch(`${await apiBase()}/api/sessions`, {
943
1020
  method: 'POST',
944
1021
  headers: { 'content-type': 'application/json' },
945
- body: JSON.stringify({ node, prompt, harness, parent, launcher }),
1022
+ body: JSON.stringify({ node, prompt, parent, launcher }),
946
1023
  })
947
1024
  } catch {
948
1025
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
949
- return newSession(node, prompt, harness, parent, launcher)
1026
+ return newSession(node, prompt, parent, launcher)
1027
+ }
1028
+ if (!res.ok) {
1029
+ const text = await res.text().catch(() => '')
1030
+ let msg = text
1031
+ try { msg = JSON.parse(text).error || text } catch {}
1032
+ const err = new Error(`backend rejected session (${res.status}): ${msg}`)
1033
+ err.name = 'BackendError'
1034
+ throw err
950
1035
  }
951
- if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
952
1036
  return await res.json() as Session
953
1037
  }
954
1038
 
@@ -959,16 +1043,14 @@ export async function createSession(node: string | null, prompt: string, harness
959
1043
  // launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
960
1044
  // only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
961
1045
  // programmatic surface, every other surface is prompt only).
962
- export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id, parent: string | null = null, launcher?: string): Promise<Session> {
1046
+ export async function newSession(node: string | null, prompt: string, parent: string | null = null, launcher?: string): Promise<Session> {
963
1047
  const id = randomUUID()
964
- // a named launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below, re-resolved at each
965
- // launch so resume reuses the same auth) AND the harness so picking one SUBSUMES the harness axis. Explicit
966
- // --launcher wins, else the configured defaultLauncher; an unknown name throws fail-loud (never a silent
967
- // wrong-auth launch). No launcher the harness arg (the zero-config path) with a null launcher field → the
968
- // unnamed global command resolution at launch.
969
- const lname = launcher ?? readConfig(mainRoot()).sessions?.defaultLauncher ?? null
970
- const chosen = lname ? resolveLauncher(lname) : null
971
- const h = harnessById(chosen ? chosen.harness : harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
1048
+ // a launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below) AND the harness — so
1049
+ // picking one is the ONLY launch choice. Explicit --launcher wins, else the configured defaultLauncher.
1050
+ // A missing/unknown default throws fail-loud; there is no built-in-claude or harness fallback.
1051
+ const lname = launcher ?? defaultLauncher(mainRoot())
1052
+ const chosen = resolveLauncher(lname)
1053
+ const h = harnessById(chosen.harness)
972
1054
  // node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
973
1055
  // none is node-agnostic and labeled by its first few words.
974
1056
  const ref = node || mentionedNode(prompt)
@@ -988,11 +1070,11 @@ export async function newSession(node: string | null, prompt: string, harness: s
988
1070
  // mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
989
1071
  node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
990
1072
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
991
- harness: h.id, harnessSessionId: null, launcher: lname,
1073
+ harness: h.id, harnessSessionId: null, launcher: chosen.name,
992
1074
  // PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
993
1075
  // (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
994
1076
  // re-resolving against a default that may have flipped (a backend restarted under a different launcher).
995
- launchCmd: h.baseCmd(chosen?.cmd),
1077
+ launchCmd: h.baseCmd(chosen.cmd),
996
1078
  }
997
1079
  writeRecord(rec)
998
1080
  writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
@@ -1104,7 +1186,7 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
1104
1186
  })
1105
1187
  return true
1106
1188
  }
1107
- export const markDone = (proposal: Proposal = 'nothing', sessionId?: string) => markState('awaiting', { proposal, sessionId })
1189
+ export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
1108
1190
  export const markError = (sessionId?: string) => markState('error', { sessionId })
1109
1191
  export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
1110
1192
  const id = sessionId || ownSessionId()
@@ -1382,8 +1464,9 @@ export function matchesSelector(s: Session, q: string): boolean {
1382
1464
  // names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
1383
1465
  // is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
1384
1466
  // comma, so without the split `a,b` would be one literal selector that matches no session and streams in
1385
- // silence forever.
1386
- return q.split(',').map((p) => p.trim()).filter(Boolean)
1467
+ // silence forever. Each part sheds an optional reference sigil (stripRefSigil): `@<sel>` / `[[<sel>]]` name
1468
+ // the same session as the bare token, so the dashboard's mention grammar is tolerated in every CLI selector.
1469
+ return q.split(',').map((p) => stripRefSigil(p.trim())).filter(Boolean)
1387
1470
  .some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
1388
1471
  }
1389
1472
 
@@ -1404,7 +1487,8 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
1404
1487
  // otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
1405
1488
  export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
1406
1489
  export function resolveSession(selector: string, sessions: Session[]): Resolved {
1407
- const exact = sessions.find((s) => s.id === selector)
1490
+ // the exact-id check sheds the optional sigil too, so `@<full-id>` keeps the exact-wins-over-prefix rule
1491
+ const exact = sessions.find((s) => s.id === stripRefSigil(selector))
1408
1492
  if (exact) return { ok: exact }
1409
1493
  const hits = sessions.filter((s) => matchesSelector(s, selector))
1410
1494
  if (hits.length === 1) return { ok: hits[0] }
@@ -1412,6 +1496,9 @@ export function resolveSession(selector: string, sessions: Session[]): Resolved
1412
1496
  }
1413
1497
 
1414
1498
  const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
1499
+ // the board table's NOTE display cap \u2014 exported so the declaration echo (cli.ts) can tell an author
1500
+ // exactly where their note gets cut, instead of the cap living as an anonymous magic number here.
1501
+ export const NOTE_BOARD_LIMIT = 50
1415
1502
  // short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
1416
1503
  const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
1417
1504
 
@@ -1438,7 +1525,7 @@ export function formatTable(sessions: Session[], color = true): string {
1438
1525
  const st = s.status.padEnd(13)
1439
1526
  const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
1440
1527
  const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
1441
- const note = s.note ? c('90', trunc(s.note, 50)) : ''
1528
+ const note = s.note ? c('90', trunc(s.note, NOTE_BOARD_LIMIT)) : ''
1442
1529
  return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
1443
1530
  })
1444
1531
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
@@ -1571,7 +1658,7 @@ export async function sendKeys(id: string, text: string, from?: string): Promise
1571
1658
  // socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
1572
1659
  // agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
1573
1660
  // move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
1574
- // keystroke-navigation state its input box is replaced by the menu, so the dashboard's nav mode forwards
1661
+ // keystroke-navigation state its input box is replaced by the menu, so the dashboard's type mode forwards
1575
1662
  // each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
1576
1663
  // key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
1577
1664
  // also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
@@ -149,19 +149,31 @@ async function rawsAsync(): Promise<Raw[]> {
149
149
  return acc
150
150
  }
151
151
 
152
- // spec node(s) that GOVERN a file by the claim rule (exact path, dir-prefix, or *-glob); reads only
153
- // frontmatter `code:` (cheap, no git) so a per-edit hook can call it. See [[governed-related]].
154
- export function specOwners(file: string): { id: string; desc: string }[] {
152
+ // the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
153
+ function claimMatcher(file: string): (cf: string) => boolean {
155
154
  const rel = file.startsWith('/') ? relative(ROOT, file) : file
156
- const claims = (cf: string): boolean => {
155
+ return (cf: string): boolean => {
157
156
  if (cf === rel) return true
158
157
  if (rel.startsWith(cf.replace(/\/+$/, '') + '/')) return true
159
158
  if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(rel)
160
159
  return false
161
160
  }
161
+ }
162
+
163
+ // spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift/yatsu); reads only
164
+ // frontmatter (cheap, no git) so a per-edit hook can call it.
165
+ export function specOwners(file: string): { id: string; desc: string }[] {
166
+ const claims = claimMatcher(file)
162
167
  return raws().filter((r) => list(r.fm.code).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
163
168
  }
164
169
 
170
+ // spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift/yatsu):
171
+ // [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
172
+ export function specRelated(file: string): { id: string; desc: string }[] {
173
+ const claims = claimMatcher(file)
174
+ return raws().filter((r) => list(r.fm.related).some(claims)).map((r) => ({ id: r.id, desc: str(r.fm.desc) }))
175
+ }
176
+
165
177
  // memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
166
178
  // commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
167
179
  const diffCache = new Map<string, { hash: string; patch: string }>()
@@ -3,13 +3,14 @@
3
3
  import net from 'node:net'
4
4
  import http from 'node:http'
5
5
  import { spawn, type ChildProcess } from 'node:child_process'
6
- import { statSync, readdirSync, type Dirent } from 'node:fs'
6
+ import { statSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync, type Dirent } from 'node:fs'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { dirname, join } from 'node:path'
9
9
  import { installProcessGuards } from './resilience.js'
10
10
  import { listenOrExit } from './listen.js'
11
11
  import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
12
12
  import { tsxBin } from './tsx-bin.js'
13
+ import { runtimeRoot } from './layout.js'
13
14
 
14
15
  // the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
15
16
  // logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
@@ -74,8 +75,11 @@ async function boot(): Promise<Backend | null> {
74
75
  const port = await freePort()
75
76
  // PORT pins the child's PRIVATE bind port; SPEXCODE_API_URL pins everything the child SPAWNS (launched
76
77
  // sessions + their hooks) at the PUBLIC port, so a launched agent's own `spex` reaches the stable proxy
77
- // and never inherits this ephemeral, soon-retired port (apiBase() prefers SPEXCODE_API_URL over PORT).
78
- const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: process.env.SPEXCODE_API_URL || childApiBase } })
78
+ // and never inherits this ephemeral, soon-retired port. ALWAYS childApiBase, never the ambient
79
+ // process.env.SPEXCODE_API_URL: the env this serve itself inherited may carry ANOTHER project's backend
80
+ // (the exact misroute [[remote-client]]'s ladder exists to kill), and a worker's env is its routing
81
+ // LIFELINE — it must be a deterministic backend-injected fact, not an inheritance gamble.
82
+ const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: childApiBase } })
79
83
  // if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
80
84
  // Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
81
85
  // check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
@@ -130,7 +134,27 @@ const proxy = net.createServer((client) => {
130
134
  client.pipe(up); up.pipe(client)
131
135
  })
132
136
 
133
- const shutdown = () => { try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
137
+ // @@@ endpoint record - this project's live backend endpoint, written into the per-project runtime tier
138
+ // (~/.spexcode/projects/<enc>/backend.json) only AFTER the public bind succeeds. It's what lets a bare
139
+ // `spex` run from this project's tree find ITS OWN backend instead of an env URL inherited from another
140
+ // project's ([[remote-client]]'s resolution ladder). Readers /health-probe before trusting, so a crash
141
+ // leaves at worst a dead record that is ignored — never followed. The recorded URL is the LOOPBACK face
142
+ // local agents reach (equals the public port when public mode is off), never the password-gated gateway.
143
+ const backendRecordPath = () => join(runtimeRoot(), 'backend.json')
144
+ function recordEndpoint(url: string): void {
145
+ try {
146
+ mkdirSync(runtimeRoot(), { recursive: true })
147
+ writeFileSync(backendRecordPath(), JSON.stringify({ url, pid: process.pid, startedAt: new Date().toISOString() }, null, 2) + '\n')
148
+ } catch (e) {
149
+ console.error(`[supervisor] could not record the backend endpoint (${(e as Error).message}) — cwd-based \`spex\` discovery won't find this backend`)
150
+ }
151
+ }
152
+ // best-effort removal on a clean stop, only if the record is OURS (a newer serve may have overwritten it).
153
+ function dropEndpoint(): void {
154
+ try { if (JSON.parse(readFileSync(backendRecordPath(), 'utf8'))?.pid === process.pid) rmSync(backendRecordPath()) } catch { /* not ours / already gone */ }
155
+ }
156
+
157
+ const shutdown = () => { dropEndpoint(); try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
134
158
  process.on('SIGINT', shutdown)
135
159
  process.on('SIGTERM', shutdown)
136
160
 
@@ -146,10 +170,10 @@ if (publicCfg) {
146
170
  // public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
147
171
  const distDir = resolveDistDir() // bundled <pkg>/dashboard-dist when installed, else monorepo spec-dashboard/dist
148
172
  ensureDashboardBuilt(repoRoot, distDir)
149
- listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`) })
173
+ listenOrExit(proxy, proxyPort, { host: '127.0.0.1', label: 'supervisor (loopback proxy)', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`) } })
150
174
  startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir, onBindFail: reapChild })
151
175
  } else {
152
- listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) })
176
+ listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) } })
153
177
  }
154
178
 
155
179
  // watch every imported source tree; debounce a burst of writes (a merge touching several files across