@corenel/tools-web 0.1.0 → 0.2.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 (48) hide show
  1. package/dist/budgetGate.d.ts +21 -0
  2. package/dist/budgetGate.d.ts.map +1 -0
  3. package/dist/budgetGate.js +41 -0
  4. package/dist/budgetGate.js.map +1 -0
  5. package/dist/contextDocs.d.ts +54 -0
  6. package/dist/contextDocs.d.ts.map +1 -0
  7. package/dist/contextDocs.js +145 -0
  8. package/dist/contextDocs.js.map +1 -0
  9. package/dist/core/protocol.d.ts +32 -2
  10. package/dist/core/protocol.d.ts.map +1 -1
  11. package/dist/idb.d.ts +9 -0
  12. package/dist/idb.d.ts.map +1 -1
  13. package/dist/idb.js +15 -0
  14. package/dist/idb.js.map +1 -1
  15. package/dist/memory/local.d.ts.map +1 -1
  16. package/dist/memory/local.js.map +1 -1
  17. package/dist/prompts/history.d.ts.map +1 -1
  18. package/dist/prompts/history.js +11 -6
  19. package/dist/prompts/history.js.map +1 -1
  20. package/dist/runWatchdog.d.ts +20 -0
  21. package/dist/runWatchdog.d.ts.map +1 -0
  22. package/dist/runWatchdog.js +66 -0
  23. package/dist/runWatchdog.js.map +1 -0
  24. package/dist/runtime.d.ts +355 -0
  25. package/dist/runtime.d.ts.map +1 -0
  26. package/dist/runtime.js +711 -0
  27. package/dist/runtime.js.map +1 -0
  28. package/dist/runtime.testHost.d.ts +9 -0
  29. package/dist/runtime.testHost.d.ts.map +1 -0
  30. package/dist/runtime.testHost.js +53 -0
  31. package/dist/runtime.testHost.js.map +1 -0
  32. package/dist/storageQuota.d.ts +25 -0
  33. package/dist/storageQuota.d.ts.map +1 -0
  34. package/dist/storageQuota.js +89 -0
  35. package/dist/storageQuota.js.map +1 -0
  36. package/dist/storageRegistry.d.ts +53 -0
  37. package/dist/storageRegistry.d.ts.map +1 -0
  38. package/dist/storageRegistry.js +40 -0
  39. package/dist/storageRegistry.js.map +1 -0
  40. package/dist/workerClient.d.ts +43 -3
  41. package/dist/workerClient.d.ts.map +1 -1
  42. package/dist/workerClient.js +72 -5
  43. package/dist/workerClient.js.map +1 -1
  44. package/dist/workerHandler.d.ts +7 -0
  45. package/dist/workerHandler.d.ts.map +1 -1
  46. package/dist/workerHandler.js +46 -7
  47. package/dist/workerHandler.js.map +1 -1
  48. package/package.json +19 -29
