@corenel/tools-web 0.8.0 → 0.10.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.
package/dist/runtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createWorkerAgent } from './workerClient';
2
2
  import { nodeUsageOf } from './nodeUsage';
3
+ import { publishSubagentEvent, publishSubagentParentEnded } from './subagentBus';
3
4
  import { foldLive } from './liveFold';
4
5
  import { createRunWatchdog } from './runWatchdog';
5
6
  import { listDocs, deleteDocsForSession } from './contextDocs';
@@ -11,15 +12,16 @@ import { sessionSystemPrompt } from './sessionSystemPrompt';
11
12
  import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
12
13
  import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
13
14
  import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
14
- import { turnsFromSessionMessages } from '@corenel/harness/conversation';
15
+ import { turnsFromSessionMessages, foldPoint, isFoldedTurn } from '@corenel/harness/conversation';
15
16
  import { allTools } from '@corenel/harness/tools/builtins';
16
17
  import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
17
18
  import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
18
19
  import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
19
20
  import { getSystemParts } from '@corenel/harness/systemParts';
20
- import { CHECKPOINT_MARKER } from '@corenel/harness/core/compactHistory';
21
+ import { CHECKPOINT_MARKER, estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
21
22
  import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
22
23
  import { enabledSkillCatalog } from '@corenel/harness/skills';
24
+ import { alog } from '@corenel/harness/log';
23
25
  // The agent's model is the user's picker selection (getModel) — a real
24
26
  // catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
25
27
  const WATCHDOG_MS = 45000;
@@ -34,7 +36,8 @@ function emptyInternal() {
34
36
  pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
35
37
  pendingCommand: null, runId: null,
36
38
  recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
37
- offloadTick: 0, policyNotes: [], degradedModel: null,
39
+ offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null, persistedLen: null,
40
+ saveBlocked: null, loadMissed: false, mergeTick: 0,
38
41
  };
39
42
  }
40
43
  /** Build the user message — a plain string, or multimodal parts when images are attached. */
@@ -62,6 +65,35 @@ function userImages(m) {
62
65
  return [];
63
66
  return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
64
67
  }
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);
79
+ }
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
+ });
96
+ }
65
97
  /** Merge a form's freshly-emitted envelope into a per-run override, against the
66
98
  * currently saved default. The form only emits the keys it still wants set — a
67
99
  * cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
@@ -127,6 +159,14 @@ export class AgentRuntime {
127
159
  writable: true,
128
160
  value: getTemperature
129
161
  });
162
+ /** Stops the catalog listener the constructor installed. Only dispose()
163
+ * calls it: reset() keeps a long-lived runtime listening. */
164
+ Object.defineProperty(this, "offModels", {
165
+ enumerable: true,
166
+ configurable: true,
167
+ writable: true,
168
+ value: null
169
+ });
130
170
  Object.defineProperty(this, "map", {
131
171
  enumerable: true,
132
172
  configurable: true,
@@ -157,9 +197,9 @@ export class AgentRuntime {
157
197
  writable: true,
158
198
  value: null
159
199
  });
160
- /** Reuse the most-recent empty session if there is one, else create a fresh one.
161
- * Returns the id to focus on page open — keeps a single throwaway "New chat"
162
- * around instead of breeding empties. Concurrent calls are deduped (React
200
+ /** Reuse the most-recent empty saved session if there is one, else open a
201
+ * DRAFT (newDraft): opening the app, or landing after a delete, saves
202
+ * nothing until the user first sends. Concurrent calls are deduped (React
163
203
  * StrictMode double-invokes the mount effect) so the page never opens two. */
164
204
  Object.defineProperty(this, "initInFlight", {
165
205
  enumerable: true,
@@ -167,6 +207,52 @@ export class AgentRuntime {
167
207
  writable: true,
168
208
  value: null
169
209
  });
210
+ /* DRAFT CHATS. A draft is a chat id held only here: loaded, empty, in no
211
+ * store and so in no chat list. Its first write (the first send, or a note,
212
+ * 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. */
216
+ Object.defineProperty(this, "drafts", {
217
+ enumerable: true,
218
+ configurable: true,
219
+ writable: true,
220
+ value: new Set()
221
+ });
222
+ Object.defineProperty(this, "materializing", {
223
+ enumerable: true,
224
+ configurable: true,
225
+ writable: true,
226
+ value: new Map()
227
+ });
228
+ /* The chat's estimated size in tokens, keyed by the turns array: turns are
229
+ * replaced, never mutated, so the same array means the same size. The picker
230
+ * asks canHold for every row on every render; this keeps that one flatten
231
+ * per transcript, not one per row. */
232
+ Object.defineProperty(this, "fill", {
233
+ enumerable: true,
234
+ configurable: true,
235
+ writable: true,
236
+ value: new WeakMap()
237
+ });
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
+ });
251
+ // Availability and the fallback are read live, so every open chat's
252
+ // notice must re-render when either moves.
253
+ if (host.perChatModel)
254
+ this.offModels = host.onModelsChange?.(() => { for (const id of this.subs.keys())
255
+ this.notify(id); }) ?? null;
170
256
  }
