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.
Files changed (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +199 -60
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +42 -30
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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",
@@ -36,11 +36,13 @@
36
36
  "prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
37
37
  },
38
38
  "dependencies": {
39
+ "tsx": "^4.19.2"
40
+ },
41
+ "optionalDependencies": {
39
42
  "@hono/node-server": "^1.13.7",
40
43
  "@hono/node-ws": "^1.0.4",
41
44
  "hono": "^4.6.14",
42
- "node-pty": "1.2.0-beta.14",
43
- "tsx": "^4.19.2"
45
+ "node-pty": "1.2.0-beta.14"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@types/node": "^20.16.0",
@@ -65,17 +65,5 @@ if (args[0] === 'serve' || args[0] === 'dashboard') {
65
65
  .split(',').map((key) => key.trim()).filter(Boolean)
66
66
  for (const key of identityKeys) delete env[key]
67
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 })
68
+ spawn(process.execPath, [tsxCli, cli, ...args], { stdio: 'inherit', env })
81
69
  .on('exit', (code) => process.exit(code ?? 0))
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
3
+ # on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
4
+ # text-sniffed from the TUI:
5
+ # the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
6
+ # (the deterministic capture of a question).
7
+ # any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
8
+ # WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
9
+ # request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
10
+ # Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
11
+ # the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
12
+ # it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
13
+ # @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
14
+ # by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
15
+ # (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
16
+ . "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
17
+ payload=$(cat 2>/dev/null)
18
+ # an IN-PROCESS SUBAGENT's tool call (Claude's Task tool) fires the parent's hooks with the PARENT's
19
+ # session_id — flipping here let a supervising parent's own subagents erase its declared park/ask within
20
+ # seconds and race the stop-gate into "undeclared stop" (issue #60). A subagent working is not the parent
21
+ # agent ACTING, so its calls never touch the record; the parent's own next tool call still flips. The
22
+ # discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
23
+ # timing window.
24
+ [ -n "$(hp_is_subagent "$payload")" ] && exit 0
25
+ sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
26
+ sdir=$(hp_store_dir "$sid") || exit 0
27
+ rec="$sdir/session.json"
28
+ # board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
29
+ grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
30
+
31
+ jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
32
+
33
+ if [ -n "$(hp_is_ask "$payload")" ]; then
34
+ status=asking
35
+ note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
36
+ else
37
+ status=active
38
+ note=
39
+ fi
40
+
41
+ # cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
42
+ [ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
43
+
44
+ # value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
45
+ # with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
46
+ # add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
47
+ note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
48
+ tmp=$(mktemp) || exit 0
49
+ sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
50
+ -e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
51
+ -e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
52
+ "$rec" > "$tmp" && mv "$tmp" "$rec"
53
+ exit 0
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
3
+ # on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
4
+ # text-sniffed from the TUI:
5
+ # the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
6
+ # (the deterministic capture of a question).
7
+ # any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
8
+ # WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
9
+ # request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
10
+ # Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
11
+ # the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
12
+ # it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
13
+ # @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
14
+ # by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
15
+ # (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
16
+ . "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
17
+ payload=$(cat 2>/dev/null)
18
+ sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
19
+ sdir=$(hp_store_dir "$sid") || exit 0
20
+ rec="$sdir/session.json"
21
+ # board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
22
+ grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
23
+
24
+ jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
25
+
26
+ if [ -n "$(hp_is_ask "$payload")" ]; then
27
+ status=asking
28
+ note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
29
+ else
30
+ status=active
31
+ note=
32
+ fi
33
+
34
+ # cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
35
+ [ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
36
+
37
+ # value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
38
+ # with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
39
+ # add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
40
+ note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
41
+ tmp=$(mktemp) || exit 0
42
+ sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
43
+ -e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
44
+ -e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
45
+ "$rec" > "$tmp" && mv "$tmp" "$rec"
46
+ exit 0
@@ -28,12 +28,14 @@ event="${1:?usage: dispatch.sh <harness> <Event>}"
28
28
  export SPEXCODE_HARNESS="$harness"
29
29
  # the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
30
30
  # source it here too for hp_runtime_dir (the per-project store dir).
31
- export SPEXCODE_HARNESS_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness.sh"
31
+ hook_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
32
+ tool_root="$(cd "$hook_root/.." && pwd)"
33
+ export SPEXCODE_HARNESS_LIB="$hook_root/harness.sh"
32
34
  . "$SPEXCODE_HARNESS_LIB"
33
35
  if [ -n "${SPEX:-}" ]; then
34
36
  read -r -a spex_cmd <<< "$SPEX"
35
37
  else
36
- spex_cmd=("$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/spex.mjs")
38
+ spex_cmd=("$tool_root/bin/spex.mjs")
37
39
  fi
38
40
  proj="${CLAUDE_PROJECT_DIR:-$PWD}"
39
41
  # the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
@@ -67,30 +69,24 @@ fi
67
69
  [ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
68
70
  input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
69
71
  err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
70
- ticket=""
71
- cleanup() {
72
- [ -z "$ticket" ] || "${spex_cmd[@]}" internal maintenance-end "$ticket" "$$" >/dev/null 2>&1 || true
73
- rm -f "$err"
74
- }
72
+ cleanup() { rm -f "$err"; }
75
73
  trap cleanup EXIT
76
74
  trap 'exit 130' INT
77
75
  trap 'exit 143' HUP TERM
78
- sid="$(hp_session_id "$input")"
79
- [ -n "$sid" ] || sid=hook-dispatch
80
- if ! ticket="$("${spex_cmd[@]}" internal maintenance-begin "$$" "$sid" 2>"$err")"; then
81
- reason="$(cat "$err" 2>/dev/null)"
82
- [ -n "$reason" ] || reason='maintenance_active: hook dispatch was not admitted'
83
- escaped="$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g')"
84
- printf '{"decision":"block","reason":"%s"}' "$escaped"
85
- [ "$SPEXCODE_HARNESS" = codex ] && printf '%s\n' "$reason" >&2
86
- exit 2
87
- fi
88
- export SPEXCODE_MAINTENANCE_PARENT_TICKET="$ticket"
89
76
  rc=0
90
77
  # manifest line: event<TAB>order<TAB>block<TAB>script (pre-sorted by event,order,script)
91
78
  while IFS=$'\t' read -r ev order block script; do
92
79
  [ "$ev" = "$event" ] || continue
93
- out="$(printf '%s' "$input" | bash "$proj/$script" 2>"$err")"; code=$?
80
+ handler="$proj/$script"
81
+ # A seeded core hook is tracked project source, so package replacement cannot safely overwrite it. These
82
+ # byte-exact default revisions compose an ask note into JSON with sed; route only them to the package
83
+ # implementation. `cmp` makes a user-modified hook ineligible without a platform-specific hash utility.
84
+ if [ "$script" = '.spec/project/.plugins/core/mark-active/mark-active.sh' ] &&
85
+ { cmp -s "$handler" "$hook_root/compat/mark-active-sed-v0.fixture" ||
86
+ cmp -s "$handler" "$hook_root/compat/mark-active-0.5.2-eef1.fixture"; }; then
87
+ handler="$tool_root/templates/spec/project/.plugins/core/mark-active/mark-active.sh"
88
+ fi
89
+ out="$(printf '%s' "$input" | bash "$handler" 2>"$err")"; code=$?
94
90
  [ -n "$out" ] && printf '%s' "$out"
95
91
  if [ "$block" = "true" ] && { [ "$code" = "2" ] || printf '%s' "$out" | grep -q '"decision"[[:space:]]*:[[:space:]]*"block"'; }; then
96
92
  cat "$err" >&2
@@ -382,6 +382,22 @@ export const extOf = (path: string): string => {
382
382
 
383
383
  // ---- anchor resolution (language-agnostic) ----
384
384
  export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
385
+ // @@@ ONE classification for "does this selector resolve here" - the gate (reading the candidate TIP) and the
386
+ // freshness probe (reading the WORKING TREE) both have to answer it, and each used to branch on
387
+ // dead/ambiguous itself. Two copies of a verdict rule drift, and these two already had: the gate warns on a
388
+ // type-only unit, the probe never noticed one. The TEXT each reads stays its own business — a gate must judge
389
+ // the tree it is gating, never a dirty worktree — but the VERDICT is decided once, here, so a new outcome
390
+ // cannot reach one caller and silently miss the other.
391
+ export type SelectorVerdict = { selector: string } & ({ ok: Unit } | { dead: true } | { ambiguous: number })
392
+ export function resolveSelectors(units: Unit[], selectors: readonly string[]): SelectorVerdict[] {
393
+ return selectors.map((selector) => {
394
+ const r = resolveAnchor(units, selector)
395
+ if ('dead' in r) return { selector, dead: true as const }
396
+ if ('ambiguous' in r) return { selector, ambiguous: r.ambiguous }
397
+ return { selector, ok: r.ok }
398
+ })
399
+ }
400
+
385
401
  export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
386
402
  const hits = units.filter((u) => u.name === symbol)
387
403
  if (!hits.length) return { dead: true }
@@ -418,9 +434,10 @@ export function diffHunkRanges(patch: string, side: 'old' | 'new' = 'new'): [num
418
434
  type FileRevisionUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
419
435
  const fileRevisionUnitMemo = new Map<string, FileRevisionUnits>()
420
436
  const MEMO_MAX = 4096
437
+ const fileRevisionMemoKey = (objectFormat: string, oid: string, x: Extractor, path: string) => `${objectFormat}\0${oid}\0${x.memoKey(path)}`
421
438
  async function unitsAtFileRevision(commit: string, path: string, x: Extractor, objectFormat: string, oid: string | null, text?: string): Promise<FileRevisionUnits> {
422
439
  if (!oid) return { absent: true }
423
- const key = `${objectFormat}\0${oid}\0${x.memoKey(path)}`
440
+ const key = fileRevisionMemoKey(objectFormat, oid, x, path)
424
441
  const hit = fileRevisionUnitMemo.get(key)
425
442
  if (hit) return hit
426
443
  if (text === undefined) throw new Error(`git cat-file --batch omitted object ${oid} for ${commit}:${path}`)
@@ -435,14 +452,50 @@ async function unitsAtFileRevision(commit: string, path: string, x: Extractor, o
435
452
  // retain one before-image per parent and only all-parent authored rows; an owned line never widens to an
436
453
  // adjacent inherited line merely because Git placed both in one `@@@` hunk.
437
454
  type HunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
455
+ // @@@ RANGE_SEMANTICS - hunk ranges must not depend on ambient Git state, because a range that moves with
456
+ // config or attributes is neither a sound anchor verdict nor a reusable fact. Outside the commit, three
457
+ // classes decide them: presentation (`.gitattributes` `-diff` prints `Binary files … differ` with no `@@` at
458
+ // all — measured: one hunk bare, ZERO under `src/x.py -diff`, so an attribute edit silently disabled an
459
+ // anchored contract's drift; a textconv or external driver replaces the text compared), the ALGORITHM and
460
+ // its heuristics (`diff.algorithm`, `diff.indentHeuristic` — both repo config), and hunk COALESCING
461
+ // (`diff.interHunkContext`). All are pinned here, on the two readers this node owns.
462
+ //
463
+ // The algorithm's VALUE is a product choice, not a formality, because the algorithms genuinely disagree:
464
+ // on `c a c a` -> `c c a a a`, myers authors new lines 4-5 and deletes old line 2, while histogram authors
465
+ // new lines 2-3 and deletes old line 3 — so a unit on line 3 is a HIT under histogram and a MISS under
466
+ // myers, on one commit. histogram is pinned because it aligns the change with the unit that actually moved,
467
+ // which for a BLOCKING gate is the conservative direction; myers (Git's default) would silently miss that
468
+ // drift. So this pin is not merely determinism: it settles which reading a verdict means.
469
+ // `--no-color` is not cosmetic here: under an ambient `color.ui=always` Git prefixes every `@@` with an
470
+ // ANSI escape, so a `/^@@/` parse finds NO hunks and every anchored path reads as "nothing changed" — a
471
+ // silent zero-drift verdict for the whole corpus, and one that would then be memoized. `-M -l0` pins rename
472
+ // detection with no candidate limit, matching the identity stream this engine's events come from, so a large
473
+ // rename set cannot quietly change which pairs are compared.
474
+ const RANGE_SEMANTICS = [
475
+ '--text', '--no-textconv', '--no-ext-diff', '--no-color',
476
+ '--diff-algorithm=histogram', '--no-indent-heuristic', '--inter-hunk-context=0',
477
+ '-M', '-l0',
478
+ ] as const
438
479
  const hunkMemo = new Map<string, HunkRanges>()
439
- async function hunksAt(root: string, event: DriftPathEvent): Promise<HunkRanges> {
480
+ // @@@ the reusable fact is a diff of ORDERED IMAGES, not of a commit id - a commit id is not immutable
481
+ // interpretation: `refs/replace` can swap the object, and a graft or an unshallow can change its parents, so
482
+ // one (commit,path) can name two different diffs inside one process. The inputs that actually decide the
483
+ // hunks are the result image and each parent image, in order, each identified by its resolved blob oid and
484
+ // historical path. Keying on those makes the key move exactly when an input does, and the oids are already
485
+ // resolved by this read's one `cat-file --batch-check`, so completeness costs no extra child and no new state.
486
+ const ABSENT_IMAGE = '-'
487
+ const hunkMemoKey = (images: string[]) => images.join('\0')
488
+ function rememberHunks(key: string, ranges: HunkRanges): HunkRanges {
489
+ if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
490
+ hunkMemo.set(key, ranges)
491
+ return ranges
492
+ }
493
+ async function hunksAt(root: string, event: DriftPathEvent, key: string): Promise<HunkRanges> {
440
494
  const paths = [...new Set([event.historicalPath, ...event.parents.map((parent) => parent.historicalPath)])]
441
- const key = `${event.commit}\0${paths.join('\x1e')}`
442
495
  const hit = hunkMemo.get(key)
443
496
  if (hit) return hit
444
497
  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,
498
+ const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'show', '--cc', '--combined-all-paths', '--unified=0', ...RANGE_SEMANTICS, '--format=', event.commit,
446
499
  ...(merge ? [] : ['--', ...paths])],
447
500
  `cannot derive anchor hunks for ${event.commit}:${event.historicalPath}`)
448
501
  let ranges: HunkRanges = { after: [], before: [[]] }
@@ -458,18 +511,35 @@ async function hunksAt(root: string, event: DriftPathEvent): Promise<HunkRanges>
458
511
  if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
459
512
  }
460
513
  }
461
- if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
462
- hunkMemo.set(key, ranges)
463
- return ranges
514
+ return rememberHunks(key, ranges)
464
515
  }
465
516
 
466
517
  // 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>> {
518
+ // image paths, so they stay on hunksAt's exact event-shaped query. Commits ride argv here, so a build-wide
519
+ // batch is split to stay clear of the kernel's exec argument limit; the records are order-independent.
520
+ //
521
+ // @@@ the demand set is the MISSES - a hunk is reusable by the identity of the IMAGES it diffed, never by the
522
+ // commit id (see hunkMemoKey): under that identity, plus this seam's pinned interpretation, the fact cannot be
523
+ // reinterpreted inside one process, so the batch may ask git only for what it has not read. Asking for the
524
+ // whole window again is what made a repeated read cost the CORPUS instead of the movement: a re-lint after one
525
+ // trunk commit or one dirty edit re-forked one `log --patch` per anchored path (22 here) to re-derive
526
+ // byte-identical answers.
527
+ const HUNK_COMMIT_CHUNK = 400
528
+ async function hunksAtMany(root: string, path: string, entries: Map<string, string>): Promise<Map<string, HunkRanges>> {
469
529
  const result = new Map<string, HunkRanges>()
470
- const ordinary = [...new Set(commits)]
530
+ const ordinary: string[] = []
531
+ for (const [commit, key] of entries) {
532
+ const hit = hunkMemo.get(key)
533
+ if (hit) result.set(commit, hit)
534
+ else ordinary.push(commit)
535
+ }
471
536
  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',
537
+ for (let cursor = 0; cursor < ordinary.length; cursor += HUNK_COMMIT_CHUNK)
538
+ await hunkRecordsInto(root, ordinary.slice(cursor, cursor + HUNK_COMMIT_CHUNK), path, entries, result)
539
+ return result
540
+ }
541
+ async function hunkRecordsInto(root: string, ordinary: string[], path: string, keys: Map<string, string>, result: Map<string, HunkRanges>): Promise<void> {
542
+ const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'log', '--no-walk', '--no-merges', '--patch', '--unified=0', ...RANGE_SEMANTICS,
473
543
  `--format=${RS}%H`, ...ordinary, '--', path], `cannot derive anchor hunks for ${path}`)
474
544
  for (const rec of out.split(RS)) {
475
545
  const normalized = rec.replace(/^\n/, '')
@@ -485,9 +555,9 @@ async function hunksAtMany(root: string, commits: string[], path: string): Promi
485
555
  if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
486
556
  if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
487
557
  }
488
- result.set(hash, ranges)
558
+ const key = keys.get(hash)
559
+ result.set(hash, key === undefined ? ranges : rememberHunks(key, ranges))
489
560
  }
490
- return result
491
561
  }
492
562
 
493
563
  // the drift window of an anchored file: every commit to `path` not reachable from the spec's version
@@ -505,63 +575,132 @@ export function windowEvents(idx: DriftIndex, sinceHash: string, path: string, n
505
575
  // through the current filename. Several selectors are OR'd and one commit still produces one hit row.
506
576
  // An historical image the designated extractor cannot parse is a conservative hit (`unparseable`).
507
577
  export type AnchorHit = { commit: string; selectors: string[]; unparseable?: string }
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 }>()
578
+ export type AnchorHitQuery = { win: DriftPathEvent[]; symbols: string[] }
579
+ type AnchorRevision = { commit: string; path: string }
580
+ const anchorRevisionKey = ({ commit, path }: AnchorRevision) => `${commit}\0${path}`
581
+
582
+ // One lint can judge several selectors with overlapping windows. Their Git images and ordinary hunks are
583
+ // reusable under the identity of those images, so the batch owns them once and each query keeps its own
584
+ // selector verdict.
585
+ async function runAnchorQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[], stopAtFirstHit: boolean): Promise<AnchorHit[][]> {
586
+ if (!queries.length) return []
510
587
  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) {
588
+ const revisions = new Map<string, AnchorRevision>()
589
+ const ordinaryEvents: { path: string; commit: string; event: DriftPathEvent }[] = []
590
+ for (const { win } of queries) for (const event of win) {
515
591
  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)
592
+ for (const ref of refs) revisions.set(anchorRevisionKey(ref), ref)
593
+ if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
594
+ ordinaryEvents.push({ path: event.historicalPath, commit: event.commit, event })
517
595
  }
