spexcode 0.5.7 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +199 -60
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +42 -30
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -17,27 +17,6 @@ export type MaterializedArtifact = {
|
|
|
17
17
|
}
|
|
18
18
|
export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
|
|
19
19
|
|
|
20
|
-
// @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
|
|
21
|
-
// spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
|
|
22
|
-
// post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
|
|
23
|
-
// a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
|
|
24
|
-
// cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
|
|
25
|
-
// harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
|
|
26
|
-
// surface:system plugin bodies (in name order) — written WHOLE into each
|
|
27
|
-
// harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
|
|
28
|
-
// (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
|
|
29
|
-
// [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
|
|
30
|
-
//
|
|
31
|
-
// THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) — one pass
|
|
32
|
-
// under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
|
|
33
|
-
// P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
|
|
34
|
-
// implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
|
|
35
|
-
// unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
|
|
36
|
-
// mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
|
|
37
|
-
// policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
|
|
38
|
-
// preserves old common ignore entries until every registered tree owns its local projection; it never reads or
|
|
39
|
-
// reconstructs a sibling policy.
|
|
40
|
-
|
|
41
20
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
42
21
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
43
22
|
// the ONE spex entry: the launcher (bin/spex.mjs), never a raw `tsx cli.ts` pair — the launcher owns tsx
|
|
@@ -62,14 +41,6 @@ export function contentHash(proj: string): string {
|
|
|
62
41
|
} catch { return '' }
|
|
63
42
|
}
|
|
64
43
|
|
|
65
|
-
// @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
|
|
66
|
-
// they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
|
|
67
|
-
// tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
|
|
68
|
-
// run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
|
|
69
|
-
// working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
|
|
70
|
-
// writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
|
|
71
|
-
// environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
|
|
72
|
-
// setup step — there is no committed-artifact delivery mode.
|
|
73
44
|
export function retiredAxisNotice(cfg: { render?: string; private?: boolean }): void {
|
|
74
45
|
if (!cfg.render?.trim() && !cfg.private) return
|
|
75
46
|
const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
|
|
@@ -123,12 +94,6 @@ function publishSelection(path: string, body: string): void {
|
|
|
123
94
|
renameSync(prepared, path)
|
|
124
95
|
}
|
|
125
96
|
|
|
126
|
-
// @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
|
|
127
|
-
// an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
|
|
128
|
-
// (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
|
|
129
|
-
// user began writing their own prose into it) → neither hidden nor tracked-for-them: the exclude entry is
|
|
130
|
-
// withheld (hiding user content would make their prose invisible to git — data-loss shaped) and the clean
|
|
131
|
-
// filter is pre-armed so their eventual, entirely-their-own `git add` strips our block automatically.
|
|
132
97
|
const SENTINEL_RE = /\n*<!-- spexcode:start -->[\s\S]*?<!-- spexcode:end -->\n*/
|
|
133
98
|
export function stripSpexcodeBlock(text: string): string {
|
|
134
99
|
const m = SENTINEL_RE.exec(text)
|
|
@@ -170,13 +135,6 @@ function sweepGeneratedAgents(dir: string | null): void {
|
|
|
170
135
|
}
|
|
171
136
|
}
|
|
172
137
|
|
|
173
|
-
// @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
|
|
174
|
-
// only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
|
|
175
|
-
// stamp, so it deletes ONLY what a materialize wrote — never the user's prose, settings, or any .spec data. Order
|
|
176
|
-
// matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
|
|
177
|
-
// (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
|
|
178
|
-
// `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
|
|
179
|
-
// covers everything materialized since, including products of renamed/deleted nodes.
|
|
180
138
|
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
181
139
|
for (const h of HARNESSES) {
|
|
182
140
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -1,225 +1,81 @@
|
|
|
1
|
-
// @@@ mentions - the two
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
|
|
7
|
-
|
|
8
|
-
// ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
-
// `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
|
|
10
|
-
// letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
|
|
11
|
-
// MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
|
|
12
|
-
// Only `new` may carry `:<launcher>` ([[launcher-select]]); keeping that choice in the token makes the same
|
|
13
|
-
// prose work from the CLI and dashboard, with no composer-only field. Both forms are deduped first-seen.
|
|
14
|
-
const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)(?::([\p{L}\p{N}_.-]+))?/gu
|
|
1
|
+
// @@@ mentions - the two passive in-text references: `[[node]]` names a topic and
|
|
2
|
+
// `@session` names a retained session. The grammar is shared by every composer; neither
|
|
3
|
+
// reference is control. Sending to a session, launching one, and inheriting one remain
|
|
4
|
+
// explicit `spex session send`, `spex session new`, and `/distill <id>` actions.
|
|
5
|
+
const SESSION_RE = /(?:^|\s)@([\p{L}\p{N}_-]+(?::[\p{L}\p{N}_.-]+)?)/gu
|
|
15
6
|
const NODE_RE = /\[\[([^\]\s]+)\]\]/g
|
|
16
7
|
|
|
17
8
|
const uniq = (xs: string[]): string[] => [...new Set(xs)]
|
|
18
9
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// ARGUMENT the whole token IS the reference, so the sigil is optional: `spex session review @graph` ≡
|
|
22
|
-
// `spex session review graph`, `spex eval add [[cli-surface]]` ≡ `spex eval add cli-surface`. One shared strip,
|
|
23
|
-
// applied by the session-selector matcher and every node-arg read site, so the habit a user learns in the
|
|
24
|
-
// dashboard's input boxes works verbatim on the CLI — never a second grammar to learn.
|
|
10
|
+
// In free text a sigil separates a reference from prose. A CLI argument is already a
|
|
11
|
+
// reference, so it tolerates either sigil without widening its normal matching grammar.
|
|
25
12
|
export function stripRefSigil(token: string): string {
|
|
26
13
|
const wrapped = /^\[\[(.*)\]\]$/.exec(token)
|
|
27
14
|
if (wrapped) return wrapped[1]
|
|
28
15
|
return token.startsWith('@') ? token.slice(1) : token
|
|
29
16
|
}
|
|
30
17
|
|
|
31
|
-
export function parseMentions(text: string): {
|
|
32
|
-
const
|
|
18
|
+
export function parseMentions(text: string): { sessions: string[]; nodes: string[] } {
|
|
19
|
+
const sessions: string[] = []
|
|
33
20
|
const nodes: string[] = []
|
|
34
|
-
for (const m of text.matchAll(
|
|
21
|
+
for (const m of text.matchAll(SESSION_RE)) sessions.push(m[1])
|
|
35
22
|
for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
|
|
36
|
-
return {
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ── resolve actors against live sessions (pure) ───────────────────────────────────────────────────────
|
|
40
|
-
// A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
|
|
41
|
-
export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
|
|
42
|
-
export type Resolved =
|
|
43
|
-
| { token: string; kind: 'new'; launcher?: string }
|
|
44
|
-
| { token: string; kind: 'session'; session: ActorSession }
|
|
45
|
-
| { token: string; kind: 'unresolved' }
|
|
46
|
-
|
|
47
|
-
// Resolve each actor token to a `new` sentinel (optionally carrying its explicit launcher), an ONLINE
|
|
48
|
-
// session (never a dead one — you don't summon a closed agent; `@new` acts on its behalf), or unresolved.
|
|
49
|
-
// Match order: new/new:<launcher> → exact id → id prefix → name/title (case-insensitive) → name/title prefix.
|
|
50
|
-
// First online match wins.
|
|
51
|
-
export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
|
|
52
|
-
const online = sessions.filter((s) => s.liveness === 'online')
|
|
53
|
-
return tokens.map((token): Resolved => {
|
|
54
|
-
const fresh = /^new(?::(.+))?$/.exec(token)
|
|
55
|
-
if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
|
|
56
|
-
const t = token.toLowerCase()
|
|
57
|
-
const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
|
|
58
|
-
const hit =
|
|
59
|
-
online.find((s) => s.id === token) ||
|
|
60
|
-
online.find((s) => s.id.startsWith(token)) ||
|
|
61
|
-
online.find((s) => label(s) === t) ||
|
|
62
|
-
online.find((s) => label(s).startsWith(t) && t.length >= 2)
|
|
63
|
-
return hit ? { token, kind: 'session', session: hit } : { token, kind: 'unresolved' }
|
|
64
|
-
})
|
|
23
|
+
return { sessions: uniq(sessions), nodes: uniq(nodes) }
|
|
65
24
|
}
|
|
66
25
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
26
|
+
// The originator courtesy is the only automatic notification left in this module. It
|
|
27
|
+
// resolves explicit stored session ids against online rows; prose @ references never call it.
|
|
28
|
+
type Session = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
|
|
29
|
+
|
|
30
|
+
function resolveOnlineSession(token: string, sessions: Session[]): Session | null {
|
|
31
|
+
const online = sessions.filter((session) => session.liveness === 'online')
|
|
32
|
+
const text = token.toLowerCase()
|
|
33
|
+
const label = (session: Session) => (session.name || session.title || '').toLowerCase()
|
|
34
|
+
return online.find((session) => session.id === token)
|
|
35
|
+
|| online.find((session) => session.id.startsWith(token))
|
|
36
|
+
|| online.find((session) => label(session) === text)
|
|
37
|
+
|| online.find((session) => label(session).startsWith(text) && text.length >= 2)
|
|
38
|
+
|| null
|
|
74
39
|
}
|
|
75
40
|
|
|
76
|
-
|
|
77
|
-
export type
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// nudge to look, never a rigid command — what to do is up to the comment's words.
|
|
81
|
-
function mentionPrompt(threadId: string, node: string | null, author: string, text: string): string {
|
|
82
|
-
const re = node ? ` (re: ${node})` : ''
|
|
83
|
-
return `You were @-mentioned in issue thread "${threadId}"${re} by ${author}:\n\n ${text.trim()}\n\n` +
|
|
84
|
-
`Read the thread and act as the comment asks (often just a look): \`spex issue ls --all\` lists them; ` +
|
|
85
|
-
`reply with \`spex issue reply ${threadId} --body -\`.`
|
|
86
|
-
}
|
|
87
|
-
// A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
|
|
88
|
-
// landed, so the prompt leads with the status and a verify-on-main-first instruction.
|
|
89
|
-
export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
|
|
90
|
-
// Keep inherited scope inside the text the worker receives: the create transaction derives its node only from the
|
|
91
|
-
// raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
|
|
92
|
-
const on = node ? ` on node [[${node}]]` : ''
|
|
93
|
-
const settled = status && status !== 'open'
|
|
94
|
-
? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
|
|
95
|
-
`Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
|
|
96
|
-
`instead of re-implementing.\n\n`
|
|
97
|
-
: ''
|
|
98
|
-
return `Issue thread "${threadId}"${on} @-mentioned @new (by ${author}) for a fresh look:\n\n ${text.trim()}\n\n` +
|
|
99
|
-
settled +
|
|
100
|
-
`Read the thread (\`spex issue ls --all\`, find ${threadId}) and act on it${node ? `; the relevant node is ${node}` : ''}.`
|
|
101
|
-
}
|
|
41
|
+
export type LoopIn = { originator: string }
|
|
42
|
+
export type LoopInPick =
|
|
43
|
+
| { kind: 'deliver'; originator: string; session: Session }
|
|
44
|
+
| { kind: 'none' }
|
|
102
45
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const { sendText, listSessions, sessionCreateRequest } = await import('./sessions.js')
|
|
113
|
-
const sessions = await listSessions()
|
|
114
|
-
const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
|
|
115
|
-
const out: DispatchOutcome[] = []
|
|
116
|
-
for (const r of resolved) {
|
|
117
|
-
if (r.kind === 'unresolved') { out.push({ token: r.token, result: 'unresolved' }); continue }
|
|
118
|
-
if (r.kind === 'new') {
|
|
119
|
-
// the drain guard ([[mentions]]): @new on a settled thread still spawns (the summons may be a
|
|
120
|
-
// deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
|
|
121
|
-
const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
|
|
122
|
-
try {
|
|
123
|
-
const created = await sessionCreateRequest({
|
|
124
|
-
prompt: newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status),
|
|
125
|
-
parent: spawnParent(ctx.author, sessions),
|
|
126
|
-
launcher: r.launcher,
|
|
127
|
-
})
|
|
128
|
-
if (created.status !== 201) throw new Error(`${created.code || 'session_create_failed'}: ${created.error}`)
|
|
129
|
-
out.push({ token: r.token, result: 'spawned', detail: created.session.id, ...(settled ? { note: `thread ${settled}` } : {}) })
|
|
130
|
-
} catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
const res = await sendText(r.session.id, mentionPrompt(ctx.threadId, ctx.node, ctx.author, text), 'issues')
|
|
134
|
-
out.push(res.ok ? { token: r.token, result: 'sent', detail: r.session.id }
|
|
135
|
-
: { token: r.token, result: 'offline', detail: res.error })
|
|
46
|
+
// A committed reply can notify its originator as a courtesy. This is not a mention
|
|
47
|
+
// dispatch or assignment, and it never creates a worker or retries an offline session.
|
|
48
|
+
export function pickLoopIn(chain: (string | null)[], replier: string, sessions: Session[]): LoopInPick {
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
for (const originator of chain) {
|
|
51
|
+
if (!originator || originator === replier || seen.has(originator)) continue
|
|
52
|
+
seen.add(originator)
|
|
53
|
+
const session = resolveOnlineSession(originator, sessions)
|
|
54
|
+
if (session) return { kind: 'deliver', originator, session }
|
|
136
55
|
}
|
|
137
|
-
return
|
|
56
|
+
return { kind: 'none' }
|
|
138
57
|
}
|
|
139
58
|
|
|
140
|
-
// ── implicit originator loop-in + the dispatch fallback chain ([[mentions]] / [[remark-substrate]] R3) ────
|
|
141
|
-
// A committed reply is ALSO auto-delivered as a COURTESY — never an assignment — to a FALLBACK CHAIN of
|
|
142
|
-
// candidates, in order, stopping at the FIRST one that can be reached: for a remark this is the reading's
|
|
143
|
-
// filer session, then the node's governing session, then nobody (it still surfaces on the board via the
|
|
144
|
-
// teeth). This is a NOTIFICATION chain only — it resolves NOTHING (resolve stays a deliberate `spex remark resolve`,
|
|
145
|
-
// R3); it just reaches an agent who can act. It is the same delivery pipe as dispatchMentions (one
|
|
146
|
-
// online-resolution + one sendText), with the same cuts that keep courtesy ≠ assignment: deliver ONLY to an
|
|
147
|
-
// ONLINE session (an unreachable link is skipped for the next, NEVER spawns a worker, NEVER drains — only an
|
|
148
|
-
// explicit @new spawns); SKIP a candidate that is the replier (no self-notify); a candidate already reached by
|
|
149
|
-
// an explicit @-target of this same text counts as delivered, so the chain STOPS (no double-delivery, no
|
|
150
|
-
// needless escalation). Store-agnostic: a forge issue's author is a github login, resolves to no live session,
|
|
151
|
-
// and the chain runs dry silently — exactly right.
|
|
152
|
-
export type LoopIn = { originator: string } // the candidate we actually reached (a filer OR a governing-session fallback)
|
|
153
|
-
|
|
154
|
-
// The courtesy prompt — framed as a heads-up, never a command (that is what an @-mention's mentionPrompt is).
|
|
155
59
|
function originatorPrompt(threadId: string, node: string | null, replier: string, text: string): string {
|
|
156
60
|
const re = node ? ` (re: ${node})` : ''
|
|
157
|
-
return `A new reply landed on a thread you originated
|
|
158
|
-
`This is a courtesy heads-up
|
|
159
|
-
`\`spex issue ls --all\` lists
|
|
61
|
+
return `A new reply landed on a thread you originated - "${threadId}"${re}, from ${replier}:\n\n ${text.trim()}\n\n` +
|
|
62
|
+
`This is a courtesy heads-up, not an assignment. Look if it concerns you; ` +
|
|
63
|
+
`\`spex issue ls --all\` lists threads and \`spex issue reply ${threadId} --body -\` replies.`
|
|
160
64
|
}
|
|
161
65
|
|
|
162
|
-
// The pure fallback decision (testable without sessions.ts): walk the ordered chain (nulls/dupes/the-replier
|
|
163
|
-
// pruned) and return the FIRST link that resolves to an online session — that is who the courtesy goes to. A
|
|
164
|
-
// link already reached by an explicit @-target of this same text short-circuits to `reached` (stop, no
|
|
165
|
-
// double-delivery — the actor already has it); an offline/absent link falls through to the next. `none` means
|
|
166
|
-
// the chain ran dry (nobody online). This is the whole fallback logic; delivery is a thin sendText around it.
|
|
167
|
-
export type LoopInPick =
|
|
168
|
-
| { kind: 'deliver'; originator: string; session: ActorSession }
|
|
169
|
-
| { kind: 'reached' }
|
|
170
|
-
| { kind: 'none' }
|
|
171
|
-
export function pickLoopIn(
|
|
172
|
-
chain: (string | null)[],
|
|
173
|
-
replier: string,
|
|
174
|
-
sessions: ActorSession[],
|
|
175
|
-
alreadyDelivered?: Set<string>,
|
|
176
|
-
): LoopInPick {
|
|
177
|
-
const seen = new Set<string>()
|
|
178
|
-
const candidates = chain.filter((c): c is string => !!c && c !== replier && !seen.has(c) && (seen.add(c), true))
|
|
179
|
-
for (const originator of candidates) {
|
|
180
|
-
const [resolved] = resolveActors([originator], sessions)
|
|
181
|
-
if (resolved.kind !== 'session') continue // offline / no live session → try the next fallback link
|
|
182
|
-
if (alreadyDelivered?.has(resolved.session.id)) return { kind: 'reached' } // already an explicit @-target: stop
|
|
183
|
-
return { kind: 'deliver', originator, session: resolved.session }
|
|
184
|
-
}
|
|
185
|
-
return { kind: 'none' }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// `chain` is the ordered fallback list. We deliver the courtesy to the first online link and STOP; an
|
|
189
|
-
// offline/failed link falls through to the next. NOTIFICATION ONLY — this never touches a `resolved` bit
|
|
190
|
-
// (resolve is a deliberate `spex remark resolve`), never spawns (only `@new` spawns).
|
|
191
66
|
export async function notifyOriginator(
|
|
192
67
|
chain: (string | null)[],
|
|
193
68
|
replier: string,
|
|
194
69
|
text: string,
|
|
195
|
-
ctx: { threadId: string; node: string | null
|
|
70
|
+
ctx: { threadId: string; node: string | null },
|
|
196
71
|
): Promise<LoopIn | null> {
|
|
197
|
-
|
|
198
|
-
if (!chain.some((c) => c && c !== replier && !seen.has(c) && (seen.add(c), true))) return null // nothing to do → no session load
|
|
72
|
+
if (!chain.some((candidate) => candidate && candidate !== replier)) return null
|
|
199
73
|
const { sendText, listSessions } = await import('./sessions.js')
|
|
200
|
-
const pick = pickLoopIn(chain, replier, await listSessions() as unknown as
|
|
201
|
-
if (pick.kind !== 'deliver') return null
|
|
202
|
-
const
|
|
203
|
-
return
|
|
74
|
+
const pick = pickLoopIn(chain, replier, await listSessions() as unknown as Session[])
|
|
75
|
+
if (pick.kind !== 'deliver') return null
|
|
76
|
+
const result = await sendText(pick.session.id, originatorPrompt(ctx.threadId, ctx.node, replier, text), 'issues')
|
|
77
|
+
return result.ok ? { originator: pick.originator } : null
|
|
204
78
|
}
|
|
205
79
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
export function deliveredIds(outcomes: DispatchOutcome[]): Set<string> {
|
|
209
|
-
return new Set(outcomes.filter((o) => (o.result === 'sent' || o.result === 'spawned') && o.detail).map((o) => o.detail!))
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// A one-line human summary of what a dispatch did, for the CLI to echo after a post. The optional loop-in is
|
|
213
|
-
// noted DISTINCT from the @-dispatch — a courtesy copy, not an assignment.
|
|
214
|
-
export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null): string {
|
|
215
|
-
const parts: string[] = []
|
|
216
|
-
if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
|
|
217
|
-
if (o.result === 'sent') return `${o.token}→sent`
|
|
218
|
-
if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
|
|
219
|
-
if (o.result === 'offline') return `${o.token}→offline (stored)`
|
|
220
|
-
if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
|
|
221
|
-
return `${o.token}→failed (${o.detail})`
|
|
222
|
-
}).join(' · '))
|
|
223
|
-
if (loopIn) parts.push(`↩ looped in originator @${loopIn.originator} (online)`)
|
|
224
|
-
return parts.join(' · ')
|
|
225
|
-
}
|
|
80
|
+
export const summarizeLoopIn = (loopIn?: LoopIn | null): string =>
|
|
81
|
+
loopIn ? `looped in originator @${loopIn.originator} (online)` : ''
|
|
@@ -131,7 +131,7 @@ function turnExited(rec: HarnessDeliveryRecord, outcome: Extract<TurnOutcome, {
|
|
|
131
131
|
const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`
|
|
132
132
|
return {
|
|
133
133
|
ok: false,
|
|
134
|
-
error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} -
|
|
134
|
+
error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - immediate wake failed`,
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -173,7 +173,7 @@ export async function spawnOpenCodeHeadlessTurn(
|
|
|
173
173
|
const outcome = readTurnOutcome(outcomePath)
|
|
174
174
|
if (outcome?.state === 'exit') return turnExited(rec, outcome)
|
|
175
175
|
if (outcome?.state === 'invalid') {
|
|
176
|
-
return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome -
|
|
176
|
+
return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
|
|
177
177
|
}
|
|
178
178
|
if (Date.now() >= deadline) break
|
|
179
179
|
await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()))
|
|
@@ -181,19 +181,19 @@ export async function spawnOpenCodeHeadlessTurn(
|
|
|
181
181
|
|
|
182
182
|
const first = readTurnOutcome(outcomePath)
|
|
183
183
|
if (first?.state === 'exit') return turnExited(rec, first)
|
|
184
|
-
if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup -
|
|
185
|
-
if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome -
|
|
184
|
+
if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - immediate wake failed` }
|
|
185
|
+
if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` }
|
|
186
186
|
if (first.state === 'reporting') {
|
|
187
|
-
return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} -
|
|
187
|
+
return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - immediate wake failed` }
|
|
188
188
|
}
|
|
189
189
|
if (!pidAlive(first.pid)) {
|
|
190
|
-
return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} -
|
|
190
|
+
return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - immediate wake failed` }
|
|
191
191
|
}
|
|
192
192
|
await sleep(OUTCOME_POLL_MS)
|
|
193
193
|
const settled = readTurnOutcome(outcomePath)
|
|
194
194
|
if (settled?.state === 'exit') return turnExited(rec, settled)
|
|
195
195
|
if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
|
|
196
|
-
return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} -
|
|
196
|
+
return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - immediate wake failed` }
|
|
197
197
|
}
|
|
198
198
|
return { ok: true }
|
|
199
199
|
} catch (error) {
|
package/spec-cli/src/opencode.ts
CHANGED
|
@@ -92,11 +92,8 @@ export const SpexcodePlugin = async (ctx) => {
|
|
|
92
92
|
})()
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// instead of confirming a prompt that can never inject); {type:"repaint"} answers repaint-done in the same
|
|
98
|
-
// synchronous parse pass — confirmation means PARSED, not processed. The injection itself (a whole model
|
|
99
|
-
// turn: the SDK prompt call resolves only when the TURN ends) runs BEHIND the confirm.
|
|
95
|
+
// The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
|
|
96
|
+
// timeline line for its next turn boundary; the SDK injection remains host-owned.
|
|
100
97
|
rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
|
|
101
98
|
|
|
102
99
|
const toolPayload = (input, output) => {
|
|
@@ -8,7 +8,7 @@ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
|
8
8
|
import { controlRequest, withTimeout } from './headless-controller.js'
|
|
9
9
|
import { shQuote } from './sh.js'
|
|
10
10
|
|
|
11
|
-
type ControlRequest = { type: 'deliver'; text: string }
|
|
11
|
+
type ControlRequest = { type: 'deliver'; text: string; mid?: string }
|
|
12
12
|
type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
|
|
13
13
|
|
|
14
14
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
@@ -24,7 +24,7 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
|
|
27
|
-
controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
|
|
27
|
+
controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text, mid: rec.mid }, {
|
|
28
28
|
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
29
29
|
rejected: 'pi-headless controller rejected the request',
|
|
30
30
|
})
|
|
@@ -101,10 +101,10 @@ export class PiHeadlessController {
|
|
|
101
101
|
if (request.type !== 'deliver') return { ok: false, error: 'unknown pi-headless control request' }
|
|
102
102
|
if (!request.text) return { ok: false, error: 'empty prompt - nothing to deliver' }
|
|
103
103
|
|
|
104
|
-
// A live extension listener is an in-flight pi turn.
|
|
105
|
-
//
|
|
104
|
+
// A live extension listener is an in-flight pi turn. Only a proven absent listener may cold-wake a saved
|
|
105
|
+
// session; an inconclusive probe must not start a duplicate turn.
|
|
106
106
|
const { deliverViaSocketOrWake } = await import('./harness.js')
|
|
107
|
-
return deliverViaSocketOrWake(this.id, request.text, async () => {
|
|
107
|
+
return deliverViaSocketOrWake(this.id, request.text, request.mid, async () => {
|
|
108
108
|
if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
|
|
109
109
|
await this.spawnTurn(request.text, true)
|
|
110
110
|
return { ok: true }
|
|
@@ -2,7 +2,6 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
import { alive, withSessionInputLock } from './sessions.js'
|
|
5
|
-
import { runSessionOperationSync } from './session-maintenance.js'
|
|
6
5
|
|
|
7
6
|
const pexec = promisify(execFile)
|
|
8
7
|
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
@@ -363,10 +362,10 @@ const MAX_INPUT_BYTES = 64 * 1024
|
|
|
363
362
|
export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
|
|
364
363
|
const subscription = currentSubscription(id, viewer)
|
|
365
364
|
if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
|
|
366
|
-
return
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
365
|
+
return withSessionInputLock(id, () => {
|
|
366
|
+
sendControl(subscription.bridge!, { t: 'input', data })
|
|
367
|
+
return true
|
|
368
|
+
}) ?? false
|
|
370
369
|
}
|
|
371
370
|
|
|
372
371
|
async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
|
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { listSessions } from './sessions.js'
|
|
3
|
-
import { getBoard } from './graphCache.js'
|
|
3
|
+
import { getBoard, getBoardForForgeRevision } from './graphCache.js'
|
|
4
4
|
import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
5
5
|
import { evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
6
6
|
import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
|
|
7
7
|
import { issueStores as issueStoresForReview } from './issues.js'
|
|
8
8
|
import { hasReviewSnapshot, readReviewSnapshot } from './reviewSnapshot.js'
|
|
9
|
+
import { residentForgeRevision, residentForgeState } from '../../spec-forge/src/resident.js'
|
|
9
10
|
// @ts-expect-error The dashboard module is deliberately plain JS so the browser and server execute the
|
|
10
11
|
// exact same tokenizer/matcher. It is shipped beside the built dashboard by the root package manifest.
|
|
11
12
|
import { EVAL_FILTER_KIND, evalFilterModel, evalReviewState, issueFilterModel, tokenFilterState } from '../../spec-dashboard/src/reviewFilters.js'
|
|
@@ -124,7 +125,11 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
|
|
|
124
125
|
// The first request must wait for the first atomic publication. Once one exists, a graph refresh may be
|
|
125
126
|
// rebuilding unrelated board/session state; the published review source remains a valid answer and its
|
|
126
127
|
// revision/poll path will deliver the next generation without making this page join that flight.
|
|
128
|
+
residentForgeState()
|
|
127
129
|
if (!hasReviewSnapshot()) await getBoard()
|
|
130
|
+
else if (readReviewSnapshot().forgeRevision < residentForgeRevision()) {
|
|
131
|
+
await getBoardForForgeRevision(residentForgeRevision())
|
|
132
|
+
}
|
|
128
133
|
const sessions = await listSessions()
|
|
129
134
|
const issues = readReviewSnapshot().issues.slice().sort(issueOrder)
|
|
130
135
|
const text = String(query ?? '').trim() || ISSUE_QUERY_DEFAULT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @@@ root-lru - ONE bounded root→key cache policy, for every layer that keeps immutable per-HEAD work warm.
|
|
2
|
+
// A leaf module on purpose: it imports nothing from the spec graph, the eval sidecar, or git, so both layers
|
|
3
|
+
// can depend on it without either owning the other ([[source-of-truth]]).
|
|
4
|
+
//
|
|
5
|
+
// The policy is reference-counted, not plain LRU, and the distinction is the whole point. Entries are keyed by
|
|
6
|
+
// something IMMUTABLE (a HEAD, or a ledger path + HEAD), so two checkouts sitting on the same commit must share
|
|
7
|
+
// one entry rather than build it twice. A root moving A→B therefore drops A only when NO other root still
|
|
8
|
+
// points at it; otherwise a sequence of successful rebuilds retains one whole index per commit until the slot
|
|
9
|
+
// bound finally evicts them. Bumping an unchanged root is a pure recency touch (delete + reinsert), which is
|
|
10
|
+
// what makes `roots` insertion-ordered enough for the eviction loop to mean "oldest root".
|
|
11
|
+
//
|
|
12
|
+
// This existed twice, verbatim in logic and even in name — `touchRoot` in git.ts (index/drift) and again in
|
|
13
|
+
// spec-eval's scenariofresh.ts (scenario chains), whose comment said it was "mirroring historyIndex/driftIndex
|
|
14
|
+
// in git.ts". Both authors knew; neither had anywhere to put it. Now they do.
|
|
15
|
+
|
|
16
|
+
// slot bound for one cache family. Every caller names its own env knob and default so operators can tune the
|
|
17
|
+
// families independently, but nobody gets to invent a different FLOOR or a bare literal — a cache whose bound
|
|
18
|
+
// is a magic number cannot be tuned in the field at all (scenariofresh's was a hardcoded 16).
|
|
19
|
+
// @@@ unparseable is not zero - the shape both copies used, `Math.max(4, Number(env || fallback))`, returns
|
|
20
|
+
// NaN for a mistyped value, and `size > NaN` is always false: one typo in an env var silently turned the
|
|
21
|
+
// bound OFF and let the cache grow without limit. A bound that fails open is worse than no bound, because
|
|
22
|
+
// nothing reports it. Anything that does not parse to a positive number falls back to the caller's default.
|
|
23
|
+
export const rootSlots = (env: string | undefined, fallback: number): number => {
|
|
24
|
+
const asked = Number(env)
|
|
25
|
+
return Math.max(4, Number.isFinite(asked) && asked > 0 ? asked : fallback)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Record that `root` now wants `key`, evicting what no root wants any more and keeping `roots` within `slots`.
|
|
29
|
+
// `cache` is the caller's own keyed store; this owns only the ROOT→key bookkeeping and the eviction decision.
|
|
30
|
+
export function touchRoot(
|
|
31
|
+
roots: Map<string, string>,
|
|
32
|
+
cache: Map<string, unknown> & { delete(key: string): boolean },
|
|
33
|
+
root: string,
|
|
34
|
+
key: string,
|
|
35
|
+
slots: number,
|
|
36
|
+
): void {
|
|
37
|
+
const previous = roots.get(root)
|
|
38
|
+
if (previous !== key) {
|
|
39
|
+
roots.set(root, key)
|
|
40
|
+
// the old key survives only while some OTHER root still names it — immutable entries are shared, so
|
|
41
|
+
// dropping one root's view must not throw away a sibling checkout's warm work.
|
|
42
|
+
if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
|
|
43
|
+
} else {
|
|
44
|
+
roots.delete(root)
|
|
45
|
+
roots.set(root, key) // recency bump: reinsertion is what makes the eviction loop below pick the oldest
|
|
46
|
+
}
|
|
47
|
+
while (roots.size > slots) {
|
|
48
|
+
const oldest = roots.keys().next().value as string | undefined
|
|
49
|
+
if (oldest === undefined) break
|
|
50
|
+
const oldKey = roots.get(oldest)
|
|
51
|
+
roots.delete(oldest)
|
|
52
|
+
if (oldKey && ![...roots.values()].includes(oldKey)) cache.delete(oldKey)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
|
|
3
|
-
// @@@ session-runtime guard ([[platform-support]]) - SpexCode's session orchestration rests on POSIX
|
|
4
|
-
// primitives with no native-Windows analog: tmux (the durable detached PTY + capture-pane scrollback +
|
|
5
|
-
// multi-client reattach fabric), hand-written bash launch scripts, and filesystem-path AF_UNIX control
|
|
6
|
-
// sockets. The supported runtime is POSIX — Linux, macOS, or Windows *via WSL2* (a real Linux kernel where
|
|
7
|
-
// tmux/bash/unix-sockets all work). This is the honest gate at the entry to the session runtime: detect the
|
|
8
|
-
// load-bearing primitive (tmux) missing and print ONE actionable line naming the fix, instead of letting a
|
|
9
|
-
// cryptic downstream ENOENT be the user's first signal. Read-only CLI (init/lint/board) never calls this,
|
|
10
|
-
// so it still runs anywhere the launcher does — only the session-launch path (`spex serve`) is gated.
|
|
11
|
-
|
|
12
3
|
// tmux presence is the primitive we actually depend on, so probe THAT rather than assuming by platform:
|
|
13
4
|
// this also catches a bare POSIX box that simply hasn't installed tmux, not only native Windows.
|
|
14
5
|
export function hasTmux(): boolean {
|