samagotchi 0.30.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -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 +7 -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/internals/engine.md +1 -1
  11. data/docs/plugins.md +77 -1
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +9 -2
  14. data/lib/samagotchi/bootstrap_command.rb +4 -1
  15. data/lib/samagotchi/bridge/card_store.rb +1 -1
  16. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  17. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  18. data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
  19. data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
  20. data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +8 -1
  28. data/lib/samagotchi/child_reports.rb +212 -0
  29. data/lib/samagotchi/client.rb +35 -4
  30. data/lib/samagotchi/config.rb +11 -0
  31. data/lib/samagotchi/context_absorber.rb +179 -0
  32. data/lib/samagotchi/context_command.rb +473 -0
  33. data/lib/samagotchi/context_fetch.rb +226 -0
  34. data/lib/samagotchi/context_note.rb +11 -5
  35. data/lib/samagotchi/context_poller.rb +100 -0
  36. data/lib/samagotchi/context_providers.rb +159 -0
  37. data/lib/samagotchi/context_sources.rb +452 -0
  38. data/lib/samagotchi/engine.rb +12 -5
  39. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  40. data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
  41. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  42. data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
  43. data/lib/samagotchi/guardrails/verdict.rb +21 -3
  44. data/lib/samagotchi/idle_recap.rb +4 -0
  45. data/lib/samagotchi/kernel_loop.rb +44 -5
  46. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  47. data/lib/samagotchi/llm/errors.rb +9 -0
  48. data/lib/samagotchi/log_line.rb +1 -1
  49. data/lib/samagotchi/log_subscriber.rb +1 -1
  50. data/lib/samagotchi/memory_bundle/installer.rb +43 -1
  51. data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
  52. data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
  53. data/lib/samagotchi/plugin/attached_context.rb +100 -0
  54. data/lib/samagotchi/plugin/context.rb +22 -0
  55. data/lib/samagotchi/prompt_warmup.rb +30 -5
  56. data/lib/samagotchi/question_desk.rb +0 -3
  57. data/lib/samagotchi/reply_wait.rb +39 -8
  58. data/lib/samagotchi/session.rb +30 -7
  59. data/lib/samagotchi/session_commands.rb +18 -1
  60. data/lib/samagotchi/session_inbox.rb +34 -1
  61. data/lib/samagotchi/session_manager.rb +37 -5
  62. data/lib/samagotchi/session_retention.rb +3 -0
  63. data/lib/samagotchi/steer.rb +48 -7
  64. data/lib/samagotchi/system_prompt.rb +1 -1
  65. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
  66. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  67. data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
  68. data/lib/samagotchi/terminal_ui.rb +9 -3
  69. data/lib/samagotchi/tool_activity.rb +8 -0
  70. data/lib/samagotchi/tool_call_parser.rb +40 -0
  71. data/lib/samagotchi/tool_declarations.rb +28 -7
  72. data/lib/samagotchi/tools/builtin_calls.rb +1 -2
  73. data/lib/samagotchi/tools/builtins.rb +5 -0
  74. data/lib/samagotchi/tools/context_read.rb +163 -0
  75. data/lib/samagotchi/tools/delegate.rb +13 -4
  76. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  77. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  78. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  79. data/lib/samagotchi/tools/task_wait.rb +2 -2
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +117 -3
  82. data/lib/samagotchi/web/public/app.js +52 -14
  83. data/lib/samagotchi/web/public/composer_size.js +7 -6
  84. data/lib/samagotchi/web/public/context_chips.js +285 -0
  85. data/lib/samagotchi/web/public/data.js +21 -0
  86. data/lib/samagotchi/web/public/format.js +8 -1
  87. data/lib/samagotchi/web/public/index.html +49 -3
  88. data/lib/samagotchi/web/public/notify.js +6 -2
  89. data/lib/samagotchi/web/public/timing.js +7 -3
  90. data/lib/samagotchi/web/public/turn_events.js +34 -10
  91. data/lib/samagotchi/web/public/turn_view.js +26 -4
  92. data/lib/samagotchi/worker.rb +343 -8
  93. metadata +16 -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
