spexcode 0.5.8 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -1,20 +1,21 @@
1
- import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
- import { readSync, writeSync } from 'node:fs'
3
- import { maintenanceBrokerDescriptors, type Capability, type LeaseOwner, type MaintenanceState } from './session-maintenance.js'
4
- import { processStartToken } from './process-identity.js'
1
+ import { existsSync } from 'node:fs'
2
+ import { platform } from 'node:os'
3
+ import { repoRoot } from './git.js'
4
+ import { resourceBudgets, type ResourceReport } from './host-resources.js'
5
+ import { listSessionIds, readPublicRecordEntry } from './layout.js'
6
+ import { cockpitReview, type CockpitReview } from './cockpit.js'
7
+ import { apiBase, apiBaseInfo, assertProjectMatch, fromRaw, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
5
8
 
6
9
  export class BackendError extends Error {
7
10
  constructor(message: string, readonly status?: number) {
8
11
  super(message)
9
- this.name = 'BackendError' // sessions.ts's isBackendDown matches on this name (no runtime import cycle)
12
+ this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
10
13
  }
11
14
  }
12
15
 
13
16
  // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
14
17
  // is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
15
18
  async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
16
- if (maintenanceBrokerDescriptors() && init?.method && !['GET', 'HEAD'].includes(init.method.toUpperCase()))
17
- throw new BackendError('maintenance_capability_missing: the operator broker admits only its exact stop/resume plan')
18
19
  const base = await apiBase()
19
20
  try {
20
21
  return await fetch(`${base}${path}`, init)
@@ -28,122 +29,108 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
28
29
  const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
29
30
  const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
30
31
  const seg = (id: string) => encodeURIComponent(id)
31
- type BrokerRequest = { op: string; sessionId?: string; force?: boolean }
32
- type BrokerOutcome = 'committed' | 'refused' | 'indeterminate'
33
- type BrokerResponse = { ok: boolean; outcome?: BrokerOutcome; status?: number; body?: any; code?: string; error?: string }
34
- function brokerRequest(request: BrokerRequest): BrokerResponse | null {
35
- const fds = maintenanceBrokerDescriptors()
36
- if (!fds) return null
37
- const startToken = processStartToken(process.pid)
38
- if (!startToken) throw new BackendError(`maintenance broker cannot identify client PID ${process.pid}`)
39
- const id = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
40
- const frame = `${JSON.stringify({ v: 1, id, ...request, client: { pid: process.pid, startToken } })}\n`
41
- if (Buffer.byteLength(frame) > 4_096) throw new BackendError('maintenance broker request exceeded PIPE_BUF')
42
- const token = Buffer.alloc(1)
43
- let ownsTurn = false
44
- try {
45
- if (readSync(fds.turn, token, 0, 1, null) !== 1 || token[0] !== 1)
46
- throw new BackendError('maintenance broker turn token was unavailable or invalid')
47
- ownsTurn = true
48
- if (writeSync(fds.request, frame) !== Buffer.byteLength(frame)) throw new BackendError('maintenance broker request write was incomplete')
49
- let line = ''
50
- const byte = Buffer.alloc(1)
51
- while (!line.endsWith('\n')) {
52
- const count = readSync(fds.response, byte, 0, 1, null)
53
- if (count === 0) throw new BackendError('maintenance broker closed before replying')
54
- line += byte.toString('utf8', 0, count)
55
- if (line.length > 64 * 1024) throw new BackendError('maintenance broker response exceeded its bound')
56
- }
57
- const response = JSON.parse(line) as BrokerResponse & { id?: string }
58
- if (response.id !== id) throw new BackendError('maintenance broker response identity mismatch')
59
- return response
60
- } finally {
61
- if (ownsTurn) {
62
- try { writeSync(fds.turn, token) } catch { /* broker shutdown owns the failure */ }
63
- }
64
- }
65
- }
66
- const brokerMutation = (request: BrokerRequest): BrokerResponse | null => brokerRequest(request)
67
32
 
68
- export type MaintenanceLeaseResponse = { token: string; epoch: number; state: 'draining' | 'active'; owner: LeaseOwner; capabilities?: Capability[] }
69
- export async function clientMaintenanceAcquire(capabilities: Capability[], ttlMs: number, waitMs: number): Promise<{ status: number; lease: MaintenanceLeaseResponse }> {
70
- await guarded('session maintain')
71
- const r = await apiFetch('/api/session-maintenance/acquire', post({ capabilities, ttlMs, waitMs }))
72
- const lease = await r.json().catch(() => ({})) as MaintenanceLeaseResponse & { error?: string }
73
- if (r.status !== 201 && r.status !== 202) throw new BackendError(`backend refused maintenance acquire (${r.status}): ${lease.error || 'invalid response'}`, r.status)
74
- return { status: r.status, lease }
33
+ // A rendezvous liveness probe can displace the real delivery client.
34
+ function cachedStatus(rec: ReturnType<typeof fromRaw>): DisplayStatus {
35
+ if (!rec.worktreePath || !existsSync(rec.worktreePath)) return 'retired'
36
+ if (rec.archived) return 'offline'
37
+ if (rec.status === 'awaiting') return rec.proposal === 'merge' ? 'review' : rec.proposal === 'close' ? 'close-pending' : 'done'
38
+ return rec.status === 'active' || rec.status === 'idle' ? 'unknown' : rec.status
75
39
  }
76
- export async function clientMaintenanceStatus(): Promise<MaintenanceState> {
77
- const r = await apiFetch('/api/session-maintenance')
78
- if (!r.ok) throw new BackendError(`backend refused maintenance status (${r.status})`, r.status)
79
- return await r.json() as MaintenanceState
40
+
41
+ function corruptCachedSession(id: string, reason: string): Session {
42
+ const label = `${id.slice(0, 8)} (unreadable record)`
43
+ return {
44
+ id, node: null, branch: null, path: '', label, headline: label, raw: { name: null, title: null },
45
+ parent: null, harness: 'unknown', capabilities: { headless: false }, launcher: null,
46
+ lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
47
+ note: `session record is unreadable: ${reason}`, archived: false, archiveHazard: null,
48
+ prompt: null, promptPreview: null, created: 0, activity: null, sortKey: null,
49
+ }
80
50
  }
81
- const maintenanceHeaders = (token: string): Record<string, string> => ({
82
- 'content-type': 'application/json',
83
- 'x-spexcode-session-maintenance': token,
84
- })
85
- export async function clientMaintenanceHeartbeat(token: string, epoch: number, ttlMs: number): Promise<MaintenanceState> {
86
- const r = await apiFetch('/api/session-maintenance/heartbeat', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch, ttlMs }) })
87
- if (!r.ok) throw new BackendError(`backend refused maintenance heartbeat (${r.status}): ${await r.text()}`, r.status)
88
- return await r.json() as MaintenanceState
51
+
52
+ // Also the board a FOLLOW resolves its selectors against ([[session-follow]]): following must work with no
53
+ // `spex serve` running, and asking a backend for the board would cost exactly the tmux/rendezvous probes the
54
+ // follow exists to eliminate. Every field here comes from the store — nothing is probed.
55
+ export function localCachedSessions(includeArchived = false): Session[] {
56
+ const rows: Session[] = []
57
+ for (const id of listSessionIds()) {
58
+ const entry = readPublicRecordEntry(id)
59
+ if (entry.kind === 'corrupt') {
60
+ rows.push(corruptCachedSession(id, entry.error))
61
+ continue
62
+ }
63
+ if (entry.kind !== 'ok') continue
64
+ const rec = fromRaw(entry.raw)
65
+ if (!rec.governed) continue
66
+ rows.push(toSession(rec, cachedStatus(rec), 'unknown'))
67
+ }
68
+ return rows.filter((session) => includeArchived || !session.archived)
69
+ .sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
89
70
  }
90
- export async function clientMaintenanceRelease(token: string, epoch: number): Promise<void> {
91
- const r = await apiFetch('/api/session-maintenance/release', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch }) })
92
- if (!r.ok) throw new BackendError(`backend refused maintenance release (${r.status}): ${await r.text()}`, r.status)
71
+
72
+ function localCachedResources(): ResourceReport {
73
+ const projectRoot = repoRoot()
74
+ const budgets = resourceBudgets(projectRoot)
75
+ const owners = localCachedSessions(true).map((session) => ({
76
+ kind: 'session' as const,
77
+ id: session.id,
78
+ label: session.label,
79
+ status: session.lifecycle,
80
+ liveness: 'unknown' as const,
81
+ proposal: session.proposal,
82
+ note: session.note,
83
+ worktreePath: session.path,
84
+ branch: session.branch,
85
+ archived: session.archived,
86
+ processes: [],
87
+ rssMiB: 0,
88
+ pssMiB: null,
89
+ cpuPercent: 0,
90
+ budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
91
+ findings: ['local-store-only: runtime liveness and process measurements are unavailable'],
92
+ reclaim: { eligible: false, reason: 'local store read cannot prove runtime ownership or liveness' },
93
+ }))
94
+ return {
95
+ version: 1,
96
+ measuredAt: new Date().toISOString(),
97
+ projectRoot,
98
+ platform: platform(),
99
+ available: false,
100
+ unavailableReason: 'backend unavailable; local session store has no runtime resource measurements',
101
+ host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null },
102
+ budgets,
103
+ owners,
104
+ totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 },
105
+ findings: owners.reduce((total, owner) => total + owner.findings.length, 0),
106
+ }
93
107
  }
