samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -0,0 +1,228 @@
1
+ // The composer's delivery line (web, steer delivery P4w): a row above the
2
+ // text field in a session, always there so the composer never changes
3
+ // height (the stage above it would jump). Idle it holds the key hint; while
4
+ // the open session's turn runs it says where a message typed now goes in,
5
+ // and offers the other two deliveries as links:
6
+ //
7
+ // ↳ goes in after this thinking step · 2:31 · 31k chars cut in now · queue for after the turn
8
+ // ↳ goes in after this tool call (execute · 0:12) queue for after the turn
9
+ //
10
+ // Send (Enter, the button) is next_step; "queue for after the turn" (or
11
+ // Alt/Option+Enter) runs the message as a turn of its own after this one;
12
+ // "cut in now" (a click only: it throws the thinking away) cuts a
13
+ // thinking-only generation (the worker's SteerCut rule: once it has
14
+ // thought for the model's cut_after seconds). A link clicked with an empty
15
+ // composer arms its choice for the next Enter; Esc or a second click
16
+ // disarms it.
17
+ //
18
+ // deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
19
+ import { html, nothing, render } from "./vendor/lit-html.js";
20
+
21
+ export const QUEUE_LABEL = "queue for after the turn";
22
+ export const CUT_LABEL = "cut in now";
23
+ // A phone's row (≤ 600 px) is short: its text and chips.
24
+ export const QUEUE_SHORT = "queue";
25
+ export const CUT_SHORT = "cut in";
26
+ // Alt+Enter to a worker without the delivery feature: not sent (a queue
27
+ // would steer it), the text kept, this said.
28
+ export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
29
+ + "Restart it (chi sessions restart ID, or the update badge), then send again.";
30
+ // The idle row: the keys (the placeholder's hint before the row existed).
31
+ export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
32
+
33
+ // m:ss (h:mm:ss past an hour) of +seconds+; "" when unknown.
34
+ export function clockText(seconds) {
35
+ if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return "";
36
+ const total = Math.floor(seconds);
37
+ const h = Math.floor(total / 3600);
38
+ const m = Math.floor((total % 3600) / 60);
39
+ const s = String(total % 60).padStart(2, "0");
40
+ return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
41
+ }
42
+
43
+ // "812 chars", "31k chars"; "" for none.
44
+ export function charsText(count) {
45
+ if (!count) return "";
46
+ return count < 1000 ? `${count} chars` : `${Math.round(count / 1000)}k chars`;
47
+ }
48
+
49
+ // Seconds the current generation has run: from the page's own clock (its
50
+ // first thinking delta, thinkingAt, as the worker's cut counts; else its
51
+ // startedAt, set at generation_started), else from the worker's snapshot
52
+ // (+serverGen+: {thinking_only, age, at}; a page that joined mid-generation
53
+ // has no start of its own: age as of +at+, the page's clock when the
54
+ // snapshot came); null when neither.
55
+ export function genAge(gen, serverGen, now) {
56
+ const since = gen?.thinkingAt ?? gen?.startedAt;
57
+ if (since != null) return Math.max(0, (now - since) / 1000);
58
+ if (serverGen?.age != null && serverGen.at != null) return serverGen.age + Math.max(0, (now - serverGen.at) / 1000);
59
+ return null;
60
+ }
61
+
62
+ // Whether the generation has streamed only thinking: thinking, no visible
63
+ // text, no tool call (the worker's GenerationPhase, freshness aside: a
64
+ // stale cut is the worker's to refuse or hold, its ack says which). The
65
+ // worker's own word (+serverGen+) wins: it also sees tool-call chunks.
66
+ function thinkingOnly(gen, serverGen) {
67
+ if (!gen?.thinking || gen.text?.trim() || gen.tools?.length) return false;
68
+ return serverGen?.thinking_only !== false;
69
+ }
70
+
71
+ // What the line shows for the running turn:
72
+ // phase stage_model.js liveSlots(turn).phase
73
+ // gen the current gen (turn_model.js: thinking, text, tools, startedAt)
74
+ // tool the running call: {name, startedAt} (activity.js row), or null
75
+ // cutAfter the run's cut wait in seconds (turn_started / current_turn
76
+ // cut_after): 0 is cutting off, null unknown (an older worker)
77
+ // serverGen the snapshot's current_turn.generation with `at`, or null
78
+ // now the page's clock (ms)
79
+ // armed "cut" | "queue" | null: the choice the next Enter sends
80
+ // canQueue the worker takes delivery: queue (its "delivery" feature)
81
+ // model the session's model, for the cut-off reason
82
+ // → {text, short, cut: {enabled, shown, reason, label, short}, queue:
83
+ // {enabled, shown, label, short}, armed}: short is the phone's text;
84
+ // armed is what is still armed (a cut no longer possible disarms). With
85
+ // cutting off the cut link goes (the text says why); without the
86
+ // feature the queue link goes.
87
+ export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutAfter = null, serverGen = null, now = Date.now(),
88
+ armed = null, canQueue = true, model = "" } = {}) {
89
+ const cutOff = cutAfter === 0 || (cutAfter != null && cutAfter < 0);
90
+ const thinking = phase === "thinking" && thinkingOnly(gen, serverGen);
91
+ const age = genAge(gen, serverGen, now);
92
+ let where;
93
+ let short;
94
+ let reason = "";
95
+ if (phase === "running a tool") {
96
+ const clock = clockText(tool?.startedAt != null ? (now - tool.startedAt) / 1000 : null);
97
+ where = `goes in after this tool call (${[tool?.name || "tool", clock].filter(Boolean).join(" · ")})`;
98
+ short = ["after this tool call", clock].filter(Boolean).join(" · ");
99
+ reason = "a tool is running: only a thinking step can be cut";
100
+ } else if (phase === "waiting for you") {
101
+ where = "goes in once the card above is answered";
102
+ short = "after the card's answer";
103
+ reason = "the turn waits for you";
104
+ } else if (phase === "writing" || phase === "writing the answer") {
105
+ where = "goes in after this answer; the turn may go on";
106
+ short = "after this answer";
107
+ reason = "the model is writing: only a thinking step can be cut";
108
+ } else if (thinking) {
109
+ where = ["goes in after this thinking step", clockText(age), charsText(gen.thinking.length)].filter(Boolean).join(" · ");
110
+ short = ["after this thinking", clockText(age)].filter(Boolean).join(" · ");
111
+ } else {
112
+ where = "goes in after this step";
113
+ short = "after this step";
114
+ reason = "nothing to cut until the model thinks";
115
+ }
116
+ if (cutOff) reason = `cutting is off for ${model || "this model"}`;
117
+ const cutEnabled = !cutOff && thinking;
118
+ let wait = "";
119
+ if (cutEnabled && cutAfter > 0 && age != null && age < cutAfter) wait = `cuts at about ${clockText(cutAfter)} if you choose ${CUT_LABEL}`;
120
+ const effective = (armed === "cut" && cutEnabled) || (armed === "queue" && canQueue) ? armed : null;
121
+ const parts = [cutOff ? reason : "", where, wait].filter(Boolean);
122
+ // A phone's short row leaves it out: its ticked chip says it.
123
+ if (effective) parts.push("Enter sends");
124
+ const tick = (label, which) => (effective === which ? `${label} \u2713` : label);
125
+ return {
126
+ text: `\u21B3 ${parts.join(" · ")}`,
127
+ short: `\u21B3 ${short}`,
128
+ cut: { enabled: cutEnabled, shown: !cutOff, reason: cutEnabled ? "" : reason, label: tick(CUT_LABEL, "cut"), short: tick(CUT_SHORT, "cut") },
129
+ queue: { enabled: canQueue, shown: canQueue, label: tick(QUEUE_LABEL, "queue"), short: tick(QUEUE_SHORT, "queue") },
130
+ armed: effective,
131
+ };
132
+ }
133
+
134
+ // The line's view: +line+ is deliveryLine's; +onCut+ / +onQueue+ the
135
+ // links' clicks. A disabled cut stays in place, its reason on hover.
136
+ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
137
+ const link = (which, part, onClick) => html`<button type="button" class="delivery-link ${which}${line.armed === which ? " armed" : ""}"
138
+ ?disabled=${!part.enabled} title=${part.reason || nothing} aria-label=${part.label} aria-pressed=${line.armed === which ? "true" : "false"}
139
+ @mousedown=${(e) => e.preventDefault()} @click=${onClick}><span class="long">${part.label}</span><span class="short">${part.short}</span></button>`;
140
+ const links = [line.cut.shown ? link("cut", line.cut, onCut) : null, line.queue.shown ? link("queue", line.queue, onQueue) : null]
141
+ .filter(Boolean);
142
+ return html`<span class="delivery-where" title=${line.text}><span class="long">${line.text}</span><span class="short">${line.short}</span></span><span class="delivery-links">${links.map((l, i) =>
143
+ (i ? html`<span class="delivery-sep" aria-hidden="true">·</span>${l}` : l))}</span>`;
144
+ }
145
+
146
+ // The idle row: the key hint (none on a phone: no keyboard keys there).
147
+ export function idleTemplate() {
148
+ return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
149
+ }
150
+
151
+ // The row on the page: the key hint while idle, the line while the turn
152
+ // runs (its clock ticking every TICK_MS), nothing outside a session; the
153
+ // armed choice is kept here. No page globals: the page passes its hooks.
154
+ // read() deliveryLine's inputs but now and armed (the turn's
155
+ // phase, gen, tool, cutAfter, serverGen, canQueue, model)
156
+ // composerEmpty() nothing typed and no image chips: a link arms
157
+ // send(delivery) send the composer's line with +delivery+ ("cut", "queue")
158
+ export const TICK_MS = 500;
159
+
160
+ export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
161
+ setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
162
+ // "off" (no session: the row is gone), "idle" or "running".
163
+ let mode = "off";
164
+ let armed = null;
165
+ let timer = null;
166
+ let line = null;
167
+
168
+ const click = (which) => {
169
+ if (mode !== "running" || !line?.[which]?.enabled) return;
170
+ if (!composerEmpty()) {
171
+ armed = null;
172
+ send(which);
173
+ return;
174
+ }
175
+ armed = armed === which ? null : which;
176
+ refresh();
177
+ };
178
+
179
+ function refresh() {
180
+ if (mode === "off") return;
181
+ if (mode === "idle") {
182
+ render(idleTemplate(), el);
183
+ return;
184
+ }
185
+ line = deliveryLine({ ...read(), now: now(), armed });
186
+ // A choice no longer possible (a tool started) disarms.
187
+ armed = line.armed;
188
+ render(deliveryLineTemplate(line, { onCut: () => click("cut"), onQueue: () => click("queue") }), el);
189
+ }
190
+
191
+ return {
192
+ // +next+: "off", "idle" or "running".
193
+ show(next) {
194
+ if (next !== mode) {
195
+ mode = next;
196
+ el.hidden = mode === "off";
197
+ el.classList.toggle("running", mode === "running");
198
+ if (mode === "running") timer ??= setTimer(refresh, TICK_MS);
199
+ else {
200
+ clearTimer(timer);
201
+ timer = null;
202
+ armed = null;
203
+ line = null;
204
+ }
205
+ }
206
+ refresh();
207
+ },
208
+ refresh,
209
+ click,
210
+ armed: () => armed,
211
+ // The armed choice for this send, and disarmed.
212
+ take() {
213
+ const was = armed;
214
+ if (was) {
215
+ armed = null;
216
+ refresh();
217
+ }
218
+ return was;
219
+ },
220
+ disarm() {
221
+ if (!armed) return false;
222
+ armed = null;
223
+ refresh();
224
+ return true;
225
+ },
226
+ line: () => line,
227
+ };
228
+ }
@@ -511,6 +511,30 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
511
511
  #composer{display:flex;flex-direction:column;gap:8px;padding:14px 14px 10px}
