samagotchi 0.2.0 → 0.3.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/README.md +40 -4
  4. data/bin/chi +86 -19
  5. data/docs/cli.md +108 -5
  6. data/docs/configuration.md +229 -45
  7. data/docs/desktop.md +6 -0
  8. data/docs/hooks.md +126 -5
  9. data/docs/plugins.md +68 -2
  10. data/docs/releasing.md +18 -8
  11. data/docs/sessions.md +30 -4
  12. data/lib/samagotchi/answer_display.rb +95 -0
  13. data/lib/samagotchi/archive_store.rb +90 -0
  14. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  16. data/lib/samagotchi/bootstrap_command.rb +347 -0
  17. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  18. data/lib/samagotchi/bridge/turn_accumulator.rb +14 -3
  19. data/lib/samagotchi/bridge.rb +9 -0
  20. data/lib/samagotchi/bridge_client.rb +6 -2
  21. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  22. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +358 -0
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  25. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  26. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  29. data/lib/samagotchi/client.rb +72 -13
  30. data/lib/samagotchi/config.rb +196 -36
  31. data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
  32. data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
  33. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  34. data/lib/samagotchi/engine.rb +233 -36
  35. data/lib/samagotchi/guardrails/approval.rb +9 -0
  36. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  37. data/lib/samagotchi/guardrails.rb +1 -0
  38. data/lib/samagotchi/hooks/registry.rb +24 -5
  39. data/lib/samagotchi/host_registry.rb +4 -3
  40. data/lib/samagotchi/idle_recap.rb +5 -1
  41. data/lib/samagotchi/kernel_loop.rb +47 -15
  42. data/lib/samagotchi/llm/chat_loop.rb +59 -20
  43. data/lib/samagotchi/llm/errors.rb +21 -3
  44. data/lib/samagotchi/llm/http.rb +42 -13
  45. data/lib/samagotchi/llm/openai_chat.rb +12 -4
  46. data/lib/samagotchi/log_subscriber.rb +18 -3
  47. data/lib/samagotchi/model_profile.rb +1 -1
  48. data/lib/samagotchi/plugin/context.rb +22 -1
  49. data/lib/samagotchi/plugin/sessions.rb +3 -1
  50. data/lib/samagotchi/reply_wait.rb +126 -0
  51. data/lib/samagotchi/sampling_settings.rb +58 -0
  52. data/lib/samagotchi/self_report.rb +1 -0
  53. data/lib/samagotchi/send_command.rb +153 -7
  54. data/lib/samagotchi/session.rb +52 -11
  55. data/lib/samagotchi/session_archive_command.rb +107 -0
  56. data/lib/samagotchi/session_commands.rb +11 -2
  57. data/lib/samagotchi/session_manager.rb +114 -9
  58. data/lib/samagotchi/session_metrics.rb +222 -106
  59. data/lib/samagotchi/steer.rb +72 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
  62. data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
  63. data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
  64. data/lib/samagotchi/terminal_ui.rb +77 -4
  65. data/lib/samagotchi/tool_activity.rb +3 -1
  66. data/lib/samagotchi/tools/builtins.rb +15 -4
  67. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  68. data/lib/samagotchi/tools/execute.rb +52 -14
  69. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  70. data/lib/samagotchi/tools/task_wait.rb +27 -3
  71. data/lib/samagotchi/turn_note.rb +60 -6
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +2 -6
  74. data/lib/samagotchi/web/app.rb +88 -4
  75. data/lib/samagotchi/web/public/activity.js +10 -1
  76. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  77. data/lib/samagotchi/web/public/annotations.js +13 -0
  78. data/lib/samagotchi/web/public/app.js +437 -88
  79. data/lib/samagotchi/web/public/card.js +5 -3
  80. data/lib/samagotchi/web/public/chat_view.js +10 -1
  81. data/lib/samagotchi/web/public/copy.js +20 -4
  82. data/lib/samagotchi/web/public/ctx.js +15 -0
  83. data/lib/samagotchi/web/public/data.js +21 -6
  84. data/lib/samagotchi/web/public/format.js +9 -0
  85. data/lib/samagotchi/web/public/index.html +38 -2
  86. data/lib/samagotchi/web/public/notify.js +175 -0
  87. data/lib/samagotchi/web/public/question_card.js +2 -1
  88. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  89. data/lib/samagotchi/web/public/timing.js +39 -14
  90. data/lib/samagotchi/web/public/turn_events.js +46 -0
  91. data/lib/samagotchi/web/public/turn_view.js +47 -7
  92. data/lib/samagotchi/web/server.rb +8 -4
  93. data/lib/samagotchi/web/session_hub.rb +2 -1
  94. data/lib/samagotchi/web/session_summary.rb +24 -1
  95. data/lib/samagotchi/worker.rb +11 -0
  96. metadata +20 -1