94
- export async function clientMaintenanceOperation(
95
- token: string,
96
- request: { op: 'stop' | 'resume'; sessionId: string; force?: boolean },
97
- options: { signal?: AbortSignal; timeoutMs?: number } = {},
98
- ): Promise<BrokerResponse> {
99
- const path = `/api/sessions/${seg(request.sessionId)}/${request.op}`
100
- const controller = new AbortController()
101
- const abort = () => controller.abort(options.signal?.reason)
102
- if (options.signal?.aborted) abort()
103
- else options.signal?.addEventListener('abort', abort, { once: true })
104
- const timer = setTimeout(() => controller.abort(new Error('maintenance operation timed out')), options.timeoutMs ?? 5_000)
105
- timer.unref()
108
+
109
+ async function cachedRead<T>(remote: () => Promise<T>, local: () => T | Promise<T>): Promise<T> {
106
110
  try {
107
- const r = await apiFetch(path, {
108
- method: 'POST', headers: maintenanceHeaders(token), signal: controller.signal,
109
- body: JSON.stringify(request.op === 'resume' ? { force: request.force === true } : {}),
110
- })
111
- let body: any
112
- try { body = await r.json() } catch {
113
- return { ok: false, outcome: 'indeterminate', status: r.status, code: 'maintenance_response_malformed', error: 'maintenance operation returned malformed JSON' }
114
- }
115
- const committed = r.ok && body?.ok === true
116
- const refused = (r.status >= 400 && r.status < 500 && (body?.ok === false || typeof body?.code === 'string'))
117
- || (r.ok && body?.ok === false && body?.refused === true)
118
- const outcome: BrokerOutcome = committed ? 'committed' : refused ? 'refused' : 'indeterminate'
119
- const code = typeof body?.code === 'string' ? body.code
120
- : refused ? 'session_operation_refused'
121
- : committed ? undefined : 'maintenance_operation_indeterminate'
122
- return {
123
- ok: committed,
124
- outcome,
125
- status: r.status,
126
- body,
127
- ...(code ? { code } : {}),
128
- ...(!committed ? { error: body?.error || `status ${r.status}` } : {}),
129
- }
130
- } finally {
131
- clearTimeout(timer)
132
- options.signal?.removeEventListener('abort', abort)
111
+ return await remote()
112
+ } catch (error) {
113
+ if (!(error instanceof BackendError) || error.status !== undefined || (await apiBaseInfo()).source === 'flag') throw error
114
+ console.error('spex session: backend unreachable; source: local session store (liveness unknown)')
115
+ return await local()
133
116
  }
134
117
  }
135
118
 
136
119
  // GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
137
120
  export async function clientListSessions(includeArchived = false): Promise<Session[]> {
138
- const r = await apiFetch(includeArchived ? '/api/sessions?all=1' : '/api/sessions')
139
- if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
140
- return await r.json() as Session[]
121
+ return cachedRead(async () => {
122
+ const r = await apiFetch(includeArchived ? '/api/sessions?all=1' : '/api/sessions')
123
+ if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
124
+ return await r.json() as Session[]
125
+ }, () => localCachedSessions(includeArchived))
141
126
  }
142
127
 
143
128
  export async function clientResources(): Promise<import('./host-resources.js').ResourceReport> {
144
- const r = await apiFetch('/api/resources')
145
- if (!r.ok) throw new BackendError(`backend error ${r.status} loading resources: ${await r.text()}`, r.status)
146
- return await r.json() as import('./host-resources.js').ResourceReport
129
+ return cachedRead(async () => {
130
+ const r = await apiFetch('/api/resources')
131
+ if (!r.ok) throw new BackendError(`backend error ${r.status} loading resources: ${await r.text()}`, r.status)
132
+ return await r.json() as import('./host-resources.js').ResourceReport
133
+ }, localCachedResources)
147
134
  }
148
135
 
149
136
  // resolve a selector (full id, id-prefix, node, or branch) against the live board, then call with the full id.
@@ -160,24 +147,24 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
160
147
  return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` }
161
148
  }
162
149
 
163
- // POST /api/sessions/:id/input {kind:"text"} prompt dispatch (the backend routes it through the rendezvous
164
- // socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
150
+ // POST /api/sessions/:id/input {kind:"text"} appends the prompt to the durable timeline, then best-effort
151
+ // pokes the resolved adapter. HTTP failure means the append itself was refused.
165
152
  export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
166
153
  await guarded('session send')
167
- const brokered = brokerMutation({ op: 'send', sessionId: id })
168
- if (brokered) return brokered.ok ? brokered.body as DispatchResult : { ok: false, error: brokered.code || brokered.error || 'maintenance_active' }
169
- // `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
154
+ // `from` = the sending agent's own session id; the recipient's log records the sender ([[session-timeline]]) only when
170
155
  // it's present (an agent send), so a human-shell send stays unrecorded.
171
156
  const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
172
157
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
173
158
  }