512
512
  #composer .compose-col{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
513
513
  #composer.dropping{outline:2px dashed var(--accent);outline-offset:-4px}
514
+ /* 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. */
515
+ #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}
516
+ #deliveryLine[hidden]{display:none}
517
+ #deliveryLine .delivery-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
518
+ #deliveryLine .delivery-where.idle{color:var(--text-faint)}
519
+ #deliveryLine .short{display:none}
520
+ #deliveryLine .delivery-links{flex:none;display:flex;align-items:center;gap:6px;white-space:nowrap}
521
+ #deliveryLine .delivery-sep{color:var(--text-faint)}
522
+ #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}
523
+ #deliveryLine .delivery-link:hover:not(:disabled):not(.armed){text-decoration:underline;filter:none}
524
+ #deliveryLine .delivery-link:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
525
+ #deliveryLine .delivery-link:disabled{color:var(--text-faint);opacity:1;cursor:default}
526
+ #deliveryLine .delivery-link.armed{color:var(--on-solid);background:var(--accent-strong);padding:0 6px}
527
+ /* In a session the composer is a row: the line's row wraps above the text field and the buttons. */
528
+ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
529
+ @media(max-width:600px){
530
+ #deliveryLine .long,#deliveryLine .delivery-sep{display:none}
531
+ #deliveryLine .short{display:inline}
532
+ #deliveryLine .delivery-link{padding:0 8px;border:1px solid var(--accent-border);border-radius:999px;background:var(--accent-bg);line-height:16px}
533
+ #deliveryLine .delivery-link:disabled{border-color:var(--border);background:transparent}
534
+ #deliveryLine .delivery-link.armed{border-color:var(--accent-strong);background:var(--accent-strong)}
535
+ }
536
+ /* A line queued for after the running turn: a dashed bubble, a turn of its own to come. */
537
+ .bubble.user.queued[data-delivery="queue"]{border:1px dashed var(--accent-border);border-left:3px dashed var(--accent);background:transparent}
514
538
  #chips{display:flex;flex-wrap:wrap;gap:6px}
