samagotchi 0.19.0 → 0.21.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +130 -1
  3. data/docs/architecture.md +8 -1
  4. data/docs/cli.md +16 -4
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +27 -8
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  13. data/lib/samagotchi/bridge.rb +16 -4
  14. data/lib/samagotchi/bridge_client.rb +33 -9
  15. data/lib/samagotchi/bundle_command.rb +28 -4
  16. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  18. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  19. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  20. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  22. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/commands/registry.rb +4 -3
  27. data/lib/samagotchi/config.rb +3 -0
  28. data/lib/samagotchi/context_usage.rb +3 -3
  29. data/lib/samagotchi/engine.rb +31 -4
  30. data/lib/samagotchi/events.rb +1 -1
  31. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  32. data/lib/samagotchi/guardrails/approval.rb +4 -3
  33. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  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/idle_recap.rb +7 -2
  46. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  47. data/lib/samagotchi/llm/openai_chat.rb +19 -5
  48. data/lib/samagotchi/llm/usage.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +4 -2
  50. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  51. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  54. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  55. data/lib/samagotchi/memory_paths.rb +6 -0
  56. data/lib/samagotchi/self_report.rb +38 -2
  57. data/lib/samagotchi/session.rb +35 -6
  58. data/lib/samagotchi/session_commands.rb +13 -2
  59. data/lib/samagotchi/session_manager.rb +26 -9
  60. data/lib/samagotchi/session_metrics.rb +230 -23
  61. data/lib/samagotchi/steer.rb +1 -1
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  63. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  64. data/lib/samagotchi/terminal_ui/formatting.rb +59 -3
  65. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  66. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  67. data/lib/samagotchi/token_usage.rb +57 -41
  68. data/lib/samagotchi/tool_runner.rb +21 -2
  69. data/lib/samagotchi/tools/execute.rb +9 -8
  70. data/lib/samagotchi/version.rb +1 -1
  71. data/lib/samagotchi/web/app.rb +19 -0
  72. data/lib/samagotchi/web/public/app.js +68 -22
  73. data/lib/samagotchi/web/public/card.js +34 -2
  74. data/lib/samagotchi/web/public/ctx.js +59 -0
  75. data/lib/samagotchi/web/public/format.js +11 -0
  76. data/lib/samagotchi/web/public/index.html +33 -2
  77. data/lib/samagotchi/web/public/model_pick.js +6 -2
  78. data/lib/samagotchi/web/public/model_picker.js +2 -1
  79. data/lib/samagotchi/web/public/notifications.js +8 -3
  80. data/lib/samagotchi/web/public/notify.js +49 -7
  81. data/lib/samagotchi/web/public/question_card.js +2 -1
  82. data/lib/samagotchi/web/public/timing.js +11 -5
  83. data/lib/samagotchi/web/public/turn_events.js +17 -1
  84. data/lib/samagotchi/web/session_summary.rb +6 -2
  85. metadata +5 -1
@@ -218,3 +218,14 @@ export function recapLabel(turnsSince, stale = false) {
218
218
  if (n > 1) return `recap · before the last ${n} turns`;
219
219
  return stale ? "recap · earlier" : "recap";
220
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
+ }
@@ -303,6 +303,17 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
303
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}
304
304
  .bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
305
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}
306
317
  .card-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}
307
318
  .card-title{font-size:14px;font-weight:600;color:var(--text-bright)}
308
319
  .card-source{font-size:11px;color:var(--text-faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
@@ -557,7 +568,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
557
568
  #infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
558
569
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
559
570
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
560
- #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}
561
575
  #infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
562
576
  /* The open session's worker runs an older chi: its version, and Restart when it can. */
563
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}
@@ -588,10 +602,19 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
588
602
  #infoBar .meta>*{flex-shrink:0}
589
603
  #infoBar .meta .id{order:-2}
590
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}
591
607
  #infoBar .meta .dir{word-break:normal}
592
608
  #infoBar button{padding:4px 8px}
609
+ /* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
610
+ #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
611
+ #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
612
+ #infoBar #infoArchiveBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E")}
613
+ #infoBar #infoArchiveBtn.archived{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
614
+ #infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
615
+ #infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
593
616
  }
594
- #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}
617
+ #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}
595
618
  #empty.hidden{display:none !important}
596
619
  /* Start page (no session): the composer alone, centered; no info bar. */
597
620
  body:not(.has-session) #center{justify-content:safe center;background:transparent}
@@ -661,6 +684,13 @@ body.has-session #scope .scope-where{display:none}
661
684
  #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
662
685
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
663
686
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
687
+ /* 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. */
688
+ #stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
689
+ @media(max-width:900px){
690
+ body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
691
+ #stripHide{display:none}
692
+ #stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
693
+ }
664
694
  body.strip-hidden #stripShow{display:block}