174
159
 
175
160
  // GET /api/sessions/:id/review — the manager cockpit review bundle (null on 404).
176
- export async function clientReview(id: string): Promise<ReviewPayload | null> {
177
- const r = await apiFetch(`/api/sessions/${seg(id)}/review`)
178
- if (r.status === 404) return null
179
- if (!r.ok) throw new BackendError(`backend error ${r.status} reviewing ${id}`, r.status)
180
- return await r.json() as ReviewPayload
161
+ export async function clientReview(id: string): Promise<CockpitReview | null> {
162
+ return cachedRead(async () => {
163
+ const r = await apiFetch(`/api/sessions/${seg(id)}/review`)
164
+ if (r.status === 404) return null
165
+ if (!r.ok) throw new BackendError(`backend error ${r.status} reviewing ${id}`, r.status)
166
+ return await r.json() as CockpitReview
167
+ }, () => cockpitReview(id))
181
168
  }
182
169
 
183
170
  // GET /api/sessions/:id/evals?format=html — the rendered EXPORT artifact ([[session-eval]]): the
@@ -237,11 +224,6 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
237
224
  // carries a non-error advisory.
238
225
  export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
239
226
  await guarded('session resume')
240
- const brokered = brokerMutation({ op: 'resume', sessionId: id, force })
241
- if (brokered) {
242
- if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
243
- return brokered.body
244
- }
245
227
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
246
228
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
247
229
  }
