spexcode 0.5.1 → 0.5.3

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 (155) 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 +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  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 +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  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 +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
@@ -0,0 +1,49 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { platform } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ export type ProcessIdentity = { pid: number; startToken: string }
7
+ export type ProcessTopology = ProcessIdentity & { processGroupId: number; sessionId: number }
8
+
9
+ export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
10
+ const end = text.lastIndexOf(')')
11
+ if (end < 0) throw new Error('malformed proc stat')
12
+ const fields = text.slice(end + 2).trim().split(/\s+/)
13
+ if (fields.length < 22) throw new Error('short proc stat')
14
+ return {
15
+ ppid: Number(fields[1]),
16
+ processGroupId: Number(fields[2]),
17
+ sessionId: Number(fields[3]),
18
+ ticks: Number(fields[11]) + Number(fields[12]),
19
+ startToken: fields[19],
20
+ rssPages: Number(fields[21]),
21
+ }
22
+ }
23
+
24
+ export function processTopology(pid: number, procRoot = '/proc'): ProcessTopology | null {
25
+ if (platform() === 'linux' || procRoot !== '/proc') {
26
+ try {
27
+ const stat = parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8'))
28
+ return { pid, startToken: stat.startToken, processGroupId: stat.processGroupId, sessionId: stat.sessionId }
29
+ } catch { return null }
30
+ }
31
+ try {
32
+ const [processGroupId, sessionId] = execFileSync('ps', ['-o', 'pgid=', '-o', 'sess=', '-p', String(pid)], { encoding: 'utf8' }).trim().split(/\s+/).map(Number)
33
+ const startToken = processStartToken(pid, procRoot)
34
+ return startToken && Number.isFinite(processGroupId) && Number.isFinite(sessionId)
35
+ ? { pid, startToken, processGroupId, sessionId }
36
+ : null
37
+ } catch { return null }
38
+ }
39
+
40
+ export function processStartToken(pid: number, procRoot = '/proc'): string | null {
41
+ if (platform() === 'linux' || procRoot !== '/proc') {
42
+ try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
43
+ catch { return null }
44
+ }
45
+ try {
46
+ const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
47
+ return started || null
48
+ } catch { return null }
49
+ }
@@ -0,0 +1,17 @@
1
+ import { homedir } from 'node:os'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ // One pure identity seam for every project-scoped runtime consumer. Git discovery stays with callers;
5
+ // once they have the shared common dir, sessions, materialize, history indexes, backends, and uninstall
6
+ // all resolve the same top-level store.
7
+ export function spexcodeHome(): string {
8
+ return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
9
+ }
10
+
11
+ export function encodeProject(root: string): string {
12
+ return root.replace(/[/.]/g, '-')
13
+ }
14
+
15
+ export function projectRuntimeRoot(commonDir: string): string {
16
+ return join(spexcodeHome(), 'projects', encodeProject(dirname(commonDir)))
17
+ }
@@ -1,7 +1,8 @@
1
1
  import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { promisify } from 'node:util'
4
- import { alive } from './sessions.js'
4
+ import { alive, withSessionInputLock } from './sessions.js'
5
+ import { runSessionOperationSync } from './session-maintenance.js'
5
6
 
6
7
  const pexec = promisify(execFile)
7
8
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
@@ -20,6 +21,7 @@ type Subscription = {
20
21
  bridge?: Bridge
21
22
  lingerTimer?: ReturnType<typeof setTimeout>
22
23
  restoreTimer?: ReturnType<typeof setTimeout>
24
+ startupError?: string
23
25
  }
24
26
 
25
27
  type Bridge = {
@@ -37,6 +39,7 @@ type Bridge = {
37
39
  refreshRunning: boolean
38
40
  refreshOffset?: number
39
41
  deliveryTimer?: ReturnType<typeof setTimeout>
42
+ startupError?: string
40
43
  }
41
44
 
42
45
  const subscribers = new Map<string, Map<Viewer, Subscription>>()
@@ -103,6 +106,8 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
103
106
  const ready = line.match(/^READY (\d+)$/)
104
107
  if (ready) {
105
108
  bridge.ptyPid = Number(ready[1])
109
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
110
+ if (subscription) subscription.startupError = undefined
106
111
  if (bridge.delivery === 'initial') {
107
112
  armDeliveryBoundary(bridge)
108
113
  queueRefresh(bridge)
@@ -117,11 +122,28 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
117
122
  queueRefresh(bridge)
118
123
  }
119
124
  } else if (line) {
125
+ const failed = line.match(/^ERROR (.+)$/)
126
+ if (failed) reportStartupError(bridge, failed[1])
120
127
  console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
121
128
  }
122
129
  }
123
130
  }
124
131
 