665
695
  body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
666
696
  #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%}
@@ -740,6 +770,7 @@ a{color:var(--accent)}
740
770
  <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>
741
771
  <div id="scope" hidden></div>
742
772
  <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
773
+ <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
743
774
  <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>
744
775
  </div>
745
776
  <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
 
@@ -35,7 +35,7 @@ function defaultChannel() {
35
35
  // @param storage () => localStorage (it can throw)
36
36
  // @param channel the BroadcastChannel to the other chi tabs, or null
37
37
  // @param gateOptions passed to createNotifyGate (specs: schedule, now)
38
- // @return {noticeSessions(type, data), setBaseTitle(title), clearBadge()}
38
+ // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
39
39
  export function createNotifications({
40
40
  button,
41
41
  doc = globalThis.document,
@@ -125,11 +125,12 @@ export function createNotifications({
125
125
  notifyGate.seenInFront(next.attentions.map((a) => a.key));
126
126
  } else {
127
127
  for (const a of next.attentions) {
128
- notifyGate.offer(a, () => {
128
+ // Every tab behind badges; one of them notifies (notify.js).
129
+ notifyGate.offer(a, (notify) => {
129
130
  if (tabInFront()) return;
130
131
  badgeKeys.add(a.key);
131
132
  setTitle();
132
- showNotification(attention.byId[a.sessionId], a);
133
+ if (notify) showNotification(attention.byId[a.sessionId], a);
133
134
  });
134
135
  }
135
136
  }
@@ -159,6 +160,10 @@ export function createNotifications({
159
160
 
160
161
  return {
161
162
  noticeSessions,
163
+ // The list fetched without a hub (app.js refresh): a snapshot.
164
+ noticeList(sessions) {
165
+ noticeSessions("snapshot", { sessions });
166
+ },
162
167
  // The title without the badge ("Chi · <project>").
163
168
  setBaseTitle(title) {
164
169
  baseTitle = title;
@@ -119,7 +119,16 @@ export function trackAttention(state, type, data = {}) {
119
119
  byId[s.id] = s;
120
120
  };
121
121
  if (type === "snapshot") {
122
- (Array.isArray(data.sessions) ? data.sessions : []).forEach(see);
122
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
123
+ sessions.forEach(see);
124
+ // The whole list: a session missing from it went while no frame said so
125
+ // (the hub away, or between two fetches), as if session_gone came.
126
+ const present = new Set(sessions.map((s) => s?.id));
127
+ for (const id of Object.keys(byId)) {
128
+ if (present.has(id)) continue;
129
+ close(byId[id], null);
130
+ delete byId[id];
131
+ }
123
132
  return { state: { seeded: true, byId }, attentions, closed };
124
133
  }
125
134
  if (type === "session") see(data.session);
@@ -152,7 +161,10 @@ export function attentionText(session, attention) {
152
161
  // (BroadcastChannel "chi-notify") which attention keys it has in front, and
153
162
  // a tab behind neither notifies nor badges those: it holds a new attention
154
163
  // for +holdMs+ in case the front tab's word is on its way, and drops a key
155
- // from its badge when the word comes later. Without BroadcastChannel every
164
+ // from its badge when the word comes later. Every tab behind badges its
165
+ // title, but only one shows the OS notification: each tab behind claims
166
+ // the key on the channel as it holds it, and at the end of the hold the
167
+ // lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
156
168
  // tab goes on alone, at once.
157
169
  export const NOTIFY_CHANNEL = "chi-notify";
158
170
  export const NOTIFY_HOLD_MS = 300;
@@ -162,16 +174,31 @@ const SEEN_TTL_MS = 10 * 60 * 1000;
162
174
  // @param channel a BroadcastChannel (postMessage, onmessage), or null
163
175
  // @param onSeen(keys) another tab has these keys in front (drop them from
164
176
  // the badge)
177
+ // @param id this tab's claim (random by default)
165
178
  // @return {seenInFront(keys), offer(attention, deliver), drop(keys)}
166
179
  export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSeen = () => {},
167
- schedule = setTimeout, cancel = clearTimeout, now = Date.now } = {}) {
180
+ schedule = setTimeout, cancel = clearTimeout, now = Date.now, id = randomTabId() } = {}) {
168
181
  const seen = new Map(); // key → when
169
182
  const pending = new Map(); // key → timer
183
+ const claims = new Map(); // key → {ids: the other tabs' claims, at}
170
184
  const mark = (keys) => {
171
185
  const at = now();
172
186
  for (const [key, when] of seen) if (at - when > SEEN_TTL_MS) seen.delete(key);
173
187
  for (const key of keys) seen.set(key, at);
174
188
  };
189
+ const claimed = (key, tab) => {
190
+ const at = now();
191
+ for (const [k, claim] of claims) if (at - claim.at > SEEN_TTL_MS) claims.delete(k);
192
+ const claim = claims.get(key) || { ids: new Set(), at };
193
+ claim.ids.add(tab);
194
+ claims.set(key, claim);
195
+ };
196
+ // This tab's claim is the lowest: it shows the OS notification.
197
+ const won = (key) => {
198
+ const others = claims.get(key)?.ids || new Set();
199
+ claims.delete(key);
200
+ return [...others].every((other) => id < other);
201
+ };
175
202
  const drop = (keys) => {
176
203
  for (const key of keys) {
177
204
  if (!pending.has(key)) continue;
@@ -181,6 +208,10 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
181
208
  };
182
209
  if (channel) {
183
210
  channel.onmessage = (e) => {
211
+ if (e?.data?.type === "claim" && typeof e.data.key === "string" && typeof e.data.id === "string") {
212
+ claimed(e.data.key, e.data.id);
213
+ return;
214
+ }
184
215
  const keys = e?.data?.type === "seen" && Array.isArray(e.data.keys) ? e.data.keys.map(String) : [];
185
216
  if (keys.length === 0) return;
186
217
  mark(keys);
@@ -194,21 +225,25 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
194
225
  if (!channel || keys.length === 0) return;
195
226
  try { channel.postMessage({ type: "seen", keys }); } catch (_) {}
196
227
  },
197
- // A new attention in a tab behind: +deliver+ (notify, badge) unless
198
- // another tab has it in front. @return whether it may still be delivered
228
+ // A new attention in a tab behind: +deliver(notify)+ (badge; the OS
229
+ // notification when +notify+) unless another tab has it in front.
230
+ // @return whether it may still be delivered
199
231
  // An attention's own holdMs (a delegated child's approval) holds it
200
232
  // longer, alone too.
201
233
  offer(attention, deliver) {
202
234
  const key = attention.key;
203
235
  if (seen.has(key)) return false;
204
236
  if (!channel && !attention.holdMs) {
205
- deliver();
237
+ deliver(true);
206
238
  return true;
207
239
  }
208
240
  drop([key]);
241
+ if (channel) {
242
+ try { channel.postMessage({ type: "claim", key, id }); } catch (_) {}
243
+ }
209
244
  pending.set(key, schedule(() => {
210
245
  pending.delete(key);
211
- if (!seen.has(key)) deliver();
246
+ if (!seen.has(key)) deliver(won(key));
212
247
  }, Math.max(holdMs, attention.holdMs || 0)));
213
248
  return true;
214
249
  },
@@ -216,3 +251,10 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
216
251
  drop,
217
252
  };
218
253
  }
254
+
255
+ function randomTabId() {
256
+ try {
257
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
258
+ } catch (_) {}
259
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
260
+ }
@@ -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)";
@@ -26,8 +26,10 @@ 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
28
28
  // turn_canceled's reason and after a re-render from the turn record's
29
- // cancellation_reason (records saved before it existed: no reason).
30
- export function cancelLineText(reason) {
29
+ // cancellation_reason (records saved before it existed: no reason). A
30
+ // hook's stop that names who (+by+, cancelled_by): "✕ stopped by loop-guard".
31
+ export function cancelLineText(reason, by) {
32
+ if (reason === "hook" && by) return `\u2715 stopped by ${by}`;
31
33
  return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
32
34
  }
33
35
 
@@ -35,7 +37,7 @@ export function cancelLineText(reason) {
35
37
  // record is not a canceled one.
36
38
  export function cancelLineHtml(record, escape) {
37
39
  if (record?.status !== "canceled") return "";
38
- return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason))}</div>`;
40
+ return `<div class="bubble cancel">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
39
41
  }
40
42
 
41
43
  export function elapsedSince(startedAt, now = Date.now()) {
@@ -52,6 +54,8 @@ export function normalizeTiming(data = {}) {
52
54
  turnRecords: Array.isArray(data.turn_records) ? data.turn_records.filter(validRecord) : [],
53
55
  toolRecords: Array.isArray(data.tool_records) ? data.tool_records.filter(validRecord) : [],
54
56
  activeTurn: validRecord(data.active_turn) ? data.active_turn : null,
57
+ // The session's token sums and speeds (SessionMetrics' tokens block).
58
+ tokens: validRecord(data.tokens) ? data.tokens : null,
55
59
  };
56
60
  }
57
61
 
@@ -61,7 +65,8 @@ export function normalizeTiming(data = {}) {
61
65
  // place; an unknown turn goes in started_at order (ties and unparsable
62
66
  // dates keep arrival order), an unknown tool record at the end (they come
63
67
  // grouped by turn, in call order). startedAt, sessionDurationMs and
64
- // activeTurn are the reply's. `short`: the reply's turn_count says the page
68
+ // activeTurn are the reply's, and so are its tokens (a reply without them
69
+ // keeps the page's). `short`: the reply's turn_count says the page
65
70
  // misses a turn (it then reads the whole timing). A reply without
66
71
  // turn_count (an older server: the whole timing) replaces, as before; more
67
72
  // records than the count (a worker died before saving a turn the page saw)
@@ -85,7 +90,8 @@ export function mergeTiming(timing, data = {}) {
85
90
  fresh.toolRecords.forEach((record) => {
86
91
  if (!replaceById(toolRecords, record)) toolRecords.push(record);
87
92
  });
88
- return { timing: { ...fresh, turnRecords, toolRecords }, short: turnRecords.length < count };
93
+ const tokens = fresh.tokens || timing?.tokens || null;
94
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens }, short: turnRecords.length < count };
89
95
  }
90
96
 
91
97
  function replaceById(records, record) {
@@ -234,7 +234,7 @@ const WEB_LOCAL_REPLIES = {
234
234
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
235
235
  "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
236
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",
237
- "/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",
238
238
  };
239
239
 
240
240
  export function webLocalReply(text) {
@@ -401,6 +401,16 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
401
401
  }
402
402
  }
403
403
 
404
+ // A stream opened with no cursor (a worker woken after a render that had
405
+ // none) replays the worker's events from its start: a turn_started for a
406
+ // turn the page drew already (+turnRecords+, the render's timing) means
407
+ // that replay overlaps the render, and the page re-reads the session
408
+ // rather than drawing the turn twice (4.01).
409
+ export function replaysDrawnTurn(event, turnRecords = []) {
410
+ if (event?.type !== "turn_started" || !event.turn_id) return false;
411
+ return turnRecords.some((record) => record.id === event.turn_id);
412
+ }
413
+
404
414
  // A session view from the server (GET /api/sessions/:id on a live worker)
405
415
  // as the live events that would have drawn it: the turn in progress (its
406
416
  // parts in order), then the prompts queued behind it. app.js feeds these to
@@ -420,6 +430,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
420
430
  };
421
431
  for (const part of turn.parts || []) {
422
432
  switch (part.kind) {
433
+ // A generation the model is on: a join during a hold (no chunk yet)
434
+ // shows its live step.
435
+ case "generation":
436
+ closeText();
437
+ events.push({ type: "generation_started", iteration: part.iteration ?? null });
438
+ break;
423
439
  // The chunks carry the part's iteration, so the turn view groups a
424
440
  // join like the live stream (turn_model.js).
425
441
  case "thinking":
@@ -31,6 +31,7 @@ module Samagotchi
31
31
  used = Array(session.used_memory_names)
32
32
  up = bridge_up?(session_dir, owner)
33
33
  sidecar = up ? WorkerSidecar.read(session_dir) : nil
34
+ saved = SessionMetrics.saved_summary(session_dir)
34
35
  {
35
36
  id: session.id,
36
37
  status: status,
@@ -52,8 +53,11 @@ module Samagotchi
52
53
  owner: owner&.kind,
53
54
  # The saved recap's first sentence, for the session card.
54
55
  recap: RecapStore.preview(session_dir),
55
- # How full the context was after the last turn (%), or nil.
56
- ctx_pct: SessionMetrics.saved_context_pct(session_dir)&.round(1),
56
+ # How full the context was after the last turn (%), or nil, and
57
+ # the session's token sums and cost (the card's ctx tooltip), from
58
+ # one read of analytics.json.
59
+ ctx_pct: saved&.ctx_pct&.round(1),
60
+ tokens: saved&.tokens,
57
61
  # Hidden from the strip and the list unless "include archived".
58
62
  archived: ArchiveStore.archived?(session_dir),
59
63
  project_root: session.project_root(cache: root_cache),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -235,9 +235,13 @@ files:
235
235
  - lib/samagotchi/guardrails/parent_approvals.rb
236
236
  - lib/samagotchi/guardrails/parent_continue.rb
237
237
  - lib/samagotchi/guardrails/protected_paths.rb
238
+ - lib/samagotchi/guardrails/read_only_shell.rb
239
+ - lib/samagotchi/guardrails/rm_targets.rb
238
240
  - lib/samagotchi/guardrails/rules.rb
239
241
  - lib/samagotchi/guardrails/scratch_writes.rb
240
242
  - lib/samagotchi/guardrails/shell_git_dirs.rb
243
+ - lib/samagotchi/guardrails/shell_lex.rb
244
+ - lib/samagotchi/guardrails/shell_paths.rb
241
245
  - lib/samagotchi/guardrails/targets.rb
242
246
  - lib/samagotchi/guardrails/verdict.rb
243
247
  - lib/samagotchi/hooks.rb