@@ -66,9 +66,11 @@ export function splitSnapshotCards(cards) {
66
66
  // goes in its reloaded turn's block: the step of its iteration (the
67
67
  // iteration-th .gen, 0-based index here) and the key of the tool row it
68
68
  // came before (it came after +calls+ of that step's calls started), or null
69
- // without an iteration (the notice then goes before the turn's answer).
69
+ // for a notice not of a turn. One from before the first generation (a
70
+ // before_turn hook's) has no iteration: the first step, as live.
70
71
  export function turnNoticePlace(notice) {
71
- const iteration = Number(notice?.iteration);
72
- if (!notice?.in_turn || !Number.isInteger(iteration) || iteration < 1) return null;
72
+ if (!notice?.in_turn) return null;
73
+ const iteration = notice.iteration == null ? 1 : Number(notice.iteration);
74
+ if (!Number.isInteger(iteration) || iteration < 1) return null;
73
75
  return { step: iteration - 1, rowKey: `${iteration}:${(Number(notice.calls) || 0) + 1}` };
74
76
  }
@@ -10,21 +10,25 @@
10
10
  // sessionId() (image thumbs of a tool row).
11
11
  import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
12
12
  import { copySourceAttr } from "./copy.js";
13
- import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize } from "./format.js";
13
+ import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
14
14
  import { thumbsHtml } from "./images.js";
15
15
  import { shouldFollowScroll } from "./scroll.js";
16
16
  import { tallyText } from "./tally.js";
17
17
  import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
18
18
 
19
+ // A row's status (live, or a saved tool record's on reload): running,
20
+ // error, stopped (a task_wait the user's Stop ended), else ok ("done").
19
21
  export function activityStatusClass(status) {
20
22
  if (status === "running") return "running";
21
23
  if (status === "error") return "error";
24
+ if (status === "stopped") return "stopped";
22
25
  return "ok";
23
26
  }
24
27
 
25
28
  export function activityStatusLabel(status) {
26
29
  if (status === "running") return "running";
27
30
  if (status === "error") return "error";
31
+ if (status === "stopped") return "stopped";
28
32
  return "done";
29
33
  }
30
34
 
