spexcode 0.6.0 → 0.6.2

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 (88) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +239 -75
  6. package/spec-cli/src/client.ts +113 -14
  7. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  8. package/spec-cli/src/commit-surgery.ts +2 -1
  9. package/spec-cli/src/contract-filter.ts +58 -42
  10. package/spec-cli/src/delivery-queue.ts +62 -4
  11. package/spec-cli/src/doctor.ts +2 -1
  12. package/spec-cli/src/execution-trace.ts +444 -0
  13. package/spec-cli/src/file-write.ts +22 -0
  14. package/spec-cli/src/gateway-hub.ts +10 -8
  15. package/spec-cli/src/gateway.ts +99 -6
  16. package/spec-cli/src/git.ts +503 -105
  17. package/spec-cli/src/graph.ts +56 -6
  18. package/spec-cli/src/graphCache.ts +86 -17
  19. package/spec-cli/src/graphStream.ts +79 -11
  20. package/spec-cli/src/guide.ts +93 -4
  21. package/spec-cli/src/harness-select.ts +2 -2
  22. package/spec-cli/src/harness.ts +355 -75
  23. package/spec-cli/src/help.ts +30 -23
  24. package/spec-cli/src/host-resources.ts +62 -7
  25. package/spec-cli/src/identity-presets.js +16 -6
  26. package/spec-cli/src/index.ts +109 -14
  27. package/spec-cli/src/init.ts +8 -17
  28. package/spec-cli/src/layout.ts +151 -40
  29. package/spec-cli/src/lint.ts +37 -7
  30. package/spec-cli/src/materialize.ts +160 -112
  31. package/spec-cli/src/plugin-harness.ts +29 -18
  32. package/spec-cli/src/process-identity.ts +13 -0
  33. package/spec-cli/src/reviews.ts +103 -30
  34. package/spec-cli/src/session-execution.ts +68 -0
  35. package/spec-cli/src/session-files.ts +121 -0
  36. package/spec-cli/src/session-follow.ts +4 -4
  37. package/spec-cli/src/session-reparent.ts +33 -0
  38. package/spec-cli/src/session-timeline.ts +163 -14
  39. package/spec-cli/src/session-web.ts +135 -0
  40. package/spec-cli/src/sessions.ts +748 -111
  41. package/spec-cli/src/uninstall.ts +3 -2
  42. package/spec-cli/templates/hooks/post-checkout +3 -0
  43. package/spec-cli/templates/hooks/post-merge +1 -0
  44. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  46. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  47. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  48. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  49. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  50. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  51. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  52. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  53. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
  54. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  55. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  56. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
  57. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  58. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
  59. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
  60. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
  61. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  62. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
  63. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  64. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  65. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  66. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
  67. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  68. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
  69. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  70. package/spec-dashboard/dist/index.html +2 -2
  71. package/spec-dashboard/src/reviewFilters.js +5 -0
  72. package/spec-dashboard/src/session.js +5 -1
  73. package/spec-eval/src/cli.ts +13 -9
  74. package/spec-eval/src/evaltab.ts +32 -10
  75. package/spec-eval/src/freshness.ts +404 -81
  76. package/spec-eval/src/scenariofresh.ts +58 -8
  77. package/spec-eval/src/scenarios.ts +60 -12
  78. package/spec-eval/src/sessioneval.ts +234 -148
  79. package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
  80. package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
  81. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
  82. package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
  83. package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
  84. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  85. package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
  86. package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
  87. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
  88. package/spec-eval/src/matrix.ts +0 -693
@@ -7,6 +7,7 @@ import { loadSystemConfig, loadSkillConfig, loadSpecs } from './specs.js'
7
7
  import { runtimeRoot, treeSlotDir, envSessionId, readAliasedRawRecord, mainCheckout, readJsonConfig } from './layout.js'
8
8
  import { loadConfig } from './lint.js'
9
9
  import { trackedSourceFiles } from './source-files.js'
10
+ import { gitBinary } from './git.js'
10
11
 
11
12
  // this file lives at <pkgRoot>/src/self.ts, so `..` is the package root — the same derivation init.ts/
