spexcode 0.5.2 → 0.5.4
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +15 -4
- package/spec-cli/src/harness.ts +1133 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +698 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +73 -15
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +189 -41
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +174 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +88 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1251 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +29 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
package/README.md
CHANGED
|
@@ -227,7 +227,7 @@ manuals are `spex guide` (the workflow), `spex guide spec`, `spex guide eval`, a
|
|
|
227
227
|
## Contributing
|
|
228
228
|
|
|
229
229
|
[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) gets you from a clone to a first merged change.
|
|
230
|
-
[
|
|
230
|
+
[spexcode.net](https://spexcode.net) has the full mechanics of the node model and the
|
|
231
231
|
reflexive plugin system.
|
|
232
232
|
|
|
233
233
|
## Credit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -53,5 +53,29 @@ function resolveTsxCli() {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
const tsxCli = resolveTsxCli()
|
|
56
|
-
|
|
56
|
+
const args = process.argv.slice(2)
|
|
57
|
+
const env = { ...process.env }
|
|
58
|
+
// A backend/dashboard is a project or host control plane, never the managed session that happened to start
|
|
59
|
+
// it. Scrub before tsx starts: doing this inside cli.ts is too late because tsx's resident esbuild helper has
|
|
60
|
+
// already inherited the caller. New session launches provide the adapter-registry-derived key manifest; the
|
|
61
|
+
// fallback covers sessions created before that manifest existed.
|
|
62
|
+
if (args[0] === 'serve' || args[0] === 'dashboard') {
|
|
63
|
+
const identityKeys = (env.SPEXCODE_SESSION_IDENTITY_VARS
|
|
64
|
+
|| 'SPEXCODE_SESSION_ID,CLAUDE_CODE_SESSION_ID,CODEX_THREAD_ID,OPENCODE_SESSION_ID,PI_SESSION_ID')
|
|
65
|
+
.split(',').map((key) => key.trim()).filter(Boolean)
|
|
66
|
+
for (const key of identityKeys) delete env[key]
|
|
67
|
+
}
|
|
68
|
+
const inheritedMaintenanceFds = [
|
|
69
|
+
...(env.SPEXCODE_MAINTENANCE_BROKER_FDS || '').split(','),
|
|
70
|
+
env.SPEXCODE_MAINTENANCE_DELEGATE_FD || '',
|
|
71
|
+
].map(Number).filter((fd) => Number.isInteger(fd) && fd >= 3)
|
|
72
|
+
if (inheritedMaintenanceFds.length) {
|
|
73
|
+
const req = createRequire(join(pkg, 'package.json'))
|
|
74
|
+
const tsxImport = req.resolve('tsx/esm')
|
|
75
|
+
const stdio = Array(Math.max(2, ...inheritedMaintenanceFds) + 1).fill('ignore')
|
|
76
|
+
stdio[0] = stdio[1] = stdio[2] = 'inherit'
|
|
77
|
+
for (const fd of inheritedMaintenanceFds) stdio[fd] = 'inherit'
|
|
78
|
+
spawn(process.execPath, ['--import', tsxImport, cli, ...args], { stdio, env })
|
|
79
|
+
.on('exit', (code) => process.exit(code ?? 0))
|
|
80
|
+
} else spawn(process.execPath, [tsxCli, cli, ...args], { stdio: 'inherit', env })
|
|
57
81
|
.on('exit', (code) => process.exit(code ?? 0))
|
|
@@ -30,6 +30,11 @@ export SPEXCODE_HARNESS="$harness"
|
|
|
30
30
|
# source it here too for hp_runtime_dir (the per-project store dir).
|
|
31
31
|
export SPEXCODE_HARNESS_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness.sh"
|
|
32
32
|
. "$SPEXCODE_HARNESS_LIB"
|
|
33
|
+
if [ -n "${SPEX:-}" ]; then
|
|
34
|
+
read -r -a spex_cmd <<< "$SPEX"
|
|
35
|
+
else
|
|
36
|
+
spex_cmd=("$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/spex.mjs")
|
|
37
|
+
fi
|
|
33
38
|
proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
34
39
|
# the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
|
|
35
40
|
# NOT the worktree — and per tree, so a dispatch can only read the manifest of the tree it fires in
|
|
@@ -52,7 +57,25 @@ fi
|
|
|
52
57
|
[ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
|
|
53
58
|
input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
|
|
54
59
|
err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
|
|
55
|
-
|
|
60
|
+
ticket=""
|
|
61
|
+
cleanup() {
|
|
62
|
+
[ -z "$ticket" ] || "${spex_cmd[@]}" internal maintenance-end "$ticket" "$$" >/dev/null 2>&1 || true
|
|
63
|
+
rm -f "$err"
|
|
64
|
+
}
|
|
65
|
+
trap cleanup EXIT
|
|
66
|
+
trap 'exit 130' INT
|
|
67
|
+
trap 'exit 143' HUP TERM
|
|
68
|
+
sid="$(hp_session_id "$input")"
|
|
69
|
+
[ -n "$sid" ] || sid=hook-dispatch
|
|
70
|
+
if ! ticket="$("${spex_cmd[@]}" internal maintenance-begin "$$" "$sid" 2>"$err")"; then
|
|
71
|
+
reason="$(cat "$err" 2>/dev/null)"
|
|
72
|
+
[ -n "$reason" ] || reason='maintenance_active: hook dispatch was not admitted'
|
|
73
|
+
escaped="$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
74
|
+
printf '{"decision":"block","reason":"%s"}' "$escaped"
|
|
75
|
+
[ "$SPEXCODE_HARNESS" = codex ] && printf '%s\n' "$reason" >&2
|
|
76
|
+
exit 2
|
|
77
|
+
fi
|
|
78
|
+
export SPEXCODE_MAINTENANCE_PARENT_TICKET="$ticket"
|
|
56
79
|
rc=0
|
|
57
80
|
# manifest line: event<TAB>order<TAB>block<TAB>script (pre-sorted by event,order,script)
|
|
58
81
|
while IFS=$'\t' read -r ev order block script; do
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
|
-
import {
|
|
3
|
+
import { git, gitRequiredA, gitObjectFormat, isGitObjectId, batchRevisionOids, batchBlobTexts, combinedDiffOwnedChanges, driftPathWindow, type DiffLineRange, type DriftIndex, type DriftPathEvent } from './git.js'
|
|
4
|
+
|
|
5
|
+
const RS = '\x1e'
|
|
4
6
|
|
|
5
7
|
// ---- the anchor vocabulary ([[code-anchor]]) ----
|
|
6
8
|
// A spec's `code:` entry may pin ONE named unit: `path#symbol` (`#Class.method` for a class method).
|
|
7
9
|
// Everything below the entry parse splits into two layers:
|
|
8
10
|
// - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
|
|
9
11
|
// Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
|
|
10
|
-
// - the LANGUAGE-AGNOSTIC ENGINE: file-revision memo (keyed by
|
|
12
|
+
// - the LANGUAGE-AGNOSTIC ENGINE: file-revision memo (keyed by the complete immutable parse input), anchor resolution
|
|
11
13
|
// (dead/ambiguous), diff-hunk ∩ unit-range intersection over the drift window. It never knows
|
|
12
14
|
// which language it is measuring.
|
|
13
15
|
|
|
@@ -16,13 +18,14 @@ export type Unit = { name: string; kind: string; start: number; end: number; typ
|
|
|
16
18
|
export type Extractor = {
|
|
17
19
|
id: string
|
|
18
20
|
claims(ext: string): boolean
|
|
19
|
-
// true = usable here. A string is WHY it cannot run — lint turns that into
|
|
20
|
-
//
|
|
21
|
-
// is unverifiable.
|
|
21
|
+
// true = usable here. A string is WHY it cannot run — lint turns that into a visible error and
|
|
22
|
+
// skips the affected anchors as unverified while continuing the other checks (never a crash or fake pass).
|
|
22
23
|
ready(): true | string
|
|
23
24
|
// PURE function of its arguments (importable by an external benchmark/scorer as-is). Throws when the
|
|
24
25
|
// content cannot be parsed — the caller maps that to a conservative verdict, never a silent skip.
|
|
25
26
|
extract(content: string, filename: string): Unit[]
|
|
27
|
+
// Every input that can affect extract() must be represented here before its result enters the memo.
|
|
28
|
+
memoKey: (filename: string) => string
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export type CodeEntry = { path: string; anchor: string | null }
|
|
@@ -43,6 +46,13 @@ export function parseCodeEntry(raw: string): CodeEntry {
|
|
|
43
46
|
// this parser never touches fs.
|
|
44
47
|
export type RelationEntry = { path: string; selectors: string[] }
|
|
45
48
|
export type RelationParse = { entries: RelationEntry[]; problems: string[] }
|
|
49
|
+
export function relationClaimsPath(claim: string, file: string): boolean {
|
|
50
|
+
if (claim === file) return true
|
|
51
|
+
if (file.startsWith(claim.replace(/\/+$/, '') + '/')) return true
|
|
52
|
+
if (!claim.includes('*')) return false
|
|
53
|
+
const pattern = claim.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
54
|
+
return new RegExp(`^${pattern}$`).test(file)
|
|
55
|
+
}
|
|
46
56
|
export function parseRelation(raws: string[], relation: 'code' | 'related'): RelationParse {
|
|
47
57
|
const order: string[] = []
|
|
48
58
|
const byPath = new Map<string, { bare: boolean; selectors: string[] }>()
|
|
@@ -69,17 +79,23 @@ export function parseRelation(raws: string[], relation: 'code' | 'related'): Rel
|
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
// ---- extractor: ts-ast (the designated extractor for the JS family) ----
|
|
72
|
-
// Parse-only via the HOST project's own typescript
|
|
73
|
-
//
|
|
74
|
-
//
|
|
82
|
+
// Parse-only via the HOST project's own typescript, so the parse matches what the project itself compiles
|
|
83
|
+
// with. If it cannot resolve, ready() returns a loud unverified verdict and lint skips these anchors
|
|
84
|
+
// without crashing (no bundled compiler, regex fallback, or fake pass for JS).
|
|
75
85
|
const JS_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
|
|
86
|
+
const TS_AST_MEMO_SCHEMA = 'ts-ast-memo-v1'
|
|
76
87
|
|
|
77
88
|
export function tsAstExtractor(root: string): Extractor {
|
|
78
89
|
let ts: any | null | undefined // undefined = unprobed; null = unresolvable
|
|
90
|
+
let tsModulePath = ''
|
|
79
91
|
let readiness: true | string | undefined
|
|
80
92
|
const probe = () => {
|
|
81
93
|
if (ts !== undefined) return
|
|
82
|
-
try {
|
|
94
|
+
try {
|
|
95
|
+
const require = createRequire(join(root, 'package.json'))
|
|
96
|
+
tsModulePath = require.resolve('typescript')
|
|
97
|
+
ts = require('typescript')
|
|
98
|
+
} catch { ts = null }
|
|
83
99
|
}
|
|
84
100
|
return {
|
|
85
101
|
id: 'ts-ast',
|
|
@@ -87,12 +103,12 @@ export function tsAstExtractor(root: string): Extractor {
|
|
|
87
103
|
ready() {
|
|
88
104
|
if (readiness !== undefined) return readiness
|
|
89
105
|
probe()
|
|
90
|
-
if (!ts) return (readiness = `typescript is not resolvable from ${root} —
|
|
106
|
+
if (!ts) return (readiness = `typescript is not resolvable from the governed repository (${root}) — JS-family anchors were skipped and remain unverified; run 'npm i -D typescript@5', or remove the #anchor`)
|
|
91
107
|
// resolvability is not usability: typescript@7 (the Go rewrite) may resolve yet not expose the JS
|
|
92
|
-
// compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse
|
|
93
|
-
//
|
|
108
|
+
// compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse. Once a candidate
|
|
109
|
+
// resolves, incompatibility is loud rather than silently changing parser versions.
|
|
94
110
|
try {
|
|
95
|
-
const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest,
|
|
111
|
+
const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS)
|
|
96
112
|
if (!sf?.statements?.length || sf.parseDiagnostics?.length) throw new Error('probe parse failed')
|
|
97
113
|
readiness = true
|
|
98
114
|
} catch {
|
|
@@ -107,7 +123,7 @@ export function tsAstExtractor(root: string): Extractor {
|
|
|
107
123
|
: /\.(jsx)$/.test(filename) ? ts.ScriptKind.JSX
|
|
108
124
|
: /\.(ts|mts|cts)$/.test(filename) ? ts.ScriptKind.TS
|
|
109
125
|
: ts.ScriptKind.JS
|
|
110
|
-
const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest,
|
|
126
|
+
const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, false, kind)
|
|
111
127
|
// parse-only gate: a file that does not parse yields GARBAGE units (a shell script's `x=$(...)`
|
|
112
128
|
// parses as a const) — throw so the caller renders an honest "cannot parse" verdict instead.
|
|
113
129
|
if (sf.parseDiagnostics?.length) throw new Error(`${filename} does not parse as ${ts.ScriptKind[kind]} (${sf.parseDiagnostics.length} syntax error(s))`)
|
|
@@ -139,6 +155,11 @@ export function tsAstExtractor(root: string): Extractor {
|
|
|
139
155
|
}
|
|
140
156
|
return units
|
|
141
157
|
},
|
|
158
|
+
memoKey(filename) {
|
|
159
|
+
probe()
|
|
160
|
+
const host = ts ? `${tsModulePath}\0${ts.version ?? 'unknown'}` : `unresolved\0${root}`
|
|
161
|
+
return `${TS_AST_MEMO_SCHEMA}\0${host}\0target:Latest\0parents:false\0${filename}`
|
|
162
|
+
},
|
|
142
163
|
}
|
|
143
164
|
}
|
|
144
165
|
|
|
@@ -284,6 +305,19 @@ export function heuristicExtractor(spec: LangSpec): Extractor {
|
|
|
284
305
|
}
|
|
285
306
|
return units
|
|
286
307
|
},
|
|
308
|
+
memoKey(filename) {
|
|
309
|
+
const regex = (r: RegExp | undefined) => r ? `${r.source}/${r.flags}` : null
|
|
310
|
+
return JSON.stringify({
|
|
311
|
+
schema: 'heuristic-memo-v1', filename, id: spec.id, extensions: spec.extensions,
|
|
312
|
+
decls: spec.decls.map((d) => ({
|
|
313
|
+
re: regex(d.re), kind: d.kind, typeOnly: !!d.typeOnly, classOpener: !!d.classOpener,
|
|
314
|
+
declList: !!d.declList, scopeOpener: !!d.scopeOpener, memberOf: d.memberOf ?? null,
|
|
315
|
+
})),
|
|
316
|
+
member: spec.member ? { re: regex(spec.member.re), blacklist: regex(spec.member.blacklist) } : null,
|
|
317
|
+
indentScopes: spec.indentScopes ? { decorator: regex(spec.indentScopes.decorator) } : null,
|
|
318
|
+
boundary: regex(spec.boundary),
|
|
319
|
+
})
|
|
320
|
+
},
|
|
287
321
|
}
|
|
288
322
|
}
|
|
289
323
|
|
|
@@ -355,21 +389,41 @@ export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
|
|
|
355
389
|
return { ok: hits[0] }
|
|
356
390
|
}
|
|
357
391
|
|
|
392
|
+
// The one selector/range intersection used by historical commits and a pinned worktree overlay.
|
|
393
|
+
export function selectorsHitRanges(units: readonly Unit[], symbols: readonly string[], ranges: readonly [number, number][]): string[] {
|
|
394
|
+
return symbols.filter((symbol) => {
|
|
395
|
+
const matching = units.filter((unit) => unit.name === symbol)
|
|
396
|
+
return matching.length > 0 && ranges.some(([start, end]) => matching.some((unit) => start <= unit.end && unit.start <= end))
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Ordinary zero-context ranges. The side chooses the immutable image whose units the caller resolved.
|
|
401
|
+
export function diffHunkRanges(patch: string, side: 'old' | 'new' = 'new'): [number, number][] {
|
|
402
|
+
const ranges: [number, number][] = []
|
|
403
|
+
for (const match of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
404
|
+
const offset = side === 'old' ? 1 : 3
|
|
405
|
+
const start = +match[offset]
|
|
406
|
+
const count = match[offset + 1] === undefined ? 1 : +match[offset + 1]
|
|
407
|
+
if (count > 0) ranges.push([start, start + count - 1])
|
|
408
|
+
}
|
|
409
|
+
return ranges
|
|
410
|
+
}
|
|
411
|
+
|
|
358
412
|
// ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
|
|
359
413
|
|
|
360
|
-
// units of a file AS OF a commit, memoized by
|
|
361
|
-
// an object id is immutable,
|
|
414
|
+
// units of a file AS OF a commit, memoized by the complete immutable parse identity. File content identified
|
|
415
|
+
// by an object id is immutable, but the extractor also consumes filename and host/config identity; distinct
|
|
416
|
+
// file revisions in a window are few.
|
|
362
417
|
// 'absent' = no file at that commit; 'unparseable' = the extractor rejected that revision's content.
|
|
363
418
|
type FileRevisionUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
|
|
364
419
|
const fileRevisionUnitMemo = new Map<string, FileRevisionUnits>()
|
|
365
420
|
const MEMO_MAX = 4096
|
|
366
|
-
async function unitsAtFileRevision(
|
|
367
|
-
const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
|
|
421
|
+
async function unitsAtFileRevision(commit: string, path: string, x: Extractor, objectFormat: string, oid: string | null, text?: string): Promise<FileRevisionUnits> {
|
|
368
422
|
if (!oid) return { absent: true }
|
|
369
|
-
const key = `${oid}\0${x.
|
|
423
|
+
const key = `${objectFormat}\0${oid}\0${x.memoKey(path)}`
|
|
370
424
|
const hit = fileRevisionUnitMemo.get(key)
|
|
371
425
|
if (hit) return hit
|
|
372
|
-
|
|
426
|
+
if (text === undefined) throw new Error(`git cat-file --batch omitted object ${oid} for ${commit}:${path}`)
|
|
373
427
|
let result: FileRevisionUnits
|
|
374
428
|
try { result = { units: x.extract(text, path) } } catch (e: any) { result = { unparseable: e?.message ?? String(e) } }
|
|
375
429
|
if (fileRevisionUnitMemo.size >= MEMO_MAX) fileRevisionUnitMemo.clear()
|
|
@@ -377,57 +431,137 @@ async function unitsAtFileRevision(root: string, commit: string, path: string, x
|
|
|
377
431
|
return result
|
|
378
432
|
}
|
|
379
433
|
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
434
|
+
// Changed line ranges on both sides of one commit's diff. Ordinary commits have one parent image. Merges
|
|
435
|
+
// retain one before-image per parent and only all-parent authored rows; an owned line never widens to an
|
|
436
|
+
// adjacent inherited line merely because Git placed both in one `@@@` hunk.
|
|
437
|
+
type HunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
|
|
438
|
+
const hunkMemo = new Map<string, HunkRanges>()
|
|
439
|
+
async function hunksAt(root: string, event: DriftPathEvent): Promise<HunkRanges> {
|
|
440
|
+
const paths = [...new Set([event.historicalPath, ...event.parents.map((parent) => parent.historicalPath)])]
|
|
441
|
+
const key = `${event.commit}\0${paths.join('\x1e')}`
|
|
386
442
|
const hit = hunkMemo.get(key)
|
|
387
443
|
if (hit) return hit
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
444
|
+
const merge = event.parents.length > 1
|
|
445
|
+
const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'show', '--cc', '--combined-all-paths', '--unified=0', '-M', '--format=', event.commit,
|
|
446
|
+
...(merge ? [] : ['--', ...paths])],
|
|
447
|
+
`cannot derive anchor hunks for ${event.commit}:${event.historicalPath}`)
|
|
448
|
+
let ranges: HunkRanges = { after: [], before: [[]] }
|
|
449
|
+
if (/^@@@/m.test(out)) {
|
|
450
|
+
const owned = combinedDiffOwnedChanges(out).get(event.historicalPath)
|
|
451
|
+
if (!owned) throw new Error(`combined diff for ${event.commit} did not expose owned ranges for '${event.historicalPath}'`)
|
|
452
|
+
ranges = owned
|
|
453
|
+
} else {
|
|
454
|
+
for (const m of out.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
455
|
+
const oldStart = +m[1], oldCount = m[2] === undefined ? 1 : +m[2]
|
|
456
|
+
const newStart = +m[3], newCount = m[4] === undefined ? 1 : +m[4]
|
|
457
|
+
if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
|
|
458
|
+
if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
|
|
459
|
+
}
|
|
393
460
|
}
|
|
394
461
|
if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
|
|
395
462
|
hunkMemo.set(key, ranges)
|
|
396
463
|
return ranges
|
|
397
464
|
}
|
|
398
465
|
|
|
466
|
+
// Ordinary commits whose two images keep one path share a single query. Renames and merges retain distinct
|
|
467
|
+
// image paths, so they stay on hunksAt's exact event-shaped query.
|
|
468
|
+
async function hunksAtMany(root: string, commits: string[], path: string): Promise<Map<string, HunkRanges>> {
|
|
469
|
+
const result = new Map<string, HunkRanges>()
|
|
470
|
+
const ordinary = [...new Set(commits)]
|
|
471
|
+
if (!ordinary.length) return result
|
|
472
|
+
const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'log', '--no-walk', '--no-merges', '--patch', '--unified=0', '-M',
|
|
473
|
+
`--format=${RS}%H`, ...ordinary, '--', path], `cannot derive anchor hunks for ${path}`)
|
|
474
|
+
for (const rec of out.split(RS)) {
|
|
475
|
+
const normalized = rec.replace(/^\n/, '')
|
|
476
|
+
if (!normalized) continue
|
|
477
|
+
const newline = normalized.indexOf('\n')
|
|
478
|
+
const hash = (newline < 0 ? normalized : normalized.slice(0, newline)).trim()
|
|
479
|
+
if (!isGitObjectId(root, hash)) throw new Error(`anchor hunk query returned malformed object id '${hash || 'empty'}'`)
|
|
480
|
+
const patch = newline < 0 ? '' : normalized.slice(newline + 1)
|
|
481
|
+
const ranges: HunkRanges = { after: [], before: [[]] }
|
|
482
|
+
for (const m of patch.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
483
|
+
const oldStart = +m[1], oldCount = m[2] === undefined ? 1 : +m[2]
|
|
484
|
+
const newStart = +m[3], newCount = m[4] === undefined ? 1 : +m[4]
|
|
485
|
+
if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
|
|
486
|
+
if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
|
|
487
|
+
}
|
|
488
|
+
result.set(hash, ranges)
|
|
489
|
+
}
|
|
490
|
+
return result
|
|
491
|
+
}
|
|
492
|
+
|
|
399
493
|
// the drift window of an anchored file: every commit to `path` not reachable from the spec's version
|
|
400
494
|
// and not covered by a valid Spec-OK ack — the SAME set driftFor counts, exposed as commits so the
|
|
401
|
-
// anchor engine can probe each one.
|
|
402
|
-
//
|
|
403
|
-
export function
|
|
495
|
+
// anchor engine can probe each one. Ordinary file commits and merge-authored dense-combined paths are
|
|
496
|
+
// indexed separately so clean merge transport is never charged twice.
|
|
497
|
+
export function windowEvents(idx: DriftIndex, sinceHash: string, path: string, nodeId?: string): DriftPathEvent[] {
|
|
404
498
|
if (!sinceHash) return []
|
|
405
|
-
|
|
406
|
-
if (!base) return []
|
|
407
|
-
const cover = ackCoverFor(idx, sinceHash)
|
|
408
|
-
return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
|
|
499
|
+
return driftPathWindow(idx, sinceHash, path, nodeId) ?? []
|
|
409
500
|
}
|
|
410
501
|
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
// (`unparseable` set) — over-warn, never silently skip.
|
|
502
|
+
// Which window commits touched an anchored unit. Added lines intersect the result-image unit; deleted lines
|
|
503
|
+
// intersect the parent-image unit. A merge owns a deletion only when every parent-side range belongs to the
|
|
504
|
+
// selector. This is why events retain their immutable post/preimage paths instead of resolving every blob
|
|
505
|
+
// through the current filename. Several selectors are OR'd and one commit still produces one hit row.
|
|
506
|
+
// An historical image the designated extractor cannot parse is a conservative hit (`unparseable`).
|
|
417
507
|
export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
|
|
418
|
-
export async function anchorHitCommits(root: string, win:
|
|
419
|
-
const hits:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
508
|
+
export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
|
|
509
|
+
const hits = new Map<string, { selectors: Set<string>; unparseable?: string }>()
|
|
510
|
+
const objectFormat = gitObjectFormat(root)
|
|
511
|
+
type Revision = { commit: string; path: string }
|
|
512
|
+
const revisionKey = ({ commit, path }: Revision) => `${commit}\0${path}`
|
|
513
|
+
const revisions = new Map<string, Revision>()
|
|
514
|
+
for (const event of win) {
|
|
515
|
+
const refs = [{ commit: event.commit, path: event.historicalPath }, ...event.parents.map(({ commit, historicalPath }) => ({ commit, path: historicalPath }))]
|
|
516
|
+
for (const ref of refs) revisions.set(revisionKey(ref), ref)
|
|
517
|
+
}
|
|
518
|
+
const refs = [...revisions.values()]
|
|
519
|
+
const oids = batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
|
|
520
|
+
const blobs = batchBlobTexts(root, oids.filter((oid): oid is string => !!oid))
|
|
521
|
+
const units = new Map<string, FileRevisionUnits>()
|
|
522
|
+
for (let index = 0; index < refs.length; index++) {
|
|
523
|
+
const ref = refs[index], oid = oids[index]
|
|
524
|
+
const x = extractorFor(regs, extOf(ref.path))
|
|
525
|
+
const ready = x?.ready()
|
|
526
|
+
if (!x || ready !== true) {
|
|
527
|
+
units.set(revisionKey(ref), { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
units.set(revisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
|
|
531
|
+
}
|
|
532
|
+
const byPath = new Map<string, string[]>()
|
|
533
|
+
for (const event of win) {
|
|
534
|
+
if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
|
|
535
|
+
const commits = byPath.get(event.historicalPath) ?? []
|
|
536
|
+
commits.push(event.commit)
|
|
537
|
+
byPath.set(event.historicalPath, commits)
|
|
538
|
+
}
|
|
539
|
+
const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
|
|
540
|
+
for (const [path, commits] of byPath) ordinaryHunks.set(path, await hunksAtMany(root, commits, path))
|
|
541
|
+
const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
|
|
542
|
+
ranges.some(([start, end]) => candidates.some((unit) => start <= unit.end && unit.start <= end))
|
|
543
|
+
for (const event of win) {
|
|
544
|
+
const after = units.get(revisionKey({ commit: event.commit, path: event.historicalPath }))!
|
|
545
|
+
const before = event.parents.map(({ commit, historicalPath }) => units.get(revisionKey({ commit, path: historicalPath }))!)
|
|
546
|
+
const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
|
|
547
|
+
?? await hunksAt(root, event)
|
|
548
|
+
if (event.parents.length && ranges.before.length !== before.length)
|
|
549
|
+
throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
|
|
550
|
+
const hit = hits.get(event.commit) ?? { selectors: new Set<string>() }
|
|
551
|
+
const broken = [after, ...before].find((image) => 'unparseable' in image)
|
|
552
|
+
if (broken && 'unparseable' in broken) {
|
|
553
|
+
for (const symbol of symbols) hit.selectors.add(symbol)
|
|
554
|
+
hit.unparseable = broken.unparseable
|
|
555
|
+
} else {
|
|
556
|
+
for (const symbol of symbols) {
|
|
557
|
+
const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
|
|
558
|
+
const authoredAfter = intersects(ranges.after, afterUnits)
|
|
559
|
+
const authoredBefore = before.length > 0 && before.every((image, parent) =>
|
|
560
|
+
'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
|
|
561
|
+
if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (hit.selectors.size) hits.set(event.commit, hit)
|
|
431
565
|
}
|
|
432
|
-
return hits
|
|
566
|
+
return [...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) }))
|
|
433
567
|
}
|
package/spec-cli/src/attach.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { spawnSync } from 'node:child_process'
|
|
9
9
|
import { networkInterfaces } from 'node:os'
|
|
10
10
|
import { alive, apiBase, TMUX_SOCK } from './sessions.js'
|
|
11
|
+
import { runSessionOperation } from './session-maintenance.js'
|
|
11
12
|
|
|
12
13
|
const AGENT_ALTERNATIVES = 'read the pane with `spex session show <SEL> --capture`, drive it with `session send` (plain text first; `--keys` only as a last resort)'
|
|
13
14
|
|
|
@@ -33,18 +34,20 @@ The tmux session lives on THAT machine — a terminal can't be attached over HTT
|
|
|
33
34
|
// foreground takeover of the session's real tmux window; returns only via detach (C-b d) or the session
|
|
34
35
|
// ending. Interactive and blocking by design — a caller without a terminal (an agent inside its turn, a
|
|
35
36
|
// pipe) is refused up front and pointed at the remote-capable verbs instead of tmux's bare "not a terminal".
|
|
36
|
-
export async function attachSession(id: string): Promise<
|
|
37
|
+
export async function attachSession(id: string): Promise<number> {
|
|
37
38
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
38
39
|
console.error(`spex session attach: attach is INTERACTIVE and needs a terminal — it blocks until you detach.
|
|
39
40
|
An agent must not run it inside a turn (it freezes you); ${AGENT_ALTERNATIVES}.`)
|
|
40
41
|
process.exit(2)
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
return runSessionOperation({ op: 'attach', sessionId: id }, async () => {
|
|
44
|
+
if (!(await alive(id))) {
|
|
45
|
+
console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
|
|
44
46
|
Bring it back with \`spex session resume ${id}\`, or read its record with \`spex session show ${id}\`.`)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
return 1
|
|
48
|
+
}
|
|
49
|
+
console.log(`attaching to ${id} — detach with C-b d (the session keeps running)`)
|
|
50
|
+
const r = spawnSync('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], { stdio: 'inherit' })
|
|
51
|
+
return r.status ?? 1
|
|
52
|
+
})
|
|
50
53
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { rmSync } from 'node:fs'
|
|
2
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
3
3
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
4
4
|
import { randomUUID } from 'node:crypto'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
9
|
+
import { controlRequest, withTimeout } from './headless-controller.js'
|
|
10
|
+
import { shQuote } from './sh.js'
|
|
9
11
|
|
|
10
12
|
type ControlRequest = { type: 'deliver'; text: string; mode: 'steer' | 'wake' } | { type: 'interrupt' }
|
|
11
13
|
type ClaudeHeadlessDeliveryRecord = HarnessDeliveryRecord & { status?: string }
|
|
@@ -32,7 +34,6 @@ const RESULT_EXIT_GRACE_MS = 500
|
|
|
32
34
|
const TERM_EXIT_GRACE_MS = 500
|
|
33
35
|
const KILL_EXIT_GRACE_MS = 2_000
|
|
34
36
|
|
|
35
|
-
const shQuote = (s: string) => `'${s.replace(/'/g, `'\''`)}'`
|
|
36
37
|
const userEvent = (text: string) => JSON.stringify({
|
|
37
38
|
type: 'user',
|
|
38
39
|
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
@@ -44,70 +45,31 @@ export function claudeHeadlessLaunchCommand(id: string, runtimeDir: string, clau
|
|
|
44
45
|
return [shQuote(SPEX), 'internal', 'claude-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(claudeCmd), '--'].join(' ')
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
48
|
-
return new Promise((resolve, reject) => {
|
|
49
|
-
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
50
|
-
promise.then(
|
|
51
|
-
(value) => { clearTimeout(timer); resolve(value) },
|
|
52
|
-
(error) => { clearTimeout(timer); reject(error) },
|
|
53
|
-
)
|
|
54
|
-
})
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
|
|
58
|
-
return new Promise((resolve) => {
|
|
59
|
-
const socket = createConnection(claudeHeadlessSock(id))
|
|
60
|
-
let buffer = ''
|
|
61
|
-
let settled = false
|
|
62
|
-
const finish = (result: DispatchResult) => {
|
|
63
|
-
if (settled) return
|
|
64
|
-
settled = true
|
|
65
|
-
clearTimeout(timer)
|
|
66
|
-
socket.destroy()
|
|
67
|
-
resolve(result)
|
|
68
|
-
}
|
|
69
|
-
const timer = setTimeout(() => finish({ ok: false, error: `claude-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
|
|
70
|
-
socket.setEncoding('utf8')
|
|
71
|
-
socket.on('connect', () => socket.write(`${JSON.stringify(request)}\n`))
|
|
72
|
-
socket.on('data', (chunk) => {
|
|
73
|
-
buffer += chunk
|
|
74
|
-
const nl = buffer.indexOf('\n')
|
|
75
|
-
if (nl < 0) return
|
|
76
|
-
try {
|
|
77
|
-
const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
|
|
78
|
-
finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'claude-headless control rejected the request' })
|
|
79
|
-
} catch (error) {
|
|
80
|
-
finish({ ok: false, error: `claude-headless returned an invalid control response: ${(error as Error).message}` })
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
socket.on('error', (error) => finish({ ok: false, error: `claude-headless controller unreachable for session ${id}: ${error.message}` }))
|
|
84
|
-
socket.on('close', () => finish({ ok: false, error: `claude-headless controller closed before confirming session ${id}` }))
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
48
|
export const deliverViaClaudeHeadless = (rec: ClaudeHeadlessDeliveryRecord, text: string) =>
|
|
89
|
-
controlRequest(rec.session, { type: 'deliver', text, mode: rec.status === 'active' ? 'steer' : 'wake' }
|
|
49
|
+
controlRequest(claudeHeadlessSock(rec.session), { type: 'deliver', text, mode: rec.status === 'active' ? 'steer' : 'wake' }, {
|
|
50
|
+
name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
51
|
+
rejected: 'claude-headless control rejected the request',
|
|
52
|
+
})
|
|
90
53
|
|
|
91
54
|
export const interruptClaudeHeadless = (rec: HarnessDeliveryRecord) =>
|
|
92
|
-
controlRequest(rec.session, { type: 'interrupt' }
|
|
55
|
+
controlRequest(claudeHeadlessSock(rec.session), { type: 'interrupt' }, {
|
|
56
|
+
name: 'claude-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
57
|
+
rejected: 'claude-headless control rejected the request',
|
|
58
|
+
})
|
|
93
59
|
|
|
94
60
|
export class ClaudeHeadlessController {
|
|
95
61
|
private server: Server | null = null
|
|
96
62
|
private child: ChildTurn | null = null
|
|
97
63
|
private controlQueue: Promise<void> = Promise.resolve()
|
|
98
64
|
private closing = false
|
|
99
|
-
private readonly messagesPath: string
|
|
100
65
|
private readonly socketPath: string
|
|
101
66
|
|
|
102
67
|
constructor(
|
|
103
68
|
private readonly id: string,
|
|
104
|
-
|
|
69
|
+
_runtimeDir: string,
|
|
105
70
|
private readonly claudeCmd: string,
|
|
106
71
|
private readonly cwd = process.cwd(),
|
|
107
72
|
) {
|
|
108
|
-
const dir = join(runtimeDir, 'sessions', id)
|
|
109
|
-
mkdirSync(dir, { recursive: true })
|
|
110
|
-
this.messagesPath = join(dir, 'messages.ndjson')
|
|
111
73
|
this.socketPath = claudeHeadlessSock(id)
|
|
112
74
|
}
|
|
113
75
|
|
|
@@ -246,7 +208,6 @@ export class ClaudeHeadlessController {
|
|
|
246
208
|
const line = stdoutBuffer.slice(0, nl)
|
|
247
209
|
stdoutBuffer = stdoutBuffer.slice(nl + 1)
|
|
248
210
|
const nativeLine = `${line}\n`
|
|
249
|
-
appendFileSync(this.messagesPath, nativeLine)
|
|
250
211
|
globalThis.process.stdout.write(nativeLine)
|
|
251
212
|
turn.sawFirstEvent()
|
|
252
213
|
this.observeEvent(turn, line)
|