@corenel/tools-web 0.11.4 → 0.12.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 (46) hide show
  1. package/dist/composerPicks.d.ts +30 -0
  2. package/dist/composerPicks.d.ts.map +1 -0
  3. package/dist/composerPicks.js +123 -0
  4. package/dist/composerPicks.js.map +1 -0
  5. package/dist/core/protocol.d.ts +36 -1
  6. package/dist/core/protocol.d.ts.map +1 -1
  7. package/dist/injectionCheck.d.ts +44 -0
  8. package/dist/injectionCheck.d.ts.map +1 -0
  9. package/dist/injectionCheck.js +73 -0
  10. package/dist/injectionCheck.js.map +1 -0
  11. package/dist/liveFold.d.ts +4 -0
  12. package/dist/liveFold.d.ts.map +1 -1
  13. package/dist/liveFold.js +15 -0
  14. package/dist/liveFold.js.map +1 -1
  15. package/dist/mutationHook.d.ts +32 -0
  16. package/dist/mutationHook.d.ts.map +1 -0
  17. package/dist/mutationHook.js +108 -0
  18. package/dist/mutationHook.js.map +1 -0
  19. package/dist/runtime.d.ts +145 -41
  20. package/dist/runtime.d.ts.map +1 -1
  21. package/dist/runtime.js +1045 -975
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/sessionTools.d.ts +5 -3
  24. package/dist/sessionTools.d.ts.map +1 -1
  25. package/dist/sessionTools.js +12 -7
  26. package/dist/sessionTools.js.map +1 -1
  27. package/dist/systemOneGate/gate.d.ts +45 -0
  28. package/dist/systemOneGate/gate.d.ts.map +1 -0
  29. package/dist/systemOneGate/gate.js +380 -0
  30. package/dist/systemOneGate/gate.js.map +1 -0
  31. package/dist/systemOneGate/requests.d.ts +67 -0
  32. package/dist/systemOneGate/requests.d.ts.map +1 -0
  33. package/dist/systemOneGate/requests.js +201 -0
  34. package/dist/systemOneGate/requests.js.map +1 -0
  35. package/dist/workerClient.d.ts +39 -1
  36. package/dist/workerClient.d.ts.map +1 -1
  37. package/dist/workerClient.js +96 -7
  38. package/dist/workerClient.js.map +1 -1
  39. package/dist/workerHandler.d.ts.map +1 -1
  40. package/dist/workerHandler.js +118 -20
  41. package/dist/workerHandler.js.map +1 -1
  42. package/dist/workspaceBlock.d.ts +5 -0
  43. package/dist/workspaceBlock.d.ts.map +1 -0
  44. package/dist/workspaceBlock.js +18 -0
  45. package/dist/workspaceBlock.js.map +1 -0
  46. package/package.json +4 -4
package/dist/runtime.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createWorkerAgent } from './workerClient';
2
2
  import { nodeUsageOf } from './nodeUsage';
3
3
  import { publishSubagentEvent, publishSubagentParentEnded } from './subagentBus';
4
- import { foldLive } from './liveFold';
5
- import { createRunWatchdog } from './runWatchdog';
4
+ import { createChat } from '@corenel/harness/chat/engine';
5
+ import { userText, userImages, outgoingMessages } from '@corenel/harness/chat/messages';
6
6
  import { listDocs, deleteDocsForSession } from './contextDocs';
7
7
  import { loadedToolNames, setLoadedTools } from './sessionLoadedTools';
8
8
  /* Read-only now: a conversation from before the snapshot may carry an explicit
@@ -10,89 +10,82 @@ import { loadedToolNames, setLoadedTools } from './sessionLoadedTools';
10
10
  * chat's prompt is decided when it starts. */
11
11
  import { sessionSystemPrompt } from './sessionSystemPrompt';
12
12
  import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
13
- import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
13
+ import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal, HOOK_ASK_TOOL } from '@corenel/protocol';
14
14
  import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
15
- import { turnsFromSessionMessages, foldPoint, isFoldedTurn } from '@corenel/harness/conversation';
15
+ import { workspaceBlock } from './workspaceBlock';
16
16
  import { allTools } from '@corenel/harness/tools/builtins';
17
17
  import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
18
18
  import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
19
19
  import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
20
20
  import { getSystemParts } from '@corenel/harness/systemParts';
