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.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +13 -52
  8. package/spec-cli/src/cli.ts +209 -71
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +15 -4
  17. package/spec-cli/src/harness.ts +1133 -71
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +698 -0
  21. package/spec-cli/src/index.ts +95 -21
  22. package/spec-cli/src/init.ts +73 -15
  23. package/spec-cli/src/issues.ts +36 -3
  24. package/spec-cli/src/layout.ts +189 -41
  25. package/spec-cli/src/lint.ts +152 -27
  26. package/spec-cli/src/localIssues.ts +5 -1
  27. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  28. package/spec-cli/src/materialize.ts +6 -7
  29. package/spec-cli/src/opencode-headless.ts +4 -8
  30. package/spec-cli/src/pi-headless.ts +17 -60
  31. package/spec-cli/src/process-identity.ts +174 -0
  32. package/spec-cli/src/project-store.ts +17 -0
  33. package/spec-cli/src/pty-bridge.ts +6 -3
  34. package/spec-cli/src/reviews.ts +17 -13
  35. package/spec-cli/src/runtime-ownership.ts +88 -0
  36. package/spec-cli/src/session-maintenance.ts +750 -0
  37. package/spec-cli/src/session-timeline.ts +6 -6
  38. package/spec-cli/src/sessions.ts +1251 -208
  39. package/spec-cli/src/sh.ts +3 -0
  40. package/spec-cli/src/source-files.ts +27 -2
  41. package/spec-cli/src/specs.ts +58 -25
  42. package/spec-cli/src/supervise.ts +31 -11
  43. package/spec-cli/src/uninstall.ts +7 -6
  44. package/spec-cli/templates/hooks/commit-msg +14 -0
  45. package/spec-cli/templates/hooks/pre-commit +29 -15
  46. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  47. package/spec-cli/templates/hooks/reference-transaction +105 -0
  48. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  49. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  50. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  51. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  53. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  54. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  56. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  57. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  58. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  59. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  60. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  61. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  116. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  117. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  118. package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
  119. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  120. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  121. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  122. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  123. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  124. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  125. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  126. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  127. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  128. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  129. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  130. package/spec-dashboard/dist/index.html +5 -4
  131. package/spec-dashboard/src/reviewFilters.js +46 -8
  132. package/spec-dashboard/src/session.js +64 -9
  133. package/spec-eval/src/cli.ts +144 -32
  134. package/spec-eval/src/evaltab.ts +16 -9
  135. package/spec-eval/src/freshness.ts +336 -54
  136. package/spec-eval/src/scenariofresh.ts +51 -1
  137. package/spec-eval/src/scenarios.ts +324 -25
  138. package/spec-eval/src/sessioneval.ts +969 -173
  139. package/spec-cli/src/message-stream.ts +0 -147
  140. package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
  141. package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
  142. package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
  143. package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
  144. package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
  145. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
  146. package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
  147. package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
  148. package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
  149. package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
  150. package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
  151. 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
- [`docs/AGENT_GUIDE.md`](docs/AGENT_GUIDE.md) has the full mechanics of the node model and the
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.2",
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",
@@ -53,5 +53,29 @@ function resolveTsxCli() {
53
53
  }
54
54
  }
55
55
  const tsxCli = resolveTsxCli()
56
- spawn(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], { stdio: 'inherit' })
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
- trap 'rm -f "$err"' EXIT
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
@@ -1,13 +1,15 @@
1
1
  import { join } from 'node:path'
2
2
  import { createRequire } from 'node:module'
