samagotchi 0.31.0 → 0.32.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +13 -0
  4. data/bin/chi +3 -0
  5. data/docs/cli.md +1 -0
  6. data/docs/configuration.md +3 -1
  7. data/docs/context.md +218 -0
  8. data/docs/guardrails.md +12 -4
  9. data/docs/hooks.md +1 -0
  10. data/docs/plugins.md +77 -1
  11. data/docs/releasing.md +1 -1
  12. data/lib/samagotchi/bootstrap_command.rb +4 -1
  13. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  16. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  17. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  18. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/system/self_map.md +6 -0
  23. data/lib/samagotchi/child_reports.rb +0 -2
  24. data/lib/samagotchi/config.rb +6 -0
  25. data/lib/samagotchi/context_absorber.rb +179 -0
  26. data/lib/samagotchi/context_command.rb +473 -0
  27. data/lib/samagotchi/context_fetch.rb +226 -0
  28. data/lib/samagotchi/context_note.rb +11 -5
  29. data/lib/samagotchi/context_poller.rb +100 -0
  30. data/lib/samagotchi/context_providers.rb +159 -0
  31. data/lib/samagotchi/context_sources.rb +452 -0
  32. data/lib/samagotchi/engine.rb +10 -5
  33. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  37. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  38. data/lib/samagotchi/log_line.rb +1 -1
  39. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  40. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  41. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  42. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  43. data/lib/samagotchi/plugin/context.rb +22 -0
  44. data/lib/samagotchi/question_desk.rb +0 -3
  45. data/lib/samagotchi/session.rb +7 -5
  46. data/lib/samagotchi/session_commands.rb +18 -1
  47. data/lib/samagotchi/session_manager.rb +5 -2
  48. data/lib/samagotchi/session_retention.rb +3 -0
  49. data/lib/samagotchi/steer.rb +12 -3
  50. data/lib/samagotchi/system_prompt.rb +1 -1
  51. data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -4
  52. data/lib/samagotchi/terminal_ui/formatting.rb +14 -3
  53. data/lib/samagotchi/terminal_ui.rb +0 -3
  54. data/lib/samagotchi/tool_activity.rb +8 -0
  55. data/lib/samagotchi/tool_declarations.rb +21 -0
  56. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  57. data/lib/samagotchi/tools/builtins.rb +5 -0
  58. data/lib/samagotchi/tools/context_read.rb +163 -0
  59. data/lib/samagotchi/version.rb +1 -1
  60. data/lib/samagotchi/web/app.rb +109 -1
  61. data/lib/samagotchi/web/public/app.js +40 -10
  62. data/lib/samagotchi/web/public/composer_size.js +7 -6
  63. data/lib/samagotchi/web/public/context_chips.js +285 -0
  64. data/lib/samagotchi/web/public/data.js +21 -0
  65. data/lib/samagotchi/web/public/index.html +49 -3
  66. data/lib/samagotchi/web/public/notify.js +5 -3
  67. data/lib/samagotchi/web/public/timing.js +7 -3
  68. data/lib/samagotchi/web/public/turn_events.js +8 -2
  69. data/lib/samagotchi/web/public/turn_view.js +4 -1
  70. data/lib/samagotchi/worker.rb +126 -0
  71. metadata +14 -1