12
13
  // materialize.ts use (never a hardcoded repo path), so the git-hook template lookup survives a relocated install.
@@ -14,7 +15,7 @@ const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
14
15
 
15
16
  // run a git query in `dir`, swallowing git's own stderr — a non-repo returns null (the absence IS the signal).
16
17
  function git(dir: string, args: string[]): string | null {
17
- try { return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
18
+ try { return execFileSync(gitBinary(process.env), ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
18
19
  catch { return null }
19
20
  }
20
21
  function repoRoot(dir: string): string | null { return git(dir, ['rev-parse', '--show-toplevel']) }
@@ -0,0 +1,444 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ export type ExecutionTurn = Readonly<{
7
+ token: string
8
+ acceptedAt: string
9
+ }>
10
+ export type ExecutionStepKind = 'command' | 'read' | 'write' | 'search' | 'tool'
11
+ export type ExecutionStep = Readonly<{
12
+ id: string
13
+ kind: ExecutionStepKind
14
+ label: string
15
+ detail?: string
16
+ state: 'running' | 'done'
17
+ }>
18
+ export type ExecutionTrace = Readonly<{
19
+ revision: string
20
+ turnId: string | null
21
+ workingNote: string | null
22
+ steps: readonly ExecutionStep[]
23
+ }>
24
+
25
+ type TraceEvent =
26
+ | Readonly<{ type: 'boundary'; ids: readonly string[]; at: number | null }>
27
+ | Readonly<{ type: 'note'; text: string }>
28
+ | Readonly<{ type: 'call'; step: ExecutionStep }>
29
+ | Readonly<{ type: 'done'; id: string }>
30
+ type CachedTrace = { size: number; remainder: Buffer; events: TraceEvent[] }
31
+ type ApplyEvent = (value: unknown, ordinal: number) => readonly TraceEvent[]
32
+ type ExportLoader = (threadId: string) => string
33
+
34
+ const cache = new Map<string, CachedTrace>()
35
+ const sessionJsonlPaths = new Map<string, string>()
36
+ const localStoreCache = new Map<string, { revision: string; events: TraceEvent[] }>()
37
+
38
+ const emptyTrace = (turn: ExecutionTurn | null, revision = '0'): ExecutionTrace => ({
39
+ revision: `${turn?.token ?? '0'}:${revision}`,
40
+ turnId: turn?.token ?? null,
41
+ workingNote: null,
42
+ steps: [],
43
+ })
44
+
45
+ const object = (value: unknown): Record<string, unknown> | null =>
46
+ value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
47
+ const items = (value: unknown): readonly unknown[] => Array.isArray(value) ? value : []
48
+ const text = (value: unknown): string | null => typeof value === 'string' && value.trim() ? value.trim() : null
49
+ const field = (value: Record<string, unknown>, ...names: string[]): string | null => {
50
+ for (const name of names) {
51
+ const found = text(value[name])
52
+ if (found) return found
53
+ }
54
+ return null
55
+ }
56
+ const children = (dir: string): string[] => { try { return readdirSync(dir).sort().reverse() } catch { return [] } }
57
+
58
+ const sensitive = /(token|secret|password|authorization|bearer|cookie|credential|api[_-]?key|private[ _-]?key)/i
59
+ const detailFields = [
60
+ ['cmd', 'cmd'], ['command', 'command'], ['path', 'path'], ['file_path', 'path'],
61
+ ['query', 'query'], ['pattern', 'pattern'], ['url', 'url'], ['workdir', 'in'],
62
+ ] as const
63
+
64
+ function toolKind(name: string): ExecutionStepKind {
65
+ const normalized = name.toLowerCase()
66
+ if (/(search|browse|web)/.test(normalized)) return 'search'
67
+ if (/(read|list|find|grep|glob|stat)/.test(normalized)) return 'read'
68
+ if (/(write|edit|patch|create|delete|move|rename)/.test(normalized)) return 'write'
69
+ if (/(exec|shell|command|terminal|bash|npm|git)/.test(normalized)) return 'command'
70
+ return 'tool'
71
+ }
72
+
73
+ function toolLabel(name: string, kind: ExecutionStepKind): string {
74
+ const labels: Record<ExecutionStepKind, string> = {
75
+ command: 'Run command', read: 'Read files', write: 'Update files', search: 'Search', tool: 'Use tool',
76
+ }
77
+ return name.trim() || labels[kind]
78
+ }
79
+
80
+ function recordInput(value: unknown): Record<string, unknown> | null {
81
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
82
+ if (typeof value !== 'string') return null
83
+ try {
84
+ const parsed = JSON.parse(value)
85
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
86
+ } catch { return null }
87
+ }
88
+
89
+ function sensitiveInput(value: unknown): boolean {
90
+ if (typeof value === 'string') return sensitive.test(value)
91
+ if (Array.isArray(value)) return value.some(sensitiveInput)
92
+ if (value && typeof value === 'object') return Object.entries(value as Record<string, unknown>)
93
+ .some(([key, entry]) => sensitive.test(key) || sensitiveInput(entry))
94
+ return false
95
+ }
96
+
97
+ function compactValue(key: string, value: string | number | boolean): string {
98
+ const compact = String(value).trim().replace(/\s+/g, ' ')
99
+ if (key === 'path' || key === 'file_path' || key === 'workdir') {
100
+ const parts = compact.replace(/\\/g, '/').split('/').filter(Boolean)
101
+ return parts.slice(-2).join('/') || compact
102
+ }
103
+ return compact.length > 96 ? `${compact.slice(0, 95)}...` : compact
104
+ }
105
+
106
+ function detailFromInput(value: unknown): string | undefined {
107
+ const input = recordInput(value)
108
+ if (!input || sensitiveInput(input)) return undefined
109
+ const details: string[] = []
110
+ for (const [key, title] of detailFields) {
111
+ const value = input[key]
112
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') continue
113
+ details.push(`${title}: ${compactValue(key, value)}`)
114
+ if (details.length === 2) break
115
+ }
116
+ const lineStart = input.line_start
117
+ const lineEnd = input.line_end
118
+ if (details.length < 2 && (typeof lineStart === 'number' || typeof lineEnd === 'number')) {
119
+ details.push(`lines: ${typeof lineStart === 'number' ? lineStart : 1}-${typeof lineEnd === 'number' ? lineEnd : lineStart}`)
120
+ }
121
+ return details.length ? details.join(' · ') : undefined
122
+ }
123
+
124
+ function displayableNote(value: unknown): string | null {
125
+ const note = text(value)?.replace(/\s+/g, ' ')
126
+ return note ? (note.length > 240 ? `${note.slice(0, 239)}...` : note) : null
127
+ }
128
+
129
+ function stepFromInput(id: string, name: string, input: unknown, state: ExecutionStep['state']): ExecutionStep {
130
+ const kind = toolKind(name)
131
+ const detail = detailFromInput(input)
132
+ return { id, kind, label: toolLabel(name, kind), ...(detail ? { detail } : {}), state }
133
+ }
134
+
135
+ function timestamp(value: unknown): number | null {
136
+ if (typeof value === 'number' && Number.isFinite(value)) return value
137
+ if (typeof value !== 'string' || !value.trim()) return null
138
+ const number = Number(value)
139
+ if (Number.isFinite(number)) return number
140
+ const parsed = Date.parse(value)
141
+ return Number.isFinite(parsed) ? parsed : null
142
+ }
143
+
144
+ function identities(...values: Array<Record<string, unknown> | null>): string[] {
145
+ const result = new Set<string>()
146
+ for (const value of values) for (const key of ['id', 'uuid', 'message_id', 'messageId', 'client_id', 'clientId', 'client_user_message_id', 'clientUserMessageId', 'promptId']) {
147
+ const id = field(value || {}, key)
148
+ if (id) result.add(id)
149
+ }
150
+ return [...result]
151
+ }
152
+
153
+ function at(value: Record<string, unknown>): number | null {
154
+ for (const key of ['timestamp', 'created_at', 'createdAt', 'created', 'time']) {
155
+ const found = timestamp(value[key])
156
+ if (found !== null) return found
157
+ }
158
+ return null
159
+ }
160
+
161
+ function isPrompt(content: unknown): boolean {
162
+ if (typeof content === 'string') return !!content.trim()
163
+ const blocks = items(content)
164
+ return blocks.some((block) => object(block)?.type !== 'tool_result')
165
+ }
166
+
167
+ function project(events: readonly TraceEvent[], sourceRevision: string, turn: ExecutionTurn | null): ExecutionTrace {
168
+ let start = -1
169
+ if (!turn) {
170
+ for (const [index, event] of events.entries()) if (event.type === 'boundary') start = index
171
+ } else {
172
+ const acceptedAt = timestamp(turn.acceptedAt)
173
+ const exact = events.flatMap((event, index) => event.type === 'boundary' && event.ids.includes(turn.token) ? [index] : [])
174
+ start = exact.length === 1 ? exact[0] : -1
175
+ if (start < 0 && exact.length === 0 && acceptedAt !== null) {
176
+ const candidates = events.flatMap((event, index) => event.type === 'boundary' && event.at !== null && event.at >= acceptedAt
177
+ ? [{ index, at: event.at }] : [])
178
+ const earliest = candidates.reduce<number | null>((value, candidate) => value === null || candidate.at < value ? candidate.at : value, null)
179
+ const matches = earliest === null ? [] : candidates.filter((candidate) => candidate.at === earliest)
180
+ if (matches.length === 1) start = matches[0].index
181
+ }
182
+ }
183
+ if (turn && start < 0) return emptyTrace(turn, sourceRevision)
184
+
185
+ let note: string | null = null
186
+ let steps: ExecutionStep[] = []
187
+ for (let index = start + 1; index < events.length; index++) {
188
+ const event = events[index]
189
+ if (event.type === 'boundary') return emptyTrace(turn, sourceRevision)
190
+ if (event.type === 'note') { note = event.text; steps = []; continue }
191
+ if (event.type === 'call') {
192
+ if (note) steps.push(event.step)
193
+ continue
194
+ }
195
+ const step = steps.find((value) => value.id === event.id)
196
+ if (step) steps[steps.indexOf(step)] = { ...step, state: 'done' }
197
+ }
198
+ return note
199
+ ? { revision: `${turn?.token ?? '0'}:${sourceRevision}`, turnId: turn?.token ?? null, workingNote: note, steps }
200
+ : emptyTrace(turn, sourceRevision)
201
+ }
202
+
203
+ function applyRolloutEvent(value: unknown, ordinal: number): readonly TraceEvent[] {
204
+ const event = object(value)
205
+ const payload = object(event?.payload)
206
+ if (!event || !payload) return []
207
+ const result: TraceEvent[] = []
208
+ const type = field(payload, 'type')
209
+ if ((event.type === 'event_msg' && type === 'user_message')
210
+ || (event.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')) {
211
+ result.push({ type: 'boundary', ids: identities(payload, event), at: at(payload) ?? at(event) })
212
+ }
213
+ if (event.type === 'event_msg' && type === 'agent_message' && payload.phase === 'commentary') {
214
+ const note = displayableNote(payload.message ?? payload.text)
215
+ if (note) result.push({ type: 'note', text: note })
216
+ }
217
+ if (event.type === 'response_item' && (type === 'custom_tool_call' || type === 'function_call')) {
218
+ const name = field(payload, 'name', 'tool_name') || 'tool'
219
+ const id = field(payload, 'call_id', 'id') || `tool-${ordinal}`
220
+ result.push({ type: 'call', step: stepFromInput(id, name, payload.input ?? payload.arguments ?? payload.args, 'running') })
221
+ }
222
+ if (event.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
223
+ const id = field(payload, 'call_id', 'id')
224
+ if (id) result.push({ type: 'done', id })
225
+ }
226
+ if (event.type === 'event_msg' && type === 'web_search_end') {
227
+ result.push({ type: 'call', step: stepFromInput(field(payload, 'id', 'call_id') || `search-${ordinal}`, 'Search', payload, 'done') })
228
+ }
229
+ return result
230
+ }
231
+
232
+ function applyProjectJsonlEvent(value: unknown, ordinal: number): readonly TraceEvent[] {
233
+ const entry = object(value)
234
+ const message = object(entry?.message)
235
+ if (!entry || !message) return []
236
+ const result: TraceEvent[] = []
237
+ if (entry.type === 'user' && message.role === 'user' && isPrompt(message.content))
238
+ result.push({ type: 'boundary', ids: identities(entry, message), at: at(entry) ?? at(message) })
239
+ if (entry.type === 'assistant' && message.role === 'assistant') for (const blockValue of items(message.content)) {
240
+ const block = object(blockValue)
241
+ if (block?.type === 'text') {
242
+ const note = displayableNote(block.text)
243
+ if (note) result.push({ type: 'note', text: note })
244
+ }
245
+ if (block?.type === 'tool_use') {
246
+ const id = field(block, 'id') || `tool-${ordinal}`
247
+ result.push({ type: 'call', step: stepFromInput(id, field(block, 'name') || 'tool', block.input, 'running') })
248
+ }
249
+ }
250
+ if (entry.type === 'user' && message.role === 'user') for (const blockValue of items(message.content)) {
251
+ const block = object(blockValue)
252
+ if (block?.type === 'tool_result') {
253
+ const id = field(block, 'tool_use_id')
254
+ if (id) result.push({ type: 'done', id })
255
+ }
256
+ }
257
+ return result
258
+ }
259
+
260
+ function applySessionJsonlEvent(value: unknown, ordinal: number): readonly TraceEvent[] {
261
+ const entry = object(value)
262
+ const message = object(entry?.message)
263
+ if (!entry || entry.type !== 'message' || !message) return []
264
+ const result: TraceEvent[] = []
265
+ if (message.role === 'user' && isPrompt(message.content))
266
+ result.push({ type: 'boundary', ids: identities(entry, message), at: at(entry) ?? at(message) })
267
+ if (message.role === 'assistant') for (const blockValue of items(message.content)) {
268
+ const block = object(blockValue)
269
+ if (block?.type === 'text') {
270
+ const note = displayableNote(block.text)
271
+ if (note) result.push({ type: 'note', text: note })
272
+ }
273
+ if (block?.type === 'toolCall') {
274
+ const id = field(block, 'id') || `tool-${ordinal}`
275
+ result.push({ type: 'call', step: stepFromInput(id, field(block, 'name') || 'tool', block.arguments, 'running') })
276
+ }
277
+ }
278
+ if (message.role === 'toolResult') {
279
+ const id = field(message, 'toolCallId')
280
+ if (id) result.push({ type: 'done', id })
281
+ }
282
+ return result
283
+ }
284
+
285
+ function completeLines(buffer: Buffer): { lines: Buffer[]; remainder: Buffer } {
286
+ const lines: Buffer[] = []
287
+ let start = 0
288
+ for (let index = 0; index < buffer.length; index++) {
289
+ if (buffer[index] !== 10) continue
290
+ if (index > start) lines.push(buffer.subarray(start, index))
291
+ start = index + 1
292
+ }
293
+ return { lines, remainder: buffer.subarray(start) }
294
+ }
295
+
296
+ function readFrom(path: string, start: number): Buffer | null {
297
+ let fd: number | null = null
298
+ try {
299
+ const size = statSync(path).size
300
+ if (size <= start) return Buffer.alloc(0)
301
+ const chunk = Buffer.allocUnsafe(size - start)
302
+ fd = openSync(path, 'r')
303
+ readSync(fd, chunk, 0, chunk.length, start)
304
+ return chunk
305
+ } catch { return null }
306
+ finally { if (fd !== null) closeSync(fd) }
307
+ }
308
+
309
+ function readIncremental(path: string | null, apply: ApplyEvent, turn: ExecutionTurn | null): ExecutionTrace {
310
+ if (!path) return emptyTrace(turn)
311
+ let size: number
312
+ try { size = statSync(path).size } catch { return emptyTrace(turn) }
313
+ const prior = cache.get(path)
314
+ const trace = prior && prior.size <= size
315
+ ? { ...prior, events: [...prior.events] }
316
+ : { size: 0, remainder: Buffer.alloc(0), events: [] as TraceEvent[] }
317
+ const appended = readFrom(path, trace.size)
318
+ if (appended === null) return emptyTrace(turn)
319
+ const { lines, remainder } = completeLines(Buffer.concat([trace.remainder, appended]))
320
+ for (const [index, line] of lines.entries()) {
321
+ try { trace.events.push(...apply(JSON.parse(line.toString('utf8')), trace.size + index)) } catch { /* malformed native data is ignored */ }
322
+ }
323
+ trace.size = size
324
+ trace.remainder = remainder
325
+ cache.set(path, trace)
326
+ return project(trace.events, String(size), turn)
327
+ }
328
+
329
+ const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
330
+
331
+ export function codexRolloutPath(threadId: string, root = codexSessionsDir()): string | null {
332
+ for (const year of children(root)) for (const month of children(join(root, year))) for (const day of children(join(root, year, month))) {
333
+ const dir = join(root, year, month, day)
334
+ const file = children(dir).find((name) => name.includes(threadId))
335
+ if (file) return join(dir, file)
336
+ }
337
+ return null
338
+ }
339
+
340
+ export function readCodexExecutionTrace(threadId: string, turn: ExecutionTurn | null, root = codexSessionsDir()): ExecutionTrace {
341
+ return readIncremental(codexRolloutPath(threadId, root), applyRolloutEvent, turn)
342
+ }
343
+
344
+ const projectTranscriptRoot = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects')
345
+
346
+ function projectJsonlPath(threadId: string, root = projectTranscriptRoot()): string | null {
347
+ for (const project of children(root)) {
348
+ const path = join(root, project, `${threadId}.jsonl`)
349
+ try { if (statSync(path).isFile()) return path } catch { /* try next */ }
350
+ }
351
+ return null
352
+ }
353
+
354
+ export function readProjectJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root = projectTranscriptRoot()): ExecutionTrace {
355
+ return readIncremental(projectJsonlPath(threadId, root), applyProjectJsonlEvent, turn)
356
+ }
357
+
358
+ const sessionJsonlRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions')
359
+
360
+ function sessionJsonlPath(threadId: string, root = sessionJsonlRoot()): string | null {
361
+ const key = `${root}:${threadId}`
362
+ const cached = sessionJsonlPaths.get(key)
363
+ if (cached) {
364
+ try { if (statSync(cached).isFile()) return cached } catch { sessionJsonlPaths.delete(key) }
365
+ }
366
+ for (const directory of children(root)) for (const file of children(join(root, directory))) {
367
+ if (!file.endsWith('.jsonl')) continue
368
+ const path = join(root, directory, file)
369
+ try {
370
+ const header = object(JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]))
371
+ if (header?.type === 'session' && header.id === threadId) {
372
+ sessionJsonlPaths.set(key, path)
373
+ return path
374
+ }
375
+ } catch { /* unreadable entries are not a match */ }
376
+ }
377
+ return null
378
+ }
379
+
380
+ export function readSessionJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root = sessionJsonlRoot()): ExecutionTrace {
381
+ return readIncremental(sessionJsonlPath(threadId, root), applySessionJsonlEvent, turn)
382
+ }
383
+
384
+ const localStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
385
+ || join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode')
386
+
387
+ function readLocalExport(threadId: string): string {
388
+ return execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId, '--sanitize'], {
389
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
390
+ })
391
+ }
392
+
393
+ function localStoreRevision(root: string): string | null {
394
+ try {
395
+ const database = statSync(join(root, 'opencode.db'))
396
+ let writeAheadLog = '0:0'
397
+ try {
398
+ const stat = statSync(join(root, 'opencode.db-wal'))
399
+ writeAheadLog = `${stat.size}:${Math.floor(stat.mtimeMs)}`
400
+ } catch { /* a checkpointed database has no separate write-ahead log */ }
401
+ return `${database.size}:${Math.floor(database.mtimeMs)}:${writeAheadLog}`
402
+ } catch { return null }
403
+ }
404
+
405
+ function localStoreEvents(value: unknown): TraceEvent[] {
406
+ const events: TraceEvent[] = []
407
+ for (const messageValue of items(object(value)?.messages)) {
408
+ const message = object(messageValue)
409
+ const info = object(message?.info)
410
+ if (!message || !info) continue
411
+ if (info.role === 'user') events.push({ type: 'boundary', ids: identities(info, message), at: at(object(info.time) || info) })
412
+ if (info.role !== 'assistant') continue
413
+ for (const partValue of items(message.parts)) {
414
+ const part = object(partValue)
415
+ if (part?.type === 'text') {
416
+ const note = displayableNote(part.text)
417
+ if (note) events.push({ type: 'note', text: note })
418
+ }
419
+ if (part?.type === 'tool') {
420
+ const state = object(part.state)
421
+ const status = field(state || {}, 'status')?.toLowerCase() || ''
422
+ const id = field(part, 'callID', 'id') || `tool-${events.length}`
423
+ events.push({ type: 'call', step: stepFromInput(id, field(part, 'tool') || 'tool', state?.input, /completed|error|cancelled/.test(status) ? 'done' : 'running') })
424
+ }
425
+ }
426
+ }
427
+ return events
428
+ }
429
+
430
+ export function readLocalStoreExecutionTrace(threadId: string, turn: ExecutionTurn | null, root = localStoreRoot(), load: ExportLoader = readLocalExport): ExecutionTrace {
431
+ const revision = localStoreRevision(root)
432
+ if (!revision) return emptyTrace(turn)
433
+ const key = `${root}:${threadId}`
434
+ let cached = localStoreCache.get(key)
435
+ if (!cached || cached.revision !== revision) {
436
+ try {
437
+ cached = { revision, events: localStoreEvents(JSON.parse(load(threadId))) }
438
+ localStoreCache.set(key, cached)
439
+ } catch { return emptyTrace(turn, revision) }
440
+ }
441
+ return project(cached.events, revision, turn)
442
+ }
443
+
444
+ export const noExecutionTrace = (_threadId: string, _turn: ExecutionTurn | null): ExecutionTrace | null => null
@@ -0,0 +1,22 @@
1
+ import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
2
+
3
+ export function writeFileIfChanged(path: string, content: string | Uint8Array): boolean {
4
+ const next = Buffer.from(content)
5
+ try {
6
+ if (readFileSync(path).equals(next)) return false
7
+ } catch (error: any) {
8
+ if (error?.code !== 'ENOENT') throw error
9
+ }
10
+ writeFileSync(path, content)
11
+ return true
12
+ }
13
+
14
+ export function copyFileIfChanged(source: string, target: string): boolean {
15
+ try {
16
+ if (readFileSync(target).equals(readFileSync(source))) return false
17
+ } catch (error: any) {
18
+ if (error?.code !== 'ENOENT') throw error
19
+ }
20
+ copyFileSync(source, target)
21
+ return true
22
+ }
@@ -30,7 +30,7 @@ import { installConnectionReaper } from './reaper.js'
30
30
  import { spexcodeHome, encodeProject } from './layout.js'
