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.
Files changed (72) 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 +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -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
+ }
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync } from 'node:fs'
2
+ import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync, readdirSync, openSync, closeSync, readSync } from 'node:fs'
3
+ import { basename, join } from 'node:path'
3
4
  import { sessionStoreDir, sessionArtifactPath, readAliasedRawRecord } from './layout.js'
4
5
  import type { Lifecycle, Proposal } from './sessions.js'
6
+ import type { ExecutionTurn } from './execution-trace.js'
5
7
 
6
8
  // @@@ session-timeline - the session's append-only log in its global store dir: every authored-lifecycle
7
9
  // transition (status + proposal + the FULL note text) and every message addressed to it. It is what a
@@ -17,11 +19,97 @@ export type TimelineEvent =
17
19
  | { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
18
20
  | { ts: string; kind: 'sent'; mid: string; text: string; from: string | null; replyVia?: 'note' }
19
21
 
22
+ export type SentDispatchReceipt = {
23
+ operation: 'merge'
24
+ requestDigest: string
25
+ payloadHash: string
26
+ delivery?: { text: string; from: string | null }
27
+ }
28
+ type DispatchSettlement = { ts: string; kind: 'dispatch-settled'; operation: 'merge'; requestDigest: string; mid: string }
29
+ type StoredTimelineEvent =
30
+ | Extract<TimelineEvent, { kind: 'status' }>
31
+ | (Extract<TimelineEvent, { kind: 'sent' }> & { dispatchReceipt?: SentDispatchReceipt })
32
+ | DispatchSettlement
33
+
20
34
  const timelinePath = (id: string): string => sessionArtifactPath(id, 'timeline.ndjson')
35
+ const segmentsDir = (id: string): string => sessionArtifactPath(id, 'timeline')
36
+ const SEGMENT = /^(\d+)\.ndjson$/
37
+ const SEGMENT_NAME_WIDTH = 12
38
+ const TAIL_BLOCK_BYTES = 64 * 1024
39
+
40
+ // One logical timeline is legacy timeline.ndjson followed by immutable numbered segments. The directory
41
+ // listing is its only index: numbering is append order, so there is no mutable manifest to repair.
42
+ function segmentFiles(id: string): string[] {
43
+ try {
44
+ const dir = segmentsDir(id)
45
+ const names = readdirSync(dir).filter((name) => SEGMENT.test(name)).sort((a, b) => {
46
+ const an = BigInt(SEGMENT.exec(a)![1]), bn = BigInt(SEGMENT.exec(b)![1])
47
+ return an < bn ? -1 : an > bn ? 1 : 0
48
+ })
49
+ return names.map((name) => join(dir, name))
50
+ } catch { /* no numbered segments yet */ }
51
+ return []
52
+ }
21
53
 
22
- function append(id: string, ev: TimelineEvent): void {
54
+ function timelineFiles(id: string): string[] {
55
+ const files: string[] = []
56
+ const legacy = timelinePath(id)
57
+ if (existsSync(legacy)) files.push(legacy)
58
+ files.push(...segmentFiles(id))
59
+ return files
60
+ }
61
+
62
+ const segmentLimit = (): number => {
63
+ const configured = Number(process.env.SPEXCODE_TIMELINE_SEGMENT_BYTES)
64
+ return Number.isFinite(configured) ? Math.max(1024, Math.floor(configured)) : 4 * 1024 * 1024
65
+ }
66
+
67
+ function activeSegment(id: string, bytes: number): string {
68
+ const dir = segmentsDir(id)
69
+ mkdirSync(dir, { recursive: true })
70
+ const segments = segmentFiles(id)
71
+ const current = segments.at(-1)
72
+ if (!current) return join(dir, `${String(1).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`)
73
+ try {
74
+ if (statSync(current).size === 0 || statSync(current).size + bytes <= segmentLimit()) return current
75
+ } catch { /* a vanished active segment is recreated under its next number */ }
76
+ const n = BigInt(SEGMENT.exec(basename(current))![1]) + 1n
77
+ return join(dir, `${String(n).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`)
78
+ }
79
+
80
+ function append(id: string, ev: StoredTimelineEvent): void {
23
81
  mkdirSync(sessionStoreDir(id), { recursive: true })
24
- appendFileSync(timelinePath(id), JSON.stringify(ev) + '\n')
82
+ const line = JSON.stringify(ev) + '\n'
83
+ appendFileSync(activeSegment(id, Buffer.byteLength(line)), line)
84
+ }
85
+
86
+ function parseLines(lines: string[]): StoredTimelineEvent[] {
87
+ return lines.map((l) => {
88
+ try { return JSON.parse(l) as StoredTimelineEvent } catch { return null }
89
+ }).filter((e): e is StoredTimelineEvent => e != null && (e.kind === 'status' || e.kind === 'sent' || e.kind === 'dispatch-settled'))
90
+ }
91
+
92
+ function tailPublicEvents(path: string, limit: number): Exclude<StoredTimelineEvent, DispatchSettlement>[] {
93
+ let fd: number | null = null
94
+ try {
95
+ const size = statSync(path).size
96
+ fd = openSync(path, 'r')
97
+ let start = size
98
+ let text = ''
99
+ while (start > 0) {
100
+ const next = Math.max(0, start - TAIL_BLOCK_BYTES)
101
+ const buf = Buffer.alloc(start - next)
102
+ readSync(fd, buf, 0, buf.length, next)
103
+ text = buf.toString('utf8') + text
104
+ const publicCount = parseLines(text.split('\n').filter(Boolean)).filter((event) => event.kind !== 'dispatch-settled').length
105
+ if (publicCount >= limit || next === 0) break
106
+ start = next
107
+ }
108
+ return parseLines(text.split('\n').filter(Boolean))
109
+ .filter((event): event is Exclude<StoredTimelineEvent, DispatchSettlement> => event.kind !== 'dispatch-settled')
110
+ .slice(-limit)
111
+ } catch { return [] }
112
+ finally { if (fd !== null) closeSync(fd) }
25
113
  }
26
114
 
27
115
  // Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
@@ -37,28 +125,70 @@ export function recordStatus(id: string, status: Lifecycle, proposal: Proposal |
37
125
  // the caller reports the throw rather than a false success. `text` is the message BEFORE any mechanism insert
38
126
  // (hints are transport, not conversation); `replyVia` is the effective channel the prompt seam chose. Returns
39
127
  // the new line's `mid`, which a best-effort poke carries.
40
- export function appendSent(id: string, text: string, from: string | null, replyVia?: 'note'): { mid: string } {
128
+ export function appendSent(id: string, text: string, from: string | null, replyVia?: 'note', dispatchReceipt?: SentDispatchReceipt): { mid: string } {
41
129
  const mid = randomUUID()
42
- append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}) })
130
+ append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}), ...(dispatchReceipt ? { dispatchReceipt } : {}) })
43
131
  return { mid }