132
+ function reportStartupError(bridge: Bridge, detail: string): void {
133
+ const subscription = currentSubscription(bridge.id, bridge.viewer)
134
+ if (!subscription || subscription.bridge !== bridge) return
135
+ const message = boundedStartupError(detail)
136
+ bridge.startupError = message
137
+ if (!message || subscription.startupError === message) return
138
+ subscription.startupError = message
139
+ deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'))
140
+ }
141
+
142
+ function boundedStartupError(detail: unknown): string {
143
+ return (detail instanceof Error ? detail.message : String(detail))
144
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start'
145
+ }
146
+
125
147
  function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
126
148
  if (subscription.bridge) return { bridge: subscription.bridge, created: false }
127
149
  let proc: ChildProcessWithoutNullStreams | undefined
@@ -130,8 +152,13 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
130
152
  stdio: ['pipe', 'pipe', 'pipe'],
131
153
  env: process.env,
132
154
  })
133
- } catch {
155
+ } catch (error) {
134
156
  try { proc?.kill() } catch { /* spawn did not complete */ }
157
+ const detail = boundedStartupError(error)
158
+ if (subscription.startupError !== detail) {
159
+ subscription.startupError = detail
160
+ try { viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8')) } catch { /* socket closed */ }
161
+ }
135
162
  return { bridge: null, created: false }
136
163
  }
137
164
  const bridge: Bridge = {
@@ -142,18 +169,19 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
142
169
  proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
143
170
  proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
144
171
  let reaped = false
145
- const gone = () => {
172
+ const gone = (detail?: string) => {
146
173
  if (reaped) return
147
174
  reaped = true
148
175
  const current = currentSubscription(id, viewer)
149
176
  if (current?.bridge !== bridge) return
177
+ if (!bridge.ptyPid && !bridge.startupError) reportStartupError(bridge, detail || 'helper exited before native PTY startup')
150
178
  current.bridge = undefined
151
179
  clearDelivery(bridge)
152
180
  try { bridge.proc.kill() } catch { /* already gone */ }
153
181
  scheduleRestore(id, viewer, current)
154
182
  }
155
- proc.on('exit', gone)
156
- proc.on('error', gone)
183
+ proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`))
184
+ proc.on('error', (error) => gone(`helper process failed: ${error.message}`))
157
185
  return { bridge, created: true }
158
186
  }
159
187
 
@@ -335,8 +363,10 @@ const MAX_INPUT_BYTES = 64 * 1024
335
363
  export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
336
364
  const subscription = currentSubscription(id, viewer)
337
365
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
338
- sendControl(subscription.bridge, { t: 'input', data })
339
- return true
366
+ return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
367
+ sendControl(subscription.bridge!, { t: 'input', data })
368
+ return true
369
+ }) ?? false)
340
370
  }
341
371
 
342
372
  async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
@@ -1,5 +1,6 @@
1
1
  import * as pty from 'node-pty'
2
2
  import { execFileSync } from 'node:child_process'
3
+ import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs'
3
4
 
4
5
  const [id, colsArg, rowsArg] = process.argv.slice(2)
5
6
  const cols = Number(colsArg)
@@ -40,12 +41,21 @@ try {
40
41
  }
41
42
  } catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
42
43
 
43
- const terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
44
- name: 'xterm-256color',
45
- cols,
46
- rows,
47
- env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
48
- })
44
+ let terminal
45
+ try {
46
+ ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native))
47
+ terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
48
+ name: 'xterm-256color',
49
+ cols,
50
+ rows,
51
+ env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
52
+ })
53
+ } catch (error) {
54
+ const detail = (error instanceof Error ? error.message : String(error))
55
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500)
56
+ process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`)
57
+ process.exit(1)
58
+ }
49
59
 
50
60
  terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
51
61
  terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
@@ -0,0 +1,22 @@
1
+ import { chmodSync, statSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ const require = createRequire(import.meta.url)
6
+
7
+ export function nodePtySpawnHelperPath(nativeModule) {
8
+ const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename
9
+ if (!nativeAddon) throw new Error('cannot locate node-pty loaded native addon')
10
+ return join(dirname(nativeAddon), 'spawn-helper')
11
+ }
12
+
13
+ export function ensureExecutableIfPresent(path) {
14
+ let mode
15
+ try {
16
+ mode = statSync(path).mode & 0o777
17
+ } catch (error) {
18
+ if (error?.code === 'ENOENT') return
19
+ throw error
20
+ }
21
+ if ((mode & 0o111) !== 0o111) chmodSync(path, mode | 0o111)
22
+ }
@@ -15,6 +15,11 @@ import { EVAL_QUERY_DEFAULT, ISSUE_QUERY_DEFAULT, readToken } from '../../spec-d
15
15
  export const REVIEW_PER_PAGE = 25
16
16
 
17
17
  type ReviewItem = Record<string, unknown>
18
+ // A section count is one number, or — when the domain adapter splits that section — its named buckets,
19
+ // which re-add to the same whole population ([[review-filters]] owns the split; Evals splits its measured
20
+ // verdicts into {fresh,stale} so the remeasurement debt travels with the count instead of being recomputed
21
+ // per surface). The fold happens ONCE, here on the server, over the complete filtered population.
22
+ export type ReviewCount = number | Record<string, number>
18
23
  type ReviewOption = { value: string; label?: string; count?: number }
19
24
  type ReviewFacet = { key: string; label?: string; value: string; meaningful?: boolean; options: ReviewOption[] }
20
25
  type EvalNeighbor = { node: string; scenario: string; state: string }
@@ -29,7 +34,7 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
29
34
  prev: number | null
30
35
  next: number | null
31
36
  revision: string
32
- counts: Record<string, number>
37
+ counts: Record<string, ReviewCount>
33
38
  facets: Record<string, ReviewFacet>
34
39
  section: { key: string; value: string; options: ReviewOption[] } | null
35
40
  }
@@ -129,12 +134,12 @@ export async function issuesReview(query: string | undefined, requestedPage: unk
129
134
  }
130
135
  }