518
596
  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))
597
+ const oids = await batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
598
+ // Every image this read will diff is now resolved, so the reusable hunk fact can be named by the identity
599
+ // that actually decides it: the result image and each parent image, in order, oid + historical path.
600
+ const oidByRef = new Map<string, string | null>()
601
+ for (let index = 0; index < refs.length; index++) oidByRef.set(anchorRevisionKey(refs[index]), oids[index])
602
+ const imageOf = (commit: string, path: string) => `${oidByRef.get(anchorRevisionKey({ commit, path })) ?? ABSENT_IMAGE}:${path}`
603
+ const imageIdentity = (event: DriftPathEvent) => hunkMemoKey([
604
+ imageOf(event.commit, event.historicalPath),
605
+ ...event.parents.map(({ commit, historicalPath }) => imageOf(commit, historicalPath)),
606
+ ])
607
+ const ordinaryByPath = new Map<string, Map<string, string>>()
608
+ for (const { path, commit, event } of ordinaryEvents) {
609
+ const entries = ordinaryByPath.get(path) ?? new Map<string, string>()
610
+ entries.set(commit, imageIdentity(event))
611
+ ordinaryByPath.set(path, entries)
612
+ }
613
+ const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
614
+ for (const [path, entries] of ordinaryByPath) ordinaryHunks.set(path, await hunksAtMany(root, path, entries))
615
+ // The image's own memo answers before any bytes move, so the read asks git only for the revisions this
616
+ // process has not parsed yet — the retention half of the same rule the hunk demand set follows.
521
617
  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