44
132
  }
45
133
 
134
+ export type SentDispatchState = {
135
+ mid: string
136
+ payloadHash: string
137
+ delivery: SentDispatchReceipt['delivery'] | null
138
+ delivered: boolean
139
+ }
140
+
141
+ export function sentDispatchReceipt(id: string, operation: SentDispatchReceipt['operation'], requestDigest: string): SentDispatchState | null {
142
+ let found: Omit<SentDispatchState, 'delivered'> | null = null
143
+ const settled = new Set<string>()
144
+ for (const path of timelineFiles(id)) {
145
+ for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
146
+ if (event.kind === 'sent' && !found && event.dispatchReceipt?.operation === operation && event.dispatchReceipt.requestDigest === requestDigest) {
147
+ found = { mid: event.mid, payloadHash: event.dispatchReceipt.payloadHash, delivery: event.dispatchReceipt.delivery ?? null }
148
+ } else if (event.kind === 'dispatch-settled' && event.operation === operation && event.requestDigest === requestDigest) {
149
+ settled.add(event.mid)
150
+ }
151
+ }
152
+ }
153
+ return found ? { ...found, delivered: settled.has(found.mid) } : null
154
+ }
155
+
156
+ export function settleSentDispatch(id: string, mid: string): void {
157
+ let receipt: SentDispatchReceipt | null = null
158
+ let settled = false
159
+ for (const path of timelineFiles(id)) {
160
+ for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
161
+ if (event.kind === 'sent' && event.mid === mid && event.dispatchReceipt?.delivery) receipt = event.dispatchReceipt
162
+ if (event.kind === 'dispatch-settled' && event.mid === mid) settled = true
163
+ }
164
+ }
165
+ if (!receipt || settled) return
166
+ append(id, { ts: new Date().toISOString(), kind: 'dispatch-settled', operation: receipt.operation, requestDigest: receipt.requestDigest, mid })
167
+ }
168
+
46
169
  // The unowned read: any process may take it with nothing but filesystem access, and taking it perturbs
47
170
  // nothing. Index = event position, which is what a cursor names ([[session-cursors]]).