31
31
  import { readEndpointRecord } from './host.js'
32
32
  import { readGatewayIdentity, type ResolvedIdentity } from './project-identity.js'
33
- import { proxyHttp } from './gateway.js'
33
+ import { proxyHttp, proxySessionWeb, proxySessionWebUpgrade } from './gateway.js'
34
34
 
35
35
  export type HubProject = { id: string; identity: ResolvedIdentity; url: string; port: number; gated: boolean }
36
36
  // @@@ extension seam - the hub stays the ONE routing+authorization server; a host-level caller
@@ -58,7 +58,7 @@ export type HubOpts = { port: number; host?: string; tls?: { cert: string; key:
58
58
  // honored — the hub proxies into this machine's trust boundary, never out to an arbitrary host a crafted
59
59
  // record names.
60
60
 
61
- function upstreamOf(id: string): { url: string; port: number; identity: ResolvedIdentity } | null {
61
+ function upstreamOf(id: string): { url: string; port: number; root: string; identity: ResolvedIdentity } | null {
62
62
  const rec = readEndpointRecord(join(spexcodeHome(), 'projects', id, 'backend.json'))
63
63
  if (!rec || encodeProject(rec.root) !== id) return null
64
64
  let u: URL
@@ -69,7 +69,7 @@ function upstreamOf(id: string): { url: string; port: number; identity: Resolved
69
69
  }
70
70
  const port = Number(u.port || 80)
71
71
  if (!Number.isInteger(port) || port <= 0) return null
72
- return { url: rec.url, port, identity: rec.identity }
72
+ return { url: rec.url, port, root: rec.root, identity: rec.identity }
73
73
  }
74
74
 
75
75
  // a projectId arrives as ONE url path segment; reject anything that could re-shape the registry path.
@@ -257,14 +257,14 @@ export function startHubGateway(opts: HubOpts): http.Server {
257
257
  return sendHtml(res, 200, loginPage(false, { action: `${base}/login`, heading: 'Project access', sub: `Enter the password for this project.` }))
258
258
  }
259
259
  if (sub === '/logout') return redirect(res, `${base}/login`, clearCookie(projectCookieName(port, id)))
260
- // browser navigation into the scoped page (`/p/<id>/`, any non-api sub) is content-negotiated
260
+ // browser navigation into the scoped page (`/p/<id>/`, any non-api/non-web sub) is content-negotiated
261
261
  // exactly like GET /projects, and — like the fallback it rides — PRE-authorization: the shell is
262
262
  // code, not data ([[projects-hub]]'s one credential card renders in-app off the scoped api's 401,
263
263
  // and a direct guest must reach that card, not a dead-end redirect). With a host fallback mounted,
264
- // an explicit text/html GET serves the SPA shell (its root-absolute assets resolve outside /p onto
265
- // the same fallback); every api/SSE/health fetch (json, */*, event-stream) and the WS upgrade keep
266
- // the auth gate and proxy to the backend untouched.
267
- if (req.method === 'GET' && !sub.startsWith('/api') && ext.fallback && (req.headers.accept ?? '').includes('text/html')) {
264
+ // an explicit text/html GET outside /api and /web serves the SPA shell (its root-absolute assets
265
+ // resolve outside /p onto the same fallback); API/SSE/health fetches, posted-web frames, and WS
266
+ // upgrades keep the auth gate and proxy to their backend untouched.
267
+ if (req.method === 'GET' && !sub.startsWith('/api') && !sub.startsWith('/web/') && ext.fallback && (req.headers.accept ?? '').includes('text/html')) {
268
268
  return ext.fallback(req, res, '/')
269
269
  }
270
270
  const d = authorize(store, { kind: 'project', projectId: id }, cookies, remote, port)
@@ -272,6 +272,7 @@ export function startHubGateway(opts: HubOpts): http.Server {
272
272
  if (sub.startsWith('/api')) return sendJson(res, 401, { error: 'authentication required', login: `${base}/login` })
273
273
  return redirect(res, `${base}/login`)
274
274
  }
275
+ if (proxySessionWeb(req, res, sub + query, up.root)) return
275
276
  return proxyTo(req, res, up.port, sub + query)
276
277
  }
277
278
 
@@ -309,6 +310,7 @@ export function startHubGateway(opts: HubOpts): http.Server {
309
310
  const d = authorize(store, { kind: 'project', projectId: id }, req.headers.cookie, req.socket.remoteAddress, port)
310
311
  if (!d.ok) return socket.destroy()
311
312
  const sub = (pm[2] || '/') + (q >= 0 ? rawUrl.slice(q) : '')
313
+ if (proxySessionWebUpgrade(req, socket, head, sub, up.root)) return
312
314
  const upstream = net.connect(up.port, '127.0.0.1', () => {
313
315
  upstream.write(`${req.method} ${sub} HTTP/1.1\r\n` + filteredRawHeaders(req))
314
316
  if (head && head.length) upstream.write(head)