@@ -250,11 +232,6 @@ export async function clientResume(id: string, force = false): Promise<{ ok: boo
250
232
  // resumable). Distinct from close. {ok:false} = no such session.
251
233
  export async function clientStop(id: string): Promise<boolean> {
252
234
  await guarded('session stop')
253
- const brokered = brokerMutation({ op: 'stop', sessionId: id })
254
- if (brokered) {
255
- if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
256
- return !!brokered.body?.ok
257
- }
258
235
  const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
259
236
  if (!r.ok) throw new BackendError(`backend refused to stop ${id}: ${await r.text()}`, r.status)
260
237
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
@@ -327,7 +304,12 @@ export async function clientSendRawKeys(id: string, keys: string[]): Promise<boo
327
304
  // originating prompt. 404 → no such session.
328
305
  export type ShowResult = { ok: true; session: Session & { prompt: string | null } } | { ok: false; status: number }
329
306
  export async function clientShow(id: string): Promise<ShowResult> {
330
- const r = await apiFetch(`/api/sessions/${seg(id)}`)
331
- if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
332
- return { ok: false, status: r.status }
307
+ return cachedRead(async () => {
308
+ const r = await apiFetch(`/api/sessions/${seg(id)}`)
309
+ if (r.ok) return { ok: true, session: await r.json() as Session & { prompt: string | null } }
310
+ return { ok: false, status: r.status }
311
+ }, () => {
312
+ const session = localCachedSessions(true).find((item) => item.id === id)
313
+ return session ? { ok: true, session } : { ok: false, status: 404 }
314
+ })
333
315
  }
@@ -0,0 +1,43 @@
1
+ import { reviewPayload, type ReviewEvalGate, type ReviewPayload } from './sessions.js'
2
+ import { sessionEvalProjection } from '../../spec-eval/src/sessioneval.js'
3
+
4
+ // @@@ cockpit - where the manager's review answer is ASSEMBLED ([[manager-cockpit]]). It sits above both the
5
+ // session layer and the eval layer and may import either; that is what makes it the only honest home for a
6
+ // value composed from both. `sessions.ts` cannot be that home — the eval package imports it, so reaching the
7
+ // other way from there is a cycle, and the cycle used to be paid for with a deferred `await import()` whose
8
+ // comment explained why it had to be wrong. `reviews.ts` cannot be it either: that file is [[paged-review]]'s
9
+ // Issues/Evals paging server, and parking a session-cockpit concern under a node about paging would make that
10
+ // node's body false. It imports the eval package for its own reasons — the same direction, a different one.
11
+
12
+ // @@@ ONE composition, every entry point - `gates.evals` has exactly one producer, and it is this function.
13
+ // The cockpit review is reachable two ways: the HTTP route, and `clientReview`'s local answer when no backend
14
+ // resolves. If each composed its own gates, the same verb would report different shapes depending on whether a
15
+ // backend happened to be running — the very asymmetry the remote-client role split exists to remove — and a
16
+ // drift between the two readouts would be caught by no gate we have. So both call HERE.
17
+ export type CockpitReview = Omit<ReviewPayload, 'gates'> & {
18
+ gates: ReviewPayload['gates'] & { evals: ReviewEvalGate }
19
+ }
20
+
21
+ // @@@ evalGate - the measured-loss readout, taken from [[session-eval]]'s EXISTING projection. This is a cache
22
+ // READ and must stay one: `buildSessionEvals()` calls `reviewPayload()` itself, so BUILDING the model from here
23
+ // would recurse. That hazard used to be held off by a comment; now it is structural — `reviewPayload` no longer
24
+ // carries this field, so the eval side cannot re-enter through it. (It never read the field anyway: `gateRows`
25
+ // consumes lint/conflict/ahead/dirty only. The payload was carrying a value for a caller that ignored it.)
26
+ async function evalGate(id: string): Promise<ReviewEvalGate> {
27
+ const projection = sessionEvalProjection(id)
28
+ if (!projection) return { phase: 'unavailable' }
29
+ // only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
30
+ if (projection.phase !== 'ready' || !projection.value) {
31
+ return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
32
+ }
33
+ const summary = projection.value
34
+ return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
35
+ }
36
+
37
+ // the cockpit's whole answer for one session: the session-side review plus the eval readout, composed once.
38
+ // The two run in parallel — the eval half is a projection read, independent of every git read in the payload.
39
+ export async function cockpitReview(id: string): Promise<CockpitReview | null> {
40
+ const [payload, evals] = await Promise.all([reviewPayload(id), evalGate(id)])
41
+ if (!payload) return null
42
+ return { ...payload, gates: { ...payload.gates, evals } }
43
+ }