samagotchi 0.8.1 → 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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +9 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/memory.md +5 -3
  9. data/docs/plugins.md +47 -17
  10. data/docs/sessions.md +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +29 -29
  12. data/lib/samagotchi/bridge/card_store.rb +4 -4
  13. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  14. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  15. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  16. data/lib/samagotchi/bridge.rb +59 -112
  17. data/lib/samagotchi/bridge_client.rb +11 -28
  18. data/lib/samagotchi/bundle_command.rb +158 -212
  19. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  21. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  22. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  25. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  27. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/skills/plugin.rb +65 -17
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  32. data/lib/samagotchi/bundles/system/identity.md +1 -1
  33. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  34. data/lib/samagotchi/bundles/system/memory_guide.md +5 -4
  35. data/lib/samagotchi/cli/command.rb +51 -0
  36. data/lib/samagotchi/cli/flags.rb +116 -0
  37. data/lib/samagotchi/config.rb +70 -55
  38. data/lib/samagotchi/config_text_edit.rb +107 -0
  39. data/lib/samagotchi/desktop_command.rb +24 -18
  40. data/lib/samagotchi/edit_preview.rb +2 -2
  41. data/lib/samagotchi/engine.rb +20 -75
  42. data/lib/samagotchi/events.rb +23 -0
  43. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  44. data/lib/samagotchi/guardrails/targets.rb +7 -1
  45. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  46. data/lib/samagotchi/hooks/loader.rb +23 -33
  47. data/lib/samagotchi/hooks/registry.rb +41 -0
  48. data/lib/samagotchi/hooks.rb +0 -3
  49. data/lib/samagotchi/host_registry.rb +15 -0
  50. data/lib/samagotchi/idle_reminders.rb +0 -7
  51. data/lib/samagotchi/image_store.rb +22 -0
  52. data/lib/samagotchi/kernel_loop.rb +12 -16
  53. data/lib/samagotchi/live_versions.rb +2 -13
  54. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  55. data/lib/samagotchi/log_subscriber.rb +5 -1
  56. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  57. data/lib/samagotchi/memory_bundle/index_sync.rb +73 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  59. data/lib/samagotchi/memory_bundle/installer.rb +25 -30
  60. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  61. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  63. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  64. data/lib/samagotchi/memory_paths.rb +10 -0
  65. data/lib/samagotchi/note_command.rb +23 -37
  66. data/lib/samagotchi/owner_lock.rb +14 -3
  67. data/lib/samagotchi/plugin/api.rb +11 -17
  68. data/lib/samagotchi/plugin/context.rb +70 -15
  69. data/lib/samagotchi/plugin/loader.rb +2 -3
  70. data/lib/samagotchi/sampling_settings.rb +3 -5
  71. data/lib/samagotchi/self_report.rb +6 -9
  72. data/lib/samagotchi/send_command.rb +33 -52
  73. data/lib/samagotchi/session.rb +88 -92
  74. data/lib/samagotchi/session_archive_command.rb +8 -14
  75. data/lib/samagotchi/session_commands.rb +3 -21
  76. data/lib/samagotchi/session_delete_command.rb +15 -29
  77. data/lib/samagotchi/session_manager.rb +29 -15
  78. data/lib/samagotchi/session_metrics.rb +2 -1
  79. data/lib/samagotchi/sessions_command.rb +3 -2
  80. data/lib/samagotchi/system_prompt.rb +2 -1
  81. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  83. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  85. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  86. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  87. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  88. data/lib/samagotchi/terminal_ui.rb +156 -234
  89. data/lib/samagotchi/thinking.rb +2 -3
  90. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  91. data/lib/samagotchi/tool_call_parser.rb +56 -292
  92. data/lib/samagotchi/tool_declarations.rb +1 -1
  93. data/lib/samagotchi/tool_runner.rb +8 -0
  94. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  95. data/lib/samagotchi/tools/builtins.rb +2 -1
  96. data/lib/samagotchi/tools/edit.rb +21 -29
  97. data/lib/samagotchi/tools/memory.rb +2 -13
  98. data/lib/samagotchi/tools/send_note.rb +1 -1
  99. data/lib/samagotchi/update_command.rb +32 -22
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/vision_support.rb +4 -5
  102. data/lib/samagotchi/web/app.rb +89 -151
  103. data/lib/samagotchi/web/public/activity.js +1 -11
  104. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  105. data/lib/samagotchi/web/public/app.js +158 -785
  106. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  107. data/lib/samagotchi/web/public/command_list.js +99 -0
  108. data/lib/samagotchi/web/public/data.js +22 -66
  109. data/lib/samagotchi/web/public/format.js +0 -23
  110. data/lib/samagotchi/web/public/index.html +0 -2
  111. data/lib/samagotchi/web/public/model_pick.js +0 -7
  112. data/lib/samagotchi/web/public/notifications.js +169 -0
  113. data/lib/samagotchi/web/public/question_cards.js +352 -0
  114. data/lib/samagotchi/web/public/session_state.js +34 -0
  115. data/lib/samagotchi/web/public/timing.js +1 -1
  116. data/lib/samagotchi/web/public/turn_events.js +11 -2
  117. data/lib/samagotchi/web/public/turn_model.js +0 -14
  118. data/lib/samagotchi/web/session_hub.rb +6 -4
  119. data/lib/samagotchi/web/session_summary.rb +5 -3
  120. data/lib/samagotchi/worker.rb +59 -53
  121. data/lib/samagotchi/worker_sidecar.rb +27 -1
  122. metadata +13 -1