@@ -0,0 +1,711 @@
1
+ import { createWorkerAgent } from './workerClient';
2
+ import { createRunWatchdog } from './runWatchdog';
3
+ import { listDocs, deleteDocsForSession } from './contextDocs';
4
+ import { allTools } from '@corenel/harness/tools/builtins';
5
+ import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
6
+ import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
7
+ import { getSystemParts } from '@corenel/harness/systemParts';
8
+ // The agent's model is the user's picker selection (getModel) — a real
9
+ // catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
10
+ const WATCHDOG_MS = 45000;
11
+ function emptyInternal() {
12
+ return {
13
+ turns: [], live: null, running: false, error: '', mode: 'auto', input: '', attachments: [],
14
+ pendingPerm: null, pendingAsk: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
15
+ recEvents: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
16
+ offloadTick: 0, policyNotes: [], degradedModel: null,
17
+ };
18
+ }
19
+ /** Build the user message — a plain string, or multimodal parts when images are attached. */
20
+ function buildUserMessage(text, images) {
21
+ if (!images.length)
22
+ return { role: 'user', content: text };
23
+ const parts = [];
24
+ if (text)
25
+ parts.push({ type: 'text', text });
26
+ for (const url of images)
27
+ parts.push({ type: 'image_url', image_url: { url } });
28
+ return { role: 'user', content: parts };
29
+ }
30
+ function userText(m) {
31
+ if (typeof m.content === 'string')
32
+ return m.content;
33
+ if (Array.isArray(m.content)) {
34
+ const t = m.content.find((p) => p.type === 'text');
35
+ return t && 'text' in t ? t.text : '';
36
+ }
37
+ return '';
38
+ }
39
+ function userImages(m) {
40
+ if (!Array.isArray(m.content))
41
+ return [];
42
+ return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
43
+ }
44
+ /** Merge a form's freshly-emitted envelope into a per-run override, against the
45
+ * currently saved default. The form only emits the keys it still wants set — a
46
+ * cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
47
+ * A plain `{...savedDefault, ...emitted}` merge at read time would then silently
48
+ * fall back to the saved default for that field, so "No limit" could never
49
+ * actually clear a saved cap. Fix: for every key present in `savedDefault` but
50
+ * absent from `emitted`, materialize an explicit `undefined` in the returned
51
+ * override so the read-time spread masks it (`{...{maxUsd:2}, ...{maxUsd:undefined}}`
52
+ * -> `maxUsd: undefined`, which all `!= null` consumers treat as no limit). Keys the
53
+ * form already sends as `undefined` (its own internal clears) pass through unchanged. */
54
+ export function mergeOverride(savedDefault, emitted) {
55
+ const next = { ...emitted };
56
+ for (const key of Object.keys(savedDefault)) {
57
+ if (!(key in emitted))
58
+ next[key] = undefined;
59
+ }
60
+ return next;
61
+ }
62
+ export class AgentRuntime {
63
+ /** `surface` namespaces this runtime's sessions in the store (so /agent and /craft
64
+ * never list each other's chats). `systemExtra` is prepended to every run's system
65
+ * context — Craft uses it to carry the document-creation directive. */
66
+ constructor(host, surface = 'agent',
67
+ // Not marked optional (`?`) — a required parameter (getModel) follows it, and
68
+ // TS forbids an optional parameter before a required one. Callers (createAgentRuntime)
69
+ // always pass all four positionally, so this is a mechanical, not semantic, change.
70
+ systemExtra,
71
+ /** Resolves the model id for each run — the host factory always passes one
72
+ * (Corenel's agent model, or Craft's own surface getter). */
73
+ getModel) {
74
+ Object.defineProperty(this, "host", {
75
+ enumerable: true,
76
+ configurable: true,
77
+ writable: true,
78
+ value: host
79
+ });
80
+ Object.defineProperty(this, "surface", {
81
+ enumerable: true,
82
+ configurable: true,
83
+ writable: true,
84
+ value: surface
85
+ });
86
+ Object.defineProperty(this, "systemExtra", {
87
+ enumerable: true,
88
+ configurable: true,
89
+ writable: true,
90
+ value: systemExtra
91
+ });
92
+ Object.defineProperty(this, "getModel", {
93
+ enumerable: true,
94
+ configurable: true,
95
+ writable: true,
96
+ value: getModel
97
+ });
98
+ Object.defineProperty(this, "map", {
99
+ enumerable: true,
100
+ configurable: true,
101
+ writable: true,
102
+ value: new Map()
103
+ });
104
+ Object.defineProperty(this, "subs", {
105
+ enumerable: true,
106
+ configurable: true,
107
+ writable: true,
108
+ value: new Map()
109
+ });
110
+ Object.defineProperty(this, "globalSubs", {
111
+ enumerable: true,
112
+ configurable: true,
113
+ writable: true,
114
+ value: new Set()
115
+ });
116
+ Object.defineProperty(this, "_ws", {
117
+ enumerable: true,
118
+ configurable: true,
119
+ writable: true,
120
+ value: null
121
+ });
122
+ Object.defineProperty(this, "worker", {
123
+ enumerable: true,
124
+ configurable: true,
125
+ writable: true,
126
+ value: null
127
+ });
128
+ /** Reuse the most-recent empty session if there is one, else create a fresh one.
129
+ * Returns the id to focus on page open — keeps a single throwaway "New chat"
130
+ * around instead of breeding empties. Concurrent calls are deduped (React
131
+ * StrictMode double-invokes the mount effect) so the page never opens two. */
132
+ Object.defineProperty(this, "initInFlight", {
133
+ enumerable: true,
134
+ configurable: true,
135
+ writable: true,
136
+ value: null
137
+ });
138
+ }
139
+ ws() {
140
+ if (!this._ws)
141
+ this._ws = this.host.workspace();
142
+ // This surface is active — its workspace backs the session/recall stores.
143
+ // Gated on whenReady() so a fresh page load never lets the session store
144
+ // resolve the default root before the persisted source has been restored.
145
+ this.host.installFilesResolver(async () => {
146
+ const w = this._ws;
147
+ await w.whenReady();
148
+ return w.get();
149
+ });
150
+ return this._ws;
151
+ }
152
+ getWorker() {
153
+ if (!this.worker)
154
+ this.worker = createWorkerAgent({ getFileService: () => this.ws().get(), ...this.host.workerDeps });
155
+ return this.worker;
156
+ }
157
+ /** The active file workspace (the page shows its label as a context chip). */
158
+ fileService() { return this.ws().get(); }
159
+ workspace() { return this.ws(); }
160
+ /* ---- reactivity ---- */
161
+ ensure(id) {
162
+ let s = this.map.get(id);
163
+ if (!s) {
164
+ s = emptyInternal();
165
+ const dm = this.host.defaultMode?.();
166
+ if (dm)
167
+ s.mode = dm;
168
+ this.map.set(id, s);
169
+ }
170
+ return s;
171
+ }
172
+ notify(id) { this.subs.get(id)?.forEach((fn) => fn()); }
173
+ notifyGlobal() { this.globalSubs.forEach((fn) => fn()); }
174
+ /** Mutate a session and notify its view; `global` also pokes the running-badge subscribers. */
175
+ patch(id, p, global = false) {
176
+ Object.assign(this.ensure(id), p);
177
+ this.notify(id);
178
+ if (global)
179
+ this.notifyGlobal();
180
+ }
181
+ subscribe(id, cb) {
182
+ let set = this.subs.get(id);
183
+ if (!set) {
184
+ set = new Set();
185
+ this.subs.set(id, set);
186
+ }
187
+ set.add(cb);
188
+ return () => { set.delete(cb); };
189
+ }
190
+ subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
191
+ getState(id) { return this.map.get(id) ?? emptyInternal(); }
192
+ status(id) {
193
+ const s = this.map.get(id);
194
+ if (!s)
195
+ return 'idle';
196
+ if (s.pendingPerm || s.pendingAsk)
197
+ return 'waiting';
198
+ return s.running ? 'running' : 'idle';
199
+ }
200
+ /* ---- session lifecycle ---- */
201
+ /** The session store reads through the module-global active-files resolver
202
+ * (stateFs), which only THIS runtime's ws() installs. Every session-store
203
+ * entry point must assert it first — a page that never touches
204
+ * workspace()/fileService() before its mount effect (CraftPage direct load)
205
+ * otherwise hits the default null resolver and the store throws
206
+ * "No file workspace is connected". */
207
+ /** Lazily load a session's turns from the store the first time it's focused. */
208
+ async load(id) {
209
+ if (!id)
210
+ return;
211
+ this.ws();
212
+ const s = this.ensure(id);
213
+ if (s.loaded)
214
+ return;
215
+ const ses = await this.host.sessions.get(id);
216
+ s.turns = ses?.messages || [];
217
+ s.loaded = true;
218
+ this.notify(id);
219
+ }
220
+ async openInitial() {
221
+ if (this.initInFlight)
222
+ return this.initInFlight;
223
+ this.ws();
224
+ this.initInFlight = (async () => {
225
+ const list = await this.host.sessions.list(this.surface);
226
+ const empty = list.find((s) => !s.messages || s.messages.length === 0);
227
+ if (empty) {
228
+ this.ensure(empty.id).loaded = true;
229
+ return empty.id;
230
+ }
231
+ return this.newSession();
232
+ })();
233
+ try {
234
+ return await this.initInFlight;
235
+ }
236
+ finally {
237
+ this.initInFlight = null;
238
+ }
239
+ }
240
+ async newSession() {
241
+ this.ws();
242
+ const ses = await this.host.sessions.create('New chat', this.surface);
243
+ const s = this.ensure(ses.id);
244
+ s.loaded = true;
245
+ this.notifyGlobal();
246
+ return ses.id;
247
+ }
248
+ async deleteSession(id) {
249
+ this.ws();
250
+ this.map.get(id)?.abort?.abort();
251
+ this.map.delete(id);
252
+ this.subs.delete(id);
253
+ await this.host.sessions.delete(id).catch(() => { });
254
+ deleteDocsForSession(id);
255
+ this.notifyGlobal();
256
+ }
257
+ /* ---- view mutators ---- */
258
+ setInput(id, v) { this.patch(id, { input: v }); }
259
+ setMode(id, m) { this.patch(id, { mode: m }); }
260
+ /** Clear the conversation (keep the session) — empties the visible transcript and
261
+ * starts a fresh recording stream for what follows. */
262
+ clear(id) {
263
+ const s = this.ensure(id);
264
+ s.recId = undefined;
265
+ s.recPrompt = undefined;
266
+ s.recImages = undefined;
267
+ s.recEvents = [];
268
+ this.patch(id, { turns: [], error: '', suggestions: [] }, true);
269
+ void this.persist(id);
270
+ }
271
+ /** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
272
+ * sent to the model, just shown in the transcript. */
273
+ note(id, text) {
274
+ const s = this.ensure(id);
275
+ const turn = { id: `n${Date.now().toString(36)}`, included: false, messages: [{ role: 'assistant', content: text }] };
276
+ this.patch(id, { turns: [...s.turns, turn] });
277
+ void this.persist(id);
278
+ }
279
+ setAttachments(id, fn) {
280
+ this.patch(id, { attachments: fn(this.ensure(id).attachments) });
281
+ }
282
+ toggleInclude(id, turnId) {
283
+ const s = this.ensure(id);
284
+ this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
285
+ void this.persist(id);
286
+ }
287
+ resolvePerm(id, allow, always = false) {
288
+ const s = this.map.get(id);
289
+ if (!s?.pendingPerm)
290
+ return;
291
+ if (allow && always)
292
+ s.allowed.add(s.pendingPerm.tool);
293
+ s.pendingPerm.resolve(allow);
294
+ this.patch(id, { pendingPerm: null }, true);
295
+ }
296
+ resolveAsk(id, answers) {
297
+ const s = this.map.get(id);
298
+ if (!s?.pendingAsk)
299
+ return;
300
+ s.pendingAsk.resolve(answers);
301
+ this.patch(id, { pendingAsk: null }, true);
302
+ }
303
+ stop(id) { this.map.get(id)?.abort?.abort(); }
304
+ /* ---- run ---- */
305
+ async persist(id) {
306
+ const s = this.map.get(id);
307
+ if (!s)
308
+ return;
309
+ const ses = await this.host.sessions.get(id);
310
+ if (!ses)
311
+ return;
312
+ ses.messages = s.turns;
313
+ await this.host.sessions.save(ses).catch(() => { });
314
+ }
315
+ async composeSystem(id, persona, partsOverride) {
316
+ const svc = this.ws().get();
317
+ const s = this.ensure(id);
318
+ // systemExtra (e.g. Craft's document-creation directive) leads the context.
319
+ const blocks = this.systemExtra ? [this.systemExtra] : [];
320
+ if (svc) {
321
+ const src = svc.kind === 'directory' ? `local folder "${svc.label}"`
322
+ : svc.kind === 'sidecar' ? `connected sidecar machine "${svc.label}"`
323
+ : `virtual in-browser workspace "${svc.label}"`;
324
+ const flags = `${svc.isLocalDisk ? "on the user's real disk" : 'in-browser'}, ${svc.writable ? 'writable' : 'read-only'}`;
325
+ blocks.push(`--- Workspace ---\n` +
326
+ `Connected source: ${src} (${flags}).\n` +
327
+ `All file paths are relative to this workspace root (POSIX, no leading slash, e.g. "prompts/base.prmd"). ` +
328
+ `There is no separate working directory or shell — inspect the workspace with list_files / read_file / ` +
329
+ `stat_file / search_files, and change it with write_file / create_file / delete_file / rename_file. ` +
330
+ `The browser does not expose the folder's absolute path, so report locations relative to the root.`);
331
+ }
332
+ else {
333
+ blocks.push(`--- Workspace ---\nNo file workspace is connected, so the file tools are unavailable. Say so rather than guessing about files.`);
334
+ }
335
+ // Host-supplied extra system-context blocks (project instructions, project
336
+ // memory, editor buffer …), resolved per send against the active FileService.
337
+ for (const p of this.host.contextProviders ?? []) {
338
+ const block = await p(svc);
339
+ if (block)
340
+ blocks.push(block);
341
+ }
342
+ // User-uploaded context documents (persistent chips): one stable block per
343
+ // doc, addedAt order — stable bytes/order keep provider prompt caches warm.
344
+ for (const d of listDocs(id)) {
345
+ blocks.push(`--- Attached document: ${d.name} ---\n${d.text}`);
346
+ }
347
+ const p = partsOverride ?? getSystemParts();
348
+ return composeAgentSystem({
349
+ persona: p.persona ? persona : '',
350
+ mode: s.mode,
351
+ includeMode: p.mode,
352
+ includeToolGuidance: p.toolGuidance,
353
+ systemPath: p.baseSystem ? undefined : this.host.systemPath?.(svc),
354
+ contextText: blocks.join('\n\n'),
355
+ toolsText: p.tools ? TOOLS_MARKER : '',
356
+ params: this.host.systemParams?.(),
357
+ memoryIndex: this.host.memoryIndex,
358
+ });
359
+ }
360
+ /** Send a message and run the agent for `id`. Concurrent with other sessions:
361
+ * each run is its own AbortController + watchdog and streams into its own slice. */
362
+ async send(id, text, images, opts) {
363
+ const s = this.ensure(id);
364
+ if ((!text && images.length === 0) || s.running)
365
+ return;
366
+ // Feature guard — the runtime is the chokepoint for every /agent run (send
367
+ // and retry), so a quota/plan guard registered later covers this surface
368
+ // too. Checked before the turn is appended or the composer cleared, so a
369
+ // denial loses nothing; s.running is re-read after the await because
370
+ // another send may have claimed the session while the guard evaluated.
371
+ const gate = await this.host.guard('llm-execution');
372
+ if (!gate.allowed) {
373
+ this.patch(id, { error: gate.reason });
374
+ return;
375
+ }
376
+ if (s.running)
377
+ return;
378
+ const isFirst = s.turns.length === 0;
379
+ const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
380
+ const nextTurns = [...s.turns, userTurn];
381
+ s.toolMs = {};
382
+ this.patch(id, {
383
+ turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
384
+ policyNotes: [], degradedModel: null,
385
+ }, true);
386
+ const startedAt = Date.now();
387
+ // Title an untitled session from its first message, so the switcher is readable.
388
+ // A hand-renamed session is locked, so its chosen title is left alone.
389
+ if (isFirst && text) {
390
+ const ses = await this.host.sessions.get(id);
391
+ if (ses && !ses.titleLocked) {
392
+ ses.title = text.slice(0, 48);
393
+ await this.host.sessions.save(ses).catch(() => { });
394
+ this.notifyGlobal();
395
+ }
396
+ }
397
+ const outgoing = nextTurns.filter((t) => t.included).flatMap((t) => t.messages);
398
+ const ac = new AbortController();
399
+ s.abort = ac;
400
+ // This turn's events append to the session's accumulated stream (one recording
401
+ // per session, not per turn) — see Internal.recEvents.
402
+ const recBuf = s.recEvents;
403
+ // Where THIS turn's events start — so `ok` reflects only this turn (a transient
404
+ // error in an earlier turn must not mark a recovered session as failed forever).
405
+ const turnStart = recBuf.length;
406
+ // Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
407
+ // first turn's prompt is the recording's `prompt` header, so only record once
408
+ // the session already has replayable content (avoids duplicating it).
409
+ if (recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
410
+ recBuf.push({ at: Date.now(), ev: { type: 'user', content: text, images: images.length ? images.slice() : undefined } });
411
+ }
412
+ // Shared wedged-run watchdog (see runWatchdog.ts): soft window while
413
+ // streaming, paused mid-tool with a hard cap so a never-settling tool
414
+ // (half-open sidecar socket) can't hang the run forever.
415
+ const dog = createRunWatchdog((reason) => {
416
+ ac.abort();
417
+ this.patch(id, {
418
+ error: reason === 'tool-hard-cap'
419
+ ? 'A tool ran for over 10 minutes without completing — aborted. The connection to it may have dropped.'
420
+ : 'No response from the gateway in 45s — aborted. Check that you are signed in and the backend is reachable.',
421
+ });
422
+ }, { softMs: WATCHDOG_MS });
423
+ dog.bump();
424
+ try {
425
+ const system = await this.composeSystem(id, opts.persona, opts.partsOverride);
426
+ const model = this.getModel();
427
+ // Live model for onBreach pricing — reassigned when a degrade breach
428
+ // switches the run onto a cheaper model (see onBreach below).
429
+ let activeModel = model;
430
+ const priced = this.host.modelInfo(model);
431
+ const contextWindow = priced.contextWindow ?? 128000;
432
+ // The surface's constraints default (Task 7), overridden per run when the
433
+ // caller passes one (a masked-override envelope from `mergeOverride`) —
434
+ // mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
435
+ // NOTE: this deliberately reads `this.surface`'s constraints, not a
436
+ // hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
437
+ // class (which always read 'agent'); 'craft' has no saved defaults so the
438
+ // effective result is unchanged.
439
+ const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
440
+ const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
441
+ // Capability seam (Task 3): only send toolNames when the host's tools
442
+ // getter is NOT the default allTools (reference inequality) — this keeps
443
+ // the default path byte-identical, since every surface today passes
444
+ // `tools: allTools` (or omits capabilities entirely).
445
+ const capTools = this.host.capabilities?.tools;
446
+ const toolNames = capTools && capTools !== allTools ? capTools().map((t) => t.name) : undefined;
447
+ const res = await this.getWorker().run(outgoing, {
448
+ model,
449
+ system,
450
+ policy: getPolicy(opts.policyName) ?? STANDARD_POLICY,
451
+ compression: { mode: this.host.compression(), contextWindow },
452
+ signal: ac.signal,
453
+ sessionId: id,
454
+ toolNames,
455
+ onPermission: (req) => new Promise((resolve) => {
456
+ if (s.allowed.has(req.tool)) {
457
+ resolve(true);
458
+ return;
459
+ }
460
+ this.patch(id, { pendingPerm: { ...req, resolve } }, true);
461
+ }),
462
+ onAsk: (questions) => new Promise((resolve) => {
463
+ dog.pause();
464
+ this.patch(id, { pendingAsk: { questions, resolve: (a) => { resolve(a); dog.bump(); } } }, true);
465
+ }),
466
+ // Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
467
+ // standalone page omits both, so propose_edit keeps its non-blocking
468
+ // fallback exactly as before.
469
+ editorFile: opts.editorFile,
470
+ onNodeEvent: opts.onNodeEvent,
471
+ onEditReview: opts.onEditReview
472
+ ? async (callId) => {
473
+ // propose_edit blocks on the user's verdict — pause the watchdog
474
+ // (like an open ask form), resume once the host resolves it.
475
+ dog.pause();
476
+ const r = await opts.onEditReview(callId, ac.signal);
477
+ dog.bump();
478
+ return r;
479
+ }
480
+ : undefined,
481
+ constraints: hasBudgetLimits ? constraints : undefined,
482
+ price: (usage, priceModel) => this.host.price(usage, priceModel),
483
+ onBreach: constraints.onBreach && this.host.breach
484
+ ? async (breach) => {
485
+ // Tracks the model actually running (workerClient resets its breach
486
+ // latch after a degrade, so a second in-run breach can fire on the
487
+ // NEW model) — price the next breach against that, not the model
488
+ // captured at run() time.
489
+ const r = await this.host.breach.decide(constraints.onBreach, breach, {
490
+ currentModel: activeModel,
491
+ // Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
492
+ // the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
493
+ // per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
494
+ ask: (m) => new Promise((resolve) => {
495
+ dog.pause();
496
+ this.patch(id, { pendingPerm: { tool: 'budget', summary: m, resolve: (b) => { resolve(b); dog.bump(); } } }, true);
497
+ }),
498
+ // Reuse the existing "append a local notice turn" affordance (used
499
+ // today for /model confirmations) for a soft warn — ALSO appended to
500
+ // policyNotes, unless the caller opted into policyNotes-only
501
+ // rendering (see SendOpts.budgetNotices).
502
+ warn: (m) => {
503
+ if ((opts.budgetNotices ?? 'transcript') === 'transcript')
504
+ this.note(id, m);
505
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
506
+ },
507
+ cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
508
+ });
509
+ if (r.model) {
510
+ activeModel = r.model;
511
+ this.patch(id, { degradedModel: r.model });
512
+ }
513
+ return r;
514
+ }
515
+ : undefined,
516
+ onEvent: (e) => {
517
+ opts.onRunEvent?.(e);
518
+ dog.onEvent(e);
519
+ recBuf.push({ at: Date.now(), ev: e });
520
+ // Heal persistence is centralized in the worker client (host workerDeps
521
+ // onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
522
+ // We just surface the live "Recovering…" status (null clears it on recovered).
523
+ if (e.type === 'heal') {
524
+ this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
525
+ return;
526
+ }
527
+ if (e.type === 'offload') {
528
+ this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
529
+ return;
530
+ }
531
+ if (e.type === 'policy-violation') {
532
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
533
+ return;
534
+ }
535
+ const live = s.live;
536
+ if (!live)
537
+ return;
538
+ if (e.type === 'assistant-delta')
539
+ this.patch(id, { live: { ...live, text: live.text + e.delta }, recovery: null });
540
+ else if (e.type === 'assistant')
541
+ this.patch(id, { live: { ...live, text: '' } });
542
+ else if (e.type === 'tool-call')
543
+ this.patch(id, { live: { ...live, tools: [...live.tools, { id: e.id, name: e.name, args: e.args }] } });
544
+ else if (e.type === 'tool-result') {
545
+ if (typeof e.durationMs === 'number')
546
+ s.toolMs[e.id] = e.durationMs;
547
+ this.patch(id, { live: { ...live, tools: live.tools.map((t) => (t.id === e.id ? { ...t, result: e.result, isError: e.isError, done: true, ms: e.durationMs } : t)) } });
548
+ }
549
+ else if (e.type === 'error')
550
+ this.patch(id, { error: e.message });
551
+ else if (e.type === 'usage')
552
+ s.lastUsage = e.usage;
553
+ // Synthetic event workerClient emits when onBreach returned {continue:false}
554
+ // — surfaced as a transcript notice via the existing note() affordance,
555
+ // AND appended to policyNotes (same warn/stop duality as above, gated
556
+ // by SendOpts.budgetNotices).
557
+ else if (e.type === 'budget-stop') {
558
+ const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
559
+ if ((opts.budgetNotices ?? 'transcript') === 'transcript')
560
+ this.note(id, msg);
561
+ this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
562
+ }
563
+ },
564
+ });
565
+ // Stitch each tool call's measured duration onto its tool message (`_ms`).
566
+ const ms = s.toolMs;
567
+ const created = res.messages.slice(outgoing.length + 1).map((m) => {
568
+ const tid = m.tool_call_id;
569
+ return m.role === 'tool' && tid && ms[tid] != null ? { ...m, _ms: ms[tid] } : m;
570
+ });
571
+ const u = res.usage;
572
+ const stats = u && (u.promptTokens || u.completionTokens)
573
+ ? { inTok: u.promptTokens, outTok: u.completionTokens, cost: (u.promptTokens / 1e6) * priced.inPrice + (u.completionTokens / 1e6) * priced.outPrice, ms: Date.now() - startedAt }
574
+ : undefined;
575
+ this.patch(id, { turns: s.turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats } : t)) });
576
+ void this.persist(id);
577
+ if (stats) {
578
+ this.host.recordUsage({ surface: this.surface, provider: priced.provider, model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
579
+ }
580
+ // Best-effort follow-up chips for this turn — generated after the reply lands,
581
+ // dropped if the user starts/aborts another run before it returns.
582
+ if (this.host.suggestFollowups) {
583
+ void this.host.suggestFollowups({ messages: res.messages, model, signal: ac.signal }).then((sug) => {
584
+ if (sug.length && !ac.signal.aborted)
585
+ this.patch(id, { suggestions: sug }, true);
586
+ });
587
+ }
588
+ }
589
+ catch (e) {
590
+ this.patch(id, { error: String(e?.message || e) });
591
+ }
592
+ finally {
593
+ // A run that ends (abort/watchdog/error) while a perm overlay (tool-permission
594
+ // or budget-breach "continue?") is still open must not leave its promise —
595
+ // and the caller awaiting it — hanging forever. Treat an unanswered perm as
596
+ // "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
597
+ // this is a no-op (idempotent) on the happy path.
598
+ if (s.pendingPerm) {
599
+ s.pendingPerm.resolve(false);
600
+ this.patch(id, { pendingPerm: null }, true);
601
+ }
602
+ dog.clear();
603
+ s.abort = null;
604
+ this.patch(id, { running: false, live: null, recovery: null }, true);
605
+ // Upsert the session recording with everything captured so far (this turn's
606
+ // events appended to prior turns), under one stable id so the whole session
607
+ // replays as a single stream. Skipped until something replayable happened.
608
+ if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
609
+ if (!s.recId) {
610
+ s.recId = this.host.recorder.newId();
611
+ s.recPrompt = text;
612
+ s.recImages = images.length ? images.slice() : undefined;
613
+ }
614
+ // `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
615
+ // events so the store doesn't hold the live array that later turns mutate.
616
+ const ok = !recBuf.slice(turnStart).some((r) => r.ev.type === 'error');
617
+ 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() });
618
+ }
619
+ }
620
+ }
621
+ /** A host-driven turn: appends the user message, then awaits a host-supplied
622
+ * `produce` (e.g. the /strategy command's own completion) instead of running
623
+ * the worker agent. Same running/live/error/abort shape as `send`, minus
624
+ * tool calls — used by surfaces that render a live "thinking" placeholder for
625
+ * work the engine itself doesn't perform. */
626
+ async hostTurn(id, userText, produce) {
627
+ const s = this.ensure(id);
628
+ if (!userText || s.running)
629
+ return;
630
+ this.ws();
631
+ const gate = await this.host.guard('llm-execution');
632
+ if (!gate.allowed) {
633
+ this.patch(id, { error: gate.reason });
634
+ return;
635
+ }
636
+ if (s.running)
637
+ return;
638
+ const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
639
+ const ac = new AbortController();
640
+ s.abort = ac;
641
+ this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
642
+ try {
643
+ const content = await produce(ac.signal);
644
+ this.patch(id, { turns: this.ensure(id).turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], { role: 'assistant', content }] } : t)) });
645
+ void this.persist(id);
646
+ }
647
+ catch (e) {
648
+ this.patch(id, { error: String(e?.message || e) });
649
+ }
650
+ finally {
651
+ s.abort = null;
652
+ this.patch(id, { running: false, live: null }, true);
653
+ }
654
+ }
655
+ /** Replace the offload stub for `blobId` inside turn `turnId` (session `id`)
656
+ * with the full content — the context drawer's per-item Restore, transplanted
657
+ * from the harness/conversation.ts singleton (same marker-matching and
658
+ * identity-swap semantics; see that module for the rationale). Returns false
659
+ * (state untouched, no persist) when the turn or the matching stub is absent. */
660
+ restoreToolResult(id, turnId, blobId, content) {
661
+ const marker = `recall_result({ id: "${blobId}" })`;
662
+ const s = this.ensure(id);
663
+ const turn = s.turns.find((t) => t.id === turnId);
664
+ if (!turn)
665
+ return false;
666
+ const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
667
+ if (idx < 0)
668
+ return false;
669
+ const next = s.turns.map((t) => {
670
+ if (t.id !== turnId)
671
+ return t;
672
+ const messages = t.messages.slice();
673
+ messages[idx] = { ...messages[idx], content };
674
+ return { ...t, messages };
675
+ });
676
+ this.patch(id, { turns: next });
677
+ void this.persist(id);
678
+ return true;
679
+ }
680
+ /** Re-run a prior user turn: drop it and everything after, then resend it. */
681
+ async retry(id, turnId, opts) {
682
+ const s = this.ensure(id);
683
+ if (s.running)
684
+ return;
685
+ const idx = s.turns.findIndex((t) => t.id === turnId);
686
+ if (idx < 0)
687
+ return;
688
+ const first = s.turns[idx].messages[0];
689
+ const text = first ? userText(first) : '';
690
+ const images = first ? userImages(first) : [];
691
+ if (!text && images.length === 0)
692
+ return;
693
+ this.patch(id, { turns: s.turns.slice(0, idx) });
694
+ await this.send(id, text, images, opts);
695
+ }
696
+ /** Drop all in-memory state and abort every run — called on a scope change so
697
+ * one identity's sessions and runs never carry into another's. */
698
+ reset() {
699
+ for (const s of this.map.values())
700
+ s.abort?.abort();
701
+ const ids = [...this.subs.keys()];
702
+ this.map.clear();
703
+ this.worker?.dispose();
704
+ this.worker = null;
705
+ this._ws = null;
706
+ for (const id of ids)
707
+ this.notify(id);
708
+ this.notifyGlobal();
709
+ }
710
+ }
711
+ //# sourceMappingURL=runtime.js.map