+ }
@@ -26,12 +26,19 @@ export function noteHtml(note) {
26
26
  // Who a steer names as its sender: a plugin's own label, chi's sender ids
27
27
  // in words (the TUI's Formatting#steer_sender agrees;
28
28
  // spec/shared/labels_matrix.json). "": no one named.
29
- const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
29
+ const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin", delegate_report: "delegate report" };
30
30
  export function steerSender(source) {
31
31
  const id = String(source ?? "");
32
32
  return STEER_SENDERS[id] || id;
33
33
  }
34
34
 
35
+ // The label a user-role line gets on reload for who sent it, by its saved
36
+ // source: a delegate report (the live bubble's clientLabel of child:<id>);
37
+ // null for the user's own (and anything else, as before).
38
+ export function sourceLabel(source) {
39
+ return source === "delegate_report" ? steerSender(source) : null;
40
+ }
41
+
35
42
  // A plugin's steer (a nudge put into the running turn): one line that
36
43
  // names who nudged the model, its text collapsed under it. +bubble+: on its
37
44
  // own between bubbles (no step holds it), not a row of a step.
@@ -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>
@@ -12,8 +12,12 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
12
12
  return wanted && permission === "granted" ? "on" : "off";
13
13
  }
14
14
 
15
- // A finished turn shorter than this was watched: no "done".
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
+ // or an attached context change (context): nobody started it, so nobody
18
+ // watched it.
16
19
  export const DONE_MIN_SECONDS = 10;
20
+ const UNWATCHED_ORIGINS = ["delegate_report", "context"];
17
21
 
18
22
  // The step-limit question (kind "continue": a turn ran out of iterations
19
23
  // and asks whether to go on) has its own reason: "out of steps".
@@ -66,7 +70,7 @@ export function attentionFor(prev, next) {
66
70
  if (next.parent_id) return null;
67
71
  const key = `${next.id}:${turn.ended_at}`;
68
72
  if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
69
- if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
73
+ if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
70
74
  return { reason: "done", sessionId: next.id, key };
71
75
  }
72
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);
@@ -93,12 +93,24 @@ export function isOwn(clientId, myId) {
93
93
 
94
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
95
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
96
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
96
+ const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
97
97
 
98
- // The label on another client's prompt, by its client_id prefix.
98
+ // A delegate child's report comes in as client child:<id8> (ChildReports).
99
+ const REPORT_CLIENT = "child:";
100
+ export function isReportClient(clientId) {
101
+ return String(clientId || "").startsWith(REPORT_CLIENT);
102
+ }
103
+
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".
99
109
  export function clientLabel(clientId) {
100
110
  if (!clientId) return null;
101
- 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";
102
114
  }
103
115
 
104
116
  // The note for reminders a turn got (a reminder turn has no prompt).
@@ -119,9 +131,10 @@ export function hookNoticeLabel(hook) {
119
131
  }
120
132
 
121
133
  // The row a step gets when the loop asks again after an empty answer, or
122
- // after a plugin cut the generation (stopped_by: the bundle).
123
- export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
124
- const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
134
+ // after a plugin cut the generation (stopped_by: the bundle), or after a
135
+ // corrupt native generation (malformed).
136
+ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy, malformed } = {}) {
137
+ const what = stoppedBy ? `cut by ${stoppedBy}` : malformed ? "malformed answer" : "empty answer";
125
138
  return `↻ ${what}, asking again (${attempt}/${of})`;
126
139
  }
127
140
 
@@ -381,9 +394,14 @@ export function composerAfterAck({ sent = "", current = "", refill = "" } = {})
381
394
  // tag — this tab's own local echo gets its enqueued_id
382
395
  // start — the bubble's turn started (drops the "queued" badge)
383
396
  // steer — the bubble was merged into the running turn
