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
@@ -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'
@@ -362,8 +363,10 @@ const MAX_INPUT_BYTES = 64 * 1024
362
363
  export function forwardInput(id: string, viewer: Viewer, data: string): boolean {
363
364
  const subscription = currentSubscription(id, viewer)
364
365
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES) return false
365
- sendControl(subscription.bridge, { t: 'input', data })
366
- return true
366
+ return runSessionOperationSync({ op: 'terminal-input', sessionId: id }, () => withSessionInputLock(id, () => {
367
+ sendControl(subscription.bridge!, { t: 'input', data })
368
+ return true
369
+ }) ?? false)
367
370
  }
368
371
 
369
372
  async function restoreBridge(id: string, viewer: Viewer, subscription: Subscription): Promise<void> {
@@ -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,88 @@
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, writeDetachedRuntimeReceipt, 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 spawnDetachedRuntime(opts: {
55
+ cwd: string
56
+ logFile: string
57
+ pidFile: string
58
+ receiptFile: string
59
+ command: string
60
+ args: string[]
61
+ env?: NodeJS.ProcessEnv
62
+ }): ProcessIdentity {
63
+ mkdirSync(dirname(opts.logFile), { recursive: true })
64
+ mkdirSync(dirname(opts.pidFile), { recursive: true })
65
+ const log = openSync(opts.logFile, 'a', 0o600)
66
+ let child: ReturnType<typeof spawn>
67
+ try {
68
+ child = spawn(opts.command, opts.args, {
69
+ cwd: opts.cwd,
70
+ env: opts.env ?? process.env,
71
+ detached: true,
72
+ stdio: ['ignore', log, log],
73
+ })
74
+ } finally { closeSync(log) }
75
+ child.once('error', () => {})
76
+ if (!child.pid) throw new Error(`could not spawn detached shared runtime: ${opts.command}`)
77
+ child.unref()
78
+ try {
79
+ const identity = writeDetachedRuntimeReceipt(child.pid, opts.receiptFile)
80
+ writeFileSync(opts.pidFile, `${child.pid}\n`, { mode: 0o600 })
81
+ return { pid: identity.pid, startToken: identity.startToken }
82
+ } catch (error) {
83
+ try { child.kill('SIGTERM') } catch { /* exact just-spawned child already exited */ }
84
+ rmSync(opts.pidFile, { force: true })
85
+ rmSync(opts.receiptFile, { force: true })
86
+ throw error
87
+ }
88
+ }