3
- import { gitA, type DriftIndex, ancestorsOf, inAncestors, ackCoverFor } from './git.js'
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 Git object id), anchor resolution
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 an ERROR with the repair
20
- // entrypoint (never a silent or degraded pass): the designated extractor either runs or the anchor
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 (resolved from the repo root, walking up like any
73
- // require) never a bundled copy, so the parse matches what the project itself compiles with. Not
74
- // resolvable => ready() returns the repair entrypoint and lint ERRORS (no regex fallback for JS).
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 { ts = createRequire(join(root, 'package.json'))('typescript') } catch { ts = null }
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} — anchors on JS-family files need the host project's typescript: run 'npm i -D typescript', or remove the #anchor`)
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 an incompatible
93
- // host typescript is a loud error, never a silent pass or downgrade.
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, true, ts.ScriptKind.TS)
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, true, kind)
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 (Git object id, extractor id). File content identified by
361
- // an object id is immutable, so the memo never invalidates; distinct file revisions in a window are few.
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(root: string, commit: string, path: string, x: Extractor): Promise<FileRevisionUnits> {
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.id}`
423
+ const key = `${objectFormat}\0${oid}\0${x.memoKey(path)}`
370
424
  const hit = fileRevisionUnitMemo.get(key)
371
425
  if (hit) return hit
372
- const text = await gitA(['-C', root, 'cat-file', 'blob', oid]) // dead-words-ok: git plumbing — 'blob' is Git's object type, not product vocabulary
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
- // post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
381
- // c..c+d-1 changed; d==0 (pure deletion) the point line after which content vanished. Immutable per
382
- // (commit, file), memoized.
383
- const hunkMemo = new Map<string, [number, number][]>()
384
- async function hunksAt(root: string, commit: string, path: string): Promise<[number, number][]> {
385
- const key = `${commit}\0${path}`
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 out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--unified=0', '--format=', commit, '--', path])
389
- const ranges: [number, number][] = []
390
- for (const m of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
391
- const c = +m[1], d = m[2] === undefined ? 1 : +m[2]
392
- ranges.push(d > 0 ? [c, c + d - 1] : [Math.max(1, c), Math.max(1, c)])
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. (fileCommits comes from a --name-only walk, which lists no files
402
- // for merge commits the window is non-merge by construction.)
403
- export function windowCommits(idx: DriftIndex, sinceHash: string, path: string): string[] {
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
- const base = ancestorsOf(idx, sinceHash)
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
- // which window commits TOUCHED any of the anchored units: the commit's --unified=0 hunks intersect a
412
- // unit's line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later
413
- // renamed or moved still attribute correctly). Several selectors are OR a commit appears ONCE, with
414
- // `selectors` naming exactly which units its hunks intersected (so diagnostics can attribute the hit).
415
- // A version whose content the extractor cannot parse is a CONSERVATIVE hit for every selector
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: string[], path: string, symbols: string[], x: Extractor): Promise<AnchorHit[]> {
419
- const hits: AnchorHit[] = []
420
- for (const c of win) {
421
- const at = await unitsAtFileRevision(root, c, path, x)
422
- if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
423
- if ('unparseable' in at) { hits.push({ commit: c, selectors: [...symbols], unparseable: at.unparseable }); continue }
424
- const bySym = symbols
425
- .map((sym) => ({ sym, ranges: at.units.filter((u) => u.name === sym) }))
426
- .filter((s) => s.ranges.length) // a unit absent under this name at that commit can't be touched
427
- if (!bySym.length) continue
428
- const hunks = await hunksAt(root, c, path)
429
- const touched = bySym.filter((s) => hunks.some(([a, b]) => s.ranges.some((u) => a <= u.end && u.start <= b))).map((s) => s.sym)
430
- if (touched.length) hits.push({ commit: c, selectors: touched })
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
  }
@@ -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<never> {
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
- if (!(await alive(id))) {
43
- console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
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
- process.exit(1)
46
- }
47
- console.log(`attaching to ${id} — detach with C-b d (the session keeps running)`)
48
- const r = spawnSync('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], { stdio: 'inherit' })
49
- process.exit(r.status ?? 1)
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 { appendFileSync, mkdirSync, rmSync } from 'node:fs'
2
- import { createConnection, createServer, type Server, type Socket } from 'node:net'
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
- runtimeDir: string,
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)