samagotchi 0.64.0 → 0.66.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +20 -3
  6. data/docs/cli.md +36 -2
  7. data/docs/configuration.md +177 -8
  8. data/docs/context.md +4 -1
  9. data/docs/internals/engine.md +19 -4
  10. data/docs/internals/prompt-caching.md +10 -2
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +163 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +13 -2
  15. data/docs/sub-agent.md +3 -1
  16. data/lib/samagotchi/bridge.rb +11 -1
  17. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  21. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  22. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  23. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  24. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  25. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  28. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  31. data/lib/samagotchi/cache_confirm.rb +9 -6
  32. data/lib/samagotchi/client.rb +4 -2
  33. data/lib/samagotchi/commands/registry.rb +25 -8
  34. data/lib/samagotchi/config.rb +13 -5
  35. data/lib/samagotchi/engine.rb +151 -11
  36. data/lib/samagotchi/generation_phase.rb +17 -0
  37. data/lib/samagotchi/host_config.rb +27 -6
  38. data/lib/samagotchi/host_registry.rb +34 -11
  39. data/lib/samagotchi/idle_client.rb +62 -25
  40. data/lib/samagotchi/idle_recap.rb +5 -2
  41. data/lib/samagotchi/idle_target.rb +30 -7
  42. data/lib/samagotchi/kernel_loop.rb +9 -1
  43. data/lib/samagotchi/llm/adapters.rb +41 -0
  44. data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
  45. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  46. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  47. data/lib/samagotchi/llm/api_key.rb +26 -9
  48. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  49. data/lib/samagotchi/llm/dialect.rb +57 -0
  50. data/lib/samagotchi/llm/errors.rb +81 -12
  51. data/lib/samagotchi/llm/model_listing.rb +111 -0
  52. data/lib/samagotchi/llm/model_result.rb +9 -2
  53. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  54. data/lib/samagotchi/model_profile.rb +1 -1
  55. data/lib/samagotchi/plugin/api.rb +80 -7
  56. data/lib/samagotchi/plugin/context.rb +22 -1
  57. data/lib/samagotchi/plugin/loader.rb +3 -2
  58. data/lib/samagotchi/reasoning_history.rb +14 -4
  59. data/lib/samagotchi/recap_setup.rb +2 -1
  60. data/lib/samagotchi/self_report.rb +18 -28
  61. data/lib/samagotchi/send_command.rb +23 -7
  62. data/lib/samagotchi/session.rb +21 -3
  63. data/lib/samagotchi/session_commands.rb +16 -1
  64. data/lib/samagotchi/session_manager.rb +4 -1
  65. data/lib/samagotchi/session_metrics.rb +6 -11
  66. data/lib/samagotchi/session_setup.rb +14 -7
  67. data/lib/samagotchi/steer_cut.rb +4 -0
  68. data/lib/samagotchi/system_prompt.rb +16 -1
  69. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  70. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
  71. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  72. data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
  73. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  74. data/lib/samagotchi/terminal_ui.rb +18 -2
  75. data/lib/samagotchi/tool_runner.rb +13 -1
  76. data/lib/samagotchi/toolkit.rb +246 -0
  77. data/lib/samagotchi/toolkit_command.rb +117 -0
  78. data/lib/samagotchi/tools/registry.rb +43 -6
  79. data/lib/samagotchi/turn_note.rb +22 -5
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +14 -1
  82. data/lib/samagotchi/web/public/activity.js +3 -0
  83. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  84. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  85. data/lib/samagotchi/web/public/app.js +111 -20
  86. data/lib/samagotchi/web/public/commands.js +4 -2
  87. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  88. data/lib/samagotchi/web/public/index.html +38 -0
  89. data/lib/samagotchi/web/public/info_bar.js +5 -3
  90. data/lib/samagotchi/web/public/live_turn.js +8 -0
  91. data/lib/samagotchi/web/public/session_state.js +3 -0
  92. data/lib/samagotchi/web/public/stage_model.js +27 -7
  93. data/lib/samagotchi/web/public/stage_view.js +32 -10
  94. data/lib/samagotchi/web/public/stream_handlers.js +21 -2
  95. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  96. data/lib/samagotchi/web/public/turn_events.js +22 -8
  97. data/lib/samagotchi/web/public/turn_html.js +20 -4
  98. data/lib/samagotchi/web/public/turn_model.js +16 -9
  99. data/lib/samagotchi/web/public/turn_view.js +6 -4
  100. data/lib/samagotchi/worker.rb +2 -0
  101. metadata +17 -1