131
136
 
132
- const byNewest = (a: any, b: any): number => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
137
+ const byNewest = (a: any, b: any): number => Number(b.filterKind === EVAL_FILTER_KIND.RESULT) - Number(a.filterKind === EVAL_FILTER_KIND.RESULT)
138
+ || String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
133
139
  || String(a.node ?? '').localeCompare(String(b.node ?? ''))
134
140
  || String(a.scenario ?? '').localeCompare(String(b.scenario ?? ''))
135
141
 
136
142
  export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
137
- const blind: any[] = []
138
143
  const items: any[] = []
139
144
  for (const node of nodes ?? []) {
140
145
  const latest = new Map<string, any>()
@@ -142,7 +147,7 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
142
147
  for (const scenario of node.scenarios ?? []) {
143
148
  const reading = latest.get(scenario.name)
144
149
  if (!reading) {
145
- blind.push({
150
+ items.push({
146
151
  scenario: scenario.name,
147
152
  expected: scenario.expected,
148
153
  tags: scenario.tags,
@@ -163,21 +168,18 @@ export function trunkEvalReviewItems(nodes: any[]): ReviewItem[] {
163
168
  })
164
169
  }
165
170
  }
166
- blind.sort((a, b) => String(a.node).localeCompare(String(b.node)) || String(a.scenario).localeCompare(String(b.scenario)))
167
- return [...blind, ...items.sort(byNewest)]
171
+ return items.sort(byNewest)
168
172
  }
169
173
 
170
174
  export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
171
- const blind: any[] = []
172
- const own: any[] = []
173
- const inherited: any[] = []
175
+ const items: any[] = []
174
176
  for (const node of model.nodes ?? []) {
175
177
  const latest = new Map<string, any>()
176
178
  for (const reading of node.evals ?? []) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading)
177
179
  for (const scenario of node.scenarios ?? []) {
178
180
  const reading = latest.get(scenario.name)
179
181
  if (!reading) {
180
- blind.push({
182
+ items.push({
181
183
  scenario: scenario.name,
182
184
  expected: scenario.expected,
183
185
  tags: scenario.tags,
@@ -192,15 +194,16 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
192
194
  ...reading,
193
195
  expected: scenario.expected ?? reading.expected,
194
196
  tags: scenario.tags,
197
+ impact: scenario.impact,
195
198
  state: evalReviewState(reading),
196
199
  node: node.id,
197
200
  hue: node.hue,
198
201
  filterKind: EVAL_FILTER_KIND.RESULT,
199
202
  }
200
- ;(reading.inSession ? own : inherited).push(item)
203
+ items.push(item)
201
204
  }
202
205
  }
203
- return [...blind, ...own.sort(byNewest), ...inherited.sort(byNewest)]
206
+ return items.sort(byNewest)
204
207
  }
205
208
 
206
209
  const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
@@ -327,9 +330,10 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
327
330
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
328
331
  summary: model.summary,
329
332
  evalRevision: model.evalRevision,
333
+ impact: model.impact,
330
334
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
331
335
  domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
332
- evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
336
+ evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
333
337
  }),
334
338
  }
335
339
  }
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { spawn } from 'node:child_process'
3
+ import { closeSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { repoRoot } from './git.js'
6
+ import { readJsonConfig, runtimeRoot } from './layout.js'
7
+ import { processStartToken, processTopology, type ProcessIdentity } from './process-identity.js'
8
+
9
+ export type BackendInstanceRecord = {
10
+ version: 1
11
+ instanceId: string
12
+ pid: number
13
+ startToken: string
14
+ projectRoot: string
15
+ startedAt: string
16
+ }
17
+
18
+ const backendInstancesDir = () => join(runtimeRoot(), 'backend-instances')
19
+ const backendInstancePath = (instanceId: string) => join(backendInstancesDir(), `${instanceId}.json`)
20
+
21
+ const atomicJson = (path: string, value: unknown) => {
22
+ mkdirSync(dirname(path), { recursive: true })
23
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
24
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
25
+ renameSync(tmp, path)
26
+ }
27
+
28
+ export function readBackendInstanceRecords(root = repoRoot()): BackendInstanceRecord[] {
29
+ let names: string[]
30
+ try { names = readdirSync(backendInstancesDir()) } catch { return [] }
31
+ return names.flatMap((name) => {
32
+ try {
33
+ const rec = readJsonConfig(join(backendInstancesDir(), name)) as BackendInstanceRecord
34
+ return rec?.version === 1 && rec.projectRoot === root ? [rec] : []
35
+ } catch { return [] }
36
+ })
37
+ }
38
+
39
+ export function registerBackendInstance(instanceId: string, pid = process.pid, root = repoRoot()): BackendInstanceRecord {
40
+ const startToken = processStartToken(pid)
41
+ if (!startToken) throw new Error(`cannot identify backend supervisor PID ${pid}`)
42
+ const record: BackendInstanceRecord = { version: 1, instanceId, pid, startToken, projectRoot: root, startedAt: new Date().toISOString() }
43
+ atomicJson(backendInstancePath(instanceId), record)
44
+ return record
45
+ }
46
+
47
+ export function unregisterBackendInstance(instanceId: string, pid = process.pid): void {
48
+ try {
49
+ const record = readJsonConfig(backendInstancePath(instanceId)) as BackendInstanceRecord
50
+ if (record.instanceId === instanceId && record.pid === pid && record.startToken === processStartToken(pid)) rmSync(backendInstancePath(instanceId), { force: true })
51
+ } catch { /* not ours / already removed */ }
52
+ }
53
+
54
+ export function writeIsolationStamp(pid: number, file: string): void {
55
+ const topology = processTopology(pid)
56
+ if (!topology) throw new Error(`cannot identify shared runtime PID ${pid}`)
57
+ if (topology.processGroupId !== pid || topology.sessionId !== pid)
58
+ throw new Error(`shared runtime PID ${pid} is not detached from its launch session (pgrp=${topology.processGroupId}, session=${topology.sessionId})`)
59
+ mkdirSync(dirname(file), { recursive: true })
60
+ writeFileSync(file, `detached-v3 ${pid} ${topology.startToken} ${topology.processGroupId} ${topology.sessionId}\n`, { mode: 0o600 })
61
+ }
62
+
63
+ export function spawnDetachedRuntime(opts: {
64
+ cwd: string
65
+ logFile: string
66
+ pidFile: string
67
+ isolationFile: string
68
+ command: string
69
+ args: string[]
70
+ env?: NodeJS.ProcessEnv
71
+ }): ProcessIdentity {
72
+ mkdirSync(dirname(opts.logFile), { recursive: true })
73
+ mkdirSync(dirname(opts.pidFile), { recursive: true })
74
+ const log = openSync(opts.logFile, 'a', 0o600)
75
+ let child: ReturnType<typeof spawn>
76
+ try {
77
+ child = spawn(opts.command, opts.args, {
78
+ cwd: opts.cwd,
79
+ env: opts.env ?? process.env,
80
+ detached: true,
81
+ stdio: ['ignore', log, log],
82
+ })
83
+ } finally { closeSync(log) }
84
+ child.once('error', () => {})
85
+ if (!child.pid) throw new Error(`could not spawn detached shared runtime: ${opts.command}`)
86
+ child.unref()
87
+ try {
88
+ writeIsolationStamp(child.pid, opts.isolationFile)
89
+ const startToken = processStartToken(child.pid)
90
+ if (!startToken) throw new Error(`cannot identify detached shared runtime PID ${child.pid}`)
91
+ writeFileSync(opts.pidFile, `${child.pid}\n`, { mode: 0o600 })
92
+ return { pid: child.pid, startToken }
93
+ } catch (error) {
94
+ try { child.kill('SIGTERM') } catch { /* exact just-spawned child already exited */ }
95
+ rmSync(opts.pidFile, { force: true })
96
+ rmSync(opts.isolationFile, { force: true })
97
+ throw error
98
+ }
99
+ }