21
- import { CHECKPOINT_MARKER, estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
21
+ import { estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
22
22
  import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
23
23
  import { enabledSkillCatalog } from '@corenel/harness/skills';
24
- import { alog } from '@corenel/harness/log';
25
- // The agent's model is the user's picker selection (getModel) — a real
26
- // catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
27
- const WATCHDOG_MS = 45000;
28
- /** A session allow is keyed by WHO asked and the tool, so allowing a callee's
29
- * write never carries to another callee, or to the caller. */
30
- function allowKey(agent, tool) {
31
- return JSON.stringify([agent ?? '', tool]);
32
- }
33
24
  function emptyInternal() {
34
25
  return {
35
26
  turns: [], live: null, running: false, error: '', mode: 'auto', input: '', attachments: [],
36
- pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
37
- pendingCommand: null, runId: null,
27
+ pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null,
28
+ pendingCommand: null,
38
29
  recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
39
- offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null, persistedLen: null,
40
- saveBlocked: null, loadMissed: false, mergeTick: 0,
30
+ offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null,
31
+ saveBlocked: null, mergeTick: 0,
41
32
  };
42
33
  }
43
- /** Build the user message — a plain string, or multimodal parts when images are attached. */
44
- function buildUserMessage(text, images) {
45
- if (!images.length)
46
- return { role: 'user', content: text };
47
- const parts = [];
48
- if (text)
49
- parts.push({ type: 'text', text });
50
- for (const url of images)
51
- parts.push({ type: 'image_url', image_url: { url } });
52
- return { role: 'user', content: parts };
34
+ /** Whether `c` is a whole stored session (a save's read hands back the store's
35
+ * own record) rather than a draft's stand-in. */
36
+ function isSession(c) {
37
+ const p = c;
38
+ return typeof p.createdAt === 'number' && typeof p.updatedAt === 'number';
39
+ }
40
+ /* The engine's frozen system prompt (StoredConversation.system, R4) is a daemon
41
+ * store field. The browser never keeps it: it is left out of every save and
42
+ * every read, so a reload composes the system afresh. */
43
+ function withoutSystem(c) {
44
+ if (!('system' in c))
45
+ return c;
46
+ const copy = { ...c };
47
+ Reflect.deleteProperty(copy, 'system');
48
+ return copy;
53
49
  }
54
- function userText(m) {
55
- if (typeof m.content === 'string')
56
- return m.content;
57
- if (Array.isArray(m.content)) {
58
- const t = m.content.find((p) => p.type === 'text');
59
- return t && 'text' in t ? t.text : '';
60
- }
61
- return '';
50
+ /** The ask detail back from the engine, which carries it as unknown: the
51
+ * worker built it as a PermDetail (already clipped), so only its fields pass. */
52
+ function permInput(x) {
53
+ if (x === null || typeof x !== 'object')
54
+ return undefined;
55
+ const o = x;
56
+ const out = {};
57
+ for (const k of ['command', 'path', 'cwd', 'root']) {
58
+ const v = o[k];
59
+ if (typeof v === 'string')
60
+ out[k] = v;
61
+ }
62
+ return Object.keys(out).length ? out : undefined;
62
63
  }
63
- function userImages(m) {
64
- if (!Array.isArray(m.content))
65
- return [];
66
- return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
64
+ /** A System One ask's detail back from the engine, which carries it as
65
+ * unknown: the tool, the reasons and an optional result, nothing else. */
66
+ function hookInput(x) {
67
+ if (x === null || typeof x !== 'object')
68
+ return undefined;
69
+ const o = x;
70
+ if (typeof o.tool !== 'string' || !Array.isArray(o.reasons))
71
+ return undefined;
72
+ const reasons = o.reasons.filter((r) => typeof r === 'string');
73
+ return { tool: o.tool, reasons, ...(typeof o.result === 'string' ? { result: o.result } : {}) };
67
74
  }
68
- /** What a send hands the run: the raw included transcript, or -- once a run
69
- * has folded -- the newest LIVE seam's folded view plus the included turns
70
- * after the user turn that produced it. Turns at or before that turn are
71
- * already in the view; sending them raw again re-folded the whole history
72
- * every send. A view whose user turn is gone is skipped for the one before it,
73
- * and with no live view at all the transcript goes raw (the next run re-folds). */
74
- function outgoingMessages(turns) {
75
- const fp = foldPoint(turns);
76
- if (fp)
77
- return [...fp.view, ...turns.slice(fp.throughIndex + 1).filter((t) => t.included).flatMap((t) => t.messages)];
78
- return turns.filter((t) => t.included).flatMap((t) => t.messages);
75
+ /** The event as recordings and host hooks see it: a tool-hooks event without
76
+ * its blocked result. */
77
+ function withoutBlockedText(e) {
78
+ if (e.type !== 'tool-hooks' || e.blockedText === undefined)
79
+ return e;
80
+ return { type: 'tool-hooks', id: e.id, mark: e.mark };
79
81
  }
80
- /** Stamp a run's folded view on its newest seam, and keep exactly ONE older
81
- * view: each is a full copy of the window and persist rewrites every turn on
82
- * every send, so views left on every seam grow without bound in a chat that
83
- * never ends. The one kept is what `retry` of the folding turn falls back to
84
- * (retry removes the newest seam with its user turn). */
85
- function stampFoldedView(turns, seamId, view, through) {
86
- const older = turns.filter((t) => t.foldedView && t.id !== seamId).map((t) => t.id);
87
- const keep = older[older.length - 1];
88
- return turns.map((t) => {
89
- if (t.id === seamId)
90
- return { ...t, foldedView: view, foldedThrough: through };
91
- if (!t.foldedView || t.id === keep)
92
- return t;
93
- const { foldedView: _view, foldedThrough: _through, ...rest } = t;
94
- return rest;
95
- });
82
+ /** The engine's permission answer in the worker's terms: a session allow says
83
+ * so, a deny with a note carries it, and every other answer stays the bare
84
+ * boolean it always was. */
85
+ function toPermissionAnswer(a) {
86
+ if (a.by === 'session')
87
+ return { allow: a.allow, by: 'session' };
88
+ return !a.allow && a.note ? { allow: a.allow, by: 'human', note: a.note } : a.allow;
96
89
  }
97
90
  /** Merge a form's freshly-emitted envelope into a per-run override, against the
98
91
  * currently saved default. The form only emits the keys it still wants set — a
@@ -185,6 +178,30 @@ export class AgentRuntime {
185
178
  writable: true,
186
179
  value: new Set()
187
180
  });
181
+ Object.defineProperty(this, "nextPermId", {
182
+ enumerable: true,
183
+ configurable: true,
184
+ writable: true,
185
+ value: 1
186
+ });
187
+ /** Asks raised while another was on screen, per session, oldest first: each
188
+ * becomes the shown card (pendingPerm) when the one before it is answered,
189
+ * so two parallel asks are two cards and an answer reaches only its own. */
190
+ Object.defineProperty(this, "permQueues", {
191
+ enumerable: true,
192
+ configurable: true,
193
+ writable: true,
194
+ value: new Map()
195
+ });
196
+ /** Tool results a System One check or hook blocked, per session and tool
197
+ * call id. Memory only, for the life of the page: never saved, recorded or
198
+ * sent to a model. */
199
+ Object.defineProperty(this, "blocked", {
200
+ enumerable: true,
201
+ configurable: true,
202
+ writable: true,
203
+ value: new Map()
204
+ });
188
205
  Object.defineProperty(this, "_ws", {
189
206
  enumerable: true,
190
207
  configurable: true,
@@ -197,6 +214,48 @@ export class AgentRuntime {
197
214
  writable: true,
198
215
  value: null
199
216
  });
217
+ /* ---- the conversation engine ---------------------------------------------
218
+ *
219
+ * Turns, the live fold, the run's lifecycle (guard, watchdog, stitch), the
220
+ * write queue and the merge-by-turn-id save are the harness chat engine's.
221
+ * This runtime is its browser host (chatHost): it supplies the worker loop,
222
+ * the session store, the composer, the approval cards and everything else
223
+ * only a browser has. */
224
+ Object.defineProperty(this, "chat", {
225
+ enumerable: true,
226
+ configurable: true,
227
+ writable: true,
228
+ value: createChat()
229
+ });
230
+ Object.defineProperty(this, "chatHost", {
231
+ enumerable: true,
232
+ configurable: true,
233
+ writable: true,
234
+ value: void 0
235
+ });
236
+ /** Forgotten in the engine (deleteSession, reset), so every engine mutator is
237
+ * a no-op for them until a load or a turn opens them again. open() reopens
238
+ * one before this side mutates it. */
239
+ Object.defineProperty(this, "forgotten", {
240
+ enumerable: true,
241
+ configurable: true,
242
+ writable: true,
243
+ value: new Set()
244
+ });
245
+ /** Sends and retries waiting for the engine to start their turn, oldest first. */
246
+ Object.defineProperty(this, "pending", {
247
+ enumerable: true,
248
+ configurable: true,
249
+ writable: true,
250
+ value: new Map()
251
+ });
252
+ /** The turn the engine is running per session, from its guard to turnComplete. */
253
+ Object.defineProperty(this, "active", {
254
+ enumerable: true,
255
+ configurable: true,
256
+ writable: true,
257
+ value: new Map()
258
+ });
200
259
  /** Reuse the most-recent empty saved session if there is one, else open a
201
260
  * DRAFT (newDraft): opening the app, or landing after a delete, saves
202
261
  * nothing until the user first sends. Concurrent calls are deduped (React
@@ -210,9 +269,10 @@ export class AgentRuntime {
210
269
  /* DRAFT CHATS. A draft is a chat id held only here: loaded, empty, in no
211
270
  * store and so in no chat list. Its first write (the first send, or a note,
212
271
  * a kept console command, /memory) creates it in the store under the SAME
213
- * id (materialize), so nothing that already holds the id has to move. A
214
- * model picked on a draft stays in memory and is saved with it. Its system
215
- * config is frozen then too, so abandoned drafts leave nothing behind. */
272
+ * id (materialize, from the host's writeConversation), so nothing that
273
+ * already holds the id has to move. A model picked on a draft stays in
274
+ * memory and is saved with it. Its system config is frozen then too, so
275
+ * abandoned drafts leave nothing behind. */
216
276
  Object.defineProperty(this, "drafts", {
217
277
  enumerable: true,
218
278
  configurable: true,
@@ -235,19 +295,7 @@ export class AgentRuntime {
235
295
  writable: true,
236
296
  value: new WeakMap()
237
297
  });
238
- /* ---- run ---- */
239
- /* One store write at a time per session: every read-modify-write of the
240
- * stored chat (persist, the first-send retitle) goes through here. Each
241
- * re-reads the session first; two overlapping would let one read the other's
242
- * write before persistedLen caught up and take it for another writer, or
243
- * write back a list the other had just shortened (a retitle restoring a
244
- * cleared chat, R37). */
245
- Object.defineProperty(this, "writing", {
246
- enumerable: true,
247
- configurable: true,
248
- writable: true,
249
- value: new Map()
250
- });
298
+ this.chatHost = this.buildChatHost();
251
299
  // Availability and the fallback are read live, so every open chat's
252
300
  // notice must re-render when either moves.
253
301
  if (host.perChatModel)
@@ -274,6 +322,7 @@ export class AgentRuntime {
274
322
  getMemoryProvider: () => this.host.memory?.() ?? null,
275
323
  getCalleeMemoryProvider: (agent) => this.host.calleeMemory?.(agent) ?? null,
276
324
  ...this.host.workerDeps,
325
+ ...(this.host.mutations ? { mutations: this.host.mutations } : {}),
277
326
  });
278
327
  return this.worker;
279
328
  }
@@ -313,6 +362,40 @@ export class AgentRuntime {
313
362
  }
314
363
  return s;
315
364
  }
365
+ /** Copy the engine's half of the session onto this side's, so the object a
366
+ * view holds reads as one SessionState. Only for a session this side holds:
367
+ * reading the engine creates its entry. */
368
+ sync(id, s) {
369
+ const c = this.chat.state(id);
370
+ s.turns = c.turns;
371
+ s.live = c.live;
372
+ s.running = c.running;
373
+ s.error = c.error;
374
+ s.loaded = c.loaded;
375
+ s.pendingCommand = c.pendingCommand;
376
+ s.lastStopReason = c.lastStopReason;
377
+ s.injectedIds = c.injectedIds;
378
+ s.saveBlocked = c.saveBlocked;
379
+ s.mergeTick = c.mergeTick;
380
+ return s;
381
+ }
382
+ /** The engine's turns for a session this side holds, else none. */
383
+ turnsOf(id) {
384
+ return this.map.has(id) ? this.chat.state(id).turns : [];
385
+ }
386
+ /** Reopen a session the engine was told to forget, before this side changes
387
+ * it: the load's first step reopens it at once, and the read it starts
388
+ * merges what is stored under anything added meanwhile. */
389
+ open(id) {
390
+ if (this.forgotten.delete(id))
391
+ void this.chat.load(id, this.chatHost);
392
+ }
393
+ /** A chat known to be empty in the store (a draft, a new chat, an empty saved
394
+ * one) needs no read: the engine opens it loaded with nothing stored. */
395
+ markLoadedEmpty(id) {
396
+ this.forgotten.delete(id);
397
+ this.chat.seedEmpty(id, this.chatHost);
398
+ }
316
399
  notify(id) { this.subs.get(id)?.forEach((fn) => fn()); }
317
400
  notifyGlobal() { this.globalSubs.forEach((fn) => fn()); }
318
401
  /** Mutate a session and notify its view; `global` also pokes the running-badge subscribers. */
@@ -336,10 +419,29 @@ export class AgentRuntime {
336
419
  return () => { set.delete(cb); };
337
420
  }
338
421
  subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
422
+ /** Every session blocked on a tool permission, for a host that shows approvals
423
+ * outside the chat that asked. The budget pseudo-permission is not a tool ask. */
424
+ pendingPerms() {
425
+ const out = [];
426
+ for (const [sessionId, s] of this.map) {
427
+ const p = s.pendingPerm;
428
+ if (!p || p.tool === 'budget')
429
+ continue;
430
+ out.push({
431
+ permId: p.id, sessionId, tool: p.tool, summary: p.summary,
432
+ ...(p.agent ? { agent: p.agent } : {}), ...(p.caller ? { caller: p.caller } : {}),
433
+ ...(p.input ? { input: p.input } : {}),
434
+ // A System One ask is answered once, for its own tool call: never a session allow.
435
+ checkpoint: isCheckpointTool(p.tool) || p.tool === HOOK_ASK_TOOL, askedAt: p.askedAt,
436
+ });
437
+ }
438
+ return out;
439
+ }
339
440
  getState(id) {
340
441
  const s = this.map.get(id);
341
442
  if (!s)
342
443
  return emptyInternal();
444
+ this.sync(id, s);
343
445
  s.modelNotice = this.modelNotice(id, s);
344
446
  return s;
345
447
  }
@@ -347,6 +449,7 @@ export class AgentRuntime {
347
449
  const s = this.map.get(id);
348
450
  if (!s)
349
451
  return 'idle';
452
+ this.sync(id, s);
350
453
  if (s.pendingPerm || s.pendingAsk)
351
454
  return 'waiting';
352
455
  // A running command is a running session. Reporting 'idle' while a command
@@ -361,61 +464,28 @@ export class AgentRuntime {
361
464
  * workspace()/fileService() before its mount effect (CraftPage direct load)
362
465
  * otherwise hits the default null resolver and the store throws
363
466
  * "No file workspace is connected". */
364
- /** Lazily load a session's turns from the store the first time it's focused. */
467
+ /** Lazily load a session's turns from the store the first time it's focused.
468
+ * The engine merges what is stored before anything this page added, keeps a
469
+ * miss unloaded (so the next ask reads again) and treats a read that throws
470
+ * as a miss; onLoaded does this side's part of the read. */
365
471
  async load(id) {
366
472
  if (!id)
367
473
  return;
368
474
  this.ws();
369
- const s = this.ensure(id);
370
- if (s.loaded)
371
- return;
372
- /* A read that THROWS is a miss, not an error for the caller: send awaits
373
- * this, and a rejected load would drop the send outright while the next
374
- * attempt fails the same way. As a miss it goes down the path that is
375
- * covered: the exchange runs, saveBlocked says it is not kept, and Retry
376
- * (retryLoad) reads again. */
377
- let ses;
378
- try {
379
- ses = await this.host.sessions.get(id);
380
- }
381
- catch {
382
- ses = null;
383
- }
384
- // A concurrent load (the focus effect beside a send) landed first, and its
385
- // merge already holds everything; merging again would double the chat.
386
- if (s.loaded)
475
+ this.ensure(id);
476
+ this.forgotten.delete(id);
477
+ await this.chat.load(id, this.chatHost);
478
+ }
479
+ /** What reading the stored chat for a LOAD means on this side, hit or miss:
480
+ * the chat's stored model, and the system-config snapshot of a chat from
481
+ * before the snapshot existed. A concurrent load that landed first already
482
+ * did both, and merging again is the engine's to refuse. */
483
+ onLoaded(id, ses) {
484
+ if (this.chat.state(id).loaded)
387
485
  return;
388
- /* MERGE, never replace: anything this page added before the stored chat
389
- * arrived -- a /model notice, say -- comes after it rather than vanishing.
390
- * A miss leaves those turns alone too. */
391
- const pre = s.turns;
392
- if (ses) {
393
- s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
394
- s.persistedLen = ses.messages.length;
395
- }
396
- s.model = ses?.model ?? null;
397
- /* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
398
- * it permanent: the session could arrive a moment later and this would
399
- * never look again, because the guard above returns on `loaded`.
400
- *
401
- * It arrives a moment later more often than it sounds. A thread MOVED into
402
- * a project (the handover offer) is read by the project's runtime, and if
403
- * anything asked for it before the move landed, the runtime cached "no such
404
- * session, zero turns" and showed an empty transcript for a conversation
405
- * that was sitting on disk. Reported as "it brought the chat over but was
406
- * empty"; switching away and back appeared to fix it only because that went
407
- * through a different runtime.
408
- *
409
- * Leaving it unloaded costs one more read the next time something asks,
410
- * and `load` is called from an effect keyed on the session and the runtime
411
- * -- not per render -- so a genuinely absent session does not spin. */
412
- if (ses)
413
- s.loaded = true;
414
- s.loadMissed = !ses;
415
- // Loaded with nothing of this page's to save: nothing is unsaved any more.
416
- // With something, the persist below clears the flag once it lands.
417
- if (ses && !pre.length)
418
- s.saveBlocked = null;
486
+ const s = this.map.get(id);
487
+ if (s)
488
+ s.model = ses?.model ?? null;
419
489
  /* MIGRATION. A conversation from before the snapshot existed has none, and
420
490
  * would otherwise keep following the live Settings default forever. Opening
421
491
  * it freezes it at what it would have used at this moment, so nothing
@@ -423,38 +493,18 @@ export class AgentRuntime {
423
493
  * freeze would re-decide a live chat at today's defaults. */
424
494
  if (!sessionSystemConfig(id))
425
495
  this.freezeSystemConfig(id);
426
- this.notify(id);
427
- // What was added before the load could not be saved then (see persist).
428
- if (ses && pre.length)
429
- void this.persist(id);
430
496
  }
431
497
  /** Try the stored chat again after a load failed (the unsaved-chat notice's
432
498
  * Retry): load it -- merging what this page added meanwhile after it, as
433
499
  * `load` always does -- then save the merged list. True once it is saved;
434
- * false when the load missed again (or threw), and `saveBlocked` stays. */
500
+ * false when the load missed again (or threw), and `saveBlocked` stays. The
501
+ * engine says so globally, because the session list may have dropped this
502
+ * chat while its load kept missing. */
435
503
  async retryLoad(id) {
436
- await this.load(id);
437
- const s = this.map.get(id);
438
- if (!s?.loaded)
439
- return false;
440
- // persistNow swallows its own failures; this catch keeps the promise a
441
- // Retry button awaits from ever rejecting.
442
- await this.persist(id).catch(() => { });
443
- /* GLOBAL, because the session list is. While the load kept missing, the
444
- * list the page shows (and the header's title, read from it) could drop
445
- * this chat; it is back now, and only a global change makes the page
446
- * re-read the list. Without it the recovered chat stayed missing from the
447
- * list, titled "New chat", until a reload. */
448
- this.notifyGlobal();
449
- return s.saveBlocked === null;
450
- }
451
- /** Raise the unsaved-chat flag when `id` is still not loaded after a load
452
- * was tried, so the page says its new messages are not being kept. */
453
- flagIfUnsaved(id) {
454
- const s = this.map.get(id);
455
- if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
456
- this.patch(id, { saveBlocked: 'load-failed' });
457
- }
504
+ this.ws();
505
+ this.ensure(id);
506
+ this.forgotten.delete(id);
507
+ return this.chat.retryLoad(id, this.chatHost);
458
508
  }
459
509
  async openInitial() {
460
510
  if (this.initInFlight)
@@ -476,15 +526,16 @@ export class AgentRuntime {
476
526
  // second one, for two callers asking moments apart.
477
527
  if (!empty) {
478
528
  for (const d of this.drafts) {
479
- const st = this.map.get(d);
480
- if (st && st.turns.length === 0 && !st.running)
529
+ if (!this.map.has(d))
530
+ continue;
531
+ const st = this.chat.state(d);
532
+ if (st.turns.length === 0 && !st.running)
481
533
  return d;
482
534
  }
483
535
  }
484
536
  if (empty) {
485
537
  const s = this.ensure(empty.id);
486
- s.loaded = true;
487
- s.persistedLen = 0;
538
+ this.markLoadedEmpty(empty.id);
488
539
  // A model picked on this empty chat before the reload is still its model.
489
540
  s.model = empty.model ?? null;
490
541
  return empty.id;
@@ -504,15 +555,14 @@ export class AgentRuntime {
504
555
  const id = store?.pendingChat();
505
556
  if (!store || !id)
506
557
  return null;
507
- if (list.some((s) => s.id === id) || (this.map.get(id)?.turns.length ?? 0) > 0) {
558
+ if (list.some((s) => s.id === id) || this.turnsOf(id).length > 0) {
508
559
  store.setPendingChat(null);
509
560
  return null;
510
561
  }
511
562
  if (!(this.map.get(id)?.input ?? store.get(id)))
512
563
  return null;
513
- const s = this.ensure(id);
514
- s.loaded = true;
515
- s.persistedLen = 0;
564
+ this.ensure(id);
565
+ this.markLoadedEmpty(id);
516
566
  this.drafts.add(id);
517
567
  return id;
518
568
  }
@@ -528,31 +578,31 @@ export class AgentRuntime {
528
578
  }
529
579
  createDraft() {
530
580
  const id = `ses_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
531
- const s = this.ensure(id);
532
- s.loaded = true;
533
- s.persistedLen = 0;
581
+ this.ensure(id);
582
+ this.markLoadedEmpty(id);
534
583
  this.drafts.add(id);
535
584
  this.host.composerDrafts?.setPendingChat(id);
536
585
  return id;
537
586
  }
538
587
  /** Whether `id` is a draft that has not been saved yet. */
539
588
  isDraft(id) { return this.drafts.has(id); }
540
- /** Save a draft under its own id; a no-op for any other chat. Concurrent
541
- * callers share one create. A failure leaves it a draft, so the next write
542
- * tries again. */
589
+ /** Save a draft under its own id, resolving the created session; null for
590
+ * any other chat. Concurrent callers share one create. A failure leaves it a
591
+ * draft, so the next write tries again. */
543
592
  materialize(id) {
544
593
  if (!this.drafts.has(id))
545
- return Promise.resolve();
594
+ return Promise.resolve(null);
546
595
  const inFlight = this.materializing.get(id);
547
596
  if (inFlight)
548
597
  return inFlight;
549
598
  const run = (async () => {
550
- await this.host.sessions.create('New chat', this.surface, { id });
599
+ const created = await this.host.sessions.create('New chat', this.surface, { id });
551
600
  this.drafts.delete(id);
552
601
  if (this.host.composerDrafts?.pendingChat() === id)
553
602
  this.host.composerDrafts.setPendingChat(null);
554
603
  this.freezeSystemConfig(id);
555
604
  this.notifyGlobal();
605
+ return created;
556
606
  })();
557
607
  this.materializing.set(id, run);
558
608
  run.finally(() => this.materializing.delete(id)).catch(() => { });
@@ -568,9 +618,8 @@ export class AgentRuntime {
568
618
  * shared list that this narrow fix could not reach. Keying the store is
569
619
  * the repair that comment asked for. */
570
620
  const ses = await this.host.sessions.create('New chat', this.surface);
571
- const s = this.ensure(ses.id);
572
- s.loaded = true;
573
- s.persistedLen = 0;
621
+ this.ensure(ses.id);
622
+ this.markLoadedEmpty(ses.id);
574
623
  // The system prompt is the head of the cached prefix, so this conversation's
575
624
  // config is decided HERE and read from the snapshot after -- see
576
625
  // sessionSystemConfig. Settings decides what the next one starts with.
@@ -580,8 +629,12 @@ export class AgentRuntime {
580
629
  }
581
630
  async deleteSession(id) {
582
631
  this.ws();
583
- this.map.get(id)?.abort?.abort();
632
+ // Aborts its run, and nothing the run writes late lands anywhere.
633
+ this.denyAllPerms(id);
634
+ this.chat.forget(id);
635
+ this.forgotten.add(id);
584
636
  this.map.delete(id);
637
+ this.blocked.delete(id);
585
638
  this.subs.delete(id);
586
639
  this.drafts.delete(id);
587
640
  this.host.composerDrafts?.set(id, '');
@@ -640,13 +693,12 @@ export class AgentRuntime {
640
693
  * plus the turns after it -- not the raw transcript, which overstates a chat
641
694
  * that has already folded. */
642
695
  canHold(id, model) {
643
- const s = this.map.get(id);
644
- if (!s)
696
+ if (!this.map.has(id))
645
697
  return true;
646
698
  const window = this.host.modelInfo(model).contextWindow ?? 128000;
647
699
  if (window <= 0)
648
700
  return true;
649
- return this.fillTokens(s.turns) / window < COMPACT_AT_FILL;
701
+ return this.fillTokens(this.turnsOf(id)) / window < COMPACT_AT_FILL;
650
702
  }
651
703
  /** Switch this chat (only) to `model`; null when it switched. Refused, with
652
704
  * nothing changed: before the chat has loaded (there is nothing to measure,
@@ -654,16 +706,17 @@ export class AgentRuntime {
654
706
  * the model is not available (it would silently run on the fallback); and
655
707
  * when it cannot hold the chat (switching never folds, spec 3.5). */
656
708
  setChatModel(id, model) {
657
- if (!this.map.get(id)?.loaded)
709
+ if (!this.map.has(id) || !this.chat.state(id).loaded)
658
710
  return 'not-loaded';
659
711
  if (!this.available(model))
660
712
  return 'unavailable';
661
713
  if (!this.canHold(id, model))
662
714
  return 'too-large';
663
715
  this.patch(id, { model });
664
- // A draft keeps the pick in memory; picking a model is not using the chat.
716
+ /* A draft keeps the pick in memory; picking a model is not using the chat.
717
+ * A saved chat is saved now, carrying the pick (writeConversation stamps it). */
665
718
  if (!this.drafts.has(id))
666
- void this.persist(id);
719
+ void this.chat.save(id, this.chatHost);
667
720
  return null;
668
721
  }
669
722
  /* ---- view mutators ---- */
@@ -673,23 +726,23 @@ export class AgentRuntime {
673
726
  * starts a fresh recording stream for what follows. */
674
727
  clear(id) {
675
728
  const s = this.ensure(id);
729
+ this.open(id);
676
730
  s.recId = undefined;
677
731
  s.recPrompt = undefined;
678
732
  s.recImages = undefined;
679
733
  s.recEvents = [];
680
- this.patch(id, { turns: [], error: '', suggestions: [], approvalSuggestion: null }, true);
681
- void this.persist(id);
734
+ s.suggestions = [];
735
+ s.approvalSuggestion = null;
736
+ this.blocked.delete(id);
737
+ // The engine empties the transcript, notifies globally and saves.
738
+ this.chat.clear(id, this.chatHost);
682
739
  }
683
740
  /** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
684
741
  * sent to the model, just shown in the transcript. Returns the turn's id. */
685
742
  note(id, text, extra) {
686
- const s = this.ensure(id);
687
- // The random suffix: two notes in one millisecond (a fold beside a budget
688
- // warning) must not share an id, or a later update by id lands on both.
689
- const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
690
- this.patch(id, { turns: [...s.turns, turn] });
691
- void this.persist(id);
692
- return turn.id;
743
+ this.ensure(id);
744
+ this.open(id);
745
+ return this.chat.note(id, this.chatHost, text, extra);
693
746
  }
694
747
  /* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
695
748
  * event's contract: compaction is lossy, and a person who cannot see that it
@@ -698,29 +751,21 @@ export class AgentRuntime {
698
751
  *
699
752
  * Recorded as an unsent note. The collapse already happened inside the run's
700
753
  * own message array, so a seam that counted as conversation would describe
701
- * the compaction back to the model on every later turn. */
754
+ * the compaction back to the model on every later turn. The body rides along
755
+ * so the seam can be READ. */
702
756
  noteCheckpoint(id, collapsed, body) {
703
- const n = collapsed === 1 ? '1 message' : `${collapsed} messages`;
704
- /* CHECKPOINT_MARKER, not the literal it happens to equal: a surface that
705
- * recognizes seams (the transcript band, the context strip) looks for the
706
- * marker the harness writes, and two copies of one string is how they end
707
- * up looking for different things.
708
- *
709
- * The body rides along so the seam can be READ. It was dropped here before
710
- * -- the event carries it, and this took only the count -- which left the
711
- * one question a seam raises with no answer anywhere in the UI. */
712
- return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
757
+ this.ensure(id);
758
+ this.open(id);
759
+ return this.chat.noteCheckpoint(id, this.chatHost, collapsed, body);
713
760
  }
714
761
  /** Show a message in the transcript as YOUR turn, without adding it to what
715
762
  * gets sent. Used for injected messages: the real copy is already in the
716
763
  * run's own message list (the loop appended it), so an included turn here
717
764
  * would send it twice. */
718
765
  noteUser(id, text) {
719
- const s = this.ensure(id);
720
- const turn = { id: `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'user', content: text }] };
721
- this.patch(id, { turns: [...s.turns, turn] });
722
- void this.persist(id);
723
- return turn.id;
766
+ this.ensure(id);
767
+ this.open(id);
768
+ return this.chat.noteUser(id, this.chatHost, text);
724
769
  }
725
770
  setAttachments(id, fn) {
726
771
  this.patch(id, { attachments: fn(this.ensure(id).attachments) });
@@ -729,11 +774,9 @@ export class AgentRuntime {
729
774
  * refused -- no state change, no persist: the folded view stands for it, so
730
775
  * including or dropping it would change nothing the next request carries. */
731
776
  toggleInclude(id, turnId) {
732
- const s = this.ensure(id);
733
- if (isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
734
- return;
735
- this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
736
- void this.persist(id);
777
+ this.ensure(id);
778
+ this.open(id);
779
+ this.chat.toggleInclude(id, this.chatHost, turnId);
737
780
  }
738
781
  /**
739
782
  * Ask the user to approve something the HOST is about to do, through the same
@@ -745,19 +788,21 @@ export class AgentRuntime {
745
788
  * Two different approval dialogs for the same act is how people learn to click
746
789
  * the one that appears more often without reading it.
747
790
  *
791
+ * The session allows are the engine's, so an always-allow given here and one
792
+ * given to the run answer each other.
793
+ *
748
794
  * Resolves false if the session is gone, so a caller can never read a dropped
749
795
  * prompt as consent.
750
796
  */
751
797
  requestPermission(id, tool, summary) {
752
- const s = this.map.get(id);
753
- if (!s)
798
+ if (!this.map.has(id))
754
799
  return Promise.resolve(false);
755
- if (s.allowed.has(allowKey(undefined, tool)))
800
+ if (this.chat.isAllowed(id, undefined, tool))
756
801
  return Promise.resolve(true);
757
802
  return new Promise((resolve) => {
758
803
  // 'you': this path exists for something the USER typed (a `>pd` command),
759
804
  // never for the run loop, which raises its own asks through onPermission.
760
- this.patch(id, { pendingPerm: { tool, summary, requester: 'you', resolve } }, true);
805
+ this.raisePerm(id, { id: this.nextPermId++, tool, summary, requester: 'you', askedAt: Date.now(), resolve });
761
806
  });
762
807
  }
763
808
  /**
@@ -771,7 +816,7 @@ export class AgentRuntime {
771
816
  * already has.
772
817
  *
773
818
  * IT IS RECORDED OUT OF CONTEXT. Persistence and context-inclusion are
774
- * separate switches here - persist() writes every turn, while only `included`
819
+ * separate switches here - every turn is saved, while only `included`
775
820
  * turns are sent to the model - and a command should default to written-down
776
821
  * but not re-sent. A transcript message is the most expensive kind of
777
822
  * context: it is paid again on every subsequent turn for the rest of the
@@ -783,59 +828,94 @@ export class AgentRuntime {
783
828
  */
784
829
  /** Mark a console command as running. Paired with recordCommand, which clears
785
830
  * it - so the indicator cannot outlive the command even if it fails, since the
786
- * failure is itself recorded. */
831
+ * failure is itself recorded. Global: the session list shows it running. */
787
832
  beginCommand(id, command, startedAt) {
788
- this.patch(id, { pendingCommand: { command, startedAt } }, true);
833
+ this.ensure(id);
834
+ this.open(id);
835
+ this.chat.beginCommand(id, command, startedAt);
836
+ this.notify(id);
837
+ this.notifyGlobal();
789
838
  }
790
839
  /** Give up on a command that will never settle (the surface unmounted, the
791
840
  * session was closed). Clears the indicator WITHOUT recording a result,
792
841
  * because there is no honest result to record. */
793
842
  abandonCommand(id) {
794
- if (!this.map.get(id)?.pendingCommand)
843
+ if (!this.map.has(id) || !this.chat.state(id).pendingCommand)
795
844
  return;
796
- this.patch(id, { pendingCommand: null }, true);
845
+ this.chat.abandonCommand(id);
846
+ this.notify(id);
847
+ this.notifyGlobal();
797
848
  }
798
849
  recordCommand(id, command, output, ok) {
799
- const s = this.ensure(id);
800
- const callId = `cmd_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
801
- const turn = {
802
- id: callId,
803
- // Not included: kept on disk, out of the context window until Keep.
804
- included: false,
805
- messages: [
806
- {
807
- role: 'assistant',
808
- content: null,
809
- tool_calls: [{
810
- id: callId,
811
- type: 'function',
812
- // `ok` rides along in the arguments deliberately. On a synthetic call
813
- // it is part of the record, and once kept it tells the model the
814
- // command failed - which it would otherwise have to infer from
815
- // whatever the tool happened to print.
816
- function: { name: 'console_command', arguments: JSON.stringify({ command, ok }) },
817
- }],
818
- },
819
- {
820
- role: 'tool',
821
- tool_call_id: callId,
822
- content: output || (ok ? '(no output)' : '(failed, no output)'),
823
- },
824
- ],
825
- };
826
- this.patch(id, { turns: [...s.turns, turn], pendingCommand: null });
827
- void this.persist(id);
850
+ this.ensure(id);
851
+ this.open(id);
852
+ this.chat.recordCommand(id, this.chatHost, command, output, ok);
853
+ }
854
+ /** Answer a permission only if it is still the request the caller was shown.
855
+ * Returns false, and changes nothing, when it was answered or replaced. */
856
+ resolvePermIf(sessionId, permId, allow, always = false, note) {
857
+ if (this.map.get(sessionId)?.pendingPerm?.id !== permId)
858
+ return false;
859
+ this.resolvePerm(sessionId, allow, always, note);
860
+ return true;
828
861
  }
829
862
  /** `note` is the person's reason for a deny (the plan sheet's Deny with a
830
- * note); it reaches the model after the denial text. Ignored with allow. */
863
+ * note); it reaches the model after the denial text. Ignored with allow.
864
+ * An always-allow is the engine's session allow (it never holds a checkpoint). */
831
865
  resolvePerm(id, allow, always = false, note) {
832
866
  const s = this.map.get(id);
833
867
  if (!s?.pendingPerm)
834
868
  return;
835
- if (allow && always && !isCheckpointTool(s.pendingPerm.tool))
836
- s.allowed.add(allowKey(s.pendingPerm.agent, s.pendingPerm.tool));
869
+ if (allow && always)
870
+ this.chat.allow(id, s.pendingPerm.agent, s.pendingPerm.tool);
837
871
  s.pendingPerm.resolve(allow, allow ? undefined : note);
838
- this.patch(id, { pendingPerm: null }, true);
872
+ // The next queued ask, if any, is the card now.
873
+ this.patch(id, { pendingPerm: this.permQueues.get(id)?.shift() ?? null }, true);
874
+ }
875
+ /** Show an ask, or queue it behind the one on screen. */
876
+ raisePerm(id, perm) {
877
+ if (!this.ensure(id).pendingPerm) {
878
+ this.patch(id, { pendingPerm: perm }, true);
879
+ return;
880
+ }
881
+ let q = this.permQueues.get(id);
882
+ if (!q) {
883
+ q = [];
884
+ this.permQueues.set(id, q);
885
+ }
886
+ q.push(perm);
887
+ this.notifyGlobal();
888
+ }
889
+ /** Answer the shown ask and every queued one with a deny, and forget them:
890
+ * nothing is left waiting on a card that can no longer be shown. */
891
+ denyAllPerms(id) {
892
+ const s = this.map.get(id);
893
+ const shown = s?.pendingPerm ?? null;
894
+ if (s)
895
+ s.pendingPerm = null;
896
+ shown?.resolve(false);
897
+ for (const p of this.permQueues.get(id)?.splice(0) ?? [])
898
+ p.resolve(false);
899
+ this.permQueues.delete(id);
900
+ }
901
+ /** Answer `permId` with a deny wherever it waits: on screen (the next one
902
+ * then shows) or in the queue (it is dropped). */
903
+ denyPerm(id, permId) {
904
+ if (this.map.get(id)?.pendingPerm?.id === permId) {
905
+ this.resolvePerm(id, false);
906
+ return;
907
+ }
908
+ const q = this.permQueues.get(id);
909
+ const i = q ? q.findIndex((p) => p.id === permId) : -1;
910
+ if (!q || i < 0)
911
+ return;
912
+ const [perm] = q.splice(i, 1);
913
+ perm.resolve(false);
914
+ }
915
+ /** A tool result a System One check or hook blocked in this page, for the
916
+ * tool card's "Show the result"; undefined once the page has reloaded. */
917
+ blockedResult(sessionId, callId) {
918
+ return this.blocked.get(sessionId)?.get(callId);
839
919
  }
840
920
  resolveAsk(id, answers) {
841
921
  const s = this.map.get(id);
@@ -858,115 +938,13 @@ export class AgentRuntime {
858
938
  this.note(id, `Could not save that approval: ${String(e?.message || e)}`);
859
939
  });
860
940
  }
861
- stop(id) { this.map.get(id)?.abort?.abort(); }
941
+ stop(id) { this.chat.stop(id); }
862
942
  /** Push user messages into the run already in flight on this session.
863
943
  * `hard` cuts the current model stream so they land immediately instead of at
864
944
  * the next turn boundary. Returns false when nothing is running -- the caller
865
945
  * keeps the message queued rather than losing it. */
866
946
  inject(id, messages, hard) {
867
- const s = this.map.get(id);
868
- if (!s || !s.running || s.runId == null)
869
- return false;
870
- this.getWorker().inject(s.runId, messages, hard);
871
- return true;
872
- }
873
- queueWrite(id, write) {
874
- // Nothing queued: start now, at the same tick as before this queue existed.
875
- // A failed write must not stop the ones queued behind it.
876
- const prev = this.writing.get(id);
877
- const next = prev ? prev.catch(() => { }).then(write) : write();
878
- this.writing.set(id, next);
879
- next.finally(() => { if (this.writing.get(id) === next)
880
- this.writing.delete(id); }).catch(() => { });
881
- return next;
882
- }
883
- persist(id) {
884
- return this.queueWrite(id, async () => {
885
- // The first write to a draft saves it first; a failed save skips this
886
- // write, and the draft stays a draft for the next one.
887
- if (this.drafts.has(id)) {
888
- try {
889
- await this.materialize(id);
890
- }
891
- catch {
892
- return;
893
- }
894
- }
895
- await this.persistNow(id);
896
- });
897
- }
898
- async persistNow(id) {
899
- const s = this.map.get(id);
900
- /* Never before the stored transcript has loaded (ruling R34). Until then
901
- * `turns` holds only what this page added -- a /model notice, say -- and
902
- * saving it would write that over the whole stored chat. Such notes stay
903
- * in memory -- and once a load has actually failed, say so. */
904
- if (!s?.loaded) {
905
- this.flagIfUnsaved(id);
906
- return;
907
- }
908
- // A read that throws is a save that did not happen: persistedLen and
909
- // saveBlocked stay as they are, and the next write tries again.
910
- let ses;
911
- try {
912
- ses = await this.host.sessions.get(id);
913
- }
914
- catch {
915
- return;
916
- }
917
- if (!ses)
918
- return;
919
- /* Shorten the stored chat (retry, /clear) only when it is the length this
920
- * page last saw (ruling R36). Otherwise another writer appended since, and
921
- * this page's shorter list would delete that writer's newer turns. */
922
- const known = ses.messages.length === s.persistedLen;
923
- let turns = s.turns;
924
- if (!known) {
925
- /* Another writer changed it since. Saving this page's list as it stands
926
- * would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
927
- * 1, over the other tab's q1 -- so merge by turn id instead, over what
928
- * was just read: stored order first, this page's version of any turn
929
- * both hold (last writer wins per turn), then this page's turns the
930
- * store does not have, in this page's order. A flat (daemon) chat's
931
- * turns get the same `stored-<i>` ids here as at load, so a turn this
932
- * page loaded from it matches and is not added twice.
933
- *
934
- * The page then shows the merged chat. Nothing it holds is dropped, and
935
- * the store loses nothing: the merge holds every stored turn, so the
936
- * save may rewrite freely (allowTruncate below), which a flat store's
937
- * conversion to turns needs. */
938
- const stored = turnsFromSessionMessages(ses.messages);
939
- const mine = new Map(s.turns.map((t) => [t.id, t]));
940
- const storedIds = new Set(stored.map((t) => t.id));
941
- /* Only the FIRST stored turn with an id takes this page's version. A
942
- * second one with the same id is another turn that collided (ids are
943
- * meant to be unique; turnsFromSessionMessages keeps its synthetic ones
944
- * so), and replacing it too would write one turn twice and lose the
945
- * other on this rewrite. */
946
- const used = new Set();
947
- const merged = stored.map((t) => {
948
- const own = mine.get(t.id);
949
- if (!own || used.has(t.id))
950
- return t;
951
- used.add(t.id);
952
- return own;
953
- });
954
- turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
955
- alog(`session ${id}: stored has ${ses.messages.length} messages, this page last saw ${s.persistedLen}; another writer changed it, so this save merges both by turn (${turns.length} turns)`);
956
- this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
957
- }
958
- ses.messages = turns;
959
- if (s.model)
960
- ses.model = s.model;
961
- try {
962
- // Known: this page's list IS the chat, and may shorten it (R36). A
963
- // merge: holds every stored turn, so rewriting cannot lose one.
964
- await this.host.sessions.save(ses, { allowTruncate: true });
965
- s.persistedLen = turns.length;
966
- if (s.saveBlocked)
967
- this.patch(id, { saveBlocked: null });
968
- }
969
- catch { /* a failed save leaves persistedLen as it was */ }
947
+ return this.chat.inject(id, this.chatHost, messages, hard);
970
948
  }
971
949
  /** Filename of the system prompt this surface will run with next — what the
972
950
  * context drawer's active-prompt chip shows. Mirrors composeSystem's own
@@ -1036,21 +1014,7 @@ export class AgentRuntime {
1036
1014
  const s = this.ensure(id);
1037
1015
  // systemExtra (e.g. Craft's document-creation directive) leads the context.
1038
1016
  const blocks = this.systemExtra ? [this.systemExtra] : [];
1039
- if (svc) {
1040
- const src = svc.kind === 'directory' ? `local folder "${svc.label}"`
1041
- : svc.kind === 'sidecar' ? `connected sidecar machine "${svc.label}"`
1042
- : `virtual in-browser workspace "${svc.label}"`;
1043
- const flags = `${svc.isLocalDisk ? "on the user's real disk" : 'in-browser'}, ${svc.writable ? 'writable' : 'read-only'}`;
1044
- blocks.push(`--- Workspace ---\n` +
1045
- `Connected source: ${src} (${flags}).\n` +
1046
- `All file paths are relative to this workspace root (POSIX, no leading slash, e.g. "prompts/base.prmd"). ` +
1047
- `There is no separate working directory or shell — inspect the workspace with list_files / read_file / ` +
1048
- `stat_file / search_files, and change it with write_file / create_file / delete_file / rename_file. ` +
1049
- `The browser does not expose the folder's absolute path, so report locations relative to the root.`);
1050
- }
1051
- else {
1052
- blocks.push(`--- Workspace ---\nNo file workspace is connected, so the file tools are unavailable. Say so rather than guessing about files.`);
1053
- }
1017
+ blocks.push(workspaceBlock(svc));
1054
1018
  // Host-supplied extra system-context blocks (project instructions, project
1055
1019
  // memory, editor buffer …), resolved per send against the active FileService.
1056
1020
  for (const p of this.host.contextProviders ?? []) {
@@ -1082,526 +1046,120 @@ export class AgentRuntime {
1082
1046
  systemPath,
1083
1047
  systemText,
1084
1048
  contextText: blocks.join('\n\n'),
1085
- toolsText: p.tools ? TOOLS_MARKER : '',
1049
+ // A user's own prompt that still prints {{ tools }} gets the sent tools there.
1050
+ toolsText: TOOLS_MARKER,
1086
1051
  params: this.host.systemParams?.(),
1087
1052
  memoryIndex: this.host.memoryIndex,
1088
1053
  });
1089
1054
  }
1090
- /** Send a message and run the agent for `id`. Concurrent with other sessions:
1091
- * each run is its own AbortController + watchdog and streams into its own slice. */
1092
- async send(id, text, images, opts) {
1093
- const s = this.ensure(id);
1094
- if ((!text && images.length === 0) || s.running)
1095
- return;
1096
- /* The stored chat first (ruling R35). Nothing stops the composer before it
1097
- * lands, and a send built on the empty pre-load list would run with no
1098
- * history, retitle the chat as if it were new, and then be wiped by the
1099
- * load or never saved. Also retries a load that missed. */
1100
- if (!s.loaded) {
1101
- await this.load(id);
1102
- // Another send started while this one waited (ruling R37): treat this
1103
- // one like a send during a run -- nothing happens, the draft stays.
1104
- if (s.running)
1105
- return;
1055
+ /* ---- run ---- */
1056
+ /* R4 on this host. The engine freezes a conversation's system after its first
1057
+ * turn; this side drops it before every send and retry, so each turn composes
1058
+ * afresh, as it always has. Nearly every input of composeSystem is something
1059
+ * the person changes between turns, and most of them never pass through this
1060
+ * runtime: the persona and a parts override arrive in SendOpts, the mode is
1061
+ * setMode, and the editor buffer, the current Craft document, the
1062
+ * destination, project instructions, attached documents, the workspace and
1063
+ * the chosen system prompt are read live from the host and its stores. A
1064
+ * reset per user action would miss every one of those it cannot see.
1065
+ * `stored: false`: this host never stores a system (withoutSystem), so the
1066
+ * engine has nothing to remove and does not read the store to look. */
1067
+ recomposeSystem(id) {
1068
+ this.chat.resetSystem(id, { stored: false });
1069
+ }
1070
+ /** A send or retry's context, queued for the engine's guard to start. */
1071
+ queueTurn(id, opts, text, images) {
1072
+ const t = {
1073
+ opts, text, images, ui: this.ensure(id), priorInput: '', priorAttachments: [], allowed: false, userTurnId: '',
1074
+ startedAt: Date.now(), recBuf: [], turnStart: 0, model: '', priced: null,
1075
+ calleeSpend: { inTok: 0, outTok: 0, cost: 0 }, control: null,
1076
+ };
1077
+ const q = this.pending.get(id);
1078
+ if (q)
1079
+ q.push(t);
1080
+ else
1081
+ this.pending.set(id, [t]);
1082
+ return t;
1083
+ }
1084
+ /** A turn that never reached the guard (refused before it started) leaves the queue. */
1085
+ dropTurn(id, t) {
1086
+ const q = this.pending.get(id);
1087
+ if (q) {
1088
+ const at = q.indexOf(t);
1089
+ if (at >= 0)
1090
+ q.splice(at, 1);
1091
+ if (!q.length)
1092
+ this.pending.delete(id);
1106
1093
  }
1107
- // Only a LOADED empty chat is new. One whose load missed again is empty
1108
- // because it was never read, and retitling it would write this message's
1109
- // text over the stored chat's title while every other write is held back.
1110
- const isFirst = s.loaded && s.turns.length === 0;
1111
- const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
1112
- const nextTurns = [...s.turns, userTurn];
1113
- s.toolMs = {};
1114
- /* OPTIMISTIC, and the ordering here is the whole point.
1115
- *
1116
- * The feature guard used to run FIRST, and on the web host it is a network
1117
- * call (src/guards/apiGuard.ts fetches /entitlements). For that entire
1118
- * round trip the composer still held the text, no turn existed and
1119
- * `running` was false -- pressing send looked like it had done nothing, for
1120
- * seconds. Reported from use.
1121
- *
1122
- * The old order existed so that "a denial loses nothing". That promise is
1123
- * kept below instead: a denial puts the turn and the draft back. */
1124
- const priorInput = s.input;
1125
- const priorAttachments = s.attachments;
1094
+ if (this.active.get(id) === t)
1095
+ this.active.delete(id);
1096
+ }
1097
+ /** The turn the engine is running on `id`; it always started one before asking. */
1098
+ turnFor(id) {
1099
+ const t = this.active.get(id);
1100
+ if (!t)
1101
+ throw new Error(`no turn in flight for ${id}`);
1102
+ return t;
1103
+ }
1104
+ /* OPTIMISTIC, and the ordering here is the whole point.
1105
+ *
1106
+ * The feature guard used to run FIRST, and on the web host it is a network
1107
+ * call (src/guards/apiGuard.ts fetches /entitlements). For that entire round
1108
+ * trip the composer still held the text, no turn existed and `running` was
1109
+ * false -- pressing send looked like it had done nothing, for seconds.
1110
+ * Reported from use.
1111
+ *
1112
+ * The engine shows the turn and reads as running before it asks the guard;
1113
+ * the guard (chatHost.guard) empties the composer in the same tick. The old
1114
+ * order existed so that "a denial loses nothing", and that is kept too: a
1115
+ * denial puts the turn (the engine) and the draft (here) back. */
1116
+ startTurn(id) {
1117
+ const q = this.pending.get(id);
1118
+ if (!q)
1119
+ return null;
1120
+ /* Which waiting send this is: the engine has just put its user turn last.
1121
+ * Two sends that both waited on the load race for it, and the one the
1122
+ * engine refused leaves the queue when its send returns. */
1123
+ const turns = this.chat.state(id).turns;
1124
+ const last = turns[turns.length - 1]?.messages[0];
1125
+ const text = last ? userText(last) : '';
1126
+ const images = last ? userImages(last) : [];
1127
+ const at = q.findIndex((x) => x.text === text && x.images.length === images.length && x.images.every((u, i) => u === images[i]));
1128
+ const [t] = q.splice(at >= 0 ? at : 0, 1);
1129
+ if (!q.length)
1130
+ this.pending.delete(id);
1131
+ if (!t)
1132
+ return null;
1133
+ t.userTurnId = turns[turns.length - 1]?.id ?? '';
1134
+ this.active.set(id, t);
1135
+ const s = t.ui;
1136
+ t.priorInput = s.input;
1137
+ t.priorAttachments = s.attachments;
1126
1138
  /* The box empties only when what it held is what is being sent. A retry,
1127
1139
  * a follow-up chip or a host's own send carries other text, and wiping
1128
1140
  * what the user was typing meanwhile would lose it (the composer clears
1129
1141
  * its own send before calling here, so that path still ends empty). */
1130
- const keptInput = priorInput.trim() && priorInput.trim() !== text.trim() ? priorInput : '';
1131
- this.patch(id, {
1132
- turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: keptInput, attachments: [], suggestions: [],
1133
- policyNotes: [], degradedModel: null, injectedIds: [], lastStopReason: null,
1134
- }, true);
1135
- // Still not loaded after that retry: this exchange runs and shows, but will
1136
- // not be saved. Flagged once the turn exists, so the notice is up for the
1137
- // whole run rather than only after it.
1138
- this.flagIfUnsaved(id);
1139
- // Feature guard — the runtime is the chokepoint for every /agent run (send
1140
- // and retry), so a quota/plan guard registered later covers this surface too.
1141
- const gate = await this.host.guard('llm-execution');
1142
- if (!gate.allowed) {
1143
- // Roll the optimism back: drop the turn, restore what was typed (and any
1144
- // pasted images), and stop reading as running. A turn left behind here is
1145
- // a message the user believes they sent and the agent never saw.
1146
- this.patch(id, {
1147
- turns: this.ensure(id).turns.filter((t) => t.id !== userTurn.id),
1148
- input: priorInput || text,
1149
- attachments: priorAttachments.length ? priorAttachments : images,
1150
- running: false,
1151
- live: null,
1152
- error: gate.reason,
1153
- }, true);
1142
+ const keptInput = t.priorInput.trim() && t.priorInput.trim() !== t.text.trim() ? t.priorInput : '';
1143
+ this.patch(id, { input: keptInput, attachments: [], suggestions: [], policyNotes: [], degradedModel: null }, true);
1144
+ return t;
1145
+ }
1146
+ /** Send a message and run the agent for `id`. Concurrent with other sessions:
1147
+ * each run is its own AbortController + watchdog and streams into its own
1148
+ * slice. The engine loads the stored chat first (ruling R35), refuses a send
1149
+ * while one runs (R37), and settles once every write the turn queued has. */
1150
+ async send(id, text, images, opts) {
1151
+ this.ensure(id);
1152
+ this.forgotten.delete(id);
1153
+ if ((!text && images.length === 0) || this.chat.state(id).running)
1154
1154
  return;
1155
- }
1156
- const startedAt = Date.now();
1157
- // A draft becomes a saved chat now, after the guard allowed the send, so a
1158
- // refused send leaves nothing behind. A failed save does not stop the run:
1159
- // its first write after it tries again.
1160
- if (this.drafts.has(id))
1161
- await this.materialize(id).catch(() => { });
1162
- // Title an untitled session from its first message, so the switcher is readable.
1163
- // A hand-renamed session is locked, so its chosen title is left alone.
1164
- if (isFirst && text) {
1165
- await this.queueWrite(id, async () => {
1166
- const ses = await this.host.sessions.get(id);
1167
- if (ses && !ses.titleLocked) {
1168
- ses.title = text.slice(0, 48);
1169
- await this.host.sessions.save(ses).catch(() => { });
1170
- this.notifyGlobal();
1171
- }
1172
- }).catch(() => { });
1173
- }
1174
- const outgoing = outgoingMessages(nextTurns);
1175
- /* Transcript-only turns noteUser() added for messages injected mid-run.
1176
- * They exist so the pivot is visible WHILE the run is in flight; once the
1177
- * run returns, its own copy of each injected message arrives in `created`
1178
- * and folds into the user turn, so keeping these would render the text a
1179
- * second time below the final reply, where it reads as unanswered. */
1180
- const injectedTurnIds = [];
1181
- /* The seams THIS run wrote. The last one carries the run's folded view
1182
- * once the run returns, so the next send starts from the fold instead of
1183
- * re-folding the raw transcript. */
1184
- const seamIds = [];
1185
- let verification;
1186
- let auditIncomplete;
1187
- const ac = new AbortController();
1188
- s.abort = ac;
1189
- // This turn's events append to the session's accumulated stream (one recording
1190
- // per session, not per turn) — see Internal.recEvents.
1191
- const recBuf = s.recEvents;
1192
- // Where THIS turn's events start — so `ok` reflects only this turn (a transient
1193
- // error in an earlier turn must not mark a recovered session as failed forever).
1194
- const turnStart = recBuf.length;
1195
- // Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
1196
- // first turn's prompt is the recording's `prompt` header, so only record once
1197
- // the session already has replayable content (avoids duplicating it).
1198
- if (recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
1199
- recBuf.push({ at: Date.now(), ev: { type: 'user', content: text, images: images.length ? images.slice() : undefined } });
1200
- }
1201
- // Shared wedged-run watchdog (see runWatchdog.ts): soft window while
1202
- // streaming, paused mid-tool with a hard cap so a never-settling tool
1203
- // (half-open sidecar socket) can't hang the run forever.
1204
- const dog = createRunWatchdog((reason) => {
1205
- ac.abort();
1206
- this.patch(id, {
1207
- error: reason === 'tool-hard-cap'
1208
- ? 'A tool ran for over 10 minutes without completing — aborted. The connection to it may have dropped.'
1209
- : 'No response from the gateway in 45s — aborted. Check that you are signed in and the backend is reachable.',
1210
- });
1211
- }, { softMs: WATCHDOG_MS });
1212
- dog.bump();
1155
+ // The browser never freezes its system: composeSystem reads live inputs each turn.
1156
+ this.recomposeSystem(id);
1157
+ const t = this.queueTurn(id, opts, text, images);
1213
1158
  try {
1214
- /* The skill catalog travels WITH the run: the worker cannot read skill
1215
- * files, and the loop owns both the index and `use_skill`. Resolved for
1216
- * THIS session, so a skill switched off in one conversation is still
1217
- * offered in every other. Read alongside compose rather than after it,
1218
- * so it adds no latency ahead of the run. Never fatal: a read failure
1219
- * costs skills, not the run. */
1220
- const skillDeny = this.host.capabilities?.skillDeny?.(id) ?? [];
1221
- const [system, skills] = await Promise.all([
1222
- this.composeSystem(id, opts.persona, opts.partsOverride),
1223
- enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
1224
- ]);
1225
- const model = this.chatModel(id);
1226
- /* Decided now, from what is true now: the chat's own model is gone and
1227
- * the one it falls back to cannot hold it. A model picked since load, or
1228
- * a catalog that has caught up, changes the answer. */
1229
- const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
1230
- // The chat now runs on `model`; storing it is what clears the notice.
1231
- if (this.host.perChatModel && s.model !== model)
1232
- this.patch(id, { model });
1233
- // Live model for onBreach pricing — reassigned when a degrade breach
1234
- // switches the run onto a cheaper model (see onBreach below).
1235
- let activeModel = model;
1236
- const priced = this.host.modelInfo(model);
1237
- const contextWindow = priced.contextWindow ?? 128000;
1238
- // The surface's constraints default (Task 7), overridden per run when the
1239
- // caller passes one (a masked-override envelope from `mergeOverride`) —
1240
- // mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
1241
- // NOTE: this deliberately reads `this.surface`'s constraints, not a
1242
- // hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
1243
- // class (which always read 'agent'); 'craft' has no saved defaults so the
1244
- // effective result is unchanged.
1245
- const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
1246
- const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
1247
- // Capability seam (Task 3): only send toolNames when the host's tools
1248
- // getter is NOT the default allTools (reference inequality) — this keeps
1249
- // the default path byte-identical, since every surface today passes
1250
- // `tools: allTools` (or omits capabilities entirely).
1251
- const capTools = this.host.capabilities?.tools;
1252
- const toolNames = capTools && capTools !== allTools ? capTools().map((t) => t.name) : undefined;
1253
- /* RESOLVED PER RUN, FOR THIS SESSION. The composer popout can switch a
1254
- * source (an MCP server, a builtin namespace, a skill) off for ONE
1255
- * conversation, and this is where that choice stops being a UI state and
1256
- * becomes tools the worker will not offer. Passing the id rather than
1257
- * reading an "active session" global keeps it correct while two surfaces
1258
- * run at once. A host that ignores the argument -- crew does -- is
1259
- * unaffected. */
1260
- const toolDeny = this.host.capabilities?.toolDeny?.(id);
1261
- // Resolved per run so an edit to a callee (or its canCall list) applies to
1262
- // the next delegation without rebuilding the runtime. Never fatal: a read
1263
- // failure costs delegation, not the run.
1264
- const resolvedDelegates = await (this.host.capabilities?.delegates?.().catch(() => []) ?? Promise.resolve([]));
1265
- /* Under a usd cap a callee's turns are priced against this run's meter,
1266
- * so a callee whose model this host cannot price would spend without
1267
- * moving it. Refused, with the daemon's words (executor.ts). */
1268
- const crewDelegates = constraints.maxUsd != null
1269
- ? resolvedDelegates.map((d) => (d.model && !pricedForUsd(this.host.usdRates?.(d.model) ?? null)
1270
- ? { ...d, model: '', refusal: unpricedCalleeRefusal(d.name, d.model) }
1271
- : d))
1272
- : resolvedDelegates;
1273
- const crewOrchestration = this.host.capabilities?.orchestration?.();
1274
- const crewTraceRoot = this.host.capabilities?.traceRoot?.();
1275
- const auditAgent = this.host.capabilities?.agentName?.();
1276
- /* What this turn's sub-agents (delegated callees) spent, from their
1277
- * node-event usage (review B-I6). Added to the turn's stats and the
1278
- * usage ledger, as the daemon's run log does. Only a tally for display:
1279
- * the budget gate meters the same events itself (workerClient), so
1280
- * nothing here feeds it. */
1281
- const calleeSpend = { inTok: 0, outTok: 0, cost: 0 };
1282
- const res = await this.getWorker().run(outgoing, {
1283
- model,
1284
- temperature: this.getTemperature?.(),
1285
- toolDeny,
1286
- // What this conversation already found with tool_search, so turn 9
1287
- // does not search again for what turn 2 loaded.
1288
- loadedTools: loadedToolNames(id),
1289
- skills,
1290
- crewDelegates,
1291
- crewOrchestration,
1292
- ...(crewTraceRoot ? { crewTraceRoot } : {}),
1293
- system,
1294
- policy: this.host.policy
1295
- ? this.host.policy(getPolicy(opts.policyName) ?? STANDARD_POLICY)
1296
- : (getPolicy(opts.policyName) ?? STANDARD_POLICY),
1297
- ...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
1298
- compression: { mode: this.host.compression(), contextWindow },
1299
- ...(forceFold ? { forceFold: true } : {}),
1300
- signal: ac.signal,
1301
- sessionId: id,
1302
- toolNames,
1303
- // Record the worker's run id the instant it exists, so inject() (which
1304
- // can arrive at any point during this run) has something live to address.
1305
- onStart: (runId) => { s.runId = runId; },
1306
- // The queue's only way to tell "landed" from "never made it": the
1307
- // AgentEvent 'injected' the loop emits per message carries text, not the
1308
- // caller's id, so a reverting caller (Task 7's queue) reads THIS to know
1309
- // which of its still-pending ids it must NOT revert (reverting one that
1310
- // already landed would re-send it as a fresh turn and duplicate it).
1311
- onInjected: (ids) => { this.patch(id, { injectedIds: [...this.ensure(id).injectedIds, ...ids] }); },
1312
- onPermission: (req) => new Promise((done) => {
1313
- // A checkpoint is asked every time, whatever the session allowed.
1314
- if (!isCheckpointTool(req.tool) && s.allowed.has(allowKey(req.agent, req.tool))) {
1315
- done({ allow: true, by: 'session' });
1316
- return;
1317
- }
1318
- // A callee's ask names who called it: this chat's own agent.
1319
- const caller = req.agent ? (auditAgent ? { caller: auditAgent } : {}) : {};
1320
- // A deny with a note carries it; every other answer stays the bare
1321
- // boolean it always was.
1322
- const resolve = (allow, note) => done(!allow && note ? { allow, by: 'human', note } : allow);
1323
- this.patch(id, { pendingPerm: { ...req, ...caller, resolve } }, true);
1324
- }),
1325
- // Non-blocking: fires when the approvals bridge records a decision and
1326
- // offers a standing rule. The context identifies which session/surface
1327
- // recorded it — the runId is added by the bridge itself.
1328
- approvalContext: { surface: this.surface, sessionId: id },
1329
- // The audit log files this run under its session, and under its agent
1330
- // when the host names one (persona is prompt text, never an identity).
1331
- auditContext: { session: id, ...(auditAgent ? { agent: auditAgent } : {}) },
1332
- onApprovalSuggestion: (sug) => this.patch(id, { approvalSuggestion: sug }, true),
1333
- onAsk: (questions) => new Promise((resolve) => {
1334
- dog.pause();
1335
- this.patch(id, { pendingAsk: { questions, resolve: (a) => { resolve(a); dog.bump(); } } }, true);
1336
- }),
1337
- // Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
1338
- // standalone page omits both, so propose_edit keeps its non-blocking
1339
- // fallback exactly as before.
1340
- editorFile: opts.editorFile,
1341
- onNodeEvent: (e) => {
1342
- publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
1343
- const c = nodeUsageOf(e);
1344
- if (c) {
1345
- calleeSpend.inTok += c.usage.promptTokens;
1346
- calleeSpend.outTok += c.usage.completionTokens;
1347
- const m = c.model ?? model;
1348
- // Priced only when this host really knows the model's rates; an
1349
- // unknown one still counts its tokens (never an invented cost).
1350
- if (pricedForUsd(this.host.usdRates?.(m) ?? null))
1351
- calleeSpend.cost += this.host.price(c.usage, m);
1352
- }
1353
- opts.onNodeEvent?.(e);
1354
- },
1355
- onEditReview: opts.onEditReview
1356
- ? async (callId) => {
1357
- // propose_edit blocks on the user's verdict — pause the watchdog
1358
- // (like an open ask form), resume once the host resolves it.
1359
- dog.pause();
1360
- const r = await opts.onEditReview(callId, ac.signal);
1361
- dog.bump();
1362
- return r;
1363
- }
1364
- : undefined,
1365
- /* start_project offering to bring the conversation with it. The run is
1366
- * STILL WRITING, so this cannot be file work the tool does on its own:
1367
- * only here is it known which session this run belongs to, and only
1368
- * here can the store that is saving it be re-pointed. The host does
1369
- * both, or the option is not offered at all. */
1370
- onMoveSession: this.host.moveSession
1371
- ? async (projectRoot) => {
1372
- dog.pause();
1373
- const message = await this.host.moveSession(id, projectRoot);
1374
- dog.bump();
1375
- // Its chats moved surfaces: the list this session was in no longer
1376
- // holds it, and the one it went to now does.
1377
- this.notifyGlobal();
1378
- return message;
1379
- }
1380
- : undefined,
1381
- constraints: hasBudgetLimits ? constraints : undefined,
1382
- price: (usage, priceModel) => this.host.price(usage, priceModel),
1383
- onBreach: constraints.onBreach && this.host.breach
1384
- ? async (breach) => {
1385
- // Tracks the model actually running (workerClient resets its breach
1386
- // latch after a degrade, so a second in-run breach can fire on the
1387
- // NEW model) — price the next breach against that, not the model
1388
- // captured at run() time.
1389
- const r = await this.host.breach.decide(constraints.onBreach, breach, {
1390
- currentModel: activeModel,
1391
- // Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
1392
- // the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
1393
- // per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
1394
- ask: (m) => new Promise((resolve) => {
1395
- dog.pause();
1396
- this.patch(id, { pendingPerm: { tool: 'budget', summary: m, resolve: (b) => { resolve(b); dog.bump(); } } }, true);
1397
- }),
1398
- // Reuse the existing "append a local notice turn" affordance (used
1399
- // today for /model confirmations) for a soft warn — ALSO appended to
1400
- // policyNotes, unless the caller opted into policyNotes-only
1401
- // rendering (see SendOpts.budgetNotices).
1402
- warn: (m) => {
1403
- if ((opts.budgetNotices ?? 'transcript') === 'transcript')
1404
- this.note(id, m);
1405
- this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
1406
- },
1407
- cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
1408
- });
1409
- if (r.model) {
1410
- activeModel = r.model;
1411
- this.patch(id, { degradedModel: r.model });
1412
- }
1413
- return r;
1414
- }
1415
- : undefined,
1416
- onEvent: (e) => {
1417
- /* The task list is fed HERE, at the one choke point every surface's
1418
- * runtime passes through - not by a host.
1419
- *
1420
- * It used to be wired in prompd's AgentPanel alone, so `todo_write`
1421
- * worked there and nowhere else: on corenel the model dutifully kept a
1422
- * plan across a multi-step job and not one surface could read it,
1423
- * because nothing was listening. A tool whose entire purpose is to be
1424
- * SEEN cannot depend on each host remembering to subscribe.
1425
- *
1426
- * Other tool calls bucket as activity under whichever task is active,
1427
- * which is what gives an expanded task its detail. */
1428
- if (e.type === 'tool-call') {
1429
- if (e.name === 'todo_write')
1430
- applyTodoWrite(id, e.args);
1431
- else
1432
- addActivity(id, e.id, e.name);
1433
- }
1434
- else if (e.type === 'tool-result') {
1435
- resolveActivity(id, e.id, e.isError);
1436
- }
1437
- opts.onRunEvent?.(e);
1438
- dog.onEvent(e);
1439
- recBuf.push({ at: Date.now(), ev: e });
1440
- // Heal persistence is centralized in the worker client (host workerDeps
1441
- // onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
1442
- // We just surface the live "Recovering…" status (null clears it on recovered).
1443
- if (e.type === 'heal') {
1444
- this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
1445
- return;
1446
- }
1447
- if (e.type === 'offload') {
1448
- this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
1449
- return;
1450
- }
1451
- if (e.type === 'checkpoint') {
1452
- seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
1453
- return;
1454
- }
1455
- if (e.type === 'tools-loaded') {
1456
- setLoadedTools(id, e.names);
1457
- return;
1458
- }
1459
- if (e.type === 'policy-violation') {
1460
- this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
1461
- return;
1462
- }
1463
- // Neither of these reads `live`, so they belong ABOVE its early
1464
- // return -- below it they would silently stop firing the moment a
1465
- // run has no live slice.
1466
- if (e.type === 'turn-verified') {
1467
- verification = e.verification;
1468
- return;
1469
- }
1470
- if (e.type === 'audit-incomplete') {
1471
- auditIncomplete = { lost: e.lost, ...(e.unconfirmed ? { unconfirmed: true } : {}) };
1472
- return;
1473
- }
1474
- if (e.type === 'injected') {
1475
- injectedTurnIds.push(this.noteUser(id, e.content));
1476
- return;
1477
- }
1478
- if (e.type === 'turn-interrupted') {
1479
- // The partial reply already streamed into `live` is never coming
1480
- // back -- clear it so the discarded text does not read as a real
1481
- // answer sitting above the injected message.
1482
- this.patch(id, { live: foldLive(this.ensure(id).live ?? { text: '', tools: [] }, e) });
1483
- return;
1484
- }
1485
- const live = s.live;
1486
- if (!live)
1487
- return;
1488
- if (e.type === 'assistant-delta' || e.type === 'assistant' || e.type === 'tool-call' || e.type === 'tool-result') {
1489
- if (e.type === 'tool-result' && typeof e.durationMs === 'number')
1490
- s.toolMs[e.id] = e.durationMs;
1491
- this.patch(id, e.type === 'assistant-delta' ? { live: foldLive(live, e), recovery: null } : { live: foldLive(live, e) });
1492
- }
1493
- else if (e.type === 'error')
1494
- this.patch(id, { error: e.message });
1495
- else if (e.type === 'usage')
1496
- s.lastUsage = e.usage;
1497
- // Synthetic event workerClient emits when onBreach returned {continue:false}
1498
- // — surfaced as a transcript notice via the existing note() affordance,
1499
- // AND appended to policyNotes (same warn/stop duality as above, gated
1500
- // by SendOpts.budgetNotices).
1501
- else if (e.type === 'budget-stop') {
1502
- const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
1503
- if ((opts.budgetNotices ?? 'transcript') === 'transcript')
1504
- this.note(id, msg);
1505
- this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
1506
- }
1507
- },
1508
- });
1509
- // Stitch each tool call's measured duration onto its tool message (`_ms`).
1510
- const ms = s.toolMs;
1511
- /* FROM THE TAIL, by the count the run reports — never an absolute offset
1512
- * into what we sent.
1513
- *
1514
- * This was `res.messages.slice(outgoing.length + 1)`, which assumed the
1515
- * loop only ever APPENDS to the array it was handed. Rung-2 compaction
1516
- * rewrites it in place and the rewrite is shorter than the input, so the
1517
- * offset ran off the end, the slice came back empty, and the reply the
1518
- * run had just produced was dropped: the transcript kept the user's
1519
- * message and nothing else, as though the agent never answered. With
1520
- * compaction on by default in the browser, that was every turn of a
1521
- * conversation past 86% fill. */
1522
- const created = res.messages.slice(res.messages.length - res.appended).map((m) => {
1523
- const tid = m.tool_call_id;
1524
- return m.role === 'tool' && tid && ms[tid] != null ? { ...m, _ms: ms[tid] } : m;
1525
- });
1526
- const u = res.usage ?? { promptTokens: 0, completionTokens: 0 };
1527
- const stats = u.promptTokens || u.completionTokens || calleeSpend.inTok || calleeSpend.outTok
1528
- ? {
1529
- // This agent's own turns plus every callee's.
1530
- inTok: u.promptTokens + calleeSpend.inTok,
1531
- outTok: u.completionTokens + calleeSpend.outTok,
1532
- // Cache-aware: the hand-rolled in*price it replaced billed cached tokens at the full rate.
1533
- cost: (priceUsageWith(u, { inPrice: priced.inPrice, outPrice: priced.outPrice }) ?? 0) + calleeSpend.cost,
1534
- ms: Date.now() - startedAt,
1535
- ...(res.contextTokens ? { ctxTok: res.contextTokens } : {}),
1536
- ...(u.cachedTokens != null ? { cachedTok: u.cachedTokens } : {}),
1537
- ...(res.calls ? { calls: res.calls } : {}),
1538
- }
1539
- : undefined;
1540
- const lastSeam = seamIds[seamIds.length - 1];
1541
- const stitched = s.turns
1542
- .filter((t) => !injectedTurnIds.includes(t.id))
1543
- .map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
1544
- this.patch(id, {
1545
- lastStopReason: res.stopReason,
1546
- turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
1547
- });
1548
- void this.persist(id);
1549
- if (stats) {
1550
- this.host.recordUsage({ surface: this.surface, provider: priced.provider, model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
1551
- }
1552
- // Best-effort follow-up chips for this turn — generated after the reply lands,
1553
- // dropped if the user starts/aborts another run before it returns.
1554
- if (this.host.suggestFollowups) {
1555
- void this.host.suggestFollowups({ messages: res.messages, model, signal: ac.signal }).then((sug) => {
1556
- if (sug.length && !ac.signal.aborted)
1557
- this.patch(id, { suggestions: sug }, true);
1558
- });
1559
- }
1560
- }
1561
- catch (e) {
1562
- // A throw never returns res.messages, so the run's own copy of every
1563
- // injected message dies with it and only the transcript-only turns
1564
- // noteUser added are left. Promote those to real history: the queue
1565
- // drops an entry the worker confirmed landed, so leaving them excluded
1566
- // would silently delete the correction from what the next turn sends.
1567
- const turns = this.ensure(id).turns;
1568
- const patch = { error: String(e?.message || e) };
1569
- if (injectedTurnIds.length) {
1570
- patch.turns = turns.map((t) => (injectedTurnIds.includes(t.id) ? { ...t, included: true } : t));
1571
- }
1572
- this.patch(id, patch);
1573
- void this.persist(id);
1159
+ await this.chat.run({ conversationId: id, input: { text, images } }, this.chatHost).done;
1574
1160
  }
1575
1161
  finally {
1576
- // A run that ends (abort/watchdog/error) while a perm overlay (tool-permission
1577
- // or budget-breach "continue?") is still open must not leave its promise —
1578
- // and the caller awaiting it — hanging forever. Treat an unanswered perm as
1579
- // "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
1580
- // this is a no-op (idempotent) on the happy path.
1581
- if (s.pendingPerm) {
1582
- s.pendingPerm.resolve(false);
1583
- this.patch(id, { pendingPerm: null }, true);
1584
- }
1585
- dog.clear();
1586
- s.abort = null;
1587
- s.runId = null;
1588
- this.patch(id, { running: false, live: null, recovery: null }, true);
1589
- // Any callee still marked running under this session will never report.
1590
- publishSubagentParentEnded(id);
1591
- // Upsert the session recording with everything captured so far (this turn's
1592
- // events appended to prior turns), under one stable id so the whole session
1593
- // replays as a single stream. Skipped until something replayable happened.
1594
- if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
1595
- if (!s.recId) {
1596
- s.recId = this.host.recorder.newId();
1597
- s.recPrompt = text;
1598
- s.recImages = images.length ? images.slice() : undefined;
1599
- }
1600
- // `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
1601
- // events so the store doesn't hold the live array that later turns mutate.
1602
- const ok = !recBuf.slice(turnStart).some((r) => r.ev.type === 'error');
1603
- this.host.recorder.save({ kind: 'agent', id: s.recId, title: (s.recPrompt || 'Agent run').slice(0, 60), at: Date.now(), ok, prompt: s.recPrompt || text, promptImages: s.recImages, events: recBuf.slice() });
1604
- }
1162
+ this.dropTurn(id, t);
1605
1163
  }
1606
1164
  }
1607
1165
  /** A host-driven turn: appends the user message, then awaits a host-supplied
@@ -1610,118 +1168,68 @@ export class AgentRuntime {
1610
1168
  * tool calls — used by surfaces that render a live "thinking" placeholder for
1611
1169
  * work the engine itself doesn't perform. */
1612
1170
  async hostTurn(id, userText, produce) {
1613
- const s = this.ensure(id);
1614
- if (!userText || s.running)
1171
+ this.ensure(id);
1172
+ this.forgotten.delete(id);
1173
+ if (!userText || this.chat.state(id).running)
1615
1174
  return;
1616
1175
  this.ws();
1617
- // As in send: never build on a transcript that has not loaded, and never
1618
- // start a second turn that began while the load was pending.
1619
- if (!s.loaded) {
1620
- await this.load(id);
1621
- if (s.running)
1622
- return;
1623
- }
1624
- // A loaded chat with no turns is new: titled from this turn, as send does.
1625
- const isFirst = s.loaded && s.turns.length === 0;
1626
- const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
1627
- const ac = new AbortController();
1628
- s.abort = ac;
1629
- // Optimistic, for the same reason as `send` above: the guard is a network
1630
- // call, and a host-driven turn that waits on it looks like nothing happened.
1631
- this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
1632
- // As in send: a load that missed again leaves this turn unsaved; say so now.
1633
- this.flagIfUnsaved(id);
1634
- const gate = await this.host.guard('llm-execution');
1635
- if (!gate.allowed) {
1636
- s.abort = null;
1637
- this.patch(id, {
1638
- turns: this.ensure(id).turns.filter((t) => t.id !== userTurn.id),
1639
- running: false,
1640
- live: null,
1641
- error: gate.reason,
1642
- }, true);
1643
- return;
1644
- }
1645
- try {
1646
- const content = await produce(ac.signal);
1647
- this.patch(id, { turns: this.ensure(id).turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], { role: 'assistant', content }] } : t)) });
1648
- void this.persist(id);
1649
- if (isFirst) {
1650
- // Queued behind the save, which is what creates a draft.
1651
- void this.queueWrite(id, async () => {
1652
- const ses = await this.host.sessions.get(id);
1653
- if (ses && !ses.titleLocked) {
1654
- ses.title = userText.slice(0, 48);
1655
- await this.host.sessions.save(ses).catch(() => { });
1656
- this.notifyGlobal();
1657
- }
1658
- }).catch(() => { });
1659
- }
1660
- }
1661
- catch (e) {
1662
- this.patch(id, { error: String(e?.message || e) });
1663
- }
1664
- finally {
1665
- s.abort = null;
1666
- this.patch(id, { running: false, live: null }, true);
1667
- }
1176
+ await this.chat.hostTurn(id, userText, produce, this.chatHost);
1668
1177
  }
1669
1178
  /** Replace the offload stub for `blobId` inside turn `turnId` (session `id`)
1670
- * with the full content — the context drawer's per-item Restore, transplanted
1671
- * from the harness/conversation.ts singleton (same marker-matching and
1672
- * identity-swap semantics; see that module for the rationale). Returns false
1673
- * (state untouched, no persist) when the turn or the matching stub is absent,
1674
- * or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
1675
- * it, so content restored into it would never be sent. */
1179
+ * with the full content — the context drawer's per-item Restore. Returns
1180
+ * false (state untouched, no persist) when the turn or the matching stub is
1181
+ * absent, or when the turn is FOLDED (see isFoldedTurn): the folded view
1182
+ * stands for it, so content restored into it would never be sent. */
1676
1183
  restoreToolResult(id, turnId, blobId, content) {
1677
- const marker = `recall_result({ id: "${blobId}" })`;
1678
- const s = this.ensure(id);
1679
- const at = s.turns.findIndex((t) => t.id === turnId);
1680
- if (at < 0 || isFoldedTurn(s.turns, at))
1681
- return false;
1682
- const turn = s.turns[at];
1683
- const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
1684
- if (idx < 0)
1685
- return false;
1686
- const next = s.turns.map((t) => {
1687
- if (t.id !== turnId)
1688
- return t;
1689
- const messages = t.messages.slice();
1690
- messages[idx] = { ...messages[idx], content };
1691
- return { ...t, messages };
1692
- });
1693
- this.patch(id, { turns: next });
1694
- void this.persist(id);
1695
- return true;
1184
+ this.ensure(id);
1185
+ this.open(id);
1186
+ return this.chat.restoreToolResult(id, this.chatHost, turnId, blobId, content);
1696
1187
  }
1697
- /** Re-run a prior user turn: drop it and everything after, then resend it. */
1188
+ /** Re-run a prior user turn: drop it and everything after, then resend it. A
1189
+ * refused retry puts the dropped turns back (the engine). */
1698
1190
  async retry(id, turnId, opts) {
1699
- const s = this.ensure(id);
1700
- if (s.running)
1191
+ this.ensure(id);
1192
+ const state = this.chat.state(id);
1193
+ if (state.running)
1701
1194
  return;
1702
- const idx = s.turns.findIndex((t) => t.id === turnId);
1703
- if (idx < 0)
1704
- return;
1705
- const first = s.turns[idx].messages[0];
1195
+ const first = state.turns.find((t) => t.id === turnId)?.messages[0];
1706
1196
  const text = first ? userText(first) : '';
1707
1197
  const images = first ? userImages(first) : [];
1708
1198
  if (!text && images.length === 0)
1709
1199
  return;
1710
- this.patch(id, { turns: s.turns.slice(0, idx) });
1711
- await this.send(id, text, images, opts);
1200
+ // The browser never freezes its system: composeSystem reads live inputs each turn.
1201
+ this.recomposeSystem(id);
1202
+ const t = this.queueTurn(id, opts, text, images);
1203
+ try {
1204
+ await this.chat.retry(id, turnId, this.chatHost).done;
1205
+ }
1206
+ finally {
1207
+ this.dropTurn(id, t);
1208
+ }
1712
1209
  }
1713
1210
  /** Drop all in-memory state and abort every run — called on a scope change so
1714
1211
  * one identity's sessions and runs never carry into another's. */
1715
1212
  reset() {
1716
- const runningIds = [...this.map.entries()].filter(([, s]) => s.abort).map(([sid]) => sid);
1717
- for (const s of this.map.values())
1718
- s.abort?.abort();
1213
+ const held = [...this.map.keys()];
1214
+ const runningIds = held.filter((sid) => this.chat.state(sid).running);
1215
+ // Nothing of the last identity's waits or blocked output outlives the switch.
1216
+ for (const sid of new Set([...held, ...this.permQueues.keys()]))
1217
+ this.denyAllPerms(sid);
1218
+ this.permQueues.clear();
1219
+ this.blocked.clear();
1220
+ // Forgotten, so a run's late writes land nowhere; reopened on next use.
1221
+ for (const sid of held) {
1222
+ this.chat.forget(sid);
1223
+ this.forgotten.add(sid);
1224
+ }
1719
1225
  // The worker is disposed below, which never settles an in-flight run, so
1720
- // its `finally` (and the parent-ended signal there) never runs: say it here.
1226
+ // its end (and the parent-ended signal there) never comes: say it here.
1721
1227
  for (const sid of runningIds)
1722
1228
  publishSubagentParentEnded(sid);
1723
1229
  const ids = [...this.subs.keys()];
1724
1230
  this.map.clear();
1231
+ this.pending.clear();
1232
+ this.active.clear();
1725
1233
  this.worker?.dispose();
1726
1234
  this.worker = null;
1727
1235
  this._ws = null;
@@ -1737,5 +1245,567 @@ export class AgentRuntime {
1737
1245
  this.offModels?.();
1738
1246
  this.offModels = null;
1739
1247
  }
1248
+ /* ---- the browser host of the chat engine ---- */
1249
+ buildChatHost() {
1250
+ return {
1251
+ /* Feature guard — the runtime is the chokepoint for every /agent run (send
1252
+ * and retry), so a quota/plan guard registered later covers this surface
1253
+ * too. Called once the engine shows the turn: this side's half of the
1254
+ * optimistic start (the composer) happens here, and so does its half of
1255
+ * a denial. */
1256
+ guard: ({ conversationId: id, kind }) => {
1257
+ /* An inject joins the run already going: it is checked as a send is
1258
+ * (llm-execution), and starts no turn, touches no composer and owes no
1259
+ * run end. The feature guards only read an entitlement, so it counts
1260
+ * nothing against a quota; usage is recorded once, at the run's end. */
1261
+ if (kind === 'inject')
1262
+ return this.host.guard('llm-execution');
1263
+ const t = id && (kind === 'send' || kind === 'retry') ? this.startTurn(id) : null;
1264
+ if (id && kind === 'host-turn')
1265
+ this.patch(id, { suggestions: [] }, true);
1266
+ /* The host's own promise where nothing waits on it, so a host turn's
1267
+ * produce starts as soon after the guard as it always did (a stop that
1268
+ * lands in between still reaches it). */
1269
+ if (!id || !t)
1270
+ return this.host.guard('llm-execution');
1271
+ // Put back what was typed (and any pasted images): a turn refused here
1272
+ // is a message the user would otherwise believe they sent.
1273
+ const putBack = () => {
1274
+ this.patch(id, { input: t.priorInput || t.text, attachments: t.priorAttachments.length ? t.priorAttachments : t.images });
1275
+ };
1276
+ return this.host.guard('llm-execution').then((gate) => {
1277
+ if (!gate.allowed)
1278
+ putBack();
1279
+ else {
1280
+ t.allowed = true;
1281
+ t.startedAt = Date.now();
1282
+ t.recBuf = t.ui.recEvents;
1283
+ t.turnStart = t.recBuf.length;
1284
+ /* A draft becomes a saved chat now, before the run, so it is in the
1285
+ * chat list while it runs; a refused send leaves nothing behind. A
1286
+ * text send's retitle would create it too, an image-only one never.
1287
+ * A failed save does not stop the run: its first write tries again. */
1288
+ if (this.drafts.has(id))
1289
+ return this.materialize(id).then(() => gate, () => gate);
1290
+ }
1291
+ return gate;
1292
+ }, (e) => { putBack(); throw e; });
1293
+ },
1294
+ // The browser always names its chat; a new one is a draft until first written.
1295
+ createConversation: () => this.newDraft(),
1296
+ /* A draft is in no store: it reads as an empty chat, so the engine's
1297
+ * first-turn retitle and its save find a record (writeConversation then
1298
+ * creates it). A LOAD's read (the chat is not loaded yet) is where this
1299
+ * side takes the stored model; a save's read-before-write is not. */
1300
+ loadConversation: (id) => {
1301
+ this.ws();
1302
+ if (this.drafts.has(id))
1303
+ return Promise.resolve({ id, title: 'New chat', messages: [] });
1304
+ const read = this.host.sessions.get(id);
1305
+ if (this.chat.state(id).loaded)
1306
+ return read.then((ses) => (ses ? withoutSystem(ses) : ses));
1307
+ return read.then((ses) => { this.onLoaded(id, ses); return ses ? withoutSystem(ses) : ses; }, (e) => { this.onLoaded(id, null); throw e; });
1308
+ },
1309
+ /* The first write to a draft saves it first; a failed save fails this
1310
+ * write, and the draft stays a draft for the next one. A save (not the
1311
+ * retitle, which writes the stored record back as read) carries the
1312
+ * chat's model. */
1313
+ writeConversation: (c, { allowTruncate }) => (this.drafts.has(c.id)
1314
+ ? this.materialize(c.id).then((created) => this.writeSession(c, allowTruncate, created))
1315
+ : this.writeSession(c, allowTruncate, null)),
1316
+ runOptions: async ({ conversationId: id }) => this.runOptionsFor(id, this.turnFor(id)),
1317
+ system: ({ conversationId: id }) => {
1318
+ const t = this.turnFor(id);
1319
+ return this.composeSystem(id, t.opts.persona, t.opts.partsOverride);
1320
+ },
1321
+ runLoop: (messages, runOpts, control) => this.runWorker(messages, runOpts, control),
1322
+ injectInto: (runId, messages, hard) => {
1323
+ this.getWorker().inject(Number(runId), messages, hard);
1324
+ return true;
1325
+ },
1326
+ askPermission: (id, req) => new Promise((done) => {
1327
+ // A callee's ask names who called it: this chat's own agent.
1328
+ const auditAgent = this.host.capabilities?.agentName?.();
1329
+ const caller = req.agent ? (auditAgent ? { caller: auditAgent } : {}) : {};
1330
+ // A System One ask keeps its own detail (tool, reasons, result); a tool ask only the clipped PermDetail.
1331
+ const hook = req.tool === HOOK_ASK_TOOL ? hookInput(req.input) : undefined;
1332
+ const input = req.tool === HOOK_ASK_TOOL ? undefined : permInput(req.input);
1333
+ const permId = this.nextPermId++;
1334
+ // The run ended while the card was up or queued: no answer is a deny (Block).
1335
+ const onAbort = () => this.denyPerm(id, permId);
1336
+ // A deny with a note carries it, as the person's reason.
1337
+ const resolve = (allow, note) => {
1338
+ req.signal.removeEventListener('abort', onAbort);
1339
+ done(!allow && note ? { allow, by: 'human', note } : { allow });
1340
+ };
1341
+ if (req.signal.aborted) {
1342
+ resolve(false);
1343
+ return;
1344
+ }
1345
+ req.signal.addEventListener('abort', onAbort, { once: true });
1346
+ this.raisePerm(id, {
1347
+ tool: req.tool, summary: req.summary, ...(req.agent ? { agent: req.agent } : {}), ...(input ? { input } : {}),
1348
+ ...(hook ? { hook } : {}), ...caller, id: permId, askedAt: Date.now(), resolve,
1349
+ });
1350
+ }),
1351
+ askUser: (id, questions, signal) => new Promise((done) => {
1352
+ // The run ended while the form was up: no answer is a decline.
1353
+ const onAbort = () => { if (this.map.get(id)?.pendingAsk === ask)
1354
+ this.resolveAsk(id, null); };
1355
+ const ask = { questions, resolve: (a) => { signal.removeEventListener('abort', onAbort); done(a); } };
1356
+ signal.addEventListener('abort', onAbort, { once: true });
1357
+ this.patch(id, { pendingAsk: ask }, true);
1358
+ }),
1359
+ /* Unused in the browser: runWorker wires propose_edit through control.paused
1360
+ * with the run's own SendOpts, so the verdict stays typed, bound to the run
1361
+ * that asked, and a surface without onEditReview keeps the non-blocking
1362
+ * fallback. Present because every ChatHost method is required. */
1363
+ reviewEdit: (id, callId, signal) => this.reviewEditFor(this.active.get(id)?.opts, callId, signal),
1364
+ // Best-effort follow-up chips for this turn — generated after the reply lands,
1365
+ // dropped if the user starts/aborts another run before it returns.
1366
+ suggestFollowups: ({ conversationId: id, messages, signal }) => {
1367
+ if (!this.host.suggestFollowups)
1368
+ return;
1369
+ const model = this.active.get(id)?.model || this.chatModel(id);
1370
+ void this.host.suggestFollowups({ messages, model, signal }).then((sug) => {
1371
+ if (sug.length && !signal.aborted)
1372
+ this.patch(id, { suggestions: sug }, true);
1373
+ });
1374
+ },
1375
+ turnComplete: (result) => this.endTurn(result),
1376
+ stateChanged: (id, global) => {
1377
+ const s = this.map.get(id);
1378
+ if (s)
1379
+ this.sync(id, s);
1380
+ this.notify(id);
1381
+ if (global)
1382
+ this.notifyGlobal();
1383
+ },
1384
+ /* ---- run events ----
1385
+ *
1386
+ * The engine keeps the live slice, the error, the turn's tool timings and
1387
+ * injection checks, the seams, injected messages, verification and audit
1388
+ * gaps. What only this side keeps is below; every other event is an
1389
+ * explicit no-op. */
1390
+ onTurn: () => { },
1391
+ onAssistantDelta: (_e, { conversationId: id }) => {
1392
+ // Output resumed: the "Recovering…" note is over.
1393
+ if (this.map.get(id)?.recovery != null)
1394
+ this.patch(id, { recovery: null });
1395
+ },
1396
+ onAssistant: () => { },
1397
+ onInjected: () => { },
1398
+ onTurnInterrupted: () => { },
1399
+ /* The task list is fed HERE, at the one choke point every surface's
1400
+ * runtime passes through - not by a host.
1401
+ *
1402
+ * It used to be wired in prompd's AgentPanel alone, so `todo_write`
1403
+ * worked there and nowhere else: on corenel the model dutifully kept a
1404
+ * plan across a multi-step job and not one surface could read it,
1405
+ * because nothing was listening. A tool whose entire purpose is to be
1406
+ * SEEN cannot depend on each host remembering to subscribe.
1407
+ *
1408
+ * Other tool calls bucket as activity under whichever task is active,
1409
+ * which is what gives an expanded task its detail. */
1410
+ onToolCall: (e, { conversationId: id }) => {
1411
+ if (e.name === 'todo_write')
1412
+ applyTodoWrite(id, e.args);
1413
+ else
1414
+ addActivity(id, e.id, e.name);
1415
+ },
1416
+ onToolDelta: () => { },
1417
+ onToolResult: (e, { conversationId: id }) => {
1418
+ resolveActivity(id, e.id, e.isError);
1419
+ },
1420
+ onOffload: (_e, { conversationId: id }) => {
1421
+ this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
1422
+ },
1423
+ // The engine stitches the check onto its tool message (`_check`).
1424
+ onToolCheck: () => { },
1425
+ // The engine stitches the marks (`_hooks`); a blocked result stays here, in memory.
1426
+ onToolHooks: (e, { conversationId: id }) => {
1427
+ if (e.blockedText === undefined)
1428
+ return;
1429
+ let m = this.blocked.get(id);
1430
+ if (!m) {
1431
+ m = new Map();
1432
+ this.blocked.set(id, m);
1433
+ }
1434
+ m.set(e.id, e.blockedText);
1435
+ },
1436
+ // The engine writes the seam (noteCheckpoint) and stamps the folded view.
1437
+ onCheckpoint: () => { },
1438
+ onToolsLoaded: (e, { conversationId: id }) => {
1439
+ // What this conversation found with tool_search, for the next run's loadedTools.
1440
+ setLoadedTools(id, e.names);
1441
+ },
1442
+ onContextPressure: () => { },
1443
+ onPermission: () => { },
1444
+ onPermissionAudit: () => { },
1445
+ onPolicyViolation: (e, { conversationId: id }) => {
1446
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
1447
+ },
1448
+ onHeal: (e, { conversationId: id }) => {
1449
+ // Heal persistence is centralized in the worker client (host workerDeps
1450
+ // onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
1451
+ // We just surface the live "Recovering…" status (null clears it on recovered).
1452
+ this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
1453
+ },
1454
+ onUsage: (e, { conversationId: id }) => {
1455
+ // Latest turn's usage, for a degrade breach's next-turn cost projection.
1456
+ const s = this.map.get(id);
1457
+ if (s)
1458
+ s.lastUsage = e.usage;
1459
+ },
1460
+ onTurnVerified: () => { },
1461
+ onDone: () => { },
1462
+ onArtifact: () => { },
1463
+ onError: () => { },
1464
+ /* Synthetic event workerClient emits when onBreach returned {continue:false}
1465
+ * — surfaced as a transcript notice via the existing note() affordance,
1466
+ * AND appended to policyNotes (same warn/stop duality as the breach warn,
1467
+ * gated by SendOpts.budgetNotices). */
1468
+ onBudgetStop: (e, { conversationId: id }) => {
1469
+ const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
1470
+ if ((this.active.get(id)?.opts.budgetNotices ?? 'transcript') === 'transcript')
1471
+ this.note(id, msg);
1472
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
1473
+ },
1474
+ onBudgetWarn: () => { },
1475
+ onAuditIncomplete: () => { },
1476
+ onMcpUnavailable: () => { },
1477
+ onAwaitingOwner: () => { },
1478
+ onDontsChecking: () => { },
1479
+ onOwnerDecided: () => { },
1480
+ };
1481
+ }
1482
+ /** Saves a conversation the engine hands over. A save's record is the
1483
+ * store's own session read just before (so every field it holds goes back);
1484
+ * a draft's stand-in takes the session its first write just created. */
1485
+ writeSession(c, allowTruncate, created) {
1486
+ const model = allowTruncate ? this.map.get(c.id)?.model : null;
1487
+ const write = (base) => {
1488
+ const ses = withoutSystem({ ...base, ...c, ...(model ? { model } : {}) });
1489
+ // A save may shorten the stored chat: the engine only asks when this
1490
+ // page's list IS the chat (R36) or holds every stored turn after a merge.
1491
+ return this.host.sessions.save(ses, allowTruncate ? { allowTruncate: true } : undefined);
1492
+ };
1493
+ if (created)
1494
+ return write(created);
1495
+ if (isSession(c))
1496
+ return write(c);
1497
+ return this.host.sessions.get(c.id).then((ses) => {
1498
+ if (!ses)
1499
+ throw new Error(`session ${c.id} is not stored`);
1500
+ return write(ses);
1501
+ });
1502
+ }
1503
+ /** propose_edit blocks on the user's verdict through the surface's own
1504
+ * onEditReview; with none, the review is unsupported and resolves null. */
1505
+ reviewEditFor(opts, callId, signal) {
1506
+ const review = opts?.onEditReview;
1507
+ return review ? review(callId, signal) : Promise.resolve(null);
1508
+ }
1509
+ /** What `send` hands the worker for one turn, beyond what the engine's
1510
+ * control supplies (the system prompt, the signal, the event stream, the
1511
+ * asks). Resolved per run, for THIS session. */
1512
+ async runOptionsFor(id, t) {
1513
+ const s = t.ui;
1514
+ const { opts } = t;
1515
+ // Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
1516
+ // first turn's prompt is the recording's `prompt` header, so only record once
1517
+ // the session already has replayable content (avoids duplicating it).
1518
+ if (t.recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
1519
+ t.recBuf.push({ at: Date.now(), ev: { type: 'user', content: t.text, images: t.images.length ? t.images.slice() : undefined } });
1520
+ }
1521
+ /* The skill catalog travels WITH the run: the worker cannot read skill
1522
+ * files, and the loop owns both the index and `use_skill`. Resolved for
1523
+ * THIS session, so a skill switched off in one conversation is still
1524
+ * offered in every other. Read while the engine composes the system
1525
+ * prompt, so it adds no latency ahead of the run. Never fatal: a read
1526
+ * failure costs skills, not the run. */
1527
+ const skillDeny = this.host.capabilities?.skillDeny?.(id) ?? [];
1528
+ const skills = await enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []);
1529
+ const model = this.chatModel(id);
1530
+ /* Decided now, from what is true now: the chat's own model is gone and
1531
+ * the one it falls back to cannot hold it. A model picked since load, or
1532
+ * a catalog that has caught up, changes the answer. */
1533
+ const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
1534
+ // The chat now runs on `model`; storing it is what clears the notice.
1535
+ if (this.host.perChatModel && s.model !== model)
1536
+ this.patch(id, { model });
1537
+ // Live model for onBreach pricing — reassigned when a degrade breach
1538
+ // switches the run onto a cheaper model (see onBreach below).
1539
+ let activeModel = model;
1540
+ const priced = this.host.modelInfo(model);
1541
+ t.model = model;
1542
+ t.priced = priced;
1543
+ const contextWindow = priced.contextWindow ?? 128000;
1544
+ // The surface's constraints default (Task 7), overridden per run when the
1545
+ // caller passes one (a masked-override envelope from `mergeOverride`) —
1546
+ // mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
1547
+ // NOTE: this deliberately reads `this.surface`'s constraints, not a
1548
+ // hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
1549
+ // class (which always read 'agent'); 'craft' has no saved defaults so the
1550
+ // effective result is unchanged.
1551
+ const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
1552
+ const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
1553
+ // Capability seam (Task 3): only send toolNames when the host's tools
1554
+ // getter is NOT the default allTools (reference inequality) — this keeps
1555
+ // the default path byte-identical, since every surface today passes
1556
+ // `tools: allTools` (or omits capabilities entirely).
1557
+ const capTools = this.host.capabilities?.tools;
1558
+ const toolNames = capTools && capTools !== allTools ? capTools().map((x) => x.name) : undefined;
1559
+ /* RESOLVED PER RUN, FOR THIS SESSION. The composer popout can switch a
1560
+ * source (an MCP server, a builtin namespace, a skill) off for ONE
1561
+ * conversation, and this is where that choice stops being a UI state and
1562
+ * becomes tools the worker will not offer. Passing the id rather than
1563
+ * reading an "active session" global keeps it correct while two surfaces
1564
+ * run at once. A host that ignores the argument -- crew does -- is
1565
+ * unaffected. */
1566
+ const toolDeny = this.host.capabilities?.toolDeny?.(id);
1567
+ // Resolved per run so an edit to a callee (or its canCall list) applies to
1568
+ // the next delegation without rebuilding the runtime. Never fatal: a read
1569
+ // failure costs delegation, not the run.
1570
+ const resolvedDelegates = await (this.host.capabilities?.delegates?.().catch(() => []) ?? Promise.resolve([]));
1571
+ /* Under a usd cap a callee's turns are priced against this run's meter,
1572
+ * so a callee whose model this host cannot price would spend without
1573
+ * moving it. Refused, with the daemon's words (executor.ts). */
1574
+ const crewDelegates = constraints.maxUsd != null
1575
+ ? resolvedDelegates.map((d) => (d.model && !pricedForUsd(this.host.usdRates?.(d.model) ?? null)
1576
+ ? { ...d, model: '', refusal: unpricedCalleeRefusal(d.name, d.model) }
1577
+ : d))
1578
+ : resolvedDelegates;
1579
+ const crewOrchestration = this.host.capabilities?.orchestration?.();
1580
+ const crewTraceRoot = this.host.capabilities?.traceRoot?.();
1581
+ const auditAgent = this.host.capabilities?.agentName?.();
1582
+ /* What this turn's sub-agents (delegated callees) spent, from their
1583
+ * node-event usage (review B-I6). Added to the turn's stats and the
1584
+ * usage ledger, as the daemon's run log does. Only a tally for display:
1585
+ * the budget gate meters the same events itself (workerClient), so
1586
+ * nothing here feeds it. */
1587
+ const calleeSpend = t.calleeSpend;
1588
+ // A prompt this side raises holds the run's watchdog while a person decides.
1589
+ const paused = (ask) => (t.control ? t.control.paused(ask) : ask());
1590
+ return {
1591
+ model,
1592
+ temperature: this.getTemperature?.(),
1593
+ toolDeny,
1594
+ // What this conversation already found with tool_search, so turn 9
1595
+ // does not search again for what turn 2 loaded.
1596
+ loadedTools: loadedToolNames(id),
1597
+ skills,
1598
+ crewDelegates,
1599
+ crewOrchestration,
1600
+ ...(crewTraceRoot ? { crewTraceRoot } : {}),
1601
+ policy: this.host.policy
1602
+ ? this.host.policy(getPolicy(opts.policyName) ?? STANDARD_POLICY)
1603
+ : (getPolicy(opts.policyName) ?? STANDARD_POLICY),
1604
+ ...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
1605
+ compression: { mode: this.host.compression(), contextWindow },
1606
+ ...(forceFold ? { forceFold: true } : {}),
1607
+ toolNames,
1608
+ // Non-blocking: fires when the approvals bridge records a decision and
1609
+ // offers a standing rule. The context identifies which session/surface
1610
+ // recorded it — the runId is added by the bridge itself.
1611
+ approvalContext: { surface: this.surface, sessionId: id },
1612
+ // The audit log files this run under its session, and under its agent
1613
+ // when the host names one (persona is prompt text, never an identity).
1614
+ auditContext: { session: id, ...(auditAgent ? { agent: auditAgent } : {}) },
1615
+ onApprovalSuggestion: (sug) => this.patch(id, { approvalSuggestion: sug }, true),
1616
+ // Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
1617
+ // standalone page omits both, so propose_edit keeps its non-blocking
1618
+ // fallback exactly as before.
1619
+ editorFile: opts.editorFile,
1620
+ onNodeEvent: (e) => {
1621
+ publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
1622
+ const c = nodeUsageOf(e);
1623
+ if (c) {
1624
+ calleeSpend.inTok += c.usage.promptTokens;
1625
+ calleeSpend.outTok += c.usage.completionTokens;
1626
+ const m = c.model ?? model;
1627
+ // Priced only when this host really knows the model's rates; an
1628
+ // unknown one still counts its tokens (never an invented cost).
1629
+ if (pricedForUsd(this.host.usdRates?.(m) ?? null))
1630
+ calleeSpend.cost += this.host.price(c.usage, m);
1631
+ }
1632
+ opts.onNodeEvent?.(e);
1633
+ },
1634
+ /* start_project offering to bring the conversation with it. The run is
1635
+ * STILL WRITING, so this cannot be file work the tool does on its own:
1636
+ * only here is it known which session this run belongs to, and only
1637
+ * here can the store that is saving it be re-pointed. The host does
1638
+ * both, or the option is not offered at all. */
1639
+ onMoveSession: this.host.moveSession
1640
+ ? async (projectRoot) => {
1641
+ const message = await paused(() => this.host.moveSession(id, projectRoot));
1642
+ // Its chats moved surfaces: the list this session was in no longer
1643
+ // holds it, and the one it went to now does.
1644
+ this.notifyGlobal();
1645
+ return message;
1646
+ }
1647
+ : undefined,
1648
+ constraints: hasBudgetLimits ? constraints : undefined,
1649
+ price: (usage, priceModel) => this.host.price(usage, priceModel),
1650
+ onBreach: constraints.onBreach && this.host.breach
1651
+ ? async (breach) => {
1652
+ // Tracks the model actually running (workerClient resets its breach
1653
+ // latch after a degrade, so a second in-run breach can fire on the
1654
+ // NEW model) — price the next breach against that, not the model
1655
+ // captured at run() time.
1656
+ const r = await this.host.breach.decide(constraints.onBreach, breach, {
1657
+ currentModel: activeModel,
1658
+ // Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
1659
+ // the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
1660
+ // per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
1661
+ ask: (m) => paused(() => new Promise((resolve) => {
1662
+ this.raisePerm(id, { id: this.nextPermId++, tool: 'budget', summary: m, askedAt: Date.now(), resolve });
1663
+ })),
1664
+ // Reuse the existing "append a local notice turn" affordance (used
1665
+ // today for /model confirmations) for a soft warn — ALSO appended to
1666
+ // policyNotes, unless the caller opted into policyNotes-only
1667
+ // rendering (see SendOpts.budgetNotices).
1668
+ warn: (m) => {
1669
+ if ((opts.budgetNotices ?? 'transcript') === 'transcript')
1670
+ this.note(id, m);
1671
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
1672
+ },
1673
+ cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
1674
+ });
1675
+ if (r.model) {
1676
+ activeModel = r.model;
1677
+ this.patch(id, { degradedModel: r.model });
1678
+ }
1679
+ return r;
1680
+ }
1681
+ : undefined,
1682
+ };
1683
+ }
1684
+ /** One turn of the worker's agent loop, under the engine's control. */
1685
+ async runWorker(messages, runOpts, control) {
1686
+ const id = control.conversationId;
1687
+ const t = this.turnFor(id);
1688
+ t.control = control;
1689
+ const { opts } = t;
1690
+ const review = opts.onEditReview;
1691
+ const res = await this.getWorker().run(messages, {
1692
+ ...runOpts,
1693
+ system: control.system,
1694
+ signal: control.signal,
1695
+ sessionId: id,
1696
+ // Record the worker's run id the instant it exists, so inject() (which
1697
+ // can arrive at any point during this run) has something live to address.
1698
+ onStart: (runId) => control.onStart(String(runId)),
1699
+ // The queue's only way to tell "landed" from "never made it": the
1700
+ // AgentEvent 'injected' the loop emits per message carries text, not the
1701
+ // caller's id, so a reverting caller reads THIS to know which of its
1702
+ // still-pending ids it must NOT revert (reverting one that already
1703
+ // landed would re-send it as a fresh turn and duplicate it).
1704
+ onInjected: (ids) => control.onInjected(ids),
1705
+ // The engine answers from the session allows, or asks (askPermission).
1706
+ onPermission: async (req) => toPermissionAnswer(await control.askPermission({
1707
+ tool: req.tool, summary: req.summary, ...(req.agent ? { agent: req.agent } : {}), ...(req.input ? { input: req.input } : {}),
1708
+ signal: control.signal,
1709
+ })),
1710
+ onAsk: (questions) => control.askUser(questions),
1711
+ // propose_edit blocks on the user's verdict — the watchdog holds (like an
1712
+ // open ask form) until the surface resolves it.
1713
+ onEditReview: review ? (callId) => control.paused(() => this.reviewEditFor(opts, callId, control.signal)) : undefined,
1714
+ onEvent: (e) => {
1715
+ // The engine first (the live slice, then this side's handlers, the task
1716
+ // list among them); a host hook that throws cannot cost it an event.
1717
+ control.onEvent(e);
1718
+ // A blocked result goes no further than this page's memory (onToolHooks above).
1719
+ const kept = withoutBlockedText(e);
1720
+ opts.onRunEvent?.(kept);
1721
+ t.recBuf.push({ at: Date.now(), ev: kept });
1722
+ },
1723
+ });
1724
+ const priced = t.priced ?? this.host.modelInfo(t.model);
1725
+ const spend = t.calleeSpend;
1726
+ const u = res.usage ?? { promptTokens: 0, completionTokens: 0 };
1727
+ const stats = u.promptTokens || u.completionTokens || spend.inTok || spend.outTok
1728
+ ? {
1729
+ // This agent's own turns plus every callee's.
1730
+ inTok: u.promptTokens + spend.inTok,
1731
+ outTok: u.completionTokens + spend.outTok,
1732
+ // Cache-aware: the hand-rolled in*price it replaced billed cached tokens at the full rate.
1733
+ cost: (priceUsageWith(u, { inPrice: priced.inPrice, outPrice: priced.outPrice }) ?? 0) + spend.cost,
1734
+ ms: Date.now() - t.startedAt,
1735
+ ...(res.contextTokens ? { ctxTok: res.contextTokens } : {}),
1736
+ ...(u.cachedTokens != null ? { cachedTok: u.cachedTokens } : {}),
1737
+ ...(res.calls ? { calls: res.calls } : {}),
1738
+ }
1739
+ : undefined;
1740
+ return {
1741
+ messages: res.messages,
1742
+ // FROM THE TAIL, by the count the run reports (stitchTurn): rung-2
1743
+ // compaction rewrites the array shorter than what was sent.
1744
+ appended: res.appended,
1745
+ stopReason: res.stopReason,
1746
+ ...(res.usage ? { usage: res.usage } : {}),
1747
+ ...(stats ? { stats } : {}),
1748
+ // The engine measures every tool's duration from its own events.
1749
+ toolMs: {},
1750
+ };
1751
+ }
1752
+ /** What a run's end owes this side, once the engine has settled the turn.
1753
+ *
1754
+ * The engine calls this once for EVERY turn (R3): one refused before it
1755
+ * started (empty input, or a second send while a run is going), a refused
1756
+ * retry, one stopped before its loop, and a host turn. Only a send or retry
1757
+ * whose loop actually ran owes the run-end effects below (usage, the
1758
+ * auto-deny of an open permission card, the subagent parent-ended signal,
1759
+ * the recording). Anything else only notifies: a refused second send must
1760
+ * not deny or bill the run that IS going. */
1761
+ endTurn(result) {
1762
+ const id = result.conversationId;
1763
+ if (!id)
1764
+ return;
1765
+ const t = this.active.get(id);
1766
+ const mine = t !== undefined && t.userTurnId === result.userTurnId;
1767
+ if (t && mine)
1768
+ this.active.delete(id);
1769
+ if (!t || !mine || !t.allowed || !result.ran || result.kind === 'host-turn') {
1770
+ this.notify(id);
1771
+ return;
1772
+ }
1773
+ const s = t.ui;
1774
+ const { stats } = result;
1775
+ const priced = t.priced;
1776
+ if (stats && priced) {
1777
+ this.host.recordUsage({ surface: this.surface, provider: priced.provider, model: t.model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
1778
+ }
1779
+ // A run that ends (abort/watchdog/error) while a perm overlay (tool-permission
1780
+ // or budget-breach "continue?") is still open must not leave its promise —
1781
+ // and the caller awaiting it — hanging forever. Treat an unanswered perm as
1782
+ // "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
1783
+ // this is a no-op (idempotent) on the happy path. Queued asks go the same way.
1784
+ if (s.pendingPerm) {
1785
+ s.pendingPerm.resolve(false);
1786
+ s.pendingPerm = null;
1787
+ }
1788
+ this.denyAllPerms(id);
1789
+ s.recovery = null;
1790
+ this.notify(id);
1791
+ this.notifyGlobal();
1792
+ // Any callee still marked running under this session will never report.
1793
+ publishSubagentParentEnded(id);
1794
+ // Upsert the session recording with everything captured so far (this turn's
1795
+ // events appended to prior turns), under one stable id so the whole session
1796
+ // replays as a single stream. Skipped until something replayable happened.
1797
+ const recBuf = t.recBuf;
1798
+ if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
1799
+ if (!s.recId) {
1800
+ s.recId = this.host.recorder.newId();
1801
+ s.recPrompt = t.text;
1802
+ s.recImages = t.images.length ? t.images.slice() : undefined;
1803
+ }
1804
+ // `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
1805
+ // events so the store doesn't hold the live array that later turns mutate.
1806
+ const ok = !recBuf.slice(t.turnStart).some((r) => r.ev.type === 'error');
1807
+ this.host.recorder.save({ kind: 'agent', id: s.recId, title: (s.recPrompt || 'Agent run').slice(0, 60), at: Date.now(), ok, prompt: s.recPrompt || t.text, promptImages: s.recImages, events: recBuf.slice() });
1808
+ }
1809
+ }
1740
1810
  }
1741
1811
  //# sourceMappingURL=runtime.js.map