@@ -480,6 +480,15 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
480
480
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
481
481
  #annotateBar[hidden]{display:none}
482
482
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
483
+ /* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
484
+ #annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
485
+ #annotateBar .annotate-actions[hidden]{display:none}
486
+ #annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
487
+ #annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
488
+ #annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
489
+ #annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
490
+ #annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
491
+ #annotateBar .annotate-more-list[hidden]{display:none}
483
492
  #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
484
493
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
485
494
  .bubble.output,.bubble.user{position:relative}
@@ -511,6 +520,30 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
511
520
  #composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
512
521
  #composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
513
522
  #composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
523
+ /* The delivery line (delivery_line.js): a session's row above the text field, always there so the composer never changes height: the key hint while idle; while a turn runs, where a message typed now goes in, then the cut and queue links. One quiet row, ellipsised; a phone gets the short text and chips. */
524
+ #deliveryLine{display:flex;align-items:center;gap:10px;min-width:0;flex:1 0 100%;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
525
+ #deliveryLine[hidden]{display:none}
526
+ #deliveryLine .delivery-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
527
+ #deliveryLine .delivery-where.idle{color:var(--text-faint)}
528
+ #deliveryLine .short{display:none}
529
+ #deliveryLine .delivery-links{flex:none;display:flex;align-items:center;gap:6px;white-space:nowrap}
530
+ #deliveryLine .delivery-sep{color:var(--text-faint)}
531
+ #deliveryLine .delivery-link{background:transparent;border:0;padding:0 2px;min-height:0;height:18px;font:inherit;line-height:18px;color:var(--accent);cursor:pointer;border-radius:4px}
532
+ #deliveryLine .delivery-link:hover:not(:disabled):not(.armed){text-decoration:underline;filter:none}
533
+ #deliveryLine .delivery-link:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
534
+ #deliveryLine .delivery-link:disabled{color:var(--text-faint);opacity:1;cursor:default}
535
+ #deliveryLine .delivery-link.armed{color:var(--on-solid);background:var(--accent-strong);padding:0 6px}
536
+ /* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
537
+ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
538
+ @media(max-width:600px){
539
+ #deliveryLine .long,#deliveryLine .delivery-sep{display:none}
540
+ #deliveryLine .short{display:inline}
541
+ #deliveryLine .delivery-link{padding:0 8px;border:1px solid var(--accent-border);border-radius:999px;background:var(--accent-bg);line-height:16px}
542
+ #deliveryLine .delivery-link:disabled{border-color:var(--border);background:transparent}
543
+ #deliveryLine .delivery-link.armed{border-color:var(--accent-strong);background:var(--accent-strong)}
544
+ }
545
+ /* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
546
+ .bubble.user.queued[data-delivery="queue"]{border:1px dashed var(--accent-border);border-left:3px dashed var(--accent);background:transparent}
514
547
  #chips{display:flex;flex-wrap:wrap;gap:6px}
515
548
  #chips[hidden]{display:none}
516
549
  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
@@ -718,6 +751,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
718
751
  /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
719
752
  /* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
720
753
  .ts-foot{flex:none;padding:0 14px 10px}
754
+ /* Below the turn's card: the lines queued for after it (each a turn of its own), apart from the turn by a dashed rule. */
755
+ .ts-queued{flex:none;display:flex;flex-direction:column;gap:6px;padding:8px 14px 10px;border-top:1px dashed var(--border-strong);max-height:calc(var(--stage-max,60vh) * .3);overflow:auto;scrollbar-width:thin}
756
+ .ts-queued:empty{display:none}
721
757
  .ts-foot[hidden],.turn-stage.collapsed .ts-foot{display:none}
722
758
  .ts-chip{display:flex;align-items:center;gap:10px;flex-wrap:nowrap;width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--text-muted);text-align:left}
723
759
  .ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
@@ -750,6 +786,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
750
786
  .ts-status{padding-left:10px}
751
787
  .ts-scroll{padding:8px 10px 10px}
752
788
  .ts-foot{padding:0 10px 10px}
789
+ .ts-queued{padding:8px 10px 10px}
753
790
  .ts-defer{display:none}
754
791
  .ts-trail-item{max-width:24ch}
755
792
  }
@@ -1132,6 +1169,7 @@ a{color:var(--accent)}
1132
1169
  <div id="modelList" role="listbox" aria-label="Models"></div>
1133
1170
  </div>
1134
1171
  <div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
