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.
- package/README.md +149 -102
- package/README.zh-CN.md +170 -0
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +24 -1
- package/spec-cli/src/attach.ts +50 -0
- package/spec-cli/src/cli.ts +227 -66
- package/spec-cli/src/client.ts +47 -9
- package/spec-cli/src/{self.ts → doctor.ts} +26 -25
- package/spec-cli/src/gateway.ts +15 -11
- package/spec-cli/src/guide.ts +73 -17
- package/spec-cli/src/harness.ts +48 -19
- package/spec-cli/src/help.ts +141 -51
- package/spec-cli/src/index.ts +41 -14
- package/spec-cli/src/issues.ts +109 -31
- package/spec-cli/src/layout.ts +4 -4
- package/spec-cli/src/localIssues.ts +59 -58
- package/spec-cli/src/materialize.ts +4 -2
- package/spec-cli/src/mentions.ts +22 -1
- package/spec-cli/src/pty-bridge.ts +39 -4
- package/spec-cli/src/ranker.ts +31 -12
- package/spec-cli/src/search.bench.mjs +30 -7
- package/spec-cli/src/search.ts +39 -0
- package/spec-cli/src/sessions.ts +149 -62
- package/spec-cli/src/specs.ts +16 -4
- package/spec-cli/src/supervise.ts +30 -6
- package/spec-cli/src/tree.ts +118 -0
- package/spec-cli/templates/hooks/post-merge +2 -2
- package/spec-cli/templates/hooks/pre-commit +34 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
- package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
- package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
- package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
- package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
- package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
- package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
- package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
- package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-yatsu/src/cli.ts +89 -15
- package/spec-yatsu/src/scenariofresh.ts +100 -30
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -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
|
|
50
|
-
//
|
|
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
|
|
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
|
|
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 →
|
|
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
|
|
441
|
-
//
|
|
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
|
|
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
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
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)
|
|
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 -
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
// served root and FAIL LOUD on a provable, same-host mismatch — never a silent
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
|
|
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(`${
|
|
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
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
`
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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-`
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -149,19 +149,31 @@ async function rawsAsync(): Promise<Raw[]> {
|
|
|
149
149
|
return acc
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|