618
+ const primeUnits = async (keys: Set<string>): Promise<void> => {
619
+ const pending: { key: string; ref: AnchorRevision; oid: string; x: Extractor }[] = []
620
+ const wanted = new Set<string>()
621
+ for (const key of keys) {
622
+ if (units.has(key)) continue
623
+ const ref = revisions.get(key)!, oid = oidByRef.get(key) ?? null
624
+ const x = extractorFor(regs, extOf(ref.path))
625
+ const ready = x?.ready()
626
+ if (!x || ready !== true) {
627
+ units.set(key, { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
628
+ continue
629
+ }
630
+ if (!oid) { units.set(key, { absent: true }); continue }
631
+ const hit = fileRevisionUnitMemo.get(fileRevisionMemoKey(objectFormat, oid, x, ref.path))
632
+ if (hit) { units.set(key, hit); continue }
633
+ pending.push({ key, ref, oid, x }); wanted.add(oid)
529
634
  }
530
- units.set(revisionKey(ref), await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, oid ? blobs.get(oid) : undefined))
635
+ if (!pending.length) return
636
+ const blobs = await batchBlobTexts(root, [...wanted])
637
+ for (const { key, ref, oid, x } of pending)
638
+ units.set(key, await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, blobs.get(oid)))
531
639
  }
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
640
  const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