@@ -0,0 +1,285 @@
1
+ // The session bar's attached context (chi context): a chip per source the
2
+ // open session sees (name · age; a dot for a change the agent hasn't read,
3
+ // red for a failing refresh), and a popover with its summary, hint link,
4
+ // text and detach. The chips refresh when a context note arrives in the
5
+ // stream and when the page gets focus (no event of their own), and their
6
+ // ages tick every minute. A "+ URL" chip (when an installed bundle's
7
+ // provider can attach one: GET …/context's can_add_url) opens a form that
8
+ // attaches a URL (a GitHub PR); the web never adds a command.
9
+
10
+ // "now", "3m", "2h", "4d" since an ISO time; "" for none.
11
+ export function ageText(iso, now = Date.now()) {
12
+ const at = Date.parse(iso || "");
13
+ if (Number.isNaN(at)) return "";
14
+ const s = Math.max(0, Math.round((now - at) / 1000));
15
+ if (s < 60) return "now";
16
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
17
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
18
+ return `${Math.floor(s / 86400)}d`;
19
+ }
20
+
21
+ // A hint the popover may link to: http(s) only.
22
+ export function hintUrl(hint) {
23
+ const text = String(hint || "").trim();
24
+ return /^https?:\/\/\S+$/i.test(text) ? text : null;
25
+ }
26
+
27
+ // GET …/context's rows as chips: the muted ones left out. state: "error"
28
+ // (the last refresh failed), "unread" (a text the agent hasn't read),
29
+ // "empty" (no text yet) or "ok".
30
+ export function chipModel(rows, now = Date.now()) {
31
+ return (rows || []).filter((row) => row && !row.muted).map((row) => {
32
+ const state = row.error ? "error" : !row.has_text ? "empty" : row.unread ? "unread" : "ok";
33
+ const age = ageText(row.fetched_at, now);
34
+ const why = { error: `last refresh failed: ${row.error}`, unread: "changed; the agent hasn't read it yet",
35
+ empty: "no text yet", ok: "the agent has read the latest" }[state];
36
+ const title = [row.summary, why].filter(Boolean).join(" · ");
37
+ return { name: row.name, label: age ? `${row.name} · ${age}` : row.name, state, title };
38
+ });
39
+ }
40
+
41
+ // addUrl: end with the "+ URL" chip.
42
+ export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
43
+ return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
44
+ `title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
45
+ `${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
46
+ (addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
47
+ }
48
+
49
+ // The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
50
+ // after a try that failed.
51
+ export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
52
+ return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
53
+ `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
54
+ `<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
55
+ `<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
56
+ `<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
57
+ (error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
58
+ `<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
59
+ }
60
+
61
+ // The popover's inside for one row (GET …/context's).
62
+ export function popoverHtml(row, escapeHtml, now = Date.now()) {
63
+ const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
64
+ const url = hintUrl(row.hint);
65
+ const hint = url
66
+ ? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
67
+ : (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
68
+ const age = ageText(row.fetched_at, now);
69
+ const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
70
+ row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
71
+ .filter(Boolean).join(" · ");
72
+ const detach = row.scope === "project" ? "mute here" : "detach";
73
+ const detachTitle = row.scope === "project"
74
+ ? "The project's source: this session stops seeing it (chi context unmute brings it back)"
75
+ : "Remove it from this session";
76
+ return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
77
+ `<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
78
+ `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
79
+ (row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
80
+ hint +
81
+ (row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
82
+ `<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
83
+ (row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
84
+ `<pre class="ctx-pop-text" hidden></pre>` +
85
+ `<div class="ctx-pop-actions">` +
86
+ (row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
87
+ `<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
88
+ }
89
+
90
+ // The chips for the session bar. el: #contextChips; api: {list(id),
91
+ // show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
92
+ // toast(text).
93
+ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
94
+ let sessionId = null;
95
+ let rows = [];
96
+ let canAdd = false;
97
+ let openName = null;
98
+ let loadSeq = 0;
99
+ const pop = doc.createElement("div");
100
+ pop.id = "contextPopover";
101
+ pop.className = "ctx-popover";
102
+ pop.setAttribute("role", "dialog");
103
+ pop.hidden = true;
104
+ doc.body.appendChild(pop);
105
+
106
+ // openName ADD: the "+ URL" form is open.
107
+ const ADD = "\u0000add";
108
+
109
+ function render() {
110
+ const chips = chipModel(rows);
111
+ el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
112
+ el.hidden = chips.length === 0 && !canAdd;
113
+ // The "+ URL" chip alone sits inline, not on a row of its own.
114
+ el.classList.toggle("only-add", chips.length === 0 && canAdd);
115
+ if (openName === ADD) {
116
+ if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
117
+ else close();
118
+ } else if (openName && !chips.some((chip) => chip.name === openName)) close();
119
+ else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
120
+ }
121
+
122
+ async function refresh() {
123
+ const id = sessionId;
124
+ if (!id) return;
125
+ const seq = ++loadSeq;
126
+ try {
127
+ const data = await api.list(id);
128
+ if (seq !== loadSeq || id !== sessionId) return;
129
+ rows = data.sources || [];
130
+ canAdd = data.can_add_url === true;
131
+ } catch {
132
+ // A session the server doesn't know (gone, or an older chi web): no chips.
133
+ if (seq !== loadSeq || id !== sessionId) return;
134
+ rows = [];
135
+ canAdd = false;
136
+ }
137
+ render();
138
+ if (openName && openName !== ADD) {
139
+ const row = rows.find((r) => r.name === openName);
140
+ if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
141
+ }
142
+ }
143
+
144
+ function load(id) {
145
+ sessionId = id;
146
+ rows = [];
147
+ canAdd = false;
148
+ close();
149
+ render();
150
+ return refresh();
151
+ }
152
+
153
+ function clear() {
154
+ sessionId = null;
155
+ rows = [];
156
+ canAdd = false;
157
+ close();
158
+ render();
159
+ }
160
+
161
+ function fill(row) {
162
+ pop.innerHTML = popoverHtml(row, escapeHtml);
163
+ }
164
+
165
+ // Above the chip, inside the window with a 16px gutter.
166
+ function place(chip) {
167
+ const rect = chip.getBoundingClientRect();
168
+ const width = Math.min(380, win.innerWidth - 32);
169
+ pop.style.width = `${width}px`;
170
+ pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
171
+ pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
172
+ }
173
+
174
+ function open(name, chip) {
175
+ const row = rows.find((r) => r.name === name);
176
+ if (!row) return;
177
+ openName = name;
178
+ fill(row);
179
+ pop.hidden = false;
180
+ place(chip);
181
+ el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
182
+ }
183
+
184
+ function openAdd(chip, state = {}) {
185
+ openName = ADD;
186
+ pop.innerHTML = addFormHtml(escapeHtml, state);
187
+ pop.hidden = false;
188
+ place(chip);
189
+ el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
190
+ pop.querySelector('input[name="url"]')?.focus();
191
+ }
192
+
193
+ async function submitAdd(form) {
194
+ const id = sessionId;
195
+ const url = form.elements.url.value.trim();
196
+ const why = form.elements.why.value.trim();
197
+ if (!id || !url) return;
198
+ const button = form.querySelector(".ctx-add-submit");
199
+ if (button) button.disabled = true;
200
+ try {
201
+ const data = await api.add(id, { url, why: why || undefined });
202
+ if (id !== sessionId) return;
203
+ close();
204
+ await refresh();
205
+ toast(`Attached ${data.name}: the agent gets a note before your next message`);
206
+ } catch (e) {
207
+ if (id !== sessionId || openName !== ADD) return;
208
+ const chip = el.querySelector(".ctx-add");
209
+ if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
210
+ }
211
+ }
212
+
213
+ function close() {
214
+ openName = null;
215
+ pop.hidden = true;
216
+ el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
217
+ }
218
+
219
+ async function viewText() {
220
+ const name = openName;
221
+ const pre = pop.querySelector(".ctx-pop-text");
222
+ if (!name || !pre) return;
223
+ try {
224
+ const data = await api.show(sessionId, name);
225
+ if (name !== openName) return;
226
+ pre.textContent = data.text || "";
227
+ pre.hidden = false;
228
+ pop.querySelector('[data-act="view"]')?.remove();
229
+ } catch (e) {
230
+ toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
231
+ }
232
+ }
233
+
234
+ async function detach() {
235
+ const row = rows.find((r) => r.name === openName);
236
+ if (!row) return;
237
+ const ask = row.scope === "project"
238
+ ? `Mute ${row.name} for this session? It is the project's: other sessions keep it.`
239
+ : `Detach ${row.name} from this session?`;
240
+ if (!win.confirm(ask)) return;
241
+ try {
242
+ await api.detach(sessionId, row.name);
243
+ close();
244
+ await refresh();
245
+ } catch (e) {
246
+ toast(`Can't detach ${row.name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
247
+ }
248
+ }
249
+
250
+ el.addEventListener("click", (e) => {
251
+ const chip = e.target.closest(".ctx-chip");
252
+ if (!chip) return;
253
+ e.stopPropagation();
254
+ if (chip.dataset.act === "add") {
255
+ if (openName === ADD) close();
256
+ else openAdd(chip);
257
+ } else if (openName === chip.dataset.name) close();
258
+ else open(chip.dataset.name, chip);
259
+ });
260
+ pop.addEventListener("submit", (e) => {
261
+ e.preventDefault();
262
+ submitAdd(e.target);
263
+ });
264
+ pop.addEventListener("click", (e) => {
265
+ const act = e.target.closest("[data-act]")?.dataset.act;
266
+ if (act === "close") close();
267
+ else if (act === "view") viewText();
268
+ else if (act === "detach") detach();
269
+ });
270
+ doc.addEventListener("click", (e) => {
271
+ if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
272
+ });
273
+ doc.addEventListener("keydown", (e) => {
274
+ if (e.key === "Escape" && openName) close();
275
+ });
276
+ win.addEventListener("resize", () => {
277
+ const chip = openName && el.querySelector(".ctx-chip.open");
278
+ if (chip) place(chip);
279
+ });
280
+ win.addEventListener("focus", () => refresh());
281
+ // The ages move on by themselves.
282
+ win.setInterval(() => { if (rows.length) render(); }, 60_000);
283
+
284
+ return { load, clear, refresh, close, get rows() { return rows; } };
285
+ }
@@ -251,3 +251,24 @@ export function openEvents(handlers = {}, opts = {}) {
251
251
  },
252
252
  };
253
253
  }
254
+
255
+ // A session's attached context (chi context): the sources it sees, one
256
+ // with its text, and detach (a session's) or mute here (a project's).
257
+ export function listContext(id, opts = {}) {
258
+ return api(`/api/sessions/${encodeURIComponent(id)}/context`, opts);
259
+ }
260
+
261
+ export function getContext(id, name, opts = {}) {
262
+ return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, opts);
263
+ }
264
+
265
+ // A URL an installed bundle's provider attaches: {url, why} → {name}.
266
+ export function addContext(id, body, opts = {}) {
267
+ return api(`/api/sessions/${encodeURIComponent(id)}/context`, {
268
+ method: "POST", body: JSON.stringify(body), ...opts,
269
+ });
270
+ }
271
+
272
+ export function detachContext(id, name, opts = {}) {
273
+ return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, { method: "DELETE", ...opts });
274
+ }
@@ -472,6 +472,12 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
472
472
  #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
473
473
  #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
474
474
  #composer #actionBtn{min-width:64px;font-weight:600}
475
+ /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
476
+ and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
477
+ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
478
+ body.has-session #composer textarea{min-height:0}
479
+ body.has-session #composeHint{display:none}
480
+ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
475
481
  /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
476
482
  /* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
477
483
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
@@ -651,7 +657,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
651
657
  #infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
652
658
  #infoBar .meta .copy-attach:hover{color:var(--text-hover);border-color:var(--border-strong)}
653
659
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
654
- #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
660
+ /* The whole path on one line: a long one loses its start ("…/project/dir", the end is what tells dirs apart); the title holds all of it. */
661
+ #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);max-width:48ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
655
662
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
656
663
  #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)}
657
664
  /* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
@@ -663,7 +670,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
663
670
  #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)}
664
671
  #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
665
672
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
666
- #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}
673
+ #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;overflow:hidden;line-height:1.4;max-height:1.4em}
667
674
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
668
675
  #infoBar .mem{font-size:12px;color:var(--text-muted)}
669
676
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
@@ -673,6 +680,38 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
673
680
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
674
681
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
675
682
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
683
+ /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
684
+ #contextChips{order:5;flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
685
+ #contextChips[hidden]{display:none}
686
+ #contextChips.only-add{flex-basis:auto}
687
+ #infoBar .ctx-chip.ctx-add{border-style:dashed;color:var(--text-muted);background:transparent}
688
+ .ctx-add-form{display:flex;flex-direction:column;gap:6px;margin:0}
689
+ .ctx-add-form .ctx-pop-head strong{font-family:inherit}
690
+ .ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
691
+ .ctx-add-input:focus{outline:none;border-color:var(--accent-border)}
692
+ #infoBar .ctx-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font:600 11px/1.5 ui-monospace,monospace;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3);border:1px solid var(--border-strong);white-space:nowrap}
693
+ #infoBar .ctx-chip:hover,#infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
694
+ #infoBar .ctx-chip.empty{color:var(--text-faint);border-style:dashed}
695
+ #infoBar .ctx-chip.error{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent);background:var(--danger-bg-dim)}
696
+ .ctx-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
697
+ .ctx-popover{position:fixed;z-index:60;max-height:min(60vh,520px);overflow:auto;padding:12px 14px;border-radius:12px;background:var(--bg-panel);color:var(--text-2);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:6px}
698
+ .ctx-popover[hidden]{display:none}
699
+ .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
700
+ .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
701
+ .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
702
+ .ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
703
+ .ctx-pop-close:hover{color:var(--text)}
704
+ .ctx-pop-why{color:var(--text)}
705
+ .ctx-pop-hint{font-size:12px;color:var(--accent);word-break:break-all}
706
+ .ctx-pop-summary{padding:6px 8px;border-radius:8px;background:var(--bg-inset);border-left:3px solid var(--accent-border)}
707
+ .ctx-pop-meta{font-size:11px;color:var(--text-muted)}
708
+ .ctx-pop-error{font-size:12px;color:var(--danger-text)}
709
+ .ctx-pop-text{margin:0;max-height:240px;overflow:auto;padding:8px;border-radius:8px;background:var(--bg-inset);font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;color:var(--text-2)}
710
+ .ctx-pop-actions{display:flex;gap:8px;justify-content:flex-end}
711
+ .ctx-pop-actions button{padding:3px 10px;border-radius:7px;font-size:12px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
712
+ .ctx-pop-actions button:hover{color:var(--text-hover)}
713
+ .ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
714
+ .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
676
715
  /* 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. */
677
716
  @media(max-width:600px){
678
717
  /* The key hint: cut off at this width, and phones have no Shift+Enter. */
@@ -688,7 +727,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
688
727
  #infoBar .meta .status{order:-1}
689
728
  /* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
690
729
  #infoBar .meta .mem-chip{display:inline;order:-1}
691
- #infoBar .meta .dir{word-break:normal}
730
+ /* The chips get a second line of their own (only when there are some), scrolling sideways. */
731
+ #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
732
+ #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
733
+ /* The meta scrolls here: a lone "+ URL" would take its width, so it gets the row too. */
734
+ #contextChips.only-add{flex-basis:100%}
735
+ #contextChips::-webkit-scrollbar{display:none}
736
+ #contextChips>*{flex-shrink:0}
692
737
  #infoBar button{padding:4px 8px}
693
738
  /* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
694
739
  #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
@@ -892,6 +937,7 @@ a{color:var(--accent)}
892
937
  </div>
893
938
  <div id="infoBar">
894
939
  <div class="info" id="infoText"></div>
940
+ <div id="contextChips" aria-label="Attached context" hidden></div>
895
941
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
896
942
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
897
943
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
@@ -13,9 +13,11 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
13
13
  }
14
14
 
15
15
  // A finished turn shorter than this was watched: no "done". Not a turn
16
- // chi ran for a delegate child's report (last_turn origin delegate_report):
17
- // nobody started it, so nobody watched it.
16
+ // chi ran for a delegate child's report (last_turn origin delegate_report)
17
+ // or an attached context change (context): nobody started it, so nobody
18
+ // watched it.
18
19
  export const DONE_MIN_SECONDS = 10;
20
+ const UNWATCHED_ORIGINS = ["delegate_report", "context"];
19
21
 
20
22
  // The step-limit question (kind "continue": a turn ran out of iterations
21
23
  // and asks whether to go on) has its own reason: "out of steps".
@@ -68,7 +70,7 @@ export function attentionFor(prev, next) {
68
70
  if (next.parent_id) return null;
69
71
  const key = `${next.id}:${turn.ended_at}`;
70
72
  if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
71
- if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || turn.origin === "delegate_report")) {
73
+ if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
72
74
  return { reason: "done", sessionId: next.id, key };
73
75
  }
74
76
  return null;
@@ -112,6 +112,10 @@ export function turnRecordAt(timing, index) {
112
112
  return Array.isArray(records) ? records[index] || null : null;
113
113
  }
114
114
 
115
+ // A history item that starts a turn: a prompt, or the note a context wake
116
+ // turn starts with (turn_start).
117
+ const startsTurn = (item) => item?.role === "user" || (item?.role === "note" && item.turn_start === true);
118
+
115
119
  // For each history item, the turn index its timing shows under, or null. A
116
120
  // turn with tool calls holds several assistant messages; only the last one
117
121
  // before the next user message carries the turn's timing. A turn with none
@@ -122,7 +126,7 @@ export function timedTurnIndexes(items) {
122
126
  let turnIndex = -1;
123
127
  let carrier = null;
124
128
  items.forEach((item, i) => {
125
- if (item?.role === "user") {
129
+ if (startsTurn(item)) {
126
130
  if (carrier !== null) result[carrier] = turnIndex;
127
131
  turnIndex += 1;
128
132
  carrier = i;
@@ -150,14 +154,14 @@ export function turnGroups(items, timing) {
150
154
  let turnIndex = -1;
151
155
  let turn = null;
152
156
  items.forEach((item, i) => {
153
- if (item?.role === "note") {
157
+ if (item?.role === "note" && !startsTurn(item)) {
154
158
  groups.push({ kind: "note", i });
155
159
  } else if (item?.role === "steer") {
156
160
  if (turn) turn.steers.push(i);
157
161
  else groups.push({ kind: "steer", i });
158
162
  } else if (item?.role === "user" && item.merged && turn) {
159
163
  turn.merged.push(i);
160
- } else if (item?.role === "user") {
164
+ } else if (startsTurn(item)) {
161
165
  turnIndex += 1;
162
166
  turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
163
167
  groups.push(turn);
@@ -101,10 +101,16 @@ export function isReportClient(clientId) {
101
101
  return String(clientId || "").startsWith(REPORT_CLIENT);
102
102
  }
103
103
 
104
- // The label on another client's prompt, by its client_id prefix.
104
+ // A turn chi ran because an attached context source changed: context:<name>.
105
+ const CONTEXT_CLIENT = "context:";
106
+
107
+ // The label on another client's prompt, by its client_id prefix; a context
108
+ // change's turn as "context <name> changed".
105
109
  export function clientLabel(clientId) {
106
110
  if (!clientId) return null;
107
- return CLIENT_LABELS[String(clientId).split(":", 1)[0]] || "user";
111
+ const id = String(clientId);
112
+ if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
113
+ return CLIENT_LABELS[id.split(":", 1)[0]] || "user";
108
114
  }
109
115
 
110
116
  // The note for reminders a turn got (a reminder turn has no prompt).
@@ -826,7 +826,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
826
826
  if (group.kind === "steer") return steerHtml(group.i, true);
827
827
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
828
828
  const prompt = items[group.user];
829
- const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
829
+ // A context wake turn starts with its note.
830
+ const parts = [prompt.role === "note"
831
+ ? `<div class="bubble note">${noteHtml(prompt)}</div>`
832
+ : `<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
830
833
  const [only] = group.steps;
831
834
  const ended = group.answer !== null || group.emptyAnswer != null;
832
835
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));