48
171
  export function timelineEvents(id: string): TimelineEvent[] {
49
172
  try {
50
- const p = timelinePath(id)
51
- if (!existsSync(p)) return []
52
- return readFileSync(p, 'utf8').split('\n').filter(Boolean).map((l) => {
53
- try { return JSON.parse(l) as TimelineEvent } catch { return null }
54
- }).filter((e): e is TimelineEvent => e != null && (e.kind === 'status' || e.kind === 'sent'))
173
+ return timelineFiles(id).flatMap((path) => parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)))
174
+ .flatMap((stored): TimelineEvent[] => {
175
+ if (stored.kind === 'dispatch-settled') return []
176
+ if (stored.kind === 'status') return [stored]
177
+ const { dispatchReceipt: _receipt, ...event } = stored
178
+ return [event]
179
+ })
55
180
  } catch { return [] }
56
181
  }
57
182
 
58
183
  // the same L0 read taken as CHEAPLY as it can be: a follower ([[session-follow]]) ticks over many logs, so it
59
184
  // stats first and parses only what grew. null = no log yet (a session that has authored nothing).
60
185
  export function timelineStamp(id: string): string | null {
61
- try { const s = statSync(timelinePath(id)); return `${s.size}:${s.mtimeMs}` }
186
+ try {
187
+ const path = timelineFiles(id).at(-1)
188
+ if (!path) return null
189
+ const s = statSync(path)
190
+ return `${path}:${s.size}:${s.mtimeMs}`
191
+ }
62
192
  catch { return null }
63
193
  }
64
194
 
@@ -86,13 +216,32 @@ export function lastHumanSendVia(id: string): 'note' | null {
86
216
  return null
87
217
  }
88
218
 
219
+ // The current human turn is a durable fact from the accepted-message log, not a backend-local generation.
220
+ export function currentHumanTurn(id: string): ExecutionTurn | null {
221
+ const evs = timelineEvents(id)
222
+ for (let i = evs.length - 1; i >= 0; i--) {
223
+ const e = evs[i]
224
+ if (e.kind === 'sent' && e.from == null) return { token: e.mid, acceptedAt: e.ts }
225
+ }
226
+ return null
227
+ }
228
+
89
229
  // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each status
90
230
  // event carrying its composed display word. null = no such session (the route 404s).
91
231
  export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
92
232
  let raw: ReturnType<typeof readAliasedRawRecord>
93
233
  try { raw = readAliasedRawRecord(id) } catch { return null }
94
234
  if (!raw || !raw.governed) return null