@@ -281,6 +285,10 @@ export function createChatView({ historyEl, appendToHistory, isNearBottom, sawTe
281
285
  notice() {
282
286
  return false;
283
287
  },
288
+ // So is a plugin's steer.
289
+ steer() {
290
+ return false;
291
+ },
284
292
  // So is a card.
285
293
  card() {
286
294
  return false;
@@ -312,6 +320,7 @@ export function chatHistoryHtml(items, timing, { thumbs }) {
312
320
  return items
313
321
  .map((m, i) => {
314
322
  if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
323
+ if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
315
324
  const turnIndex = timedTurns[i];
316
325
  const cls = m.role === "user" ? "user" : "output";
317
326
  const { body, renderedMarkdown } = messageBodyHtml(m);
@@ -1,8 +1,8 @@
1
1
  // Copy buttons: one on every prompt and answer bubble, one on every code
2
2
  // block of a rendered answer. An answer copies its markdown source (the
3
- // saved message's `content`, kept on the bubble as data-copy-source when the
4
- // page shows rendered html), a prompt the text as typed, a code block just
5
- // its code.
3
+ // saved message's `display` when a hook presented one, else its `content`,
4
+ // kept on the bubble as data-copy-source when the page shows rendered
5
+ // html), a prompt the text as typed, a code block just its code.
6
6
  //
7
7
  // The buttons carry no text (an icon; "copied ✓" is CSS content), so a
8
8
  // bubble's textContent, the turn output dedupe and an annotation's selected
@@ -57,12 +57,28 @@ export function codeText(pre) {
57
57
  return visibleText(code, ".copy-btn").replace(/\n$/, "");
58
58
  }
59
59
 
60
+ // What an answer copies: what a hook presented in its place (`display`,
61
+ // AnswerDisplay), else its markdown source.
62
+ export function copySource(message) {
63
+ return String(message?.display ?? message?.content ?? "");
64
+ }
65
+
66
+ // Whether +bubble+ shows the answer +message+ (the page's re-read at the
67
+ // end of a turn): its streamed text is the answer's, or it was rendered
68
+ // from it already (its kept source is the answer or its display: an
69
+ // answer_display re-read after the turn_completed one).
70
+ export function showsAnswer(bubble, message, normalize) {
71
+ const source = bubble.getAttribute?.(COPY_SOURCE_ATTR);
72
+ if (source !== null && source !== undefined && (source === message.content || source === message.display)) return true;
73
+ return normalize(bubble.textContent) === normalize(message.content);
74
+ }
75
+
60
76
  // The attribute that keeps a message's source on its reloaded bubble:
61
77
  // rendered html and a prompt (its `>` lines render as quotes) need it; a
62
78
  // plain answer's text is its source.
63
79
  export function copySourceAttr(message, renderedMarkdown) {
64
80
  if (!renderedMarkdown && message?.role !== "user") return "";
65
- const text = String(message?.content ?? "")
81
+ const text = copySource(message)
66
82
  .replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
67
83
  return ` ${COPY_SOURCE_ATTR}="${text}"`;
68
84
  }
@@ -33,3 +33,18 @@ export function extractCtxPct(data, windowTokens = null) {
33
33
  if (!window || window <= 0 || used === null) return null;
34
34
  return (used / window) * 100;
35
35
  }
36
+
37
+ // The saved context (the session's timing.context: used_tokens after the
38
+ // last counted turn, the window now): the meter's value on load, before a
39
+ // turn streams. null when either count is missing.
40
+ export function savedCtxPct(context) {
41
+ const used = num(context?.used_tokens);
42
+ const window = num(context?.window_tokens);
43
+ if (used === null || !window || window <= 0) return null;
44
+ return (used / window) * 100;
45
+ }
46
+
47
+ // A session card's quiet ctx: "12%", or "" when the list row has none.
48
+ export function cardCtxText(pct) {
49
+ return num(pct) === null ? "" : `${Math.round(pct)}%`;
50
+ }
@@ -4,6 +4,7 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
4
4
  "generation_completed",
5
5
  "turn_started",
6
6
  "turn_completed",
7
+ "answer_display",
7
8
  "turn_canceled",
8
9
  "turn_failed",
9
10
  "turn_enqueued",
@@ -27,6 +28,7 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
27
28
  "context_added",
28
29
  "guardrail_warning",
29
30
  "hook_notice",
31
+ "empty_answer_retry",
30
32
  "card",
31
33
  "plugin_init_started",
32
34
  "plugin_init_finished",
@@ -65,10 +67,11 @@ export function createSession(prompt, { dir, model, ...opts } = {}) {
65
67
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
66
68
  }
67
69
 
68
- // A session with no first turn: a first message with images is sent into
69
- // it once they are uploaded.
70
- export function createIdleSession({ dir, model, ...opts } = {}) {
71
- const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
70
+ // A session with no first turn: the page's first message is sent into it
71
+ // as a turn (with its images, once they are uploaded). preview: that
72
+ // message, which names the session until its turn is saved.
73
+ export function createIdleSession({ dir, model, preview, ...opts } = {}) {
74
+ const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
72
75
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
73
76
  }
74
77
 
@@ -111,10 +114,11 @@ export function sendTurn(id, prompt, { clientId, images, ...opts } = {}) {
111
114
 
112
115
  // A session command (/model, /models, !rollback, !cmd, /continue …) for the
113
116
  // worker to run; its command_ran reaches every client.
114
- export function sendCommand(id, line, { clientId, ...opts } = {}) {
117
+ // card: the line is a card's action (its echo stays out of the history).
118
+ export function sendCommand(id, line, { clientId, card = false, ...opts } = {}) {
115
119
  return api(`/api/sessions/${id}/command`, {
116
120
  method: "POST",
117
- body: JSON.stringify(clientId ? { line, client_id: clientId } : { line }),
121
+ body: JSON.stringify({ line, ...(clientId ? { client_id: clientId } : {}), ...(card ? { card: true } : {}) }),
118
122
  ...opts,
119
123
  });
120
124
  }
@@ -148,6 +152,17 @@ export function stopSession(id, opts = {}) {
148
152
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
149
153
  }
150
154
 
155
+ // Hides the session (and its delegates) from the lists, kept for good; the
156
+ // server stops a live idle worker first and answers 409 while a turn runs.
157
+ export function archiveSession(id, opts = {}) {
158
+ return api(`/api/sessions/${encodeURIComponent(id)}/archive`, { method: "POST", body: JSON.stringify({}), ...opts });
159
+ }
160
+
161
+ // Back to the lists, delegates too.
162
+ export function unarchiveSession(id, opts = {}) {
163
+ return api(`/api/sessions/${encodeURIComponent(id)}/unarchive`, { method: "POST", body: JSON.stringify({}), ...opts });
164
+ }
165
+
151
166
  // Deletes the session for good; the server stops a live worker first.
152
167
  export function deleteSession(id, opts = {}) {
153
168
  return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
@@ -23,6 +23,15 @@ export function noteHtml(note) {
23
23
  return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
24
24
  }
25
25
 
26
+ // A plugin's steer (a nudge put into the running turn): one line that
27
+ // names who nudged the model, its text collapsed under it. +bubble+: on its
28
+ // own between bubbles (the chat view), not a row of a step.
29
+ export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
30
+ const who = source ? `${source} nudged the model` : "nudged the model";
31
+ return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
32
+ `<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
33
+ }
34
+
26
35
  // A user message's body: runs of `>` lines become <blockquote>s, the rest
27
36
  // stays escaped text. The bubble is pre-wrap, so the blank lines around a
28
37
  // quote are dropped (the blockquote's margin spaces it instead).
@@ -115,6 +115,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
115
115
  .activity-status.running{color:var(--warn)}
116
116
  .activity-status.ok{color:var(--ok)}
117
117
  .activity-status.error{color:var(--danger)}
118
+ .activity-status.stopped{color:var(--warn-text);opacity:.8}
118
119
  .activity-tool{color:var(--accent);font-weight:600}
119
120
  .activity-params{color:var(--text-muted);word-break:break-all}
120
121
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -258,6 +259,14 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
258
259
  .gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
259
260
  .gen .activity-body>.hook-notice:first-child{border-top:none}
260
261
  .gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
262
+ /* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
263
+ .steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
264
+ .steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
265
+ .steer-row>summary::-webkit-details-marker{display:none}
266
+ .steer-row>summary::before{content:"↪ ";font-style:normal;color:var(--accent)}
267
+ .steer-text{white-space:pre-wrap;word-break:break-word;color:var(--text-muted);margin-top:4px}
268
+ .gen>.steer-row{padding:4px 2px 4px 8px;margin:2px 0 6px}
269
+ .bubble.steer-row{background:transparent;padding:6px 12px;border-left-width:3px}
261
270
  /* A plugin's card (card.js): the question card's glass with a cool tint; its actions are commands the session runs. */
262
271
  .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}
263
272
  .bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
@@ -278,8 +287,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
278
287
  /* In a step: a row, lighter (no float), like the step's notices. */
279
288
  .gen .bubble.plugin-card{margin:6px 0 4px;box-shadow:inset 0 1px 0 var(--glass-highlight);padding:8px 10px}
280
289
  .hint{color:var(--text-muted);font-size:13px;padding:12px}
281
- #annotateBtn{position:fixed;z-index:20;font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
282
- #annotateBtn[hidden]{display:none}
290
+ /* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
291
+ #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
292
+ #annotateBar[hidden]{display:none}
293
+ #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
294
+ #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
283
295
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
284
296
  .bubble.output,.bubble.user{position:relative}
285
297
  .copy-btn{position:absolute;top:6px;right:6px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);font-size:11px;line-height:1;white-space:nowrap;user-select:none;-webkit-user-select:none;opacity:0;transition:opacity .12s ease,color .12s ease}
@@ -376,6 +388,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
376
388
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
377
389
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
378
390
  #infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
391
+ #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
392
+ #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
379
393
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
380
394
  #infoBar #infoDeleteBtn:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
381
395
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
@@ -437,6 +451,16 @@ body.has-session #scope .scope-where{display:none}
437
451
  }
438
452
  /* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
439
453
  as the pill text's capitals (phone and desktop alike). */
454
+ /* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
455
+ #notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
456
+ #notifyBtn[hidden]{display:none}
457
+ #notifyBtn:hover{color:var(--text-2);border-color:var(--border)}
458
+ #notifyBtn svg{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round}
459
+ #notifyBtn .slash{display:none}
460
+ #notifyBtn[data-state="on"]{color:var(--accent)}
461
+ #notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
462
+ #notifyBtn[data-state="denied"]{color:var(--text-faint)}
463
+ #notifyBtn[data-state="denied"] .slash{display:inline}
440
464
  #stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
441
465
  .card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
442
466
  .card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
@@ -465,6 +489,13 @@ body.all-view #center, body.all-view #topStrip{display:none}
465
489
  #allPath[hidden]{display:none}
466
490
  #allPath code{font-family:ui-monospace,monospace;background:var(--bg-hover);padding:1px 4px;border-radius:4px}
467
491
  #allHead .count{font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
492
+ #allHead .include-archived{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);white-space:nowrap;cursor:pointer;user-select:none}
493
+ .card.archived{opacity:.6}
494
+ .card.archived:hover{opacity:.85}
495
+ .card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
496
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
497
+ #toast[hidden]{display:none}
498
+ #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
468
499
  #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
469
500
  .card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
470
501
  .card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
@@ -484,6 +515,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
484
515
  .card.active{background:var(--accent-bg);border-color:var(--accent-border)}
485
516
  .card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
486
517
  .card .time{font-variant-numeric:tabular-nums;white-space:nowrap}
518
+ .card .ctx{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-3)}
487
519
  #topStrip .card .preview{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
488
520
  #allList .card .preview{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;word-break:break-word;line-height:1.4}
489
521
  .card .recap{font-size:12px;line-height:1.4;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:-2px 0 2px}
@@ -517,6 +549,7 @@ a{color:var(--accent)}
517
549
  <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>
518
550
  <div id="scope" hidden></div>
519
551
  <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
552
+ <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>
520
553
  </div>
521
554
  <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>
522
555
  <section id="center">
@@ -548,6 +581,7 @@ a{color:var(--accent)}
548
581
  </div>
549
582
  <div id="infoBar">
550
583
  <div class="info" id="infoText"></div>
584
+ <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
551
585
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
552
586
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
553
587
  </div>
@@ -557,12 +591,14 @@ a{color:var(--accent)}
557
591
  <div id="allHead">
558
592
  <button class="ghost" id="allBack" title="Back to the chat (Esc)">← Back</button>
559
593
  <input id="filter" type="search" placeholder="Search… (preview / id / status)" autocomplete="off">
594
+ <label class="include-archived" title="Show archived sessions too"><input id="includeArchived" type="checkbox"> include archived</label>
560
595
  <span class="count" id="count"></span>
561
596
  </div>
562
597
  <div id="allPath" hidden>Stored in <code></code></div>
563
598
  <div id="allList"></div>
564
599
  </section>
565
600
  </main>
601
+ <div id="toast" role="status" aria-live="polite" hidden></div>
566
602
  <script type="module" src="/assets/app.js"></script>
567
603
  </body>
568
604
  </html>
@@ -0,0 +1,175 @@
1
+ // When a session needs the user: a pure reducer over the hub's session
2
+ // summaries (GET /api/events). The page shows an OS notification and a
3
+ // title badge for what it returns, while the tab is not in front.
4
+
5
+ // A finished turn shorter than this was watched: no "done".
6
+ export const DONE_MIN_SECONDS = 10;
7
+
8
+ // What changed between two summaries of one session that needs the user.
9
+ // @param prev the summary held before (null for a session not seen yet)
10
+ // @param next the new summary
11
+ // @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
12
+ // key names this one event, the same in every tab (the Notification tag)
13
+ export function attentionFor(prev, next) {
14
+ if (!next || !next.id) return null;
15
+ const q = next.pending_question;
16
+ if (q && q.id && q.id !== prev?.pending_question?.id) {
17
+ return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
18
+ }
19
+ // A running turn's card with actions (check-in's Nudge / Keep going /
20
+ // Stop) asks the user as a question does.
21
+ const card = next.pending_card;
22
+ if (card && card.id && card.id !== prev?.pending_card?.id) {
23
+ return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
24
+ }
25
+ const turn = next.last_turn;
26
+ if (!turn || !turn.ended_at || turn.ended_at === prev?.last_turn?.ended_at) return null;
27
+ // A delegated child's turn is reported by its parent's.
28
+ if (next.parent_id) return null;
29
+ const key = `${next.id}:${turn.ended_at}`;
30
+ if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
31
+ if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
32
+ return { reason: "done", sessionId: next.id, key };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // The tracker's state: the last summary of each session, and whether the
38
+ // first snapshot has been seen (it seeds, so old state never notifies).
39
+ export function initialAttentionState() {
40
+ return { seeded: false, byId: {} };
41
+ }
42
+
43
+ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
44
+
45
+ // The keys of what the session no longer has open: its question (answered
46
+ // here or in another client), its card with actions (resolved, or the turn
47
+ // ended), or both when the session is gone.
48
+ // @param prev the summary held before (null for a session not seen yet)
49
+ // @param next the new summary (null when the session is gone)
50
+ // @return [String] keys, maybe none
51
+ export function closedKeys(prev, next) {
52
+ const keys = [];
53
+ const q = prev?.pending_question;
54
+ if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
55
+ const card = prev?.pending_card;
56
+ if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
57
+ return keys;
58
+ }
59
+
60
+ // @param state initialAttentionState() or a previous result's state
61
+ // @param type "snapshot" | "session" | "session_gone"
62
+ // @param data the frame's data
63
+ // @return {state, attentions, closed}; state is a new object, attentions a
64
+ // list, closed the keys of questions and cards no longer open (the badge
65
+ // drops them)
66
+ export function trackAttention(state, type, data = {}) {
67
+ const byId = { ...state.byId };
68
+ const attentions = [];
69
+ const closed = [];
70
+ const close = (prev, next) => closed.push(...closedKeys(prev, next));
71
+ const see = (s) => {
72
+ if (!s || !s.id) return;
73
+ const prev = byId[s.id] || null;
74
+ if (state.seeded) {
75
+ const a = attentionFor(prev, s);
76
+ if (a) attentions.push(a);
77
+ }
78
+ close(prev, s);
79
+ byId[s.id] = s;
80
+ };
81
+ if (type === "snapshot") {
82
+ (Array.isArray(data.sessions) ? data.sessions : []).forEach(see);
83
+ return { state: { seeded: true, byId }, attentions, closed };
84
+ }
85
+ if (type === "session") see(data.session);
86
+ else if (type === "session_gone") {
87
+ close(byId[data.id] || null, null);
88
+ delete byId[data.id];
89
+ }
90
+ return { state: { seeded: state.seeded, byId }, attentions, closed };
91
+ }
92
+
93
+ // The notification's words: a short title (the session) and the reason.
94
+ // No question or answer text (it would show on a lock screen).
95
+ export function attentionText(session, attention) {
96
+ const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
97
+ const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
98
+ const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
99
+ const body = {
100
+ question: "needs an answer",
101
+ approval: "needs approval",
102
+ card: "needs you",
103
+ failed: "turn failed",
104
+ done: `done in ${seconds} s`
105
+ }[attention.reason] || "";
106
+ return { title, body };
107
+ }
108
+
109
+ // ── Several tabs ──────────────────────────────────────────────────────────
110
+ // Every chi tab gets the same hub frames. A tab in front tells the others
111
+ // (BroadcastChannel "chi-notify") which attention keys it has in front, and
112
+ // a tab behind neither notifies nor badges those: it holds a new attention
113
+ // for +holdMs+ in case the front tab's word is on its way, and drops a key
114
+ // from its badge when the word comes later. Without BroadcastChannel every
115
+ // tab goes on alone, at once.
116
+ export const NOTIFY_CHANNEL = "chi-notify";
117
+ export const NOTIFY_HOLD_MS = 300;
118
+ // How long a key seen in front is remembered (its frame may come late).
119
+ const SEEN_TTL_MS = 10 * 60 * 1000;
120
+
121
+ // @param channel a BroadcastChannel (postMessage, onmessage), or null
122
+ // @param onSeen(keys) another tab has these keys in front (drop them from
123
+ // the badge)
124
+ // @return {seenInFront(keys), offer(attention, deliver), drop(keys)}
125
+ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSeen = () => {},
126
+ schedule = setTimeout, cancel = clearTimeout, now = Date.now } = {}) {
127
+ const seen = new Map(); // key → when
128
+ const pending = new Map(); // key → timer
129
+ const mark = (keys) => {
130
+ const at = now();
131
+ for (const [key, when] of seen) if (at - when > SEEN_TTL_MS) seen.delete(key);
132
+ for (const key of keys) seen.set(key, at);
133
+ };
134
+ const drop = (keys) => {
135
+ for (const key of keys) {
136
+ if (!pending.has(key)) continue;
137
+ cancel(pending.get(key));
138
+ pending.delete(key);
139
+ }
140
+ };
141
+ if (channel) {
142
+ channel.onmessage = (e) => {
143
+ const keys = e?.data?.type === "seen" && Array.isArray(e.data.keys) ? e.data.keys.map(String) : [];
144
+ if (keys.length === 0) return;
145
+ mark(keys);
146
+ drop(keys);
147
+ onSeen(keys);
148
+ };
149
+ }
150
+ return {
151
+ // This tab is in front with +keys+.
152
+ seenInFront(keys) {
153
+ if (!channel || keys.length === 0) return;
154
+ try { channel.postMessage({ type: "seen", keys }); } catch (_) {}
155
+ },
156
+ // A new attention in a tab behind: +deliver+ (notify, badge) unless
157
+ // another tab has it in front. @return whether it may still be delivered
158
+ offer(attention, deliver) {
159
+ const key = attention.key;
160
+ if (seen.has(key)) return false;
161
+ if (!channel) {
162
+ deliver();
163
+ return true;
164
+ }
165
+ drop([key]);
166
+ pending.set(key, schedule(() => {
167
+ pending.delete(key);
168
+ if (!seen.has(key)) deliver();
169
+ }, holdMs));
170
+ return true;
171
+ },
172
+ // Closed before its hold was up (a question answered at once).
173
+ drop,
174
+ };
175
+ }
@@ -26,7 +26,8 @@ export function approvalView(pq) {
26
26
  return {
27
27
  // A plugin tool's label ("chrome: screenshot"), as its row shows it.
28
28
  tool: a.label || a.tool || "",
29
- what: command || paths.join("\n"),
29
+ // A tool without a command or path (an MCP tool): its compact args.
30
+ what: command || paths.join("\n") || a.args || "",
30
31
  isCommand: !!command,
31
32
  where,
32
33
  why: `${reason} (${who})`,
@@ -28,6 +28,13 @@ export function applySessionEvent(list, type, data = {}) {
28
28
  return list;
29
29
  }
30
30
 
31
+ // The sessions a list shows: archived ones only when asked (the all view's
32
+ // "include archived"). The page keeps them in its list either way: a parent
33
+ // chip and the open session still need them.
34
+ export function listedSessions(list, includeArchived = false) {
35
+ return includeArchived ? list : list.filter((s) => !s.archived);
36
+ }
37
+
31
38
  const updatedMs = (s) => {
32
39
  const ms = Date.parse(s.updated_at || "");
33
40
  return Number.isNaN(ms) ? 0 : ms;
@@ -92,9 +92,12 @@ export function turnGroups(items, timing) {
92
92
  items.forEach((item, i) => {
93
93
  if (item?.role === "note") {
94
94
  groups.push({ kind: "note", i });
95
+ } else if (item?.role === "steer") {
96
+ if (turn) turn.steers.push(i);
97
+ else groups.push({ kind: "steer", i });
95
98
  } else if (item?.role === "user") {
96
99
  turnIndex += 1;
97
- turn = { kind: "turn", turnIndex, user: i, texts: [] };
100
+ turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
98
101
  groups.push(turn);
99
102
  } else if (item?.role === "assistant") {
100
103
  if (turn) turn.texts.push(i);
@@ -103,22 +106,44 @@ export function turnGroups(items, timing) {
103
106
  });
104
107
  return groups.map((g) => {
105
108
  if (g.kind !== "turn") return g;
106
- const record = turnRecordAt(timing, g.turnIndex);
107
- const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
108
- const stepsOnly = canceledSteps(g, record, tools);
109
- if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
110
- const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
111
- const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
112
- const steps = [];
113
- for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
114
- const iteration = k + 1;
115
- steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
116
- }
117
- const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
118
- return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
109
+ return withSteers(turnGroup(g, items, timing), g.steers, items);
119
110
  });
120
111
  }
121
112
 
113
+ // A turn's steers (a plugin's nudges) into its steps: each goes to the step
114
+ // that answered it (`step`: the server counts the model messages since the
115
+ // prompt, plus one). One whose step isn't there (the answer, which leaves
116
+ // the block) stays on the group (`steers`): a bubble before the answer, as
117
+ // live. A turn without steers keeps its shape.
118
+ function withSteers(group, steerIndexes, items) {
119
+ if (!steerIndexes.length) return group;
120
+ const steps = group.steps.map((step) => ({ ...step, steers: [] }));
121
+ const left = [];
122
+ for (const i of steerIndexes) {
123
+ const n = Number(items[i].step) || 1;
124
+ const step = steps.find((s) => s.iteration === n);
125
+ if (step) step.steers.push(i);
126
+ else left.push(i);
127
+ }
128
+ return { ...group, steps, steers: left };
129
+ }
130
+
131
+ function turnGroup(g, items, timing) {
132
+ const record = turnRecordAt(timing, g.turnIndex);
133
+ const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
134
+ const stepsOnly = canceledSteps(g, record, tools);
135
+ if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
136
+ const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
137
+ const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
138
+ const steps = [];
139
+ for (let k = 0; k < Math.max(stepTexts.length, maxIteration); k += 1) {
140
+ const iteration = k + 1;
141
+ steps.push({ i: stepTexts[k] ?? null, iteration, tools: recordRows(tools, iteration) });
142
+ }
143
+ const answer = g.texts.length && !stepsOnly ? g.texts[g.texts.length - 1] : null;
144
+ return { kind: "turn", turnIndex: g.turnIndex, user: g.user, answer, record, steps };
145
+ }
146
+
122
147
  // A canceled turn that ran more than one step or any call has no answer:
123
148
  // its partial text stays in the block, as it does live (turn_view.js
124
149
  // turnEnded promotes only a completed turn or a lone step with no tools).