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