95
- const evs = timelineEvents(id)
96
- const tail = evs.slice(Math.max(0, evs.length - Math.max(1, limit)))
97
- return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: timelineDisplay(e) } : e)) }
235
+ const wanted = Math.max(1, limit)
236
+ const tail: Exclude<StoredTimelineEvent, DispatchSettlement>[] = []
237
+ for (const path of timelineFiles(id).reverse()) {
238
+ const remaining = wanted - tail.length
239
+ if (remaining <= 0) break
240
+ tail.unshift(...tailPublicEvents(path, remaining))
241
+ }
242
+ return { events: tail.map((e) => {
243
+ if (e.kind === 'status') return { ...e, display: timelineDisplay(e) }
244
+ const { dispatchReceipt: _receipt, ...event } = e
245
+ return event
246
+ }) }
98
247
  }
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { readRecordEntry, sessionArtifactPath, sessionStoreDir } from './layout.js'
5
+ import { projectRuntimeRoot } from './project-store.js'
6
+
7
+ export class SessionWebError extends Error {
8
+ constructor(readonly status: 400 | 403 | 404 | 500, message: string) {
9
+ super(message)
10
+ this.name = 'SessionWebError'
11
+ }
12
+ }
13
+
14
+ type SessionWebLock = <T>(id: string, body: () => T) => T
15
+ export type SessionWeb = { url: string; key: string }
16
+
17
+ const webPath = (id: string, projectRoot?: string): string => projectRoot
18
+ ? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'web.json')
19
+ : sessionArtifactPath(id, 'web.json')
20
+
21
+ const recordPath = (id: string, projectRoot?: string): string => projectRoot
22
+ ? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'session.json')
23
+ : join(sessionStoreDir(id), 'session.json')
24
+
25
+ function validSessionId(id: string): boolean {
26
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)
27
+ }
28
+
29
+ function requireSession(id: string, projectRoot?: string): void {
30
+ if (!validSessionId(id)) throw new SessionWebError(404, `session ${id} does not exist`)
31
+ if (!projectRoot) {
32
+ const record = readRecordEntry(id)
33
+ if (record.kind === 'absent') throw new SessionWebError(404, `session ${id} does not exist`)
34
+ if (record.kind === 'corrupt') throw new SessionWebError(500, `session ${id} has an unreadable record: ${record.error}`)
35
+ return
36
+ }
37
+ const path = recordPath(id, projectRoot)
38
+ let record: unknown
39
+ try { record = JSON.parse(readFileSync(path, 'utf8')) }
40
+ catch (error) {
41
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new SessionWebError(404, `session ${id} does not exist`)
42
+ throw new SessionWebError(500, `session ${id} has an unreadable record: ${error instanceof Error ? error.message : String(error)}`)
43
+ }
44
+ if (!record || typeof record !== 'object' || (record as { session_id?: unknown }).session_id !== id)
45
+ throw new SessionWebError(500, `session ${id} has an unreadable record`)
46
+ }
47
+
48
+ export function canonicalSessionWebUrl(input: string): string {
49
+ let url: URL
50
+ try { url = new URL(input.trim()) }
51
+ catch { throw new SessionWebError(400, `invalid local web URL: ${input}`) }
52
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
53
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '::1'].includes(host) || !url.port || url.username || url.password) {
54
+ throw new SessionWebError(400, 'web URL must be http://127.0.0.1:<port>/..., http://localhost:<port>/..., or http://[::1]:<port>/...')
55
+ }
56
+ if (url.search || url.hash) throw new SessionWebError(400, 'web URL must not include a query or fragment; publish the service base URL')
57
+ return url.href
58
+ }
59
+
60
+ export function sessionWebKey(url: string): string {
61
+ return createHash('sha256').update(url).digest('base64url')
62
+ }
63
+
64
+ function readWebs(id: string, projectRoot?: string): string[] {
65
+ const path = webPath(id, projectRoot)
66
+ let parsed: unknown
67
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
68
+ catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
70
+ throw new SessionWebError(500, `session web list is unreadable: ${path} — ${error instanceof Error ? error.message : String(error)}`)
71
+ }
72
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== 'string'))
73
+ throw new SessionWebError(500, `session web list is invalid: ${path}`)
74
+ try {
75
+ if (parsed.some((value) => canonicalSessionWebUrl(value) !== value)) throw new Error('not canonical')
76
+ } catch {
77
+ throw new SessionWebError(500, `session web list is invalid: ${path}`)
78
+ }
79
+ return [...new Set(parsed)]
80
+ }
81
+
82
+ function writeWebs(id: string, webs: string[]): void {
83
+ const path = webPath(id)
84
+ const tmp = `${path}.${process.pid}.tmp`
85
+ try {
86
+ writeFileSync(tmp, `${JSON.stringify(webs, null, 2)}\n`)
87
+ renameSync(tmp, path)
88
+ } finally {
89
+ try { unlinkSync(tmp) } catch { /* rename already consumed the temporary file */ }
90
+ }
91
+ }
92
+
93
+ const toWeb = (url: string): SessionWeb => ({ url, key: sessionWebKey(url) })
94
+
95
+ export function listSessionWebs(id: string, projectRoot?: string): SessionWeb[] {
96
+ requireSession(id, projectRoot)
97
+ return readSessionWebs(id, projectRoot)
98
+ }
99
+
100
+ // Session projections already hold a parsed record; re-checking session.json here would make an in-memory
101
+ // projection depend on the public route's existence guard. The route-facing list keeps that guard above.
102
+ export function readSessionWebs(id: string, projectRoot?: string): SessionWeb[] {
103
+ return readWebs(id, projectRoot).map(toWeb)
104
+ }
105
+
106
+ export function addSessionWeb(id: string, input: string, lock: SessionWebLock): { url: string; added: boolean } {
107
+ const url = canonicalSessionWebUrl(input)
108
+ return lock(id, () => {
109
+ requireSession(id)
110
+ const webs = readWebs(id)
111
+ if (webs.includes(url)) return { url, added: false }
112
+ writeWebs(id, [...webs, url])
113
+ return { url, added: true }
114
+ })
115
+ }
116
+
117
+ export function retractSessionWeb(id: string, input: string, lock: SessionWebLock): { url: string; removed: boolean } {
118
+ const url = canonicalSessionWebUrl(input)
119
+ return lock(id, () => {
120
+ requireSession(id)
121
+ const webs = readWebs(id)
122
+ if (!webs.includes(url)) return { url, removed: false }
123
+ writeWebs(id, webs.filter((web) => web !== url))
124
+ return { url, removed: true }
125
+ })
126
+ }
127
+
128
+ export function postedSessionWeb(id: string, key: string, projectRoot?: string): URL {
129
+ requireSession(id, projectRoot)
130
+ const web = readWebs(id, projectRoot).map(toWeb).find((entry) => entry.key === key)
131
+ if (!web) throw new SessionWebError(403, 'that web service was not posted by this session')
132
+ return new URL(web.url)
133
+ }
134
+
135
+ export const sessionWebsPath = (id: string) => webPath(id)