1172
+ <div id="deliveryLine" aria-live="off" hidden></div>
1135
1173
  <div class="compose-col">
1136
1174
  <div id="chips" hidden></div>
1137
1175
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
@@ -1,6 +1,6 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx, thinking and reasoning history chips, model notes, memories, folder, ctx, speed,
3
+ // llm ctx, thinking, reasoning history and tools chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
5
  // plain model app.js builds (updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
@@ -16,6 +16,7 @@ import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
16
  import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
17
  import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
18
  import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
19
+ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
19
20
 
20
21
  export const PREV = "←";
21
22
  export const NEXT = "→";
@@ -62,7 +63,8 @@ function kidsTemplate(kids) {
62
63
  // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
63
64
  // links or null), parent ({id, title} or null), kids (format.js
64
65
  // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
- // summaries; reasoningHistory null on a native host: no chip), notes
66
+ // summaries; reasoningHistory null on a native host: no chip), toolkit
67
+ // (the tools chip's summary, null on full: no chip), notes
66
68
  // ({text, title} or null), memories (the used names), dir, ctx ({text,
67
69
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
68
70
  // for none), worker ({badge, busy} or null).
@@ -70,7 +72,7 @@ export function infoBarHtml(bar) {
70
72
  if (!bar) return nothing;
71
73
  const attach = `chi --attach ${bar.id}`;
72
74
  const memories = bar.memories || [];
73
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
75
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
74
76
  }
75
77
 
76
78
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -26,6 +26,12 @@ export function createLiveTurn(timingDeps) {
26
26
  displayPending: null,
27
27
  // The last turn_failed's line, shown again after prompt_restored's resync.
28
28
  failedText: null,
29
+ // The run's cut wait in seconds (turn_started / the snapshot's
30
+ // cut_after; null: unknown, an older worker) and, on a join
31
+ // mid-generation, the snapshot's generation {thinking_only, age, at}
32
+ // until the next generation starts: the composer's delivery line.
33
+ cutAfter: null,
34
+ serverGen: null,
29
35
 
30
36
  releaseDisplay() {
31
37
  this.displayPending?.finish();
@@ -39,6 +45,8 @@ export function createLiveTurn(timingDeps) {
39
45
  this.unmatchedMerge = false;
40
46
  this.wakeStart = false;
41
47
  this.failedText = null;
48
+ this.cutAfter = null;
49
+ this.serverGen = null;
42
50
  this.releaseDisplay();
43
51
  },
44
52
  };
@@ -48,6 +48,7 @@ export function emptySessionState() {
48
48
  // Which of its reasoning the model gets back from the next turn on, with
49
49
  // its source (the reasoning history chip), or null (a native host).
50
50
  reasoningHistory: null,
51
+ toolkit: null,
51
52
  timing: normalizeTiming(),
52
53
  };
53
54
  }
@@ -83,6 +84,8 @@ export function sessionStateFrom(data) {
83
84
  state.thinking = s.thinking || null;
84
85
  // null on a native host (it ignores the level) and from an older worker.
85
86
  state.reasoningHistory = s.reasoning_history || null;
87
+ // The tools chip's (null on full).
88
+ state.toolkit = s.toolkit || null;
86
89
  state.parentId = s.parent_id || null;
87
90
  state.continues = s.continues || null;
88
91
  state.commands = Array.isArray(data.commands) ? data.commands : [];
@@ -222,14 +222,30 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
222
222
  // A handed-off turn's nodes in the order the turn view leaves them: the
223
223
  // prompt bubble (none for a reminder turn), the block, the extras in
224
224
  // arrival order, the answer's pieces, the timing line, the end line, the
225
- // kept cards.
226
- export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [] }) {
227
- return [prompt, block, ...extras, ...answer, timing, end, ...kept].filter(Boolean);
225
+ // kept cards, then the lines queued for after the turn (they run next).
226
+ export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
227
+ return [prompt, block, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
228
228
  }
229
229
 
230
- // Where a row app.js would append goes: the stage's extras while a turn is
231
- // live (and not handing off), else the history; the recap always the
232
- // history (it is idle-only).
230
+ // The rows that wait for the running turn's end to run as their own: a
231
+ // prompt queued for after the turn (its delivery queue: the composer's
232
+ // queue, /queue, chi send --queue, an image line) and a command queued
233
+ // for the turn's end (commands.js drawCommandBubble's queued).
234
+ export const QUEUED_ROWS = '.bubble.user.queued[data-delivery="queue"], .bubble.command.queued';
235
+
236
+ // QUEUED_ROWS among +parent+'s own children (querySelectorAll on it).
237
+ export const QUEUED_CHILDREN = QUEUED_ROWS.split(", ").map((sel) => `:scope > ${sel}`).join(", ");
238
+
239
+ // The kind placeFor reads off an element app.js places.
240
+ export function placeKind(el) {
241
+ if (el.matches?.(".bubble.recap")) return "recap";
242
+ if (el.matches?.(QUEUED_ROWS)) return "queued";
243
+ return "row";
244
+ }
245
+
246
+ // Where a row app.js would append goes (placeFor, at the end of this
247
+ // file): the stage's extras while a turn is live (and not handing off),
248
+ // else the history; the recap always the history (it is idle-only).
233
249
  // What flashes in the trail for a few seconds while the turn runs (the
234
250
  // rows themselves go into their step inside the closed cloud): a hook's
235
251
  // notice, warn or info, as its row reads, the ✂ row of an applied LLM
@@ -258,7 +274,11 @@ export function flashOf(what, data = {}) {
258
274
  return { kind: data.level === "warn" ? "warn" : "info", text: data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}` };
259
275
  }
260
276
 
277
+ // A queued row ("queued", placeKind) goes after the running turn, not
278
+ // into it: the stage's queued slot below the turn's card while one is
279
+ // live, else the history's end (after the turn there too).
261
280
  export function placeFor({ live, handingOff, kind }) {
262
281
  if (kind === "recap") return "history";
263
- return live && !handingOff ? "stage" : "history";
282
+ if (!live || handingOff) return "history";
283
+ return kind === "queued" ? "queued" : "stage";
264
284
  }
@@ -13,7 +13,7 @@
13
13
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
14
14
  // and annotate survive. The rules are pure in stage_model.js.
15
15
  import { createTurnView } from "./turn_view.js";
16
- import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
16
+ import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, placeKind, QUEUED_CHILDREN, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
17
17
  import { formatDuration } from "./timing.js";
18
18
  import { easeScrollTo } from "./scroll.js";
19
19
  import { taskCommand, taskStopButton } from "./task_stop.js";
@@ -124,6 +124,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
124
124
  chip.type = "button";
125
125
  const chipLabel = node("span", "ts-chip-label", chip);
126
126
  const ticks = node("span", "ts-ticks", chip);
127
+ // Below the turn's card: the lines queued for after it (placeKind
128
+ // "queued"), each to run as a turn of its own; they hand off last.
129
+ const queuedSlot = node("div", "ts-queued", stage);
127
130
 
128
131
  // The turn in the stage, from turnStarted until the hand-off (or a reset).
129
132
  let holding = false;
@@ -600,13 +603,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
600
603
  maxContent = holds ? Math.max(maxContent, content) : 0;
601
604
  const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
602
605
  if (holdEl.style.top !== mark) holdEl.style.top = mark;
603
- const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight);
606
+ const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
607
+ + queuedSlot.offsetHeight;
604
608
  held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
605
609
  if (held === null) frameFree();
606
610
  else frameTo(held, { animate: true });
607
611
  }
608
612
  const frameObserver = new ResizeObserver(() => fitFrame());
609
- for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud]) frameObserver.observe(el);
613
+ for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud, queuedSlot]) frameObserver.observe(el);
610
614
 
611
615
  // ── in use: pointer over it (tracked on the stage itself: :hover goes
612
616
  // stale when children change), a touch or wheel in the last 4 s (phones
@@ -677,7 +681,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
677
681
  }
678
682
 
679
683
  function emptySlots() {
680
- for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
684
+ for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
681
685
  promptSlot.classList.remove("full");
682
686
  asking.hidden = true;
683
687
  shown.headline = shown.now = shown.trail = shown.asking = null;
@@ -744,6 +748,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
744
748
  answer: [...answer.children],
745
749
  timing: timingSlot.firstElementChild,
746
750
  kept: [...tail.children],
751
+ queued: [...queuedSlot.children],
747
752
  });
748
753
  const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
749
754
  folding = { pieces, block: blockEl, wasAtEnd: historyNearBottom(), animate };
@@ -779,6 +784,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
779
784
  return {
780
785
  turnStarted(data) {
781
786
  if (folding) finishFold();
787
+ // A turn that never ended (released, not handed off) leaves its queued
788
+ // lines waiting still: they stay.
789
+ const waiting = [...queuedSlot.children].filter((el) => el !== data?.promptBubble);
782
790
  if (holding) {
783
791
  if (endKind) handOff({ immediate: true });
784
792
  else release();
@@ -794,15 +802,19 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
794
802
  // The turn's prompt bubble (app.js found or drew it) sits in the prompt
795
803
  // line until the hand-off takes it back first.
796
804
  if (data?.promptBubble) promptSlot.appendChild(data.promptBubble);
805
+ // The lines still queued behind this turn went into the history with
806
+ // the turn before (the hand-off at this turn_started, or an earlier
807
+ // one): back below this turn's card, as a reload places them.
808
+ queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
797
809
  view.turnStarted(data || {});
798
810
  show();
799
811
  refresh();
800
812
  },
801
- generationStarted(data) {
813
+ generationStarted(data, now = null) {
802
814
  // The next step: the live slots' waiting height is let go.
803
815
  stage.classList.remove("held-wait");
804
816
  stage.style.removeProperty("--live-held");
805
- view.generationStarted(data);
817
+ view.generationStarted(data, now);
806
818
  },
807
819
  chunk: (data) => view.chunk(data),
808
820
  generationRestarted: (data) => view.generationRestarted(data),
@@ -853,18 +865,28 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
853
865
  },
854
866
  containsLive: (el) => view.containsLive(el),
855
867
  askingText: () => view.askingText(),
868
+ // The turn model and its live phase as the status row reads it (the
869
+ // composer's delivery line).
870
+ current: () => view.current(),
871
+ phase: () => liveSlots(view.current().turn, { pending: pendingCard() || asksCard() }).phase,
856
872
  reset() {
857
873
  if (folding) finishFold();
858
874
  release();
859
875
  },
860
- // The stage's extras while it holds a turn (a card, a queued prompt, a
861
- // command, a note, the end line); false: the history takes it.
876
+ // The stage's extras while it holds a turn (a card, a prompt for its
877
+ // next step, a command, a note, the end line), its queued slot for a
878
+ // line queued for after it; false: the history takes it.
862
879
  place(el) {
863
880
  // A turn still sliding away goes into the history first: what comes
864
881
  // now comes after it.
865
882
  if (folding) finishFold();
866
- const kind = el.matches?.(".bubble.recap") ? "recap" : "row";
867
- if (placeFor({ live: holding, handingOff: false, kind }) !== "stage") return false;
883
+ const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
884
+ if (where === "history") return false;
885
+ if (where === "queued") {
886
+ queuedSlot.appendChild(el);
887
+ scheduleRefresh();
888
+ return true;
889
+ }
868
890
  const slot = endKind ? tail : extras;
869
891
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
870
892
  slot.appendChild(el);
@@ -64,7 +64,7 @@ export function createStreamHandlers(deps) {
64
64
  // chunk_router.js). Raw `content` is only a fallback for an older server.
65
65
  const { text, thinking } = routeChunk(data);
66
66
  liveTurn.timing.setNote("");
67
- turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
67
+ turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
68
68
  const current = state();
69
69
  const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
70
70
  if (pct !== null && !Number.isNaN(pct)) {
@@ -79,7 +79,11 @@ export function createStreamHandlers(deps) {
79
79
  current.ctxWindowSource = data.context_window_source || null;
80
80
  }
81
81
  liveTurn.timing.setNote("");
82
- turnDom.generationStarted(data || {});
82
+ // The step's clock (the composer's delivery line): the page's own at a
83
+ // live start; a replay's comes from the snapshot's generation age,
84
+ // which a live start makes stale.
85
+ if (!data?.replayed) liveTurn.serverGen = null;
86
+ turnDom.generationStarted(data || {}, data?.replayed ? null : Date.now());
83
87
  },
84
88
  generation_completed: (data) => {
85
89
  liveTurn.timing.setNote("");
@@ -115,6 +119,8 @@ export function createStreamHandlers(deps) {
115
119
  markRecapStale();
116
120
  shownTexts.turnStarted();
117
121
  const promptBubble = applyPromptOps(data);
122
+ liveTurn.cutAfter = data.cut_after ?? null;
123
+ liveTurn.serverGen = null;
118
124
  setTurnRunning(true);
119
125
  setLiveStatus("running");
120
126
  liveTurn.timing.detach();
@@ -223,6 +229,11 @@ export function createStreamHandlers(deps) {
223
229
  updateInfoBar();
224
230
  reasoningHistoryControl.refresh();
225
231
  }
232
+ // /toolkit changed what the next turn declares; null: full (no chip).
233
+ if ("toolkit" in data) {
234
+ state().toolkit = data.toolkit || null;
235
+ updateInfoBar();
236
+ }
226
237
  if (view.resync) await resync();
227
238
  // A card's action that isn't anytime (the step-limit question's
228
239
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -240,6 +251,9 @@ export function createStreamHandlers(deps) {
240
251
  // message renders as one) — no second bubble, or the output shows twice.
241
252
  else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
242
253
  else addCommandBubble(view);
254
+ // This tab's command refused as busy (a plugin's command mid-turn): the
255
+ // composer was cleared when it went; its line comes back to send later.
256
+ if (view.own && view.busy && composer.empty()) composer.refill(view.line);
243
257
  },
244
258
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
245
259
  // turn: its bubble goes here, so the cards it shows come after it. One
@@ -295,6 +309,11 @@ export function createStreamHandlers(deps) {
295
309
  // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
296
310
  // not even in the stage trail; a joined turn's notices come here too.
297
311
  hook_notice: (data) => {
312
+ // A plugin put the session on another toolkit (ctx.use_toolkit).
313
+ if ("toolkit" in data) {
314
+ state().toolkit = data.toolkit || null;
315
+ updateInfoBar();
316
+ }
298
317
  if (shownElsewhere(data, capabilities())) return;
299
318
  if (!turnDom.notice(data)) showHookNotice(data);
300
319
  },
@@ -0,0 +1,29 @@
1
+ // The session bar's tools chip: the session's toolkit when it isn't full
2
+ // ("tools lean"), with the tools it leaves out and how to change them in
3
+ // its tooltip. The summary is the session's toolkit ({name, label,
4
+ // left_out, fallback}: Toolkit::Explained#summary; the worker's snapshot or
5
+ // a command_ran; the server's own reading of the session file has no
6
+ // left_out). Changing it is /toolkit (no popover yet).
7
+
8
+ import { html, nothing } from "./vendor/lit-html.js";
9
+
10
+ // The chip's text and tooltip; null without a summary (full).
11
+ export function chipModel(summary) {
12
+ if (!summary) return null;
13
+ const label = summary.label || summary.name;
14
+ const parts = [`Toolkit: ${label}, the tools this session declares.`];
15
+ if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
16
+ if (Array.isArray(summary.left_out)) {
17
+ parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
18
+ }
19
+ parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
20
+ return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
21
+ }
22
+
23
+ // The session bar's chip (a lit-html template; info_bar.js), nothing on
24
+ // full.
25
+ export function chipTemplate(summary) {
26
+ const chip = chipModel(summary);
27
+ if (!chip) return nothing;
28
+ return html`<span class="model tools-chip" title=${chip.title}>${chip.text}</span>`;
29
+ }
@@ -197,9 +197,14 @@ export function llmContextNotice(data) {
197
197
  }
198
198
 
199
199
  // The one muted line for a turn that ended with no answer (turn_completed's
200
- // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
201
- // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
202
- export function emptyAnswerLine({ retries = 0 } = {}) {
200
+ // turn_summary.empty_answer, or a reloaded turn's marker), or one the
201
+ // provider refused (its `refusal`, with the category when named): the TUI
202
+ // words it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
203
+ export function emptyAnswerLine({ retries = 0, refusal = null } = {}) {
204
+ if (refusal) {
205
+ const category = String(refusal.category ?? "").trim();
206
+ return `no answer: refused by the provider${category ? ` (${category})` : ""}`;
207
+ }
203
208
  const count = Number(retries) || 0;
204
209
  const after = count > 0 ? ` (after ${count} ${count === 1 ? "retry" : "retries"})` : "";
205
210
  return `no answer: the model returned nothing${after}`;
@@ -254,9 +259,11 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
254
259
  switch (event.type) {
255
260
  case "turn_enqueued": {
256
261
  const id = event.enqueued_id;
257
- if (isOwn(event.client_id, myId)) return [withImages({ op: "tag", enqueuedId: id, prompt: event.prompt }, event.images)];
262
+ // Queued for after the running turn (delivery queue): its bubble says so.
263
+ const delivery = event.delivery ? { delivery: event.delivery } : {};
264
+ if (isOwn(event.client_id, myId)) return [withImages({ op: "tag", enqueuedId: id, prompt: event.prompt, ...delivery }, event.images)];
258
265
  if (id && known(id)) return [];
259
- return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "queued", label: clientLabel(event.client_id) }, event.images)];
266
+ return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "queued", label: clientLabel(event.client_id), ...delivery }, event.images)];
260
267
  }
261
268
  case "turn_started": {
262
269
  if (event.continue || !event.prompt) return [];
@@ -308,7 +315,10 @@ export function replaysDrawnTurn(event, turnRecords = []) {
308
315
  export function snapshotEvents({ current_turn: turn = null, queued = [], queued_commands: commands = [], started_at = undefined } = {}) {
309
316
  const events = [];
310
317
  if (turn) {
311
- events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at }, turn.images));
318
+ // The run's cut wait (TurnAccumulator, as turn_started carried it): the
319
+ // composer's delivery line reads it.
320
+ const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
321
+ events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...cutAfter }, turn.images));
312
322
  let textIteration = null; // a text bubble is open for this iteration
313
323
  const closeText = () => {
314
324
  if (textIteration !== null) events.push({ type: "generation_completed" });
@@ -340,7 +350,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
340
350
  // EVENT_FIELDS), as the live events carry them.
341
351
  const title = pickFields(part, ACTIVITY_FIELDS);
342
352
  const fields = pickFields(part, EVENT_FIELDS);
343
- events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...fields });
353
+ // A running call's time so far (TurnAccumulator elapsed_ms): a join
354
+ // clocks it from its start (activity.js markStart).
355
+ const elapsed = part.status === "running" && part.elapsed_ms != null ? { elapsed_ms: part.elapsed_ms } : {};
356
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title, ...fields, ...elapsed });
344
357
  if (part.status !== "running") {
345
358
  // The activity as the live event had it; action and duration_ms
346
359
  // only in snapshots from a worker that keeps them (the TUI's
@@ -385,7 +398,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
385
398
  }
386
399
  }
387
400
  for (const entry of queued || []) {
388
- events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt }, entry.images));
401
+ const delivery = entry.delivery ? { delivery: entry.delivery } : {};
402
+ events.push(withImages({ type: "turn_enqueued", enqueued_id: entry.enqueued_id, client_id: entry.client_id, prompt: entry.prompt, ...delivery }, entry.images));
389
403
  }
390
404
  // Commands waiting for the turn's end: their bubbles, as live.
391
405
  for (const entry of commands || []) {
@@ -116,8 +116,22 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
116
116
  // gave it back). Any other state ("started", none) is a plain bubble.
117
117
  export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
118
118
 
119
- export function stateBadgeTemplate(state) {
120
- return BADGES[state] ? html`<span class="state-badge">${BADGES[state]}</span>` : nothing;
119
+ // A queued line's badge by its delivery (the composer's choice, the
120
+ // turn_enqueued's): what it waits for. next_step: the running turn's next
121
+ // step; cut: the cut (the ack may say it waits); queue: a turn of its own
122
+ // after this one.
123
+ const QUEUED_BADGES = { next_step: "waits for the next step", cut: "cutting in\u2026", queue: "after this turn" };
124
+
125
+ // A bubble's badge words for +state+ (and +delivery+ while queued); null
126
+ // for a plain bubble.
127
+ export function badgeText(state, delivery = null) {
128
+ if (!BADGES[state]) return null;
129
+ return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
130
+ }
131
+
132
+ export function stateBadgeTemplate(state, delivery = null) {
133
+ const text = badgeText(state, delivery);
134
+ return text ? html`<span class="state-badge">${text}</span>` : nothing;
121
135
  }
122
136
 
123
137
  // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
@@ -203,10 +217,12 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
203
217
  // merged mid-turn (a reload's; a resolved question goes before it).
204
218
  // The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
205
219
  // (its normalized text: an echo waiting for its id is matched by it).
206
- export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null } = {}) {
220
+ // +delivery+: how it was sent into a running turn (next_step, cut, queue):
221
+ // its data-delivery and its queued badge's words.
222
+ export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null } = {}) {
207
223
  const named = BADGES[state] ? state : null;
208
224
  const from = label ? html`<span class="origin-label">${label}</span>` : nothing;
209
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named)}</div>`;
225
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
210
226
  }
211
227
 
212
228
  // The notice of a turn that ended with no answer (the server's
@@ -7,7 +7,8 @@
7
7
  // Rules:
8
8
  // - turn_started opens the first gen (no iteration yet).
9
9
  // - generation_started(n) opens a new gen unless the current one is still
10
- // empty (then it takes n).
10
+ // empty (then it takes n); either way the gen's startedAt is the page's
11
+ // clock at it (null for a replay).
11
12
  // - a chunk goes to the gen of its iteration (opening one when none has it);
12
13
  // a chunk with no iteration goes to the current gen, unless that one is
13
14
  // textDone or already has tool rows (an older server's replay), which
@@ -45,7 +46,7 @@ function isEmpty(gen) {
45
46
 
46
47
  function openGen(turn, iteration) {
47
48
  const closed = closeCurrent(turn);
48
- const gen = { iteration: iteration ?? null, thinking: "", text: "", activity: newActivity(), textDone: false, closed: false, notices: [] };
49
+ const gen = { iteration: iteration ?? null, thinking: "", text: "", activity: newActivity(), textDone: false, closed: false, notices: [], startedAt: null, thinkingAt: null };
49
50
  gen.tools = gen.activity.rows;
50
51
  turn.gens.push(gen);
51
52
  return { gen, opened: true, closed };
@@ -81,16 +82,21 @@ export function turnStarted(turn) {
81
82
  return openGen(turn, null);
82
83
  }
83
84
 
84
- export function generationStarted(turn, iteration = null) {
85
+ // +now+ (ms): when the page saw the generation start, its startedAt (the
86
+ // composer's delivery line clocks the step from it); null for a replayed
87
+ // start (a join: the snapshot's generation age stands in).
88
+ export function generationStarted(turn, iteration = null, now = null) {
85
89
  const current = currentGen(turn);
86
- if (current && isEmpty(current)) {
87
- current.iteration = iteration ?? current.iteration;
88
- return { gen: current, opened: false, closed: null };
89
- }
90
- return openGen(turn, iteration);
90
+ const r = current && isEmpty(current) ? { gen: current, opened: false, closed: null } : openGen(turn, iteration);
91
+ if (!r.opened) r.gen.iteration = iteration ?? r.gen.iteration;
92
+ r.gen.startedAt = now;
93
+ return r;
91
94
  }
92
95
 
93
- export function chunk(turn, { text = "", thinking = "", iteration = null } = {}) {
96
+ // +now+ (ms): when the page got the chunk (null for a replay): a gen's
97
+ // first thinking delta stamps its thinkingAt, the clock the worker's cut
98
+ // counts from too (GenerationPhase).
99
+ export function chunk(turn, { text = "", thinking = "", iteration = null, now = null } = {}) {
94
100
  let r;
95
101
  if (iteration == null) {
96
102
  const current = currentGen(turn);
@@ -102,6 +108,7 @@ export function chunk(turn, { text = "", thinking = "", iteration = null } = {})
102
108
  // Leading whitespace (the "\n" after <think>, or after a closed
103
109
  // think/tool_call block) would render as an empty first line; trim it at
104
110
  // the start of a gen's thinking and text only.
111
+ if (thinking && !gen.thinking && now != null) gen.thinkingAt = now;
105
112
  if (thinking) gen.thinking += leadTrimmed(gen.thinking, thinking);
106
113
  if (text) gen.text += leadTrimmed(gen.text, text);
107
114
  return r;
@@ -700,14 +700,16 @@ export function createTurnView({
700
700
  draw(entry);
701
701
  }
702
702
  },
703
- generationStarted(data) {
703
+ // +now+: the page's clock at a live start (stream_handlers.js), null
704
+ // for a replayed one.
705
+ generationStarted(data, now = null) {
704
706
  if (!turn) this.turnStarted({});
705
- apply(model.generationStarted(turn, data.iteration ?? null));
707
+ apply(model.generationStarted(turn, data.iteration ?? null, now));
706
708
  },
707
- chunk({ text, thinking, iteration }) {
709
+ chunk({ text, thinking, iteration, now = null }) {
708
710
  if (!text && !thinking) return;
709
711
  if (!turn) this.turnStarted({});
710
- const r = model.chunk(turn, { text, thinking, iteration });
712
+ const r = model.chunk(turn, { text, thinking, iteration, now });
711
713
  apply(r);
712
714
  scheduleFlush(r.gen);
713
715
  },
@@ -878,6 +878,8 @@ module Samagotchi
878
878
  # /reasoning-history or /model: the level the next turn runs under
879
879
  # (the web's chip); nil on a native host, which ignores it.
880
880
  event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
881
+ # /toolkit: what the next turn declares (the web's chip; nil on full).
882
+ event[:toolkit] = @engine.toolkit_summary if changed.include?(:toolkit)
881
883
  event[:confirm] = confirm.to_h if confirm
882
884
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
883
885
  @engine.announce(event)