spexcode 0.6.1 → 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.
- package/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
|
@@ -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
|
|
@@ -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
|
|
265
|
-
// the same fallback);
|
|
266
|
-
// the auth gate and proxy to
|
|
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)
|