515
539
  #chips[hidden]{display:none}
516
540
  .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 +742,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
718
742
  /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
719
743
  /* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
720
744
  .ts-foot{flex:none;padding:0 14px 10px}
745
+ /* 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. */
746
+ .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}
747
+ .ts-queued:empty{display:none}
721
748
  .ts-foot[hidden],.turn-stage.collapsed .ts-foot{display:none}
722
749
  .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
750
  .ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
@@ -750,6 +777,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
750
777
  .ts-status{padding-left:10px}
751
778
  .ts-scroll{padding:8px 10px 10px}
752
779
  .ts-foot{padding:0 10px 10px}
780
+ .ts-queued{padding:8px 10px 10px}
753
781
  .ts-defer{display:none}
754
782
  .ts-trail-item{max-width:24ch}
755
783
  }
@@ -1132,6 +1160,7 @@ a{color:var(--accent)}
1132
1160
  <div id="modelList" role="listbox" aria-label="Models"></div>
1133
1161
  </div>
1134
1162
  <div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
1163
+ <div id="deliveryLine" aria-live="off" hidden></div>
1135
1164
  <div class="compose-col">
1136
1165
  <div id="chips" hidden></div>
1137
1166
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
@@ -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
  };
@@ -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();
@@ -33,7 +33,8 @@ export function liveDurationText(durationMs) {
33
33
 
34
34
  // A cancel's reason as the terminal UIs name it too (Formatting::
35
35
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
36
- const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped" };
36
+ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped",
37
+ session_stopped: "session stopped" };
37
38
 
38
39
  // The line a canceled turn ends with: "■ canceled (stopped)", live from
39
40
  // turn_canceled's reason and after a re-render from the turn record's
@@ -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