@@ -0,0 +1,352 @@
1
+ // The cards that ask the user inside the history, and their DOM (the words
2
+ // are question_card.js'):
3
+ //
4
+ // ── ask_user_question card ─────────────────────────────────────────────────
5
+ // Inline card in the message history: rendered on question_requested (or from
6
+ // pending_question on session select for reload recovery), resolved/disabled
7
+ // on question_answered / question_cancelled so SSE replay and answers from
8
+ // another client converge to the same state.
9
+ //
10
+ // ── continue card ──────────────────────────────────────────────────────────
11
+ // A turn ran out of iterations: continue it, drop it, or drop it saying why
12
+ // (the terminal's continue prompt). Answers go as /continue commands; the
13
+ // card closes on the continue_resolved every client gets.
14
+
15
+ import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
+ import { diffHtml } from "./diff_view.js";
17
+ import { approvalAllowed, approvalView, nextCardAction, resultText, summaryText } from "./question_card.js";
18
+ import { clientLabel, continueLine, isOwn } from "./turn_events.js";
19
+
20
+ // @param sessionId () => the open session's id (answers go there)
21
+ // @param clientId this tab's id in the session's events
22
+ // @param place (card) puts a new card at the end of the history
23
+ // @param isNearBottom () => whether the history follows its end
24
+ // @param scrollToEnd () scrolls the history to its end
25
+ // @param removeHintIfEmpty () drops the history's placeholder hint
26
+ // @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
27
+ // @param doc the page's document
28
+ // @return {renderQuestion(pq), resolveQuestion(id, opts), removeQuestion(),
29
+ // questionCard() (the live card or null), pendingQuestion() (or null),
30
+ // renderContinue(offer), resolveContinue(data), forgetContinue()}
31
+ export function createQuestionCards({
32
+ sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty,
33
+ api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
34
+ }) {
35
+ // Live ask_user_question card, keyed by pending question id. Re-rendered from
36
+ // pending_question on session select so a reload mid-question restores it.
37
+ let questionEl = null;
38
+ let pending = null;
39
+ // The continue offer's card while the offer is pending.
40
+ let continueEl = null;
41
+
42
+ function renderQuestionCard(pq) {
43
+ const action = nextCardAction({ cardId: questionEl?.dataset.qid ?? null, pendingId: pending?.id ?? null }, pq);
44
+ if (action === "same") return;
45
+ // A resolved card stays where it was; only a pending one is replaced.
46
+ if (action === "keep") questionEl = null;
47
+ else removeQuestionCard();
48
+ pending = pq;
49
+ removeHintIfEmpty();
50
+
51
+ const approval = approvalView(pq);
52
+ const card = doc.createElement("details");
53
+ card.className = approval ? "bubble question approval" : "bubble question";
54
+ card.dataset.qid = pq.id;
55
+ // Open while pending: the question must be actionable.
56
+ card.open = true;
57
+
58
+ // The summary is the card's one line: the question (or its header) while
59
+ // pending, the result once resolved. It replaces the old .question-header.
60
+ const summary = doc.createElement("summary");
61
+ summary.textContent = summaryText(pq);
62
+ card.appendChild(summary);
63
+ // A hand toggle: the resolve's auto-collapse then leaves it as the user
64
+ // left it (rememberToggle-style, as turn_view.js does).
65
+ summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
66
+
67
+ if (approval) {
68
+ card.appendChild(renderApprovalDetails(approval));
69
+ } else {
70
+ const q = doc.createElement("div");
71
+ q.className = "question-text";
72
+ q.textContent = pq.question || "";
73
+ card.appendChild(q);
74
+ }
75
+
76
+ const options = Array.isArray(pq.options) ? pq.options : [];
77
+ if (options.length) {
78
+ const list = doc.createElement("div");
79
+ list.className = "question-options";
80
+ const inputType = pq.multi_select ? "checkbox" : "radio";
81
+ options.forEach((label, i) => {
82
+ const row = doc.createElement("label");
83
+ row.className = "question-option";
84
+ const input = doc.createElement("input");
85
+ input.type = inputType;
86
+ input.name = `q-${pq.id}`;
87
+ input.value = String(label);
88
+ input.dataset.index = String(i);
89
+ const text = doc.createElement("span");
90
+ text.textContent = String(label);
91
+ row.appendChild(input);
92
+ row.appendChild(text);
93
+ list.appendChild(row);
94
+ });
95
+ card.appendChild(list);
96
+ }
97
+
98
+ let freeformInput = null;
99
+ if (pq.allow_freeform) {
100
+ freeformInput = doc.createElement("input");
101
+ freeformInput.type = "text";
102
+ freeformInput.className = "question-freeform";
103
+ freeformInput.placeholder = approval ? "Reason for the model (with Deny, or alone to deny)…" : "Other…";
104
+ card.appendChild(freeformInput);
105
+ }
106
+
107
+ const err = doc.createElement("div");
108
+ err.className = "question-error hidden";
109
+ card.appendChild(err);
110
+
111
+ const submit = doc.createElement("button");
112
+ submit.className = "question-submit";
113
+ submit.textContent = "Submit";
114
+ submit.addEventListener("click", () => {
115
+ const chosen = Array.from(card.querySelectorAll(".question-option input:checked")).map((i) => i.value);
116
+ const freeform = freeformInput ? freeformInput.value.trim() : "";
117
+ if (!chosen.length && !freeform) {
118
+ err.textContent = "Pick an option or enter a response.";
119
+ err.classList.remove("hidden");
120
+ return;
121
+ }
122
+ err.classList.add("hidden");
123
+ submit.disabled = true;
124
+ card.classList.add("submitting");
125
+ api.sendAnswer(sessionId(), {
126
+ id: pq.id,
127
+ selected: chosen,
128
+ ...(freeform ? { freeform } : {}),
129
+ }).catch((e) => {
130
+ submit.disabled = false;
131
+ card.classList.remove("submitting");
132
+ err.textContent = /503|not_live/.test(e.message)
133
+ ? "Session is not running — restart it to answer."
134
+ : e.message;
135
+ err.classList.remove("hidden");
136
+ });
137
+ });
138
+ card.appendChild(submit);
139
+
140
+ // Leaves the question unanswered, like an empty answer in the terminal.
141
+ // The card closes on the question_cancelled every client gets.
142
+ // For an approval, dismissing denies the call.
143
+ const dismiss = doc.createElement("button");
144
+ dismiss.className = "question-dismiss ghost";
145
+ dismiss.textContent = approval ? "Deny" : "Dismiss";
146
+ dismiss.addEventListener("click", () => {
147
+ err.classList.add("hidden");
148
+ submit.disabled = true;
149
+ dismiss.disabled = true;
150
+ api.dismissQuestion(sessionId(), pq.id).catch((e) => {
151
+ submit.disabled = false;
152
+ dismiss.disabled = false;
153
+ err.textContent = /503|not_live/.test(e.message)
154
+ ? "Session is not running — restart it to answer."
155
+ : e.message;
156
+ err.classList.remove("hidden");
157
+ });
158
+ });
159
+ card.appendChild(dismiss);
160
+
161
+ place(card);
162
+ questionEl = card;
163
+ if (isNearBottom()) scrollToEnd();
164
+ }
165
+
166
+ // The tool, the command (or paths, or args) as code, where and why; no
167
+ // code box when there is nothing to show.
168
+ function renderApprovalDetails(view) {
169
+ const box = doc.createElement("div");
170
+ box.className = "approval-details";
171
+ const tool = doc.createElement("div");
172
+ tool.className = "approval-tool";
173
+ tool.textContent = view.tool;
174
+ box.appendChild(tool);
175
+ if (view.what) {
176
+ const what = doc.createElement("pre");
177
+ what.className = view.isCommand ? "approval-what command" : "approval-what paths";
178
+ const code = doc.createElement("code");
179
+ code.textContent = view.what;
180
+ what.appendChild(code);
181
+ box.appendChild(what);
182
+ }
183
+ // An edit/write: the diff it would make, under the path.
184
+ if (view.preview) {
185
+ const diff = doc.createElement("div");
186
+ diff.className = "approval-diff";
187
+ diff.innerHTML = diffHtml(view.preview);
188
+ box.appendChild(diff);
189
+ }
190
+ for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
191
+ if (!text) continue;
192
+ const row = doc.createElement("div");
193
+ row.className = cls;
194
+ const b = doc.createElement("span");
195
+ b.className = "approval-label";
196
+ b.textContent = `${label} `;
197
+ row.appendChild(b);
198
+ row.appendChild(doc.createTextNode(text));
199
+ box.appendChild(row);
200
+ }
201
+ return box;
202
+ }
203
+
204
+ function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
205
+ if (!questionEl) return;
206
+ if (id && pending && String(pending.id) !== String(id)) return;
207
+ // A double-delivered question_answered / question_cancelled (SSE replay
208
+ // after a reconnect) is a no-op: the card is already resolved.
209
+ if (questionEl.classList.contains("answered") || questionEl.classList.contains("cancelled")
210
+ || questionEl.querySelector(".question-result")) return;
211
+
212
+ const result = resultText(pending, { answer, cancelled, reason });
213
+ const note = doc.createElement("div");
214
+ note.className = "question-result";
215
+ note.textContent = result;
216
+ if (cancelled) {
217
+ questionEl.classList.add("cancelled");
218
+ } else {
219
+ questionEl.classList.add("answered");
220
+ if (approvalAllowed(pending, answer) === false) questionEl.classList.add("denied");
221
+ markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
222
+ }
223
+ questionEl.appendChild(note);
224
+ questionEl.classList.remove("submitting");
225
+ disableQuestionInputs();
226
+ // The summary becomes the result; the card collapses to that one line
227
+ // unless the user opened or closed it by hand.
228
+ const summary = questionEl.querySelector("summary");
229
+ if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
230
+ if (!questionEl.dataset.toggled) questionEl.open = false;
231
+ pending = null;
232
+ }
233
+
234
+ function markQuestionSelection(sel) {
235
+ if (!questionEl || !sel.length) return;
236
+ questionEl.querySelectorAll(".question-option input").forEach((input) => {
237
+ if (sel.includes(input.value)) {
238
+ input.checked = true;
239
+ input.closest(".question-option").classList.add("selected");
240
+ }
241
+ });
242
+ }
243
+
244
+ function disableQuestionInputs() {
245
+ if (!questionEl) return;
246
+ questionEl.querySelectorAll("input, button").forEach((el) => {
247
+ el.disabled = true;
248
+ });
249
+ }
250
+
251
+ function removeQuestionCard() {
252
+ if (questionEl) {
253
+ questionEl.remove();
254
+ questionEl = null;
255
+ }
256
+ pending = null;
257
+ }
258
+
259
+ // ── continue card ──────────────────────────────────────────────────────────
260
+ // A turn ran out of iterations: continue it, drop it, or drop it saying why
261
+ // (the terminal's continue prompt). Answers go as /continue commands; the
262
+ // card closes on the continue_resolved every client gets.
263
+
264
+ const CONTINUE_DECISIONS = {
265
+ resume: "Continued",
266
+ abort: "Not continued",
267
+ abort_with_reason: "Not continued (reason noted)",
268
+ dropped: "Dropped: a new prompt came",
269
+ };
270
+
271
+ function renderContinueCard(offer) {
272
+ if (continueEl?.isConnected) return;
273
+ removeHintIfEmpty();
274
+ const card = doc.createElement("div");
275
+ card.className = "bubble continue";
276
+ const text = doc.createElement("div");
277
+ text.className = "continue-text";
278
+ text.textContent = "The turn ran out of iterations. Continue it?";
279
+ card.appendChild(text);
280
+ const task = offer?.context?.original_prompt;
281
+ if (task) {
282
+ const ctx = doc.createElement("div");
283
+ ctx.className = "continue-context";
284
+ ctx.textContent = task;
285
+ card.appendChild(ctx);
286
+ }
287
+ const err = doc.createElement("div");
288
+ err.className = "question-error hidden";
289
+ const answer = (line) => {
290
+ err.classList.add("hidden");
291
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
292
+ api.sendCommand(sessionId(), line, { clientId }).catch((e) => {
293
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
294
+ err.textContent = e.message;
295
+ err.classList.remove("hidden");
296
+ });
297
+ };
298
+ const yes = doc.createElement("button");
299
+ yes.className = "continue-yes";
300
+ yes.textContent = "Yes";
301
+ yes.addEventListener("click", () => answer(continueLine("yes")));
302
+ const no = doc.createElement("button");
303
+ no.className = "continue-no ghost";
304
+ no.textContent = "No";
305
+ no.addEventListener("click", () => answer(continueLine("no")));
306
+ const reason = doc.createElement("input");
307
+ reason.type = "text";
308
+ reason.className = "continue-reason";
309
+ reason.placeholder = "Why not? (the model reads it)";
310
+ const noBecause = doc.createElement("button");
311
+ noBecause.className = "continue-no-reason ghost";
312
+ noBecause.textContent = "No, because…";
313
+ noBecause.addEventListener("click", () => {
314
+ if (!reason.value.trim()) return reason.focus();
315
+ answer(continueLine("no", reason.value));
316
+ });
317
+ const row = doc.createElement("div");
318
+ row.className = "continue-actions";
319
+ [yes, no, reason, noBecause].forEach((el) => row.appendChild(el));
320
+ card.appendChild(row);
321
+ card.appendChild(err);
322
+ const follow = isNearBottom();
323
+ place(card);
324
+ continueEl = card;
325
+ if (follow) scrollToEnd();
326
+ }
327
+
328
+ function resolveContinueCard(data) {
329
+ const card = continueEl;
330
+ continueEl = null;
331
+ if (!card) return;
332
+ card.classList.add("resolved");
333
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
334
+ const note = doc.createElement("div");
335
+ note.className = "question-result";
336
+ const who = isOwn(data.client_id, clientId) ? "" : ` (${clientLabel(data.client_id) || "another client"})`;
337
+ note.textContent = (CONTINUE_DECISIONS[data.decision] || data.decision || "Answered") + who;
338
+ card.appendChild(note);
339
+ }
340
+
341
+ return {
342
+ renderQuestion: renderQuestionCard,
343
+ resolveQuestion: resolveQuestionCard,
344
+ removeQuestion: removeQuestionCard,
345
+ questionCard: () => questionEl,
346
+ pendingQuestion: () => pending,
347
+ renderContinue: renderContinueCard,
348
+ resolveContinue: resolveContinueCard,
349
+ // The turn view was reset: a later offer draws a new card.
350
+ forgetContinue() { continueEl = null; },
351
+ };
352
+ }
@@ -0,0 +1,34 @@
1
+ // What the page knows about the open session: the info bar's fields, the
2
+ // composer's owner check, the command list's commands and the turn timing.
3
+ // One object, so switching sessions starts every field from here (a field
4
+ // the previous session set, its "delegated by" parent, its commands, never
5
+ // shows for the next one).
6
+
7
+ import { normalizeTiming } from "./timing.js";
8
+
9
+ export function emptySessionState() {
10
+ return {
11
+ firstPreview: "",
12
+ usedMemories: [],
13
+ // The session's --memory and --mute lists (from the session card; shown
14
+ // in the info bar's tooltip).
15
+ preloadedMemories: [],
16
+ mutedMemories: [],
17
+ // A delegated session's parent (the info bar's "delegated by" chip).
18
+ parentId: null,
19
+ ctxPct: null,
20
+ // Window the kernel resolved for the running generation (:generation_started).
21
+ ctxWindow: null,
22
+ status: "",
23
+ model: "",
24
+ // What the worker's server said it served for a model name: [served, asked].
25
+ served: null,
26
+ dir: "",
27
+ // Who holds the session: "worker" (shared), "tui" (a plain terminal chi;
28
+ // sending would be refused) or null.
29
+ owner: null,
30
+ // The session's commands (its worker's, plugins' too) for the / list.
31
+ commands: [],
32
+ timing: normalizeTiming(),
33
+ };
34
+ }
@@ -21,7 +21,7 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
21
21
  }