542
641
  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)
642
+ // @@@ the scan advances in DOUBLING corpus-wide rounds - an existence read may stop at its window's first
643
+ // hit, but which event that is cannot be known before the images are parsed, so the demand set has to be
644
+ // discovered rather than declared. Each round still asks the WHOLE unsettled corpus at once (never one
645
+ // reading at a time, which is what would re-fork the batch per unit), and doubling bounds the rounds by hit
646
+ // DEPTH, not by how many readings are asked. An enumeration read takes its whole remaining window in one
647
+ // slice, so it keeps the single-round shape it has always had.
648
+ const runs = queries.map(() => ({ hits: new Map<string, { selectors: Set<string>; unparseable?: string }>(), cursor: 0, settled: false }))
649
+ for (let chunk = 1; ; chunk *= 2) {
650
+ const slices: { run: typeof runs[number]; symbols: string[]; events: DriftPathEvent[] }[] = []
651
+ for (let index = 0; index < queries.length; index++) {
652
+ const run = runs[index], { win, symbols } = queries[index]
653
+ if (run.settled || run.cursor >= win.length) continue
654
+ slices.push({ run, symbols, events: win.slice(run.cursor, run.cursor + (stopAtFirstHit ? chunk : win.length - run.cursor)) })
655
+ }
656
+ if (!slices.length) break
657
+ const keys = new Set<string>()
658
+ for (const { events } of slices) for (const event of events) {
659
+ keys.add(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))
660
+ for (const parent of event.parents) keys.add(anchorRevisionKey({ commit: parent.commit, path: parent.historicalPath }))
661
+ }
662
+ await primeUnits(keys)
663
+ for (const { run, symbols, events } of slices) {
664
+ for (const event of events) {
665
+ run.cursor++
666
+ const after = units.get(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))!
667
+ const before = event.parents.map(({ commit, historicalPath }) => units.get(anchorRevisionKey({ commit, path: historicalPath }))!)
668
+ const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
669
+ ?? await hunksAt(root, event, imageIdentity(event))
670
+ if (event.parents.length && ranges.before.length !== before.length)
671
+ throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
672
+ const hit = run.hits.get(event.commit) ?? { selectors: new Set<string>() }
673
+ const broken = [after, ...before].find((image) => 'unparseable' in image)
674
+ if (broken && 'unparseable' in broken) {
675
+ for (const symbol of symbols) hit.selectors.add(symbol)
676
+ hit.unparseable = broken.unparseable
677
+ } else {
678
+ for (const symbol of symbols) {
679
+ const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
680
+ const authoredAfter = intersects(ranges.after, afterUnits)
681
+ const authoredBefore = before.length > 0 && before.every((image, parent) =>
682
+ 'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
683
+ if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
684
+ }
685
+ }
686
+ if (hit.selectors.size) run.hits.set(event.commit, hit)
687
+ if (stopAtFirstHit && run.hits.size) { run.settled = true; break }
562
688
  }
563
689
  }
