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
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { listSessions } from './sessions.js'
3
3
  import { getBoard, getBoardForForgeRevision } from './graphCache.js'
4
- import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
4
+ import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
5
5
  import { evalTimeline } from '../../spec-eval/src/evaltab.js'
6
6
  import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
7
7
  import { issueStores as issueStoresForReview } from './issues.js'
@@ -42,6 +42,9 @@ export type PagedReview<T extends ReviewItem = ReviewItem> = {
42
42
 
43
43
  export type EvalDetailReview = {
44
44
  scope: string | null
45
+ requestedScope: string | null
46
+ scopeFallback: 'trunk' | null
47
+ availability: 'measured' | 'unmeasured' | 'missing'
45
48
  selected: ReviewItem | null
46
49
  history: ReviewItem[]
47
50
  neighbors: {
@@ -56,6 +59,16 @@ export type EvalDetailReview = {
56
59
  evalRevision?: SessionEvals['evalRevision']
57
60
  }
58
61
 
62
+ type EvalDetailMetadata = {
63
+ scope?: string | null
64
+ requestedScope?: string | null
65
+ scopeFallback?: 'trunk' | null
66
+ summary?: SessionEvals['summary']
67
+ evalRevision?: SessionEvals['evalRevision']
68
+ // the scope's whole measured sequence, when `items` deliberately holds only the rendered window
69
+ sequence?: { node: string; scenario: string }[]
70
+ }
71
+
59
72
  const revisionOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
60
73
 
61
74
  export function reviewPageNumber(value: unknown): number {
@@ -113,7 +126,7 @@ export function paginateReview<T extends ReviewItem>(
113
126
  pageCount,
114
127
  prev: page > 1 ? page - 1 : null,
115
128
  next: page < pageCount || overflow ? page + 1 : null,
116
- revision: revisionOf(revisionInputs),
129
+ revision: revisionOf({ page, source: revisionInputs }),
117
130
  ...responseModel(model),
118
131
  }
119
132
  }
@@ -217,15 +230,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
217
230
 
218
231
  const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
219
232
 
220
- function evalNeighbor(item: any): EvalNeighbor {
233
+ function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
221
234
  return {
222
235
  node: String(item.node),
223
236
  scenario: String(item.scenario),
224
- state: String(item.state ?? evalReviewState(item)),
237
+ state: stateOf(item),
225
238
  }
226
239
  }
227
240
 
228
- export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario: string, want = 5) {
241
+ // `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
242
+ // was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
243
+ // states cover the selected row and its window — which is exactly why the state is looked up rather than
244
+ // carried: a row this response does not render contributes its POSITION and nothing else.
245
+ export function boundedEvalNeighbors(
246
+ sequence: { node: string; scenario: string }[],
247
+ node: string,
248
+ scenario: string,
249
+ stateOf: (row: { node: string; scenario: string }) => string,
250
+ want = 5,
251
+ ) {
252
+ const items = sequence
229
253
  const key = `${node}\0${scenario}`
230
254
  const index = items.findIndex((item) => evalItemKey(item) === key)
231
255
  if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
@@ -235,8 +259,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
235
259
  const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
236
260
  const prevN = Math.min(before, take - nextN)
237
261
  return {
238
- prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
239
- next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
262
+ prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
263
+ next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
240
264
  total: items.length,
241
265
  index,
242
266
  order: 'default' as const,
@@ -248,59 +272,102 @@ export function projectEvalDetail(
248
272
  historySource: ReviewItem[],
249
273
  node: string,
250
274
  scenario: string,
251
- metadata: { scope?: string | null; summary?: SessionEvals['summary']; evalRevision?: SessionEvals['evalRevision'] } = {},
275
+ metadata: EvalDetailMetadata = {},
252
276
  ): EvalDetailReview {
253
277
  const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
254
278
  const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
279
+ const availability = selected
280
+ ? 'measured'
281
+ : items.some((item: any) => evalItemKey(item) === `${node}\0${scenario}` && item.filterKind === EVAL_FILTER_KIND.BLIND)
282
+ ? 'unmeasured'
283
+ : 'missing'
255
284
  const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
256
- const neighbors = boundedEvalNeighbors(results, node, scenario)
285
+ const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
286
+ const sequence = metadata.sequence
287
+ ?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
288
+ const neighbors = boundedEvalNeighbors(sequence, node, scenario,
289
+ (row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
257
290
  const scope = metadata.scope ?? null
291
+ const requestedScope = metadata.requestedScope ?? scope
292
+ const scopeFallback = metadata.scopeFallback ?? null
258
293
  return {
259
294
  scope,
295
+ requestedScope,
296
+ scopeFallback,
297
+ availability,
260
298
  selected,
261
299
  history,
262
300
  neighbors,
263
- revision: revisionOf({ scope, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
301
+ revision: revisionOf({ scope, requestedScope, scopeFallback, availability, selected, history, neighbors, summary: metadata.summary, evalRevision: metadata.evalRevision }),
264
302
  ...(metadata.summary ? { summary: metadata.summary } : {}),
265
303
  ...(metadata.evalRevision ? { evalRevision: metadata.evalRevision } : {}),
266
304
  }
267
305
  }
268
306
 
269
- export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
307
+ // the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
308
+ // and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
309
+ // actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
310
+ export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
311
+ return order.filter((row) => row.ts)
312
+ .sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
313
+ || a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
314
+ .map((row) => ({ node: row.node, scenario: row.scenario }))
315
+ }
316
+
317
+ // the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
318
+ // contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
319
+ export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
320
+ const sequence = measuredSequence(order)
321
+ const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
322
+ if (index < 0) return [node]
323
+ return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
324
+ }
325
+
326
+ async function trunkEvalDetailReview(node: string, scenario: string, metadata: EvalDetailMetadata = {}): Promise<EvalDetailReview> {
327
+ await getBoard()
328
+ const snapshot = readReviewSnapshot()
329
+ const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
330
+ return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario, metadata)
331
+ }
332
+
333
+ export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview> {
270
334
  if (scope) {
271
- const model = await buildSessionEvals(scope)
272
- if (!model) return null
335
+ // A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
336
+ // So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
337
+ // a build that finds a full cached model ignores the pick and answers from it instead.
338
+ const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
339
+ if (!model) return trunkEvalDetailReview(node, scenario, { requestedScope: scope, scopeFallback: 'trunk' })
273
340
  const sourceNode = model.nodes.find((candidate) => candidate.id === node)
274
341
  return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
275
342
  scope,
343
+ requestedScope: scope,
276
344
  summary: model.summary,
277
345
  evalRevision: model.evalRevision,
346
+ ...(model.order ? { sequence: measuredSequence(model.order) } : {}),
278
347
  })
279
348
  }
280
- await getBoard()
281
- const snapshot = readReviewSnapshot()
282
- const sourceNode = snapshot.evalNodes.find((candidate) => candidate.id === node)
283
- return projectEvalDetail(trunkEvalReviewItems(snapshot.evalNodes), sourceNode?.readings ?? [], node, scenario)
349
+ return trunkEvalDetailReview(node, scenario)
284
350
  }
285
351
 
286
- async function timelineEvalReview(text: string, requestedPage: unknown) {
287
- const node = readToken(text, 'node')
288
- if (!node) return null
289
- const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
290
- const measured = new Set(timeline.readings.map((reading) => reading.scenario))
291
- const items = [
292
- ...timeline.scenarios.filter((scenario) => !measured.has(scenario.name)).map((scenario) => ({
352
+ export function timelineEvalReviewItems(timeline: Awaited<ReturnType<typeof evalTimeline>>, node: string): ReviewItem[] {
353
+ const declared = new Set(timeline.scenarios.map((scenario) => scenario.name))
354
+ const latest = new Map<string, any>()
355
+ for (const reading of timeline.readings) {
356
+ if (declared.has(reading.scenario) && !latest.has(reading.scenario)) latest.set(reading.scenario, reading)
357
+ }
358
+ return [
359
+ ...timeline.scenarios.filter((scenario) => !latest.has(scenario.name)).map((scenario) => ({
293
360
  ...scenario,
294
361
  scenario: scenario.name,
295
362
  node,
296
363
  filterKind: EVAL_FILTER_KIND.UNMEASURED,
297
364
  })),
298
- ...timeline.readings.map((reading, index) => ({
365
+ ...[...latest.values()].map((reading) => ({
299
366
  ...reading,
300
367
  state: evalReviewState(reading),
301
368
  node,
302
369
  filterKind: EVAL_FILTER_KIND.RESULT,
303
- filterKey: `${EVAL_FILTER_KIND.RESULT}:${index}`,
370
+ filterKey: `${EVAL_FILTER_KIND.RESULT}:${reading.scenario}`,
304
371
  })),
305
372
  ...(timeline.dangling ?? []).map((track) => ({
306
373
  ...track,
@@ -309,6 +376,13 @@ async function timelineEvalReview(text: string, requestedPage: unknown) {
309
376
  filterKey: `${EVAL_FILTER_KIND.DANGLING}:${track.threadId}`,
310
377
  })),
311
378
  ]
379
+ }
380
+
381
+ async function timelineEvalReview(text: string, requestedPage: unknown) {
382
+ const node = readToken(text, 'node')
383
+ if (!node) return null
384
+ const [timeline, sessions] = await Promise.all([evalTimeline(node), listSessions()])
385
+ const items = timelineEvalReviewItems(timeline, node)
312
386
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
313
387
  return {
314
388
  scope: null,
@@ -335,14 +409,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
335
409
  const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
336
410
  return {
337
411
  scope,
338
- gates: model.gates,
412
+ gates: [],
339
413
  unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
340
414
  summary: model.summary,
341
415
  evalRevision: model.evalRevision,
342
- impact: model.impact,
343
416
  ...paginateReview(items, filtered.shown, filtered, requestedPage, {
344
- domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
345
- evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
417
+ domain: 'evals', scope, query: text, gates: [], summary: model.summary,
418
+ evalRevision: model.evalRevision, sessions: sessions.map((session) => session.id),
346
419
  }),
347
420
  }
348
421
  }
@@ -0,0 +1,68 @@
1
+ import { streamSSE } from 'hono/streaming'
2
+ import type { Context } from 'hono'
3
+ import { harnessById } from './harness.js'
4
+ import { readAliasedRawRecord } from './layout.js'
5
+ import { currentHumanTurn } from './session-timeline.js'
6
+ import type { ExecutionStep, ExecutionTurn } from './execution-trace.js'
7
+
8
+ export type SessionExecution = Readonly<{
9
+ revision: string
10
+ turnId: string | null
11
+ workingNote: string | null
12
+ steps: readonly ExecutionStep[]
13
+ }>
14
+
15
+ const absentExecution = (turn: ExecutionTurn | null = null): SessionExecution => ({ revision: `turn:${turn?.token ?? '0'}`, turnId: turn?.token ?? null, workingNote: null, steps: [] })
16
+
17
+ // This is deliberately a read projection. Native transcript bytes stay within the adapter, while the API owns
18
+ // only enough information for the frontend's fixed renderer to paint the current execution slice.
19
+ export function readSessionExecution(id: string): SessionExecution | null {
20
+ let record: ReturnType<typeof readAliasedRawRecord>
21
+ try { record = readAliasedRawRecord(id) } catch { return null }
22
+ if (!record?.governed) return null
23
+ const turn = currentHumanTurn(id)
24
+ const threadId = typeof record.harness_session_id === 'string' ? record.harness_session_id : ''
25
+ if (!threadId) return absentExecution(turn)
26
+ try {
27
+ const trace = harnessById(typeof record.harness === 'string' && record.harness ? record.harness : 'claude')
28
+ .executionTrace(threadId, turn)
29
+ return trace || absentExecution(turn)
30
+ } catch {
31
+ // A missing/unreadable native source is an absent transient trace, not a synthetic conversation failure.
32
+ return absentExecution(turn)
33
+ }
34
+ }
35
+
36
+ const TRACE_TICK_MS = 500
37
+ const HEARTBEAT_TICKS = 20
38
+
39
+ export async function sessionExecutionStream(c: Context) {
40
+ const id = c.req.param('id') || ''
41
+ if (!readSessionExecution(id)) return c.json({ error: 'no such session' }, 404)
42
+ return streamSSE(c, async (stream) => {
43
+ let aborted = false
44
+ let lastRevision = ''
45
+ let ticks = 0
46
+ const publish = async (): Promise<boolean> => {
47
+ const execution = readSessionExecution(id)
48
+ if (!execution) return false
49
+ if (execution.revision === lastRevision) return true
50
+ lastRevision = execution.revision
51
+ await stream.writeSSE({ event: 'execution', data: JSON.stringify(execution) })
52
+ return true
53
+ }
54
+
55
+ stream.onAbort(() => { aborted = true })
56
+ try {
57
+ if (!await publish()) return
58
+ while (!aborted) {
59
+ await stream.sleep(TRACE_TICK_MS)
60
+ if (aborted) break
61
+ if (!await publish()) break
62
+ if (++ticks % HEARTBEAT_TICKS === 0) await stream.writeSSE({ event: 'ping', data: 'x' })
63
+ }
64
+ } catch {
65
+ // EventSource reconnects a dropped stream. A native trace read must never take down the session server.
66
+ }
67
+ })
68
+ }
@@ -0,0 +1,121 @@
1
+ import { accessSync, constants, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { basename, isAbsolute, resolve } from 'node:path'
3
+ import { readRecordEntry, sessionArtifactPath } from './layout.js'
4
+
5
+ export class SessionFileError extends Error {
6
+ constructor(readonly status: 400 | 403 | 404 | 500, message: string) {
7
+ super(message)
8
+ this.name = 'SessionFileError'
9
+ }
10
+ }
11
+
12
+ type SessionFileLock = <T>(id: string, body: () => T) => T
13
+
14
+ export const SESSION_FILE_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
15
+ export type SessionFilePreviewKind = 'text' | 'image'
16
+ const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.json', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html', '.xml', '.py', '.go', '.rs', '.java', '.sh', '.sql'])
17
+ const IMAGE_TYPES: Record<string, string> = {
18
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
19
+ }
20
+
21
+ const filesPath = (id: string) => sessionArtifactPath(id, 'files.json')
22
+
23
+ function requireSession(id: string): void {
24
+ const record = readRecordEntry(id)
25
+ if (record.kind === 'absent') throw new SessionFileError(404, `session ${id} does not exist`)
26
+ if (record.kind === 'corrupt') throw new SessionFileError(500, `session ${id} has an unreadable record: ${record.error}`)
27
+ }
28
+
29
+ function readFiles(id: string): string[] {
30
+ const path = filesPath(id)
31
+ let parsed: unknown
32
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
33
+ catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
35
+ throw new SessionFileError(500, `session file list is unreadable: ${path} — ${error instanceof Error ? error.message : String(error)}`)
36
+ }
37
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== 'string' || !isAbsolute(value)))
38
+ throw new SessionFileError(500, `session file list is invalid: ${path}`)
39
+ return [...new Set(parsed)]
40
+ }
41
+
42
+ function writeFiles(id: string, files: string[]): void {
43
+ const path = filesPath(id)
44
+ const tmp = `${path}.${process.pid}.tmp`
45
+ try {
46
+ writeFileSync(tmp, `${JSON.stringify(files, null, 2)}\n`)
47
+ renameSync(tmp, path)
48
+ } finally {
49
+ try { unlinkSync(tmp) } catch { /* rename already consumed the temporary file */ }
50
+ }
51
+ }
52
+
53
+ function currentFile(path: string): void {
54
+ try {
55
+ if (!statSync(path).isFile()) throw new SessionFileError(400, `not a regular file: ${path}`)
56
+ accessSync(path, constants.R_OK)
57
+ } catch (error) {
58
+ if (error instanceof SessionFileError) throw error
59
+ const code = (error as NodeJS.ErrnoException).code
60
+ if (code === 'ENOENT' || code === 'ENOTDIR') throw new SessionFileError(404, `file does not exist: ${path}`)
61
+ throw new SessionFileError(403, `file is not readable: ${path}`)
62
+ }
63
+ }
64
+
65
+ export function listSessionFiles(id: string): string[] {
66
+ requireSession(id)
67
+ return readSessionFiles(id)
68
+ }
69
+
70
+ // Session projections already hold a parsed record; re-checking session.json here would make an in-memory
71
+ // projection depend on the public route's existence guard. The route-facing list keeps that guard above.
72
+ export function readSessionFiles(id: string): string[] {
73
+ return readFiles(id)
74
+ }
75
+
76
+ export function addSessionFile(id: string, input: string, lock: SessionFileLock, cwd = process.cwd()): { path: string; added: boolean } {
77
+ const path = resolve(cwd, input)
78
+ currentFile(path)
79
+ return lock(id, () => {
80
+ requireSession(id)
81
+ const files = readFiles(id)
82
+ if (files.includes(path)) return { path, added: false }
83
+ writeFiles(id, [...files, path])
84
+ return { path, added: true }
85
+ })
86
+ }
87
+
88
+ export function retractSessionFile(id: string, input: string, lock: SessionFileLock, cwd = process.cwd()): { path: string; removed: boolean } {
89
+ const path = resolve(cwd, input)
90
+ return lock(id, () => {
91
+ requireSession(id)
92
+ const files = readFiles(id)
93
+ if (!files.includes(path)) return { path, removed: false }
94
+ writeFiles(id, files.filter((file) => file !== path))
95
+ return { path, removed: true }
96
+ })
97
+ }
98
+
99
+ export function openSessionFile(id: string, path: string): { path: string; name: string; size: number } {
100
+ requireSession(id)
101
+ if (!readFiles(id).includes(path)) throw new SessionFileError(403, 'that path was not posted by this session')
102
+ try {
103
+ const stat = statSync(path)
104
+ if (!stat.isFile()) throw new SessionFileError(404, `file no longer exists: ${path}`)
105
+ accessSync(path, constants.R_OK)
106
+ return { path, name: basename(path) || 'download', size: stat.size }
107
+ } catch (error) {
108
+ if (error instanceof SessionFileError) throw error
109
+ throw new SessionFileError(404, `file no longer exists: ${path}`)
110
+ }
111
+ }
112
+
113
+ export function sessionFilePreviewKind(path: string): { kind: SessionFilePreviewKind; contentType: string } | null {
114
+ const extension = basename(path).toLowerCase().match(/\.[^.]+$/)?.[0]
115
+ if (!extension) return null
116
+ if (TEXT_EXTENSIONS.has(extension)) return { kind: 'text', contentType: 'text/plain; charset=utf-8' }
117
+ const contentType = IMAGE_TYPES[extension]
118
+ return contentType ? { kind: 'image', contentType } : null
119
+ }
120
+
121
+ export const sessionFilesPath = (id: string) => filesPath(id)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { sessionStoreDir } from './layout.js'
3
3
  import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
4
4
  import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
5
- import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
5
+ import { sessionTitle, type DisplayStatus, type Session } from './sessions.js'
6
6
 
7
7
  // @@@ session-follow - supervision is FOLLOWING a log past a cursor, never polling a derived board. One tick
8
8
  // costs ONE stat per target: if timeline.ndjson has not grown, nothing is opened and nothing is parsed. No
@@ -28,7 +28,7 @@ const trunc = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(
28
28
  export function sessionEvent(s: Session): string {
29
29
  const note = s.note ? ` — note: ${s.note}` : ''
30
30
  const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
31
- return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
31
+ return `[spex] ${s.status} · ${sessionTitle(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
32
32
  }
33
33
  // @@@ launchEvent - a session's FIRST sighting, emitted once per id whatever its status, so the stream is a
34
34
  // complete lifecycle feed: launched → [transitions] → closed. A launch's own first line is `active` (not
@@ -36,7 +36,7 @@ export function sessionEvent(s: Session): string {
36
36
  export function launchEvent(s: Session): string {
37
37
  const note = s.note ? ` — note: ${s.note}` : ''
38
38
  const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
39
- return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
39
+ return `[spex] launched · ${sessionTitle(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
40
40
  }
41
41
 
42
42
  // `targets` is re-read every tick so a BROAD follow picks up sessions that launch while it runs; an explicit
@@ -131,7 +131,7 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
131
131
  const e = slice.events[k]
132
132
  if (e.kind === 'sent') {
133
133
  const s = row?.(id, f.prev ?? 'unknown', null)
134
- if (s) emit(`${tag}[spex] message · ${sessionLabel(s)} — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${id}]`)
134
+ if (s) emit(`${tag}[spex] message · ${sessionTitle(s)} — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${id}]`)
135
135
  continue
136
136
  }
137
137
  const st = timelineDisplay(e)
@@ -0,0 +1,33 @@
1
+ import { apiBaseInfo, reparentSessionRecords, type SessionReparentResult } from './sessions.js'
2
+ import { BackendError, backendConnectionRefused, clientReparent } from './client.js'
3
+
4
+ export class SessionReparentRequestError extends Error {
5
+ constructor(message: string) {
6
+ super(message)
7
+ this.name = 'SessionReparentRequestError'
8
+ }
9
+ }
10
+
11
+ export function parseReparentRequest(body: unknown): { children: string[]; parent: string } {
12
+ if (!body || typeof body !== 'object') throw new SessionReparentRequestError('reparent body must be JSON')
13
+ const raw = body as { children?: unknown; parent?: unknown }
14
+ if (!Array.isArray(raw.children) || !raw.children.length || raw.children.some((id) => typeof id !== 'string' || !id.trim()))
15
+ throw new SessionReparentRequestError('reparent children must be one or more session ids')
16
+ if (typeof raw.parent !== 'string' || !raw.parent.trim()) throw new SessionReparentRequestError('reparent parent must be a session id')
17
+ return { children: raw.children.map((id) => id.trim()), parent: raw.parent.trim() }
18
+ }
19
+
20
+ export async function reparentRequest(body: unknown): Promise<SessionReparentResult> {
21
+ const request = parseReparentRequest(body)
22
+ return reparentSessionRecords(request.children, request.parent)
23
+ }
24
+
25
+ export async function reparentSessions(children: string[], parent: string): Promise<SessionReparentResult> {
26
+ try {
27
+ return await clientReparent(children, parent)
28
+ } catch (error) {
29
+ if (!(error instanceof BackendError) || (await apiBaseInfo()).source === 'flag' || !backendConnectionRefused(error)) throw error
30
+ console.error('spex: no backend reachable — reparenting in-process under the session record locks')
31
+ return reparentSessionRecords(children, parent)
32
+ }
33
+ }