22
22
 
23
23
  // A cancel's reason as the terminal UIs name it too (Formatting::
24
- // CANCEL_REASONS); anything else as it is.
24
+ // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
25
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
26
 
27
27
  // The line a canceled turn ends with: "✕ canceled (stopped)", live from
@@ -30,6 +30,8 @@ export function isOwn(clientId, myId) {
30
30
  return clientId != null && clientId === myId;
31
31
  }
32
32
 
33
+ // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
34
+ // helpers and the TUI's agree per spec/shared/labels_matrix.json.
33
35
  const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
34
36
 
35
37
  // The label on another client's prompt, by its client_id prefix.
@@ -220,7 +222,9 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
220
222
  // parts in order), then the prompts queued behind it. app.js feeds these to
221
223
  // its stream handlers, so a join renders exactly like watching live.
222
224
  // `merged_input` is the one synthetic type: a prompt merged into the turn.
223
- // A `notice` part carries its event as it came (TurnNotice).
225
+ // A `notice` part carries its event as it came (TurnNotice). The TUI replays
226
+ // a turn the same way (TurnAccumulator.replay_events); spec/shared/
227
+ // turn_snapshot.json pins both.
224
228
  export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
225
229
  const events = [];
226
230
  if (turn) {
@@ -249,10 +253,15 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
249
253
  const title = part.title ? { title: part.title } : {};
250
254
  events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
251
255
  if (part.status !== "running") {
256
+ // The activity as the live event had it; action and duration_ms
257
+ // only in snapshots from a worker that keeps them (the TUI's
258
+ // row; the web times rows from the turn's tool records).
259
+ const action = part.action ? { action: part.action } : {};
252
260
  const completed = {
253
261
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
254
- activity: { status: part.status, params: part.params, ...title },
262
+ activity: { ...action, tool: part.tool, status: part.status, params: part.params, ...title },
255
263
  };
264
+ if (part.duration_ms != null) completed.duration_ms = part.duration_ms;
256
265
  if (part.diff) completed.diff = part.diff;
257
266
  events.push(withImages(completed, part.images));
258
267
  }
@@ -155,20 +155,6 @@ function isStep(gen) {
155
155
  return !gen.answer || !!gen.thinking || gen.tools.length > 0;
156
156
  }
157
157
 
158
- // One stream event into the model; null for the types it ignores.
159
- export function applyEvent(turn, event) {
160
- switch (event?.type) {
161
- case "turn_started": return turnStarted(turn);
162
- case "generation_started": return generationStarted(turn, event.iteration ?? null);
163
- case "generation_chunk": return chunk(turn, { text: event.text, thinking: event.thinking, iteration: event.iteration ?? null });
164
- case "generation_completed": return generationCompleted(turn);
165
- case "tool_call_started": return toolStarted(turn, event);
166
- case "tool_call_completed": return toolCompleted(turn, event);
167
- case "turn_completed": case "turn_canceled": case "turn_failed": return turnEnded(turn);
168
- default: return null;
169
- }
170
- }
171
-
172
158
  function cut(line) {
173
159
  return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
174
160
  }
@@ -244,7 +244,11 @@ module Samagotchi
244
244
 
245
245
  stamp = [stat.mtime, stat.size]
246
246
  changed = false
247
- if stamp != entry.stamp
247
+ dir = Session.session_dir(id, state_dir: @state_dir)
248
+ dir_stamp = dir_mtime(dir)
249
+ # The folder changing re-reads the file too: a stop is a marker file
250
+ # there (Session::STOPPED_FILE), not a change to the session file.
251
+ if stamp != entry.stamp || dir_stamp != entry.dir_stamp
248
252
  session = Session.summary_from_file(path)
249
253
  # Corrupt, or missing a field: not a session, as Session.list has it.
250
254
  # A `chi scratch` session is never shown.
@@ -257,8 +261,6 @@ module Samagotchi
257
261
  entry.stamp = stamp
258
262
  changed = true
259
263
  end
260
- dir = Session.session_dir(id, state_dir: @state_dir)
261
- dir_stamp = dir_mtime(dir)
262
264
  if dir_stamp != entry.dir_stamp
263
265
  entry.dir_stamp = dir_stamp
264
266
  changed = true
@@ -304,7 +306,7 @@ module Samagotchi
304
306
  def live_stamp(owner, dir)
305
307
  return nil unless owner
306
308
 
307
- { pid: owner["pid"], kind: owner["kind"], bridge_started_at: bridge_started_at(dir) }
309
+ { pid: owner.pid, kind: owner.kind, bridge_started_at: bridge_started_at(dir) }
308
310
  end
309
311
 
310
312
  def bridge_started_at(dir)
@@ -18,7 +18,7 @@ module Samagotchi
18
18
  module_function
19
19
 
20
20
  # @param session [Session] a saved session (messages may be empty)
21
- # @param owner [Hash, nil] SessionManager.session_owner's answer; its
21
+ # @param owner [OwnerLock::Owner, nil] SessionManager.session_owner's; its
22
22
  # kind is shown as `owner`
23
23
  # @param session_dir [String] the session's folder (recap.json, bridge.json)
24
24
  # @param status [String] the turn state to show; by default
@@ -48,7 +48,7 @@ module Samagotchi
48
48
  # The session that delegated this one (the `delegate` tool), else nil.
49
49
  parent_id: session.respond_to?(:parent_id) ? session.parent_id : nil,
50
50
  first_preview: first_preview_for(session),
51
- owner: owner&.fetch("kind", nil),
51
+ owner: owner&.kind,
52
52
  # The saved recap's first sentence, for the session card.
53
53
  recap: RecapStore.preview(session_dir),
54
54
  # How full the context was after the last turn (%), or nil.
@@ -88,7 +88,9 @@ module Samagotchi
88
88
  end
89
89
 
90
90
  # @return [Boolean] the sidecar file is there and the session has a
91
- # live owner (the file alone says nothing: the worker may be gone)
91
+ # live owner (the file alone says nothing: the worker may be gone).
92
+ # The owner lock, not WorkerSidecar.live's port probe: a list builds
93
+ # this for every session, and the lock costs no connect.
92
94
  def bridge_up?(session_dir, owner)
93
95
  !owner.nil? && File.file?(WorkerSidecar.path(session_dir))
94
96
  rescue StandardError