@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-alpha.0

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 (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. package/studio/server/state/harness-token.ts +0 -0
@@ -1,487 +0,0 @@
1
- /**
2
- * agent/runner.ts — orchestrates one live agent turn per domain.
3
- *
4
- * submit → gather the threads awaiting a reply + current changes + context →
5
- * scaffold the prompt → run the harness (streaming activity to SSE) → on finish,
6
- * merge the agent's machine-state reply block back into comments.json and persist
7
- * the session id so the next turn resumes the same conversation. The agent's file
8
- * edits flow through the existing watch→re-render path with no extra plumbing.
9
- *
10
- * One run at a time per domain; the live run is held in memory and mirrored to
11
- * the client over SSE, then a compact transcript is written under
12
- * `.domain-studio/.cache/agent/` (ignored, never pollutes committed state).
13
- */
14
- import { randomUUID } from 'node:crypto'
15
-
16
- import type {
17
- AgentEvent,
18
- AgentPromptSnapshot,
19
- AgentRun,
20
- AgentRunSnapshot,
21
- Comment,
22
- ConversationInfo,
23
- StudioEvent,
24
- } from '../../shared/types'
25
-
26
- import { getBundle } from '../cache'
27
- import { type DomainHandle, getDomain } from '../domain'
28
- import { mergeReply, readComments } from '../state/comments'
29
- import { readContext } from '../state/context'
30
- import { listDocuments } from '../state/documents'
31
- import { refreshAuto } from '../state/handoff'
32
- import { resolveHarnessEnv } from '../state/harness-gateway'
33
- import { readSettings } from '../state/settings'
34
- import { readJson, removeState, writeJson } from '../state/store'
35
- import { recordRun } from '../state/usage'
36
- import { startBridge } from './bridge'
37
- import { buildResumePrompt, buildSystemPrompt, buildTurnPrompt } from './prompt'
38
- import { getHarness } from './registry'
39
- import { studioSessionId } from './session-id'
40
-
41
- type Notify = (e: StudioEvent) => void
42
-
43
- const runs = new Map<string, AgentRun>()
44
- const controllers = new Map<string, AbortController>()
45
- /** domains whose run is being SET UP — a synchronous reservation that closes the
46
- * check-then-set race between isRunning() and runs.set() (which span awaits). */
47
- const starting = new Set<string>()
48
- /** domains whose last-run has been rehydrated from disk this process (once). */
49
- const hydrated = new Set<string>()
50
-
51
- const SESSION_FILE = '.cache/agent/session.json'
52
- /** A stable pointer to the latest run, written on START and at terminal, so a fresh
53
- * process can show the last run (and reconcile one orphaned by a crash). */
54
- const LAST_RUN_FILE = '.cache/agent/last-run.json'
55
- const runFile = (id: string) => `.cache/agent/runs/${id}.json`
56
- const MCP_TOOLS = [
57
- 'list_open_threads',
58
- 'reply_to_thread',
59
- 'resolve_thread',
60
- 'post_progress',
61
- 'raise_question',
62
- ]
63
-
64
- /** The persisted conversation handle: one resumable harness session per domain,
65
- * with a turn counter so the UI can show "N turns" and resume continuity. */
66
- interface SessionState {
67
- harness?: string
68
- sessionId?: string
69
- turns?: number
70
- updatedAt?: string
71
- }
72
- const readSession = (root: string): SessionState => readJson<SessionState>(root, SESSION_FILE, {})
73
-
74
- /** Persist the run to disk (best-effort). `transcript` also writes the per-id record. */
75
- function persistRun(root: string, run: AgentRun, transcript = false): void {
76
- try {
77
- writeJson(root, LAST_RUN_FILE, run)
78
- if (transcript) writeJson(root, runFile(run.id), run)
79
- } catch {
80
- /* transcript is best-effort — never let it break a run */
81
- }
82
- }
83
-
84
- /** On a fresh process the in-memory run map is empty. Seed the latest run from disk
85
- * so the drawer shows it after a studio restart — and if that run was still
86
- * `running`/`queued`, its agent died with the old process, so reconcile it to
87
- * `interrupted` (an honest terminal state) instead of a perpetual spinner. */
88
- function hydrate(domainId: string, root: string): void {
89
- if (hydrated.has(domainId) || runs.has(domainId)) return
90
- hydrated.add(domainId)
91
- const last = readJson<AgentRun | null>(root, LAST_RUN_FILE, null)
92
- if (!last || last.domainId !== domainId) return
93
- if (last.status === 'running' || last.status === 'queued') {
94
- last.status = 'interrupted'
95
- last.finishedAt = last.finishedAt ?? new Date().toISOString()
96
- last.error =
97
- 'the studio restarted during this turn — your conversation is preserved; submit again to continue'
98
- persistRun(root, last)
99
- }
100
- runs.set(domainId, last)
101
- }
102
-
103
- /** The resumable-conversation summary for the snapshot. */
104
- function conversationOf(root: string): ConversationInfo {
105
- const s = readSession(root)
106
- const harness = getHarness()
107
- return {
108
- active: s.harness === harness.id && !!s.sessionId,
109
- turns: s.turns ?? 0,
110
- harness: s.harness,
111
- }
112
- }
113
-
114
- /** Open threads whose last entry is NOT the agent — the ones it owes a reply. */
115
- function awaitingThreads(root: string): Comment[] {
116
- return readComments(root).comments.filter(
117
- (c) => c.status === 'open' && c.thread.at(-1)?.role !== 'author',
118
- )
119
- }
120
-
121
- /** Hide the machine-state reply block from the activity log (it's the wire protocol,
122
- * not user-facing — the replies land in the threads). Other code fences are kept. */
123
- function stripMachineState(text: string): string {
124
- return text
125
- .replace(/```(?:json)?\s*[\s\S]*?```/g, (m) => (/"comments"|"schemaVersion"/.test(m) ? '' : m))
126
- .trim()
127
- }
128
-
129
- export function isRunning(domainId: string): boolean {
130
- const s = runs.get(domainId)?.status
131
- return s === 'running' || s === 'queued'
132
- }
133
-
134
- export async function getSnapshot(domainId: string): Promise<AgentRunSnapshot> {
135
- const harness = getHarness()
136
- const handle = getDomain(domainId)
137
- if (handle) hydrate(domainId, handle.root)
138
- const conversation = handle ? conversationOf(handle.root) : { active: false, turns: 0 }
139
- return {
140
- harness: harness.id,
141
- available: await harness.isAvailable(),
142
- run: runs.get(domainId) ?? null,
143
- conversation,
144
- }
145
- }
146
-
147
- export function cancelRun(domainId: string): boolean {
148
- const c = controllers.get(domainId)
149
- if (!c) return false
150
- c.abort()
151
- return true
152
- }
153
-
154
- /** The forkable conversation session id for this domain (current harness only).
155
- * Undefined when there's no conversation yet — an Ask then runs fresh (no fork).
156
- * Read-only: never mutates session.json, so it's safe to call while a run is live. */
157
- export function forkableSession(domainId: string): string | undefined {
158
- const handle = getDomain(domainId)
159
- if (!handle) return undefined
160
- const s = readSession(handle.root)
161
- return s.harness === getHarness().id ? s.sessionId : undefined
162
- }
163
-
164
- /** Forget the resumable conversation so the NEXT submit starts a brand-new session.
165
- * Refused mid-run (the live turn owns the session). Returns false if unknown. */
166
- export function resetConversation(domainId: string): boolean {
167
- const handle = getDomain(domainId)
168
- if (!handle) return false
169
- if (isRunning(domainId)) return false
170
- removeState(handle.root, SESSION_FILE)
171
- return true
172
- }
173
-
174
- /** The raw resumable session id (+ turn count / harness), for viewing in Settings.
175
- * Read-only: safe to call while a run is live. */
176
- export function getSessionId(domainId: string): {
177
- sessionId: string | null
178
- turns: number
179
- harness?: string
180
- } {
181
- const handle = getDomain(domainId)
182
- if (!handle) return { sessionId: null, turns: 0 }
183
- const s = readSession(handle.root)
184
- return { sessionId: s.sessionId ?? null, turns: s.turns ?? 0, harness: s.harness }
185
- }
186
-
187
- /** Overwrite (or clear) the resumable session id by hand. Empty ⇒ forget the
188
- * conversation (next submit starts fresh). Refused mid-run (the live turn owns it). */
189
- export function setSessionId(domainId: string, sessionId: string): boolean {
190
- const handle = getDomain(domainId)
191
- if (!handle) return false
192
- if (isRunning(domainId)) return false
193
- const trimmed = sessionId.trim()
194
- if (!trimmed) {
195
- removeState(handle.root, SESSION_FILE)
196
- return true
197
- }
198
- const prev = readSession(handle.root)
199
- writeJson(handle.root, SESSION_FILE, {
200
- harness: prev.harness ?? getHarness().id,
201
- sessionId: trimmed,
202
- turns: prev.turns ?? 0,
203
- updatedAt: new Date().toISOString(),
204
- })
205
- return true
206
- }
207
-
208
- /** What a submit carries: an optional typed instruction, or a bare `resume` — a
209
- * seamless "continue where you left off" after an interruption (no re-briefing). */
210
- export interface SubmitOpts {
211
- message?: string
212
- resume?: boolean
213
- }
214
-
215
- export async function submitRun(
216
- handle: DomainHandle,
217
- notify: Notify,
218
- opts?: SubmitOpts,
219
- ): Promise<{ run?: AgentRun; error?: string }> {
220
- const domainId = handle.id
221
- // Reserve the slot SYNCHRONOUSLY (before any await) so two concurrent submits
222
- // — a double-click / retry — can't both pass the gate and start two runs.
223
- if (isRunning(domainId) || starting.has(domainId))
224
- return { error: 'an agent run is already in progress for this domain' }
225
- starting.add(domainId)
226
- try {
227
- return await startRun(handle, notify, opts)
228
- } finally {
229
- starting.delete(domainId)
230
- }
231
- }
232
-
233
- async function startRun(
234
- handle: DomainHandle,
235
- notify: Notify,
236
- opts?: SubmitOpts,
237
- ): Promise<{ run?: AgentRun; error?: string }> {
238
- const domainId = handle.id
239
- const root = handle.root
240
-
241
- const harness = getHarness()
242
- if (!(await harness.isAvailable()))
243
- return { error: `${harness.label} is not available on this machine` }
244
-
245
- const session = readSession(root)
246
- const resume = session.harness === harness.id ? session.sessionId : undefined
247
- // A bare resume only continues an EXISTING session — without one there's nothing
248
- // in the agent's memory to pick up, so a nudge alone would be useless. When the
249
- // caller asks to resume but no session survives, fall through to a normal full turn.
250
- const bareResume = opts?.resume === true && !!resume
251
-
252
- const awaiting = awaitingThreads(root)
253
- const msg = (opts?.message ?? '').trim()
254
- if (!bareResume && awaiting.length === 0 && !msg)
255
- return { error: 'nothing to send — type an instruction or open a thread' }
256
-
257
- // refresh the auto-context digests on disk first, so "read these files" holds
258
- await refreshAuto(handle).catch(() => {})
259
-
260
- const bundle = await getBundle(domainId)
261
- const schemaHash = bundle?.schemaHash ?? ''
262
- const ctx = readContext(root)
263
- const documents = listDocuments(root)
264
- const settings = readSettings(root)
265
- // custom model-gateway env (ANTHROPIC_*), injected into the harness child only.
266
- // Resolved up-front so a token failure fails the submit cleanly (rather than
267
- // silently spawning on the default Claude auth).
268
- const envResult = await resolveHarnessEnv(root)
269
- if (!envResult.ok) return { error: `model gateway auth failed — ${envResult.error}` }
270
- // Surface-owned telemetry session: the harness's astrale calls bucket per
271
- // domain per hour instead of falling back to ambient cwd inference.
272
- const harnessEnv = { ...envResult.env, ASTRALE_SESSION: studioSessionId(domainId) }
273
-
274
- // per-run write-back bridge (token-scoped MCP tools); harmless if the harness ignores it
275
- const bridge = startBridge(handle, () => runs.get(domainId)?.id ?? '', notify)
276
-
277
- // Build the turn prompt for a given continuity. Reused when a rejected resume forces
278
- // a fresh restart — the header/orientation differ between "resume" and "new session".
279
- // A bare resume sends just the nudge (the live session still holds everything); but a
280
- // FRESH start (firstTurn — incl. the resume-rejected fallback) must carry full context.
281
- const makeTurn = (firstTurn: boolean) =>
282
- bareResume && !firstTurn
283
- ? buildResumePrompt()
284
- : buildTurnPrompt({
285
- origin: bundle?.overlay.origin ?? domainId,
286
- root,
287
- schemaHash,
288
- awaitingThreads: awaiting,
289
- userContext: ctx.user,
290
- autoContext: ctx.auto.filter((a) => a.includeInHandoff),
291
- documents,
292
- firstTurn,
293
- message: msg,
294
- ir: bundle?.ir ?? null,
295
- overlay: bundle?.overlay,
296
- })
297
- const system = buildSystemPrompt({ bridge: bridge.enabled })
298
- const promptSnapshot = (
299
- sessionId: string | undefined,
300
- firstTurn: boolean,
301
- ): AgentPromptSnapshot => ({
302
- createdAt: new Date().toISOString(),
303
- systemPrompt: system,
304
- turnPrompt: makeTurn(firstTurn),
305
- firstTurn,
306
- resumed: !!sessionId,
307
- sessionId,
308
- effort: settings.agentEffort,
309
- mcpTools: bridge.enabled ? MCP_TOOLS : [],
310
- })
311
-
312
- const run: AgentRun = {
313
- id: randomUUID(),
314
- domainId,
315
- harness: harness.id,
316
- status: 'running',
317
- createdAt: new Date().toISOString(),
318
- summary: bareResume
319
- ? 'continuing after interruption'
320
- : msg
321
- ? msg.slice(0, 60) + (msg.length > 60 ? '…' : '')
322
- : awaiting.length === 1
323
- ? '1 open thread'
324
- : `${awaiting.length} open threads`,
325
- targetCommentIds: awaiting.map((c) => c.id),
326
- events: [],
327
- sessionId: resume,
328
- resumed: !!resume,
329
- prompt: promptSnapshot(resume, !resume),
330
- }
331
- runs.set(domainId, run)
332
- persistRun(root, run) // record on START so a crash mid-turn leaves a reconcilable trace
333
- const controller = new AbortController()
334
- controllers.set(domainId, controller)
335
- notify({ type: 'agent-run', domainId, run })
336
-
337
- const pushEvent = (e: Omit<AgentEvent, 'id' | 'ts'>) => {
338
- let text = e.text
339
- if (e.kind === 'message') {
340
- text = stripMachineState(text)
341
- if (!text) return // the message was ONLY the machine-state reply block
342
- }
343
- const ev: AgentEvent = { id: randomUUID(), ts: new Date().toISOString(), ...e, text }
344
- run.events.push(ev)
345
- notify({ type: 'agent-event', domainId, runId: run.id, event: ev })
346
- }
347
- let bridgeReplies = 0
348
- // commentId → texts the agent already posted LIVE this run, so the end-of-turn
349
- // merge can skip exactly those (and nothing else) without duplicating.
350
- const liveByComment = new Map<string, Set<string>>()
351
- bridge.onReply((commentId, text) => {
352
- bridgeReplies += 1
353
- if (!liveByComment.has(commentId)) liveByComment.set(commentId, new Set())
354
- liveByComment.get(commentId)!.add(text.trim())
355
- pushEvent({ kind: 'reply', text, commentId })
356
- })
357
- bridge.onProgress((text) => pushEvent({ kind: 'status', text }))
358
-
359
- // fire-and-forget; the HTTP response returns the running run immediately
360
- void (async () => {
361
- try {
362
- // run one harness turn with the given continuity
363
- const runTurn = (sessionId: string | undefined, firstTurn: boolean) => {
364
- const prompt = promptSnapshot(sessionId, firstTurn)
365
- run.prompt = prompt
366
- notify({ type: 'agent-run', domainId, run })
367
- return harness.run({
368
- root,
369
- prompt: prompt.turnPrompt,
370
- appendSystemPrompt: prompt.systemPrompt,
371
- sessionId,
372
- effort: settings.agentEffort,
373
- mcpConfigPath: bridge.mcpConfigPath,
374
- env: harnessEnv,
375
- signal: controller.signal,
376
- onEvent: pushEvent,
377
- })
378
- }
379
-
380
- let result = await runTurn(resume, !resume)
381
- let convoTurns = resume ? (session.turns ?? 0) : 0
382
-
383
- // Auto-recover a rejected resume: the stored session is gone on the harness side
384
- // (pruned/expired). Drop it and transparently re-run the SAME work as a NEW
385
- // conversation so the user's turn still lands instead of dead-ending on a stale id.
386
- if (resume && result.resumeRejected && !controller.signal.aborted) {
387
- removeState(root, SESSION_FILE)
388
- convoTurns = 0
389
- run.sessionId = undefined
390
- run.resumed = false
391
- pushEvent({
392
- kind: 'status',
393
- text: 'previous conversation was no longer available — started a new one',
394
- })
395
- result = await runTurn(undefined, true)
396
- }
397
-
398
- run.sessionId = result.sessionId ?? run.sessionId
399
- run.costUsd = result.costUsd
400
- run.tokens = result.tokens
401
- run.numTurns = result.numTurns
402
- run.liveReplies = bridgeReplies
403
-
404
- if (bridgeReplies > 0 && !controller.signal.aborted)
405
- pushEvent({
406
- kind: 'status',
407
- text: `replied to ${bridgeReplies} thread${bridgeReplies === 1 ? '' : 's'} live`,
408
- })
409
-
410
- // Merge the agent's end-of-turn machine-state block. ALWAYS run it on a clean
411
- // turn (so threads answered only in the block still land); dedupe is scoped to
412
- // this run's live replies. SKIP it when the turn was canceled (re-checked LIVE,
413
- // not a stale snapshot) or the harness errored — neither should apply a
414
- // partial/untrusted answer or close threads.
415
- let replyError: string | undefined
416
- if (
417
- !controller.signal.aborted &&
418
- !result.isError &&
419
- result.finalText &&
420
- result.finalText.trim()
421
- ) {
422
- try {
423
- const merged = mergeReply(root, schemaHash, result.finalText, {
424
- skipByComment: liveByComment,
425
- })
426
- run.merge = merged
427
- if (merged.merged || merged.closed)
428
- pushEvent({
429
- kind: 'status',
430
- text: `merged ${merged.merged} repl${merged.merged === 1 ? 'y' : 'ies'}, closed ${merged.closed}`,
431
- })
432
- notify({ type: 'comments', domainId })
433
- } catch (e: any) {
434
- // a MALFORMED block is real data loss → fail the run so it's visible; an
435
- // ABSENT block is benign (the agent used the bridge or had nothing to say).
436
- if (/```json/i.test(result.finalText)) {
437
- replyError = `agent reply block was malformed JSON — reply not merged (${String(e?.message ?? e).slice(0, 80)})`
438
- pushEvent({ kind: 'error', text: replyError })
439
- } else if (bridgeReplies === 0) {
440
- pushEvent({ kind: 'status', text: 'no machine-state reply block in the final message' })
441
- }
442
- }
443
- }
444
-
445
- if (controller.signal.aborted) run.status = 'canceled'
446
- else if (result.isError) {
447
- run.status = 'failed'
448
- run.error = result.errorMessage
449
- pushEvent({ kind: 'error', text: result.errorMessage ?? 'agent error' })
450
- } else if (replyError) {
451
- run.status = 'failed'
452
- run.error = replyError
453
- } else run.status = 'succeeded'
454
-
455
- // Session persistence. Keep the conversation across an UNRELATED transient
456
- // failure (rate limit, a typecheck slip, a cancel) so the user can just resume —
457
- // only a genuinely rejected resume drops the id, and that was already handled
458
- // above (with a fresh restart). On success, persist the id and bump the turn
459
- // count; otherwise leave the stored session exactly as it was.
460
- if (run.status === 'succeeded' && result.sessionId)
461
- writeJson(root, SESSION_FILE, {
462
- harness: harness.id,
463
- sessionId: result.sessionId,
464
- turns: convoTurns + 1,
465
- updatedAt: new Date().toISOString(),
466
- })
467
- } catch (e: any) {
468
- run.status = controller.signal.aborted ? 'canceled' : 'failed'
469
- run.error = String(e?.message ?? e)
470
- pushEvent({ kind: 'error', text: run.error })
471
- // Keep the stored session — an unexpected throw is not proof the conversation is
472
- // dead. The next submit resumes it; a genuinely dead id self-heals via the
473
- // resumeRejected → fresh-restart path above.
474
- } finally {
475
- run.finishedAt = new Date().toISOString()
476
- // only clear the controller if it is still OURS (defensive against overwrite)
477
- if (controllers.get(domainId) === controller) controllers.delete(domainId)
478
- bridge.dispose()
479
- recordRun(root, run) // fold this turn's tokens/cost into the domain's running total
480
- persistRun(root, run, true) // final state → both the latest-run pointer and the transcript
481
- notify({ type: 'agent-run', domainId, run })
482
- notify({ type: 'comments', domainId })
483
- }
484
- })()
485
-
486
- return { run }
487
- }