564
- if (hit.selectors.size) hits.set(event.commit, hit)
565
690
  }
566
- return [...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) }))
691
+ return runs.map((run) => [...run.hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
692
+ }
693
+
694
+ // The two consumers ask DIFFERENT questions of this engine, so they enter through different doors rather
695
+ // than through one call with a mode flag. Enumeration must scan every window event because its answer IS the
696
+ // per-commit, per-selector list; existence is discharged by the first hit, and scanning past it computes rows
697
+ // nobody reads.
698
+ export async function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]> {
699
+ return runAnchorQueries(root, queries, regs, false)
700
+ }
701
+ export async function anchorHitExists(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<boolean[]> {
702
+ return (await runAnchorQueries(root, queries, regs, true)).map((hits) => hits.length > 0)
703
+ }
704
+ export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
705
+ return (await anchorHitQueries(root, [{ win, symbols }], regs))[0]
567
706
  }
@@ -1,14 +1,6 @@
1
- // @@@ session attach - the HUMAN escape hatch into a worker: every session is just a tmux session on the
2
- // backend's private socket, and the most direct way to see or rescue one is to sit in it. This verb is the
3
- // sanctioned foreground `tmux attach` — no programmatic exception-handling ambition, the user fixes it by
4
- // being there. It is deliberately the ONE session verb that does NOT route through the backend
5
- // ([[remote-client]]'s exception): a terminal cannot be brokered over HTTP, and attaching a tmux CLIENT to
6
- // the same server is tmux's native multi-client support, not a second actor on the socket. That makes it
7
- // LOCAL-only by nature — the guards below fail loud (never degrade) when the premise doesn't hold.
8
1
  import { spawnSync } from 'node:child_process'
9
2
  import { networkInterfaces } from 'node:os'
10
3
  import { alive, apiBase, TMUX_SOCK } from './sessions.js'
11
- import { runSessionOperation } from './session-maintenance.js'
12
4
 
13
5
  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)'
14
6
 
@@ -40,14 +32,12 @@ export async function attachSession(id: string): Promise<number> {
40
32
  An agent must not run it inside a turn (it freezes you); ${AGENT_ALTERNATIVES}.`)
41
33
  process.exit(2)
42
34
  }
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.
35
+ if (!(await alive(id))) {
36
+ console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
46
37
  Bring it back with \`spex session resume ${id}\`, or read its record with \`spex session show ${id}\`.`)
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
- })
38
+ return 1
39
+ }
40
+ console.log(`attaching to ${id} — detach with C-b d (the session keeps running)`)
41
+ const r = spawnSync('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], { stdio: 'inherit' })
42
+ return r.status ?? 1
53
43
  }