samagotchi 0.18.1 → 0.20.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 +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. metadata +7 -1
@@ -48,3 +48,62 @@ export function savedCtxPct(context) {
48
48
  export function cardCtxText(pct) {
49
49
  return num(pct) === null ? "" : `${Math.round(pct)}%`;
50
50
  }
51
+
52
+ // A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
53
+ // server timings); "" without one. The TUI's /stats says the same
54
+ // (Formatting#speed_text; spec/shared/labels_matrix.json).
55
+ export function speedText(tps, source) {
56
+ const value = num(tps);
57
+ if (value === null || value <= 0) return "";
58
+ const number = value >= 1000 ? `${(Math.round(value / 100) / 10).toFixed(1)}k` : String(Math.round(value));
59
+ return `${source === "estimate" ? "~" : ""}${number} tok/s`;
60
+ }
61
+
62
+ // A cost in USD: "$0.42"; under a cent, four decimals ("$0.0012"); "" for
63
+ // none or zero. As Formatting#cost_text.
64
+ export function costText(cost) {
65
+ const value = num(cost);
66
+ if (value === null || value <= 0) return "";
67
+ return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
68
+ }
69
+
70
+ function count(n) {
71
+ return Math.round(n).toLocaleString("en-US");
72
+ }
73
+
74
+ // The ctx tooltip (info bar and card): +title+, then the session's tokens
75
+ // (in summed over every request, cached with its share, out with the
76
+ // reasoning part) and its cost, when it has any. A delegated session keeps
77
+ // its own metrics, so these are this session's only.
78
+ export function tokensTipText(tokens, title = "Context used after the last turn") {
79
+ const prompt = num(tokens?.prompt_sum) || 0;
80
+ const completion = num(tokens?.completion_sum) || 0;
81
+ if (!prompt && !completion) return title;
82
+ const cached = num(tokens.cached_sum) || 0;
83
+ const reasoning = num(tokens.reasoning_sum) || 0;
84
+ const parts = [`in ${count(prompt)}`];
85
+ if (cached > 0 && prompt > 0) parts.push(`cached ${count(cached)} (${Math.round((cached * 100) / prompt)}%)`);
86
+ parts.push(`out ${count(completion)}${reasoning > 0 ? ` (reasoning ${count(reasoning)})` : ""}`);
87
+ const lines = [title, `tokens: ${parts.join(" · ")}`];
88
+ const cost = costText(tokens.cost_sum);
89
+ if (cost) lines.push(`cost: ${cost}`);
90
+ lines.push("this session only, all requests");
91
+ return lines.join("\n");
92
+ }
93
+
94
+ // The info bar's speed: the newest generation's decode speed, "" before one.
95
+ export function lastSpeedText(tokens) {
96
+ return speedText(tokens?.last_decode_tps, tokens?.tps_source);
97
+ }
98
+
99
+ // The session's tokens block after a :generation_completed: the event's
100
+ // running totals (the server closed the generation and put them on it),
101
+ // with its speed; an event without them (a replay, an older worker) keeps
102
+ // +previous+.
103
+ export function generationTokens(previous, data) {
104
+ const tokens = data?.tokens;
105
+ if (!tokens || typeof tokens !== "object") return previous ?? null;
106
+ const speed = data.speed;
107
+ if (!speed || num(speed.decode_tps) === null) return tokens;
108
+ return { ...tokens, last_decode_tps: speed.decode_tps, tps_source: speed.source };
109
+ }
@@ -123,6 +123,12 @@ export function stopSession(id, opts = {}) {
123
123
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
124
124
  }
125
125
 
126
+ // Hands the session to a new worker on the newest chi installed: 200
127
+ // {status, from_version, version}, or 409 with the reason in words (detail).
128
+ export function restartSession(id, opts = {}) {
129
+ return api(`/api/sessions/${encodeURIComponent(id)}/restart`, { method: "POST", body: JSON.stringify({}), ...opts });
130
+ }
131
+
126
132
  // Hides the session (and its delegates) from the lists, kept for good; the
127
133
  // server stops a live idle worker first and answers 409 while a turn runs.
128
134
  export function archiveSession(id, opts = {}) {
@@ -86,7 +86,9 @@ export function previewForCard(session) {
86
86
 
87
87
  const DELETE_PREVIEW_CHARS = 40;
88
88
 
89
- // The confirm before deleting a session (the info bar's and a card's).
89
+ // The confirm before deleting a session (the info bar's and a card's). The
90
+ // list's owner can be stale (a worker idle-exits), so a worker is only
91
+ // "stopped if it's still running", never promised.
90
92
  export function deleteConfirmText(session) {
91
93
  const n = normalize(session.first_preview || session.last_prompt || "");
92
94
  const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
@@ -95,7 +97,7 @@ export function deleteConfirmText(session) {
95
97
  `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
96
98
  "Its history, notes and images are removed; this can't be undone.",
97
99
  ];
98
- if (session.owner === "worker") lines.push("Its worker is running and will be stopped first.");
100
+ if (session.owner === "worker") lines.push("If its worker is still running, it is stopped first.");
99
101
  return lines.join("\n");
100
102
  }
101
103
 
@@ -216,3 +218,14 @@ export function recapLabel(turnsSince, stale = false) {
216
218
  if (n > 1) return `recap · before the last ${n} turns`;
217
219
  return stale ? "recap · earlier" : "recap";
218
220
  }
221
+
222
+ // The info bar's memory chip on a phone (where the "mem: …" line goes),
223
+ // for a session that used memories: "mem 2", the names its tooltip.
224
+ export function memChipText(names) {
225
+ return `mem ${(names || []).length}`;
226
+ }
227
+
228
+ export function memChipTitle(names) {
229
+ const list = names || [];
230
+ return list.length ? `memories: ${list.join(", ")}` : "no memories in this session";
231
+ }
@@ -242,6 +242,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
242
242
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
243
243
  .question-error.hidden{display:none}
244
244
  .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
245
+ /* 4.02: a resolved card whose summary already carries the result hides the
246
+ body line; a hand-toggled one keeps it (its expanded body shows it). */
247
+ .question-result.hidden{display:none}
248
+ /* N1: an approval's verdict sits above its command block, not after it. */
249
+ .question-result.verdict{margin-top:0;margin-bottom:8px}
245
250
  .bubble.question.cancelled .question-result{color:var(--text-muted)}
246
251
  .question-submit{align-self:flex-end;font-size:13px}
247
252
  .question-dismiss{font-size:13px;margin-left:8px}
@@ -298,6 +303,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
298
303
  .bubble.plugin-card{border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--glass-bg));padding:10px 12px;transition:box-shadow .3s ease}
299
304
  .bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
300
305
  .bubble.plugin-card.card-updated{box-shadow:inset 0 1px 0 var(--glass-highlight),0 0 0 1px var(--accent-ring),var(--glass-shadow)}
306
+ /* A notice card (card.js isNoticeCard): a one-line row like a resolved question, "▸ check-in: Nudged …"; opened, the line wraps and the card's glass comes back. */
307
+ .bubble.plugin-card.notice:not([open]){padding:3px 10px;background:transparent;border-color:transparent;border-left-color:var(--accent);box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;align-self:flex-start;max-width:100%;border-radius:6px;transition:background .12s ease}
308
+ .bubble.plugin-card.notice:not([open]):hover{background:var(--bg-panel)}
309
+ .bubble.plugin-card.notice>summary{cursor:pointer;color:var(--text-muted);font-size:12px;font-weight:500;user-select:none;list-style:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
310
+ .bubble.plugin-card.notice>summary::-webkit-details-marker{display:none}
311
+ .bubble.plugin-card.notice>summary::before{content:"▸ ";color:var(--accent)}
312
+ .bubble.plugin-card.notice[open]{padding:6px 10px}
313
+ .bubble.plugin-card.notice[open]>summary{white-space:pre-wrap;font-size:13px;color:var(--text-2)}
314
+ .bubble.plugin-card.notice[open]>summary::before{content:"▾ "}
315
+ .notice-from{margin-top:4px;font-size:11px;color:var(--text-faint)}
316
+ .gen .bubble.plugin-card.notice:not([open]){margin:2px 0;padding:2px 8px}
301
317
  .card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
302
318
  .card-title{font-size:14px;font-weight:600;color:var(--text-bright)}
303
319
  .card-source{font-size:11px;color:var(--text-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -552,8 +568,17 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
552
568
  #infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
553
569
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
554
570
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
555
- #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:var(--bg-hover);color:var(--text-3)}
571
+ /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
572
+ #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
573
+ /* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
574
+ #infoBar .meta .mem-chip{display:none}
556
575
  #infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
576
+ /* The open session's worker runs an older chi: its version, and Restart when it can. */
577
+ #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
578
+ #infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
579
+ #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
580
+ #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
581
+ #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
557
582
  #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
558
583
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
559
584
  #infoBar .mem{font-size:12px;color:var(--text-muted)}
@@ -577,10 +602,12 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
577
602
  #infoBar .meta>*{flex-shrink:0}
578
603
  #infoBar .meta .id{order:-2}
579
604
  #infoBar .meta .status{order:-1}
605
+ /* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
606
+ #infoBar .meta .mem-chip{display:inline;order:-1}
580
607
  #infoBar .meta .dir{word-break:normal}
581
608
  #infoBar button{padding:4px 8px}
582
609
  }
583
- #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:var(--bg);z-index:1}
610
+ #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
584
611
  #empty.hidden{display:none !important}
585
612
  /* Start page (no session): the composer alone, centered; no info bar. */
586
613
  body:not(.has-session) #center{justify-content:safe center;background:transparent}
@@ -650,6 +677,13 @@ body.has-session #scope .scope-where{display:none}
650
677
  #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
651
678
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
652
679
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
680
+ /* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
681
+ #stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
682
+ @media(max-width:900px){
683
+ body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
684
+ #stripHide{display:none}
685
+ #stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
686
+ }
653
687
  body.strip-hidden #stripShow{display:block}
654
688
  body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
655
689
  #allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
@@ -729,6 +763,7 @@ a{color:var(--accent)}
729
763
  <button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
730
764
  <div id="scope" hidden></div>
731
765
  <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
766
+ <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
732
767
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
733
768
  </div>
734
769
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
@@ -17,7 +17,9 @@ export function pickModel(names, defaultName, stored) {
17
17
 
18
18
  export const RECENT_KEY = "chi_model_recent";
19
19
 
20
- // The payload's models as rows: {name, host, id, isDefaultHost}. The
20
+ // The payload's models as rows: {name, host, id, isDefaultHost}, and
21
+ // sampling (the configured request parameters as /model words them) when
22
+ // the model has some. The
21
23
  // default host's names are bare (name == id); the server's own default,
22
24
  // unshifted when no host lists it, has no host and goes with them, shown
23
25
  // by its name.
@@ -29,7 +31,9 @@ export function modelRows(payload) {
29
31
  const name = String(m.name);
30
32
  const host = m.host ? String(m.host) : "";
31
33
  const id = host ? String(m.id || name) : name;
32
- return { name, host, id, isDefaultHost: !host || name === id };
34
+ const row = { name, host, id, isDefaultHost: !host || name === id };
35
+ if (m.sampling) row.sampling = String(m.sampling);
36
+ return row;
33
37
  });
34
38
  }
35
39
 
@@ -59,7 +59,8 @@ export function createModelPicker({ button, panel, input, list, composer, center
59
59
  const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
60
60
  const note = row.name === defaultName ? `<span class="model-note">default</span>` : "";
61
61
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}`;
62
- return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}">` +
62
+ const title = row.sampling ? ` title="${escapeHtml(`sampling: ${row.sampling}`)}"` : "";
63
+ return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
63
64
  `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
64
65
  }
65
66
 
@@ -52,7 +52,8 @@ export function approvalView(pq) {
52
52
  const command = a.command || null;
53
53
  let where = a.cwd || "";
54
54
  if (a.repo_root) {
55
- const repo = String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
55
+ // The repository's name (a worktree's main checkout), else the root's folder.
56
+ const repo = a.repo_name || String(a.repo_root).split("/").filter(Boolean).pop() || a.repo_root;
56
57
  where += ` (repo ${repo}${a.branch ? `, branch ${a.branch}` : ""})`;
57
58
  } else if (where) {
58
59
  where += " (not in a repo)";
@@ -161,6 +162,18 @@ export function approvalAllowed(pq, answer) {
161
162
  return resultText(pq, { answer }).startsWith("Allowed");
162
163
  }
163
164
 
165
+ // What the card says when an answer or a dismissal didn't go through. 503
166
+ // (the server found no live bridge) reads as the session not running; any
167
+ // other failure — the worker's own (the server maps it to a 502
168
+ // bridge_error) or the connection's — shows what it said, so a worker that
169
+ // failed the request is not mistaken for one that is gone.
170
+ export function answerErrorText(error) {
171
+ const status = error?.status;
172
+ const message = String(error?.message ?? error ?? "");
173
+ if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
174
+ return message;
175
+ }
176
+
164
177
  // What a new question does to the card on the page: "same" (the card
165
178
  // already shows it: a replay), "keep" (the card is resolved, so it stays in
166
179
  // the history and the new one goes under it), "replace" (the card is still
@@ -15,8 +15,8 @@
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
17
  import {
18
- approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction, relayedToView, resultText,
19
- summaryText,
18
+ answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
19
+ relayedToView, resultText, summaryText,
20
20
  } from "./question_card.js";
21
21
  import { sessionHash } from "./route.js";
22
22
  import { clientLabel, continueLine, isOwn } from "./turn_events.js";
@@ -148,9 +148,7 @@ export function createQuestionCards({
148
148
  }).catch((e) => {
149
149
  submit.disabled = false;
150
150
  card.classList.remove("submitting");
151
- err.textContent = /503|not_live/.test(e.message)
152
- ? "Session is not running — restart it to answer."
153
- : e.message;
151
+ err.textContent = answerErrorText(e);
154
152
  err.classList.remove("hidden");
155
153
  });
156
154
  });
@@ -169,9 +167,7 @@ export function createQuestionCards({
169
167
  api.dismissQuestion(sessionId(), pq.id).catch((e) => {
170
168
  submit.disabled = false;
171
169
  dismiss.disabled = false;
172
- err.textContent = /503|not_live/.test(e.message)
173
- ? "Session is not running — restart it to answer."
174
- : e.message;
170
+ err.textContent = answerErrorText(e);
175
171
  err.classList.remove("hidden");
176
172
  });
177
173
  });
@@ -210,7 +206,7 @@ export function createQuestionCards({
210
206
  api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
211
207
  card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
212
208
  card.classList.remove("submitting");
213
- err.textContent = /503|not_live/.test(e.message) ? "Session is not running — restart it to answer." : e.message;
209
+ err.textContent = answerErrorText(e);
214
210
  err.classList.remove("hidden");
215
211
  });
216
212
  };
@@ -320,14 +316,20 @@ export function createQuestionCards({
320
316
  const note = doc.createElement("div");
321
317
  note.className = "question-result";
322
318
  note.textContent = result;
319
+ // N1: an approval's verdict goes before its command block, not after it:
320
+ // with the details expanded the command reads like another tool call.
321
+ const allowed = approvalAllowed(pending, answer);
322
+ if (allowed !== null) note.classList.add("verdict");
323
323
  if (cancelled) {
324
324
  questionEl.classList.add("cancelled");
325
325
  } else {
326
326
  questionEl.classList.add("answered");
327
- if (approvalAllowed(pending, answer) === false) questionEl.classList.add("denied");
327
+ if (allowed === false) questionEl.classList.add("denied");
328
328
  markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
329
329
  }
330
- questionEl.appendChild(note);
330
+ const before = allowed !== null ? questionEl.querySelector(".approval-details") : null;
331
+ if (before) questionEl.insertBefore(note, before);
332
+ else questionEl.appendChild(note);
331
333
  questionEl.classList.remove("submitting");
332
334
  disableQuestionInputs();
333
335
  questionEl.querySelector(".question-relayed")?.classList.add("hidden");
@@ -335,6 +337,10 @@ export function createQuestionCards({
335
337
  // unless the user opened or closed it by hand.
336
338
  const summary = questionEl.querySelector("summary");
337
339
  if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
340
+ // 4.02: a reopened card's summary already carries the result (the
341
+ // snapshot re-renders it resolved), and the body line repeats it. It
342
+ // stays for a hand-toggled card, whose expanded body the user looks at.
343
+ if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
338
344
  if (!questionEl.dataset.toggled) questionEl.open = false;
339
345
  pending = null;
340
346
  }
@@ -10,14 +10,23 @@ export function scopeDir(search) {
10
10
 
11
11
  // The same place in the all view: the page without ?dir, keeping the hash.
12
12
  // +fromDir+ (the project view's folder) rides along as ?from=, so the all
13
- // view can link back to it.
14
- export function allScopeHref(hash, fromDir = null) {
15
- return `/${fromDir ? `?from=${dirParam(fromDir)}` : ""}${hash || ""}`;
13
+ // view can link back to it. +search+ (the page's location.search) keeps
14
+ // its ?view= (VIEW_PARAM) on the new page.
15
+ export function allScopeHref(hash, fromDir = null, search = "") {
16
+ const params = [fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
17
+ return `/${params.length ? `?${params.join("&")}` : ""}${hash || ""}`;
16
18
  }
17
19
 
18
- // A folder's project view, keeping the hash.
19
- export function projectScopeHref(dir, hash) {
20
- return `/?dir=${dirParam(dir)}${hash || ""}`;
20
+ // A folder's project view, keeping the hash and +search+'s ?view=.
21
+ export function projectScopeHref(dir, hash, search = "") {
22
+ const view = viewParam(search);
23
+ return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
24
+ }
25
+
26
+ // "view=<v>" from a location.search string, or "" without one.
27
+ function viewParam(search) {
28
+ const view = new URLSearchParams(search || "").get("view");
29
+ return view ? `view=${encodeURIComponent(view)}` : "";
21
30
  }
22
31
 
23
32
  // A folder for the query string, its slashes left readable (as chi web
@@ -29,6 +29,10 @@ export function emptySessionState() {
29
29
  owner: null,
30
30
  // The session's commands (its worker's, plugins' too) for the / list.
31
31
  commands: [],
32
+ // The chi its live worker runs and what it can do (the session card's
33
+ // worker_version / worker_features): the info bar's stale-worker badge.
34
+ workerVersion: null,
35
+ workerFeatures: [],
32
36
  timing: normalizeTiming(),
33
37
  };
34
38
  }
@@ -52,6 +52,8 @@ export function normalizeTiming(data = {}) {
52
52
  turnRecords: Array.isArray(data.turn_records) ? data.turn_records.filter(validRecord) : [],
53
53
  toolRecords: Array.isArray(data.tool_records) ? data.tool_records.filter(validRecord) : [],
54
54
  activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
55
+ // The session's token sums and speeds (SessionMetrics' tokens block).
56
+ tokens: validRecord(data.tokens) ? data.tokens : null,
55
57
  };
56
58
  }
57
59
 
@@ -61,7 +63,8 @@ export function normalizeTiming(data = {}) {
61
63
  // place; an unknown turn goes in started_at order (ties and unparsable
62
64
  // dates keep arrival order), an unknown tool record at the end (they come
63
65
  // grouped by turn, in call order). startedAt, sessionDurationMs and
64
- // activeTurn are the reply's. `short`: the reply's turn_count says the page
66
+ // activeTurn are the reply's, and so are its tokens (a reply without them
67
+ // keeps the page's). `short`: the reply's turn_count says the page
65
68
  // misses a turn (it then reads the whole timing). A reply without
66
69
  // turn_count (an older server: the whole timing) replaces, as before; more
67
70
  // records than the count (a worker died before saving a turn the page saw)
@@ -85,7 +88,8 @@ export function mergeTiming(timing, data = {}) {
85
88
  fresh.toolRecords.forEach((record) => {
86
89
  if (!replaceById(toolRecords, record)) toolRecords.push(record);
87
90
  });
88
- return { timing: { ...fresh, turnRecords, toolRecords }, short: turnRecords.length < count };
91
+ const tokens = fresh.tokens || timing?.tokens || null;
92
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
89
93
  }
90
94
 
91
95
  function replaceById(records, record) {
@@ -20,10 +20,69 @@ export function workerGoneText({ turnRunning, stopped = false }) {
20
20
  return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
21
21
  }
22
22
 
23
+ // How long a turn's answer waits for the after_turn hooks' answer_display
24
+ // before the page stops waiting and pops what it has (4.14).
25
+ export const DISPLAY_WAIT_MS = 20000;
26
+
27
+ // The wait between a turn_completed with display_pending and its
28
+ // answer_display. The worker can die in between (a crash, a stop, an idle
29
+ // exit) and never send one, so the wait also ends on the caller's finish()
30
+ // (workerGone, a re-render) and by the timeout: it must never hold the
31
+ // turn's answer bubble forever.
32
+ // @return {{promise, finish, settled: () => Boolean}}
33
+ export function displayWait({
34
+ timeoutMs = DISPLAY_WAIT_MS, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
35
+ } = {}) {
36
+ let settle = null;
37
+ let done = false;
38
+ const promise = new Promise((resolve) => { settle = resolve; });
39
+ const finish = () => {
40
+ if (done) return;
41
+ done = true;
42
+ clearTimeoutImpl(timer);
43
+ settle();
44
+ };
45
+ const timer = setTimeoutImpl(finish, timeoutMs);
46
+ return { promise, finish, settled: () => done };
47
+ }
48
+
23
49
  // This tab's id in the events (`origin.client_id`, turn_enqueued's
24
- // client_id): new per page load, like the TUI's `tui:<pid>`.
25
- export function newClientId() {
26
- return `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
50
+ // client_id): kept in sessionStorage per tab, so a reload mid-turn still
51
+ // recognizes this tab's own prompt (4.03), which a fresh random id every
52
+ // load labelled "web". Falls back to a fresh id when sessionStorage is
53
+ // unavailable (a private window that refuses it) or holds nonsense.
54
+ export const CLIENT_ID_KEY = "chi_client_id";
55
+ export function newClientId(storage = safeSessionStorage()) {
56
+ const kept = readClientId(storage);
57
+ if (kept) return kept;
58
+ const id = `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
59
+ writeClientId(storage, id);
60
+ return id;
61
+ }
62
+
63
+ function safeSessionStorage() {
64
+ try {
65
+ return globalThis.sessionStorage || null;
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function readClientId(storage) {
72
+ try {
73
+ const value = storage?.getItem(CLIENT_ID_KEY);
74
+ return typeof value === "string" && /^web:/.test(value) ? value : null;
75
+ } catch (_) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeClientId(storage, id) {
81
+ try {
82
+ storage?.setItem(CLIENT_ID_KEY, id);
83
+ } catch (_) {
84
+ // Full or refused storage: the id stays for this page only.
85
+ }
27
86
  }
28
87
 
29
88
  export function isOwn(clientId, myId) {
@@ -175,7 +234,7 @@ const WEB_LOCAL_REPLIES = {
175
234
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
176
235
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
177
236
  "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
178
- "/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
237
+ "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
179
238
  };
180
239
 
181
240
  export function webLocalReply(text) {
@@ -190,6 +249,17 @@ export function startPageReply(text, { images = 0 } = {}) {
190
249
  return images ? null : webLocalReply(text);
191
250
  }
192
251
 
252
+ // The start page's reply to a first message that needs no session at all
253
+ // (2.29): its own command's reply (startPageReply), or the hint for a
254
+ // command word no command answers (a typo like /modle) — the hint the page
255
+ // shows in a session too, as a command bubble. Decided before the session
256
+ // is created, so a first /stats or /modle leaves none behind. A null here
257
+ // means the message goes to a session (a prompt, or anything with images).
258
+ export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
259
+ if (images) return null;
260
+ return startPageReply(text) || unknownCommandHint(text, commands);
261
+ }
262
+
193
263
  // The continue card's answers, as the terminal's continue prompt reads them.
194
264
  export function continueLine(choice, reason = "") {
195
265
  const why = String(reason || "").trim();
@@ -257,6 +327,40 @@ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
257
327
  return restoreAction(event, { myId, sentIds, composerEmpty: true });
258
328
  }
259
329
 
330
+ // The /turn this kept entry was for failed: no ack comes, so nothing would
331
+ // take it back. The whole map goes: this tab sends one turn at a time, so
332
+ // every entry waits for an ack that is now never coming (an older one is
333
+ // left over from a send that failed the same way).
334
+ export function dropEarlyRestores(early) {
335
+ const count = early.size;
336
+ early.clear();
337
+ return count;
338
+ }
339
+
340
+ // The composer's text with a restored prompt put back into it: the restored
341
+ // text alone when nothing is typed, else the typed text with the restored
342
+ // prompt under it (the user typed between the restore and the ack; their
343
+ // text must not be replaced).
344
+ export function restoreInto(refill, current) {
345
+ const restored = String(refill ?? "");
346
+ const typed = String(current ?? "");
347
+ if (!restored) return typed;
348
+ if (!typed.trim()) return restored;
349
+ return `${typed.replace(/\n+$/, "")}\n${restored}`;
350
+ }
351
+
352
+ // What the composer holds once a /turn ack lands (4.13): the composer still
353
+ // carries the text that was just sent, so that text goes (it is the history's
354
+ // now) — but text typed since the send stays, and a prompt restored before
355
+ // this ack (restoreOnAck's refill) is put back without replacing it.
356
+ // @param sent the composer's text when the turn was sent
357
+ // @param current the composer's text as the ack lands
358
+ // @param refill the prompt an early restore handed back, if any
359
+ export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
360
+ const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
361
+ return restoreInto(refill, kept);
362
+ }
363
+
260
364
  // What an event does to the prompt bubbles, as a list of ops for app.js:
261
365
  // add — a bubble for a prompt this tab hasn't shown (state queued /
262
366
  // started / steered, labelled by its sender)