397
+ // report — the merge brought a delegate child's report (origin
398
+ // child:<id8>, no bubble anywhere): its text gets a labelled one
384
399
  // `known(enqueuedId)` says whether a bubble carries that id already;
385
- // `unmatchedMerge` whether the last input_merged had an origin with no bubble.
386
- export function promptOps(event, { myId, known, unmatchedMerge = false }) {
400
+ // `unmatchedMerge` whether the last input_merged had an origin with no
401
+ // bubble ("report": a delegate report's); `wakeStart` whether this is the
402
+ // first merge of a turn chi ran for reports (its bubble starts the turn,
403
+ // no "steered" badge, as a reload shows it).
404
+ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStart = false }) {
387
405
  if (!event) return [];
388
406
  switch (event.type) {
389
407
  case "turn_enqueued": {
@@ -403,12 +421,18 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
403
421
  }
404
422
  return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
405
423
  }
406
- case "input_merged":
407
- return (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
424
+ case "input_merged": {
425
+ const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
408
426
  known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
409
427
  );
428
+ if ((event.origins || []).some((o) => o && !o.enqueued_id && isReportClient(o.client_id))) ops.push({ op: "report" });
429
+ return ops;
430
+ }
410
431
  case "pending_input_merged":
411
432
  if (!unmatchedMerge || !event.content) return [];
433
+ if (unmatchedMerge === "report") {
434
+ return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
435
+ }
412
436
  return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
413
437
  default:
414
438
  return [];
@@ -14,7 +14,7 @@ import { leavesBlock } from "./card.js";
14
14
  import { copySourceAttr } from "./copy.js";
15
15
  import { commandBlockHtml, rowHover } from "./command_view.js";
16
16
  import { activityDiffHtml } from "./diff_view.js";
17
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
18
18
  import { thumbsHtml } from "./images.js";
19
19
  import { shouldFollowScroll } from "./scroll.js";
20
20
  import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
@@ -805,13 +805,31 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
805
805
  const { body, renderedMarkdown } = messageBodyHtml(m);
806
806
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
807
807
  };
808
+ // The live bubble's origin label (app.js makeUserBubble), for a line
809
+ // chi brought in: a delegate report.
810
+ const originLabelHtml = (m) => {
811
+ const label = sourceLabel(m?.source);
812
+ return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
813
+ };
814
+ // The first text after a delegate report merged mid-turn, unless it is
815
+ // the turn's answer (shown anyway).
816
+ const reportReplyHtml = (i, group) => {
817
+ if (items[i]?.source !== "delegate_report") return "";
818
+ const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
819
+ if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
820
+ const { body, renderedMarkdown } = messageBodyHtml(items[j]);
821
+ return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
822
+ };
808
823
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
809
824
  return turnGroups(items, timing).map((group) => {
810
825
  if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
811
826
  if (group.kind === "steer") return steerHtml(group.i, true);
812
827
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
813
828
  const prompt = items[group.user];
814
- const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><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>`];
815
833
  const [only] = group.steps;
816
834
  const ended = group.answer !== null || group.emptyAnswer != null;
817
835
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -843,9 +861,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
843
861
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
844
862
  parts.push(loose.map((i) => steerHtml(i, true)).join(""));
845
863
  // The user's lines merged into the turn: the live "steered" bubbles,
846
- // after its steps and before its answer.
864
+ // after its steps and before its answer. A delegate report's is
865
+ // followed by the text that answered it when that isn't the answer: a
866
+ // small model tells its user about a report before its next tool call,
867
+ // and that step's text would stay folded in the block.
847
868
  parts.push((group.merged || []).map((i) =>
848
- `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
869
+ `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}>${originLabelHtml(items[i])}<div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>` +
870
+ reportReplyHtml(i, group)).join(""));
849
871
  // A turn that ended with no answer: chi's muted notice where the answer
850
872
  // would be, before the timing, as live.
851
873
  if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);