171
257
  ws() {
172
258
  if (!this._ws)
@@ -244,7 +330,13 @@ export class AgentRuntime {
244
330
  return () => { set.delete(cb); };
245
331
  }
246
332
  subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
247
- getState(id) { return this.map.get(id) ?? emptyInternal(); }
333
+ getState(id) {
334
+ const s = this.map.get(id);
335
+ if (!s)
336
+ return emptyInternal();
337
+ s.modelNotice = this.modelNotice(id, s);
338
+ return s;
339
+ }
248
340
  status(id) {
249
341
  const s = this.map.get(id);
250
342
  if (!s)
@@ -271,8 +363,31 @@ export class AgentRuntime {
271
363
  const s = this.ensure(id);
272
364
  if (s.loaded)
273
365
  return;
274
- const ses = await this.host.sessions.get(id);
275
- s.turns = turnsFromSessionMessages(ses?.messages);
366
+ /* A read that THROWS is a miss, not an error for the caller: send awaits
367
+ * this, and a rejected load would drop the send outright while the next
368
+ * attempt fails the same way. As a miss it goes down the path that is
369
+ * covered: the exchange runs, saveBlocked says it is not kept, and Retry
370
+ * (retryLoad) reads again. */
371
+ let ses;
372
+ try {
373
+ ses = await this.host.sessions.get(id);
374
+ }
375
+ catch {
376
+ ses = null;
377
+ }
378
+ // A concurrent load (the focus effect beside a send) landed first, and its
379
+ // merge already holds everything; merging again would double the chat.
380
+ if (s.loaded)
381
+ return;
382
+ /* MERGE, never replace: anything this page added before the stored chat
383
+ * arrived -- a /model notice, say -- comes after it rather than vanishing.
384
+ * A miss leaves those turns alone too. */
385
+ const pre = s.turns;
386
+ if (ses) {
387
+ s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
388
+ s.persistedLen = ses.messages.length;
389
+ }
390
+ s.model = ses?.model ?? null;
276
391
  /* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
277
392
  * it permanent: the session could arrive a moment later and this would
278
393
  * never look again, because the guard above returns on `loaded`.
@@ -290,6 +405,11 @@ export class AgentRuntime {
290
405
  * -- not per render -- so a genuinely absent session does not spin. */
291
406
  if (ses)
292
407
  s.loaded = true;
408
+ s.loadMissed = !ses;
409
+ // Loaded with nothing of this page's to save: nothing is unsaved any more.
410
+ // With something, the persist below clears the flag once it lands.
411
+ if (ses && !pre.length)
412
+ s.saveBlocked = null;
293
413
  /* MIGRATION. A conversation from before the snapshot existed has none, and
294
414
  * would otherwise keep following the live Settings default forever. Opening
295
415
  * it freezes it at what it would have used at this moment, so nothing
@@ -298,6 +418,37 @@ export class AgentRuntime {
298
418
  if (!sessionSystemConfig(id))
299
419
  this.freezeSystemConfig(id);
300
420
  this.notify(id);
421
+ // What was added before the load could not be saved then (see persist).
422
+ if (ses && pre.length)
423
+ void this.persist(id);
424
+ }
425
+ /** Try the stored chat again after a load failed (the unsaved-chat notice's
426
+ * Retry): load it -- merging what this page added meanwhile after it, as
427
+ * `load` always does -- then save the merged list. True once it is saved;
428
+ * false when the load missed again (or threw), and `saveBlocked` stays. */
429
+ async retryLoad(id) {
430
+ await this.load(id);
431
+ const s = this.map.get(id);
432
+ if (!s?.loaded)
433
+ return false;
434
+ // persistNow swallows its own failures; this catch keeps the promise a
435
+ // Retry button awaits from ever rejecting.
436
+ await this.persist(id).catch(() => { });
437
+ /* GLOBAL, because the session list is. While the load kept missing, the
438
+ * list the page shows (and the header's title, read from it) could drop
439
+ * this chat; it is back now, and only a global change makes the page
440
+ * re-read the list. Without it the recovered chat stayed missing from the
441
+ * list, titled "New chat", until a reload. */
442
+ this.notifyGlobal();
443
+ return s.saveBlocked === null;
444
+ }
445
+ /** Raise the unsaved-chat flag when `id` is still not loaded after a load
446
+ * was tried, so the page says its new messages are not being kept. */
447
+ flagIfUnsaved(id) {
448
+ const s = this.map.get(id);
449
+ if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
450
+ this.patch(id, { saveBlocked: 'load-failed' });
451
+ }
301
452
  }
302
453
  async openInitial() {
303
454
  if (this.initInFlight)
@@ -310,10 +461,14 @@ export class AgentRuntime {
310
461
  // the traces, to be pruned with them.
311
462
  const empty = list.find((s) => !s.parent && (!s.messages || s.messages.length === 0));
312
463
  if (empty) {
313
- this.ensure(empty.id).loaded = true;
464
+ const s = this.ensure(empty.id);
465
+ s.loaded = true;
466
+ s.persistedLen = 0;
467
+ // A model picked on this empty chat before the reload is still its model.
468
+ s.model = empty.model ?? null;
314
469
  return empty.id;
315
470
  }
316
- return this.newSession();
471
+ return this.newDraft();
317
472
  })();
318
473
  try {
319
474
  return await this.initInFlight;
@@ -322,6 +477,37 @@ export class AgentRuntime {
322
477
  this.initInFlight = null;
323
478
  }
324
479
  }
480
+ /** A new chat that is saved only when it is first used. */
481
+ async newDraft() {
482
+ this.ws();
483
+ const id = `ses_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
484
+ const s = this.ensure(id);
485
+ s.loaded = true;
486
+ s.persistedLen = 0;
487
+ this.drafts.add(id);
488
+ return id;
489
+ }
490
+ /** Whether `id` is a draft that has not been saved yet. */
491
+ isDraft(id) { return this.drafts.has(id); }
492
+ /** Save a draft under its own id; a no-op for any other chat. Concurrent
493
+ * callers share one create. A failure leaves it a draft, so the next write
494
+ * tries again. */
495
+ materialize(id) {
496
+ if (!this.drafts.has(id))
497
+ return Promise.resolve();
498
+ const inFlight = this.materializing.get(id);
499
+ if (inFlight)
500
+ return inFlight;
501
+ const run = (async () => {
502
+ await this.host.sessions.create('New chat', this.surface, { id });
503
+ this.drafts.delete(id);
504
+ this.freezeSystemConfig(id);
505
+ this.notifyGlobal();
506
+ })();
507
+ this.materializing.set(id, run);
508
+ run.finally(() => this.materializing.delete(id)).catch(() => { });
509
+ return run;
510
+ }
325
511
  async newSession() {
326
512
  this.ws();
327
513
  /* A new chat starts with no tasks because the task store is KEYED BY
@@ -334,6 +520,7 @@ export class AgentRuntime {
334
520
  const ses = await this.host.sessions.create('New chat', this.surface);
335
521
  const s = this.ensure(ses.id);
336
522
  s.loaded = true;
523
+ s.persistedLen = 0;
337
524
  // The system prompt is the head of the cached prefix, so this conversation's
338
525
  // config is decided HERE and read from the snapshot after -- see
339
526
  // sessionSystemConfig. Settings decides what the next one starts with.
@@ -346,12 +533,86 @@ export class AgentRuntime {
346
533
  this.map.get(id)?.abort?.abort();
347
534
  this.map.delete(id);
348
535
  this.subs.delete(id);
536
+ this.drafts.delete(id);
349
537
  // The frozen system config goes with the conversation it described.
350
538
  clearSessionSystemConfig(id);
351
539
  await this.host.sessions.delete(id).catch(() => { });
352
540
  deleteDocsForSession(id);
353
541
  this.notifyGlobal();
354
542
  }
543
+ /* ---- model per chat ---- */
544
+ available(model) {
545
+ return this.host.isModelAvailable ? this.host.isModelAvailable(model) : true;
546
+ }
547
+ /** Is the chat's STORED model still usable? Trusted until the catalog has
548
+ * been consulted: before that, "not in the catalog" means "not looked yet",
549
+ * and acting on it would announce a fallback and fold for nothing. */
550
+ storedAvailable(model) {
551
+ if (this.host.modelsSettled && !this.host.modelsSettled())
552
+ return true;
553
+ return this.available(model);
554
+ }
555
+ /** The stored model when it is gone and the chat would run on a fallback. */
556
+ lostModel(s) {
557
+ if (!this.host.perChatModel || !s.model)
558
+ return null;
559
+ return this.storedAvailable(s.model) ? null : s.model;
560
+ }
561
+ modelNotice(id, s) {
562
+ const lost = this.lostModel(s);
563
+ if (!lost)
564
+ return null;
565
+ const fallback = this.getModel();
566
+ return `This chat used ${lost}, which is not available. It will run on ${fallback}`
567
+ + (this.canHold(id, fallback) ? '.' : ', and older history will be folded to fit.');
568
+ }
569
+ /** The model this chat runs on: its own if still available, else the
570
+ * last-used pick. Without `perChatModel`, always the last-used pick. */
571
+ chatModel(id) {
572
+ const s = this.map.get(id);
573
+ if (!this.host.perChatModel || !s?.model || this.lostModel(s))
574
+ return this.getModel();
575
+ return s.model;
576
+ }
577
+ fillTokens(turns) {
578
+ let n = this.fill.get(turns);
579
+ if (n === undefined) {
580
+ n = estimateTokens(outgoingMessages(turns));
581
+ this.fill.set(turns, n);
582
+ }
583
+ return n;
584
+ }
585
+ /** Can `model`'s window hold this chat as it stands (below the fold line)?
586
+ * Measured over what a send would actually hand the run -- the folded view
587
+ * plus the turns after it -- not the raw transcript, which overstates a chat
588
+ * that has already folded. */
589
+ canHold(id, model) {
590
+ const s = this.map.get(id);
591
+ if (!s)
592
+ return true;
593
+ const window = this.host.modelInfo(model).contextWindow ?? 128000;
594
+ if (window <= 0)
595
+ return true;
596
+ return this.fillTokens(s.turns) / window < COMPACT_AT_FILL;
597
+ }
598
+ /** Switch this chat (only) to `model`; null when it switched. Refused, with
599
+ * nothing changed: before the chat has loaded (there is nothing to measure,
600
+ * and persisting would write an empty transcript over the stored one); when
601
+ * the model is not available (it would silently run on the fallback); and
602
+ * when it cannot hold the chat (switching never folds, spec 3.5). */
603
+ setChatModel(id, model) {
604
+ if (!this.map.get(id)?.loaded)
605
+ return 'not-loaded';
606
+ if (!this.available(model))
607
+ return 'unavailable';
608
+ if (!this.canHold(id, model))
609
+ return 'too-large';
610
+ this.patch(id, { model });
611
+ // A draft keeps the pick in memory; picking a model is not using the chat.
612
+ if (!this.drafts.has(id))
613
+ void this.persist(id);
614
+ return null;
615
+ }
355
616
  /* ---- view mutators ---- */
356
617
  setInput(id, v) { this.patch(id, { input: v }); }
357
618
  setMode(id, m) { this.patch(id, { mode: m }); }
@@ -367,12 +628,15 @@ export class AgentRuntime {
367
628
  void this.persist(id);
368
629
  }
369
630
  /** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
370
- * sent to the model, just shown in the transcript. */
631
+ * sent to the model, just shown in the transcript. Returns the turn's id. */
371
632
  note(id, text, extra) {
372
633
  const s = this.ensure(id);
373
- const turn = { id: `n${Date.now().toString(36)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
634
+ // The random suffix: two notes in one millisecond (a fold beside a budget
635
+ // warning) must not share an id, or a later update by id lands on both.
636
+ const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
374
637
  this.patch(id, { turns: [...s.turns, turn] });
375
638
  void this.persist(id);
639
+ return turn.id;
376
640
  }
377
641
  /* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
378
642
  * event's contract: compaction is lossy, and a person who cannot see that it
@@ -392,7 +656,7 @@ export class AgentRuntime {
392
656
  * The body rides along so the seam can be READ. It was dropped here before
393
657
  * -- the event carries it, and this took only the count -- which left the
394
658
  * one question a seam raises with no answer anywhere in the UI. */
395
- this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
659
+ return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
396
660
  }
397
661
  /** Show a message in the transcript as YOUR turn, without adding it to what
398
662
  * gets sent. Used for injected messages: the real copy is already in the
@@ -408,8 +672,13 @@ export class AgentRuntime {
408
672
  setAttachments(id, fn) {
409
673
  this.patch(id, { attachments: fn(this.ensure(id).attachments) });
410
674
  }
675
+ /** Flip whether turn `turnId` is sent. A FOLDED turn (see isFoldedTurn) is
676
+ * refused -- no state change, no persist: the folded view stands for it, so
677
+ * including or dropping it would change nothing the next request carries. */
411
678
  toggleInclude(id, turnId) {
412
679
  const s = this.ensure(id);
680
+ if (isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
681
+ return;
413
682
  this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
414
683
  void this.persist(id);
415
684
  }
@@ -548,16 +817,103 @@ export class AgentRuntime {
548
817
  this.getWorker().inject(s.runId, messages, hard);
549
818
  return true;
550
819
  }
551
- /* ---- run ---- */
552
- async persist(id) {
820
+ queueWrite(id, write) {
821
+ // Nothing queued: start now, at the same tick as before this queue existed.
822
+ // A failed write must not stop the ones queued behind it.
823
+ const prev = this.writing.get(id);
824
+ const next = prev ? prev.catch(() => { }).then(write) : write();
825
+ this.writing.set(id, next);
826
+ next.finally(() => { if (this.writing.get(id) === next)
827
+ this.writing.delete(id); }).catch(() => { });
828
+ return next;
829
+ }
830
+ persist(id) {
831
+ return this.queueWrite(id, async () => {
832
+ // The first write to a draft saves it first; a failed save skips this
833
+ // write, and the draft stays a draft for the next one.
834
+ if (this.drafts.has(id)) {
835
+ try {
836
+ await this.materialize(id);
837
+ }
838
+ catch {
839
+ return;
840
+ }
841
+ }
842
+ await this.persistNow(id);
843
+ });
844
+ }
845
+ async persistNow(id) {
553
846
  const s = this.map.get(id);
554
- if (!s)
847
+ /* Never before the stored transcript has loaded (ruling R34). Until then
848
+ * `turns` holds only what this page added -- a /model notice, say -- and
849
+ * saving it would write that over the whole stored chat. Such notes stay
850
+ * in memory -- and once a load has actually failed, say so. */
851
+ if (!s?.loaded) {
852
+ this.flagIfUnsaved(id);
853
+ return;
854
+ }
855
+ // A read that throws is a save that did not happen: persistedLen and
856
+ // saveBlocked stay as they are, and the next write tries again.
857
+ let ses;
858
+ try {
859
+ ses = await this.host.sessions.get(id);
860
+ }
861
+ catch {
555
862
  return;
556
- const ses = await this.host.sessions.get(id);
863
+ }
557
864
  if (!ses)
558
865
  return;
559
- ses.messages = s.turns;
560
- await this.host.sessions.save(ses).catch(() => { });
866
+ /* Shorten the stored chat (retry, /clear) only when it is the length this
867
+ * page last saw (ruling R36). Otherwise another writer appended since, and
868
+ * this page's shorter list would delete that writer's newer turns. */
869
+ const known = ses.messages.length === s.persistedLen;
870
+ let turns = s.turns;
871
+ if (!known) {
872
+ /* Another writer changed it since. Saving this page's list as it stands
873
+ * would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
874
+ * 1, over the other tab's q1 -- so merge by turn id instead, over what
875
+ * was just read: stored order first, this page's version of any turn
876
+ * both hold (last writer wins per turn), then this page's turns the
877
+ * store does not have, in this page's order. A flat (daemon) chat's
878
+ * turns get the same `stored-<i>` ids here as at load, so a turn this
879
+ * page loaded from it matches and is not added twice.
880
+ *
881
+ * The page then shows the merged chat. Nothing it holds is dropped, and
882
+ * the store loses nothing: the merge holds every stored turn, so the
883
+ * save may rewrite freely (allowTruncate below), which a flat store's
884
+ * conversion to turns needs. */
885
+ const stored = turnsFromSessionMessages(ses.messages);
886
+ const mine = new Map(s.turns.map((t) => [t.id, t]));
887
+ const storedIds = new Set(stored.map((t) => t.id));
888
+ /* Only the FIRST stored turn with an id takes this page's version. A
889
+ * second one with the same id is another turn that collided (ids are
890
+ * meant to be unique; turnsFromSessionMessages keeps its synthetic ones
891
+ * so), and replacing it too would write one turn twice and lose the
892
+ * other on this rewrite. */
893
+ const used = new Set();
894
+ const merged = stored.map((t) => {
895
+ const own = mine.get(t.id);
896
+ if (!own || used.has(t.id))
897
+ return t;
898
+ used.add(t.id);
899
+ return own;
900
+ });
901
+ turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
902
+ 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)`);
903
+ this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
904
+ }
905
+ ses.messages = turns;
906
+ if (s.model)
907
+ ses.model = s.model;
908
+ try {
909
+ // Known: this page's list IS the chat, and may shorten it (R36). A
910
+ // merge: holds every stored turn, so rewriting cannot lose one.
911
+ await this.host.sessions.save(ses, { allowTruncate: true });
912
+ s.persistedLen = turns.length;
913
+ if (s.saveBlocked)
914
+ this.patch(id, { saveBlocked: null });
915
+ }
916
+ catch { /* a failed save leaves persistedLen as it was */ }
561
917
  }
562
918
  /** Filename of the system prompt this surface will run with next — what the
563
919
  * context drawer's active-prompt chip shows. Mirrors composeSystem's own
@@ -684,7 +1040,21 @@ export class AgentRuntime {
684
1040
  const s = this.ensure(id);
685
1041
  if ((!text && images.length === 0) || s.running)
686
1042
  return;
687
- const isFirst = s.turns.length === 0;
1043
+ /* The stored chat first (ruling R35). Nothing stops the composer before it
1044
+ * lands, and a send built on the empty pre-load list would run with no
1045
+ * history, retitle the chat as if it were new, and then be wiped by the
1046
+ * load or never saved. Also retries a load that missed. */
1047
+ if (!s.loaded) {
1048
+ await this.load(id);
1049
+ // Another send started while this one waited (ruling R37): treat this
1050
+ // one like a send during a run -- nothing happens, the draft stays.
1051
+ if (s.running)
1052
+ return;
1053
+ }
1054
+ // Only a LOADED empty chat is new. One whose load missed again is empty
1055
+ // because it was never read, and retitling it would write this message's
1056
+ // text over the stored chat's title while every other write is held back.
1057
+ const isFirst = s.loaded && s.turns.length === 0;
688
1058
  const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
689
1059
  const nextTurns = [...s.turns, userTurn];
690
1060
  s.toolMs = {};
@@ -704,6 +1074,10 @@ export class AgentRuntime {
704
1074
  turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
705
1075
  policyNotes: [], degradedModel: null, injectedIds: [], lastStopReason: null,
706
1076
  }, true);
1077
+ // Still not loaded after that retry: this exchange runs and shows, but will
1078
+ // not be saved. Flagged once the turn exists, so the notice is up for the
1079
+ // whole run rather than only after it.
1080
+ this.flagIfUnsaved(id);
707
1081
  // Feature guard — the runtime is the chokepoint for every /agent run (send
708
1082
  // and retry), so a quota/plan guard registered later covers this surface too.
709
1083
  const gate = await this.host.guard('llm-execution');
@@ -722,23 +1096,34 @@ export class AgentRuntime {
722
1096
  return;
723
1097
  }
724
1098
  const startedAt = Date.now();
1099
+ // A draft becomes a saved chat now, after the guard allowed the send, so a
1100
+ // refused send leaves nothing behind. A failed save does not stop the run:
1101
+ // its first write after it tries again.
1102
+ if (this.drafts.has(id))
1103
+ await this.materialize(id).catch(() => { });
725
1104
  // Title an untitled session from its first message, so the switcher is readable.
726
1105
  // A hand-renamed session is locked, so its chosen title is left alone.
727
1106
  if (isFirst && text) {
728
- const ses = await this.host.sessions.get(id);
729
- if (ses && !ses.titleLocked) {
730
- ses.title = text.slice(0, 48);
731
- await this.host.sessions.save(ses).catch(() => { });
732
- this.notifyGlobal();
733
- }
1107
+ await this.queueWrite(id, async () => {
1108
+ const ses = await this.host.sessions.get(id);
1109
+ if (ses && !ses.titleLocked) {
1110
+ ses.title = text.slice(0, 48);
1111
+ await this.host.sessions.save(ses).catch(() => { });
1112
+ this.notifyGlobal();
1113
+ }
1114
+ }).catch(() => { });
734
1115
  }
735
- const outgoing = nextTurns.filter((t) => t.included).flatMap((t) => t.messages);
1116
+ const outgoing = outgoingMessages(nextTurns);
736
1117
  /* Transcript-only turns noteUser() added for messages injected mid-run.
737
1118
  * They exist so the pivot is visible WHILE the run is in flight; once the
738
1119
  * run returns, its own copy of each injected message arrives in `created`
739
1120
  * and folds into the user turn, so keeping these would render the text a
740
1121
  * second time below the final reply, where it reads as unanswered. */
741
1122
  const injectedTurnIds = [];
1123
+ /* The seams THIS run wrote. The last one carries the run's folded view
1124
+ * once the run returns, so the next send starts from the fold instead of
1125
+ * re-folding the raw transcript. */
1126
+ const seamIds = [];
742
1127
  let verification;
743
1128
  let auditIncomplete;
744
1129
  const ac = new AbortController();
@@ -779,7 +1164,14 @@ export class AgentRuntime {
779
1164
  this.composeSystem(id, opts.persona, opts.partsOverride),
780
1165
  enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
781
1166
  ]);
782
- const model = this.getModel();
1167
+ const model = this.chatModel(id);
1168
+ /* Decided now, from what is true now: the chat's own model is gone and
1169
+ * the one it falls back to cannot hold it. A model picked since load, or
1170
+ * a catalog that has caught up, changes the answer. */
1171
+ const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
1172
+ // The chat now runs on `model`; storing it is what clears the notice.
1173
+ if (this.host.perChatModel && s.model !== model)
1174
+ this.patch(id, { model });
783
1175
  // Live model for onBreach pricing — reassigned when a degrade breach
784
1176
  // switches the run onto a cheaper model (see onBreach below).
785
1177
  let activeModel = model;
@@ -846,6 +1238,7 @@ export class AgentRuntime {
846
1238
  : (getPolicy(opts.policyName) ?? STANDARD_POLICY),
847
1239
  ...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
848
1240
  compression: { mode: this.host.compression(), contextWindow },
1241
+ ...(forceFold ? { forceFold: true } : {}),
849
1242
  signal: ac.signal,
850
1243
  sessionId: id,
851
1244
  toolNames,
@@ -888,6 +1281,7 @@ export class AgentRuntime {
888
1281
  // fallback exactly as before.
889
1282
  editorFile: opts.editorFile,
890
1283
  onNodeEvent: (e) => {
1284
+ publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
891
1285
  const c = nodeUsageOf(e);
892
1286
  if (c) {
893
1287
  calleeSpend.inTok += c.usage.promptTokens;
@@ -997,7 +1391,7 @@ export class AgentRuntime {
997
1391
  return;
998
1392
  }
999
1393
  if (e.type === 'checkpoint') {
1000
- this.noteCheckpoint(id, e.collapsed, e.checkpoint);
1394
+ seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
1001
1395
  return;
1002
1396
  }
1003
1397
  if (e.type === 'tools-loaded') {
@@ -1085,11 +1479,13 @@ export class AgentRuntime {
1085
1479
  ...(res.calls ? { calls: res.calls } : {}),
1086
1480
  }
1087
1481
  : undefined;
1482
+ const lastSeam = seamIds[seamIds.length - 1];
1483
+ const stitched = s.turns
1484
+ .filter((t) => !injectedTurnIds.includes(t.id))
1485
+ .map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
1088
1486
  this.patch(id, {
1089
1487
  lastStopReason: res.stopReason,
1090
- turns: s.turns
1091
- .filter((t) => !injectedTurnIds.includes(t.id))
1092
- .map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t)),
1488
+ turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
1093
1489
  });
1094
1490
  void this.persist(id);
1095
1491
  if (stats) {
@@ -1132,6 +1528,8 @@ export class AgentRuntime {
1132
1528
  s.abort = null;
1133
1529
  s.runId = null;
1134
1530
  this.patch(id, { running: false, live: null, recovery: null }, true);
1531
+ // Any callee still marked running under this session will never report.
1532
+ publishSubagentParentEnded(id);
1135
1533
  // Upsert the session recording with everything captured so far (this turn's
1136
1534
  // events appended to prior turns), under one stable id so the whole session
1137
1535
  // replays as a single stream. Skipped until something replayable happened.
@@ -1158,12 +1556,21 @@ export class AgentRuntime {
1158
1556
  if (!userText || s.running)
1159
1557
  return;
1160
1558
  this.ws();
1559
+ // As in send: never build on a transcript that has not loaded, and never
1560
+ // start a second turn that began while the load was pending.
1561
+ if (!s.loaded) {
1562
+ await this.load(id);
1563
+ if (s.running)
1564
+ return;
1565
+ }
1161
1566
  const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
1162
1567
  const ac = new AbortController();
1163
1568
  s.abort = ac;
1164
1569
  // Optimistic, for the same reason as `send` above: the guard is a network
1165
1570
  // call, and a host-driven turn that waits on it looks like nothing happened.
1166
1571
  this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
1572
+ // As in send: a load that missed again leaves this turn unsaved; say so now.
1573
+ this.flagIfUnsaved(id);
1167
1574
  const gate = await this.host.guard('llm-execution');
1168
1575
  if (!gate.allowed) {
1169
1576
  s.abort = null;
@@ -1192,13 +1599,16 @@ export class AgentRuntime {
1192
1599
  * with the full content — the context drawer's per-item Restore, transplanted
1193
1600
  * from the harness/conversation.ts singleton (same marker-matching and
1194
1601
  * identity-swap semantics; see that module for the rationale). Returns false
1195
- * (state untouched, no persist) when the turn or the matching stub is absent. */
1602
+ * (state untouched, no persist) when the turn or the matching stub is absent,
1603
+ * or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
1604
+ * it, so content restored into it would never be sent. */
1196
1605
  restoreToolResult(id, turnId, blobId, content) {
1197
1606
  const marker = `recall_result({ id: "${blobId}" })`;
1198
1607
  const s = this.ensure(id);
1199
- const turn = s.turns.find((t) => t.id === turnId);
1200
- if (!turn)
1608
+ const at = s.turns.findIndex((t) => t.id === turnId);
1609
+ if (at < 0 || isFoldedTurn(s.turns, at))
1201
1610
  return false;
1611
+ const turn = s.turns[at];
1202
1612
  const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
1203
1613
  if (idx < 0)
1204
1614
  return false;
@@ -1232,8 +1642,13 @@ export class AgentRuntime {
1232
1642
  /** Drop all in-memory state and abort every run — called on a scope change so
1233
1643
  * one identity's sessions and runs never carry into another's. */
1234
1644
  reset() {
1645
+ const runningIds = [...this.map.entries()].filter(([, s]) => s.abort).map(([sid]) => sid);
1235
1646
  for (const s of this.map.values())
1236
1647
  s.abort?.abort();
1648
+ // The worker is disposed below, which never settles an in-flight run, so
1649
+ // its `finally` (and the parent-ended signal there) never runs: say it here.
1650
+ for (const sid of runningIds)
1651
+ publishSubagentParentEnded(sid);
1237
1652
  const ids = [...this.subs.keys()];
1238
1653
  this.map.clear();
1239
1654
  this.worker?.dispose();
@@ -1243,5 +1658,13 @@ export class AgentRuntime {
1243
1658
  this.notify(id);
1244
1659
  this.notifyGlobal();
1245
1660
  }
1661
+ /** End this runtime for good: reset, and stop listening to the catalog. For
1662
+ * a runtime that is recreated rather than reused (a project's, rebuilt on
1663
+ * every open); a long-lived one is reset() instead and keeps listening. */
1664
+ dispose() {
1665
+ this.reset();
1666
+ this.offModels?.();
1667
+ this.offModels = null;
1668
+ }
1246
1669
  }
1247
1670
  //# sourceMappingURL=runtime.js.map