samagotchi 0.122.0 → 0.124.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -88,6 +88,23 @@ export function listModels(opts = {}) {
88
88
  return api("/api/models", opts);
89
89
  }
90
90
 
91
+ // Update chi to this exact version; progress and component rows arrive as
92
+ // `chi_update` events on the page's events stream.
93
+ export function updateChi(version, opts = {}) {
94
+ return api("/api/update", { method: "POST", body: JSON.stringify({ version }), ...opts });
95
+ }
96
+
97
+ // Restart every worker running an older chi; results arrive as
98
+ // `chi_update` events on the page's events stream.
99
+ export function restartWorkers(opts = {}) {
100
+ return api("/api/update/restart_workers", { method: "POST", body: JSON.stringify({}), ...opts });
101
+ }
102
+
103
+ // Ask this chi web to shut down and exec the installed version.
104
+ export function restartWeb(opts = {}) {
105
+ return api("/api/update/restart_web", { method: "POST", body: JSON.stringify({}), ...opts });
106
+ }
107
+
91
108
  // The prompt history the composer's ↑/↓ walk (the TUI's too), oldest first.
92
109
  export function fetchHistory(opts = {}) {
93
110
  return api("/api/history", opts).then((j) => (Array.isArray(j.entries) ? j.entries : []));
@@ -20,9 +20,10 @@
20
20
  // read-only review turn): while the composer holds it (hold(), till it is
21
21
  // sent, emptied or Esc'd) Enter queues it, and the line says so.
22
22
  //
23
- // While a participant's review turn runs, the worker queues the owner's
24
- // input behind it whatever it asks (never a steer or a cut into a
25
- // read-only turn): Enter queues, no cut is offered, and the line says so.
23
+ // While a participant's turn runs (a review turn, or a working session's
24
+ // pair turn), the worker queues the owner's input behind it whatever it
25
+ // asks (never a steer or a cut into their turn): Enter queues, no cut is
26
+ // offered, and the line says so.
26
27
  //
27
28
  // deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
28
29
  import { html, nothing, render } from "./vendor/lit-html.js";
@@ -41,9 +42,10 @@ export const APPLY_QUEUES = "your Apply turn queues for after this turn · Esc t
41
42
  export const APPLY_QUEUES_SHORT = "Apply: queued after the turn";
42
43
  // The same to a worker without the delivery feature: Enter refuses it.
43
44
  export const APPLY_CANT_QUEUE = "your Apply turn can't queue on this worker (restart it) · Esc to steer it in instead";
44
- // The line while a participant's review turn runs (the worker queues).
45
- export const PARTICIPANT_TURN_QUEUES = "goes in as your own turn after this participant's review turn";
46
- export const PARTICIPANT_TURN_QUEUES_SHORT = "after their review turn";
45
+ // The line while a participant's turn runs (the worker queues), whichever
46
+ // mode: review or pair.
47
+ export const PARTICIPANT_TURN_QUEUES = "goes in as your own turn after this participant's turn";
48
+ export const PARTICIPANT_TURN_QUEUES_SHORT = "after their turn";
47
49
  // The idle row: the keys (the placeholder's hint before the row existed).
48
50
  export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
49
51
 
@@ -1182,6 +1182,23 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1182
1182
  .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
1183
1183
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1184
1184
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1185
+ #updateSheet{width:calc(100vw - 32px);max-width:560px;max-height:calc(100dvh - 32px);overflow:auto;margin:auto;padding:14px 16px;border:1px solid var(--border-strong);border-radius:14px;background:var(--bg-panel);color:var(--text);box-shadow:var(--toast-shadow)}
1186
+ #updateSheet::backdrop{background:var(--backdrop);backdrop-filter:blur(3px)}
1187
+ .us-head{display:flex;align-items:center;gap:10px}
1188
+ .us-head h2{flex:1;min-width:0;font-size:16px;font-weight:650;color:var(--text-bright);overflow-wrap:anywhere}
1189
+ .us-close{flex:none;padding:0 6px;background:transparent;color:var(--text-muted);font-size:19px;line-height:1.2}
1190
+ .us-status{margin:8px 0;color:var(--text-2);font-size:13px;overflow-wrap:anywhere}
1191
+ .us-rows{display:flex;flex-direction:column;gap:7px;list-style:none;margin:10px 0;padding:0}
1192
+ .us-row{min-width:0;padding:9px 10px;border-radius:9px;background:var(--bg-inset);overflow-wrap:anywhere}
1193
+ .us-row-head{display:flex;align-items:baseline;justify-content:space-between;gap:8px;min-width:0}
1194
+ .us-row-head strong{flex:none;max-width:100%;color:var(--text-bright);overflow-wrap:anywhere}
1195
+ .us-row-head span{flex:1 1 0;min-width:0;text-align:right;color:var(--text-2);font-size:12px;overflow-wrap:anywhere}
1196
+ .us-detail{margin-top:4px;color:var(--text-muted);font-size:12px;overflow-wrap:anywhere}
1197
+ .us-kept{margin-top:6px;color:var(--warn-text);font-size:12px}
1198
+ .us-kept ul{margin:3px 0 0;padding-left:18px;color:var(--text-2)}
1199
+ .us-kept li{overflow-wrap:anywhere}
1200
+ .us-foot{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
1201
+ @media(max-width:600px){#updateSheet{position:fixed;inset:auto 8px 8px;width:auto;max-width:none;max-height:min(80dvh,calc(100dvh - 16px));margin:0}}
1185
1202
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
1186
1203
  .ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
1187
1204
  .ctx-pop-close:hover{color:var(--text)}
@@ -1448,8 +1465,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1448
1465
  /* bottom: above the session's composer card when one shows (--toast-bottom, dock_reserve.js placeToast). */
1449
1466
  #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1450
1467
  #toast[hidden]{display:none}
1451
- #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1452
- #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1468
+ #toast .toast-item{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1469
+ #toast .toast-message{flex:1 1 180px;min-width:0;overflow-wrap:anywhere}
1470
+ #toast .toast-action{flex:0 0 auto;white-space:nowrap;background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1453
1471
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1454
1472
  #toast .toast-close:hover{color:var(--text)}
1455
1473
  /* Each row is as tall as its tallest card. An `auto` row in a grid that overflows its
@@ -1608,6 +1626,7 @@ a{color:var(--accent)}
1608
1626
  <div id="allList"></div>
1609
1627
  </section>
1610
1628
  </main>
1629
+ <dialog id="updateSheet" aria-labelledby="updateSheetTitle"></dialog>
1611
1630
  <div id="toast" role="status" aria-live="polite" hidden></div>
1612
1631
  <script type="module" src="/assets/app.js"></script>
1613
1632
  </body>
@@ -200,7 +200,7 @@ button.small{font-size:12px;padding:2px 8px}
200
200
  </div>
201
201
  <div class="p-foot">
202
202
  <div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
203
- <div class="p-hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
203
+ <div class="p-hint" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
204
204
  </div>
205
205
  </div>
206
206
  </footer>
@@ -12,9 +12,11 @@
12
12
  // (spend vs cap, queue, link expiry, model). The owner's participant view
13
13
  // mounts none.
14
14
  import {
15
- authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
15
+ authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
16
16
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
17
+ modeCopy,
17
18
  } from "./participant_model.js";
19
+ import { toolsOverview } from "./tools_overview.js";
18
20
  import { fullTime } from "./format.js";
19
21
  import { createAuthLost, lostAnswer } from "./auth_lost.js";
20
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
@@ -40,7 +42,6 @@ const $ = (id) => document.getElementById(id);
40
42
  const body = document.body.dataset;
41
43
  const ownerView = body.viewer === "owner";
42
44
  const sessionId = body.session || new URLSearchParams(location.hash.slice(1)).get("s") || "";
43
- const phone = window.matchMedia("(max-width: 640px)");
44
45
 
45
46
  let data = null;
46
47
  let live = null;
@@ -209,7 +210,9 @@ function render() {
209
210
  return chip;
210
211
  }));
211
212
  $("viewBanner").hidden = !ownerView;
212
- $("note").textContent = ownerView ? "" : "You can comment and ask; the model answers in a turn that can only read. The owner sees everything.";
213
+ const copy = modeCopy(viewer.mode);
214
+ $("note").textContent = ownerView ? "" : copy.note;
215
+ $("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
213
216
  $("head").hidden = false;
214
217
  $("main").hidden = false;
215
218
  renderPlan();
@@ -284,7 +287,7 @@ function messageNode(message) {
284
287
  const who = el("div", "p-who");
285
288
  who.append(el("b", "", authorLabel(message)));
286
289
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
287
- if (message.author) who.append(el("span", "p-tag", "review"));
290
+ if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
288
291
  node.append(who);
289
292
  const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
290
293
  node.append(el("div", "p-bubble", text));
@@ -305,16 +308,11 @@ function messageNode(message) {
305
308
  return node;
306
309
  }
307
310
 
308
- // +open+: the list's state as the reader left it, else open when short on
309
- // a wide screen.
311
+ // +open+: the overview's state as the reader left it.
310
312
  function toolsNode(rows, open = null) {
311
- if (rows.length === 1) {
312
- const row = rows[0];
313
- return el("div", `p-tools p-tool-one${row.status === "error" ? " error" : row.status === "running" ? " running" : ""}`, `↳ ${toolLine(row)}`);
314
- }
315
313
  const details = el("details", "p-tools");
316
- details.open = open ?? (!phone.matches && rows.length <= 4);
317
- details.append(el("summary", "", `${rows.length} tool${rows.length > 1 ? "s" : ""}: ${toolsSummary(rows)}`));
314
+ details.open = open ?? false;
315
+ details.append(el("summary", "", toolsOverview(rows)));
318
316
  const list = el("ul");
319
317
  for (const row of rows) list.append(el("li", row.status === "error" ? "error" : row.status === "running" ? "running" : "", toolLine(row)));
320
318
  details.append(list);
@@ -38,14 +38,6 @@ export function toolLine(row) {
38
38
  return row?.path ? `${row.tool} ${row.path}` : String(row?.tool || "tool");
39
39
  }
40
40
 
41
- // A tool rows' summary: "read docs/plan.md", "read docs/plan.md + 2 more".
42
- export function toolsSummary(rows) {
43
- const list = Array.isArray(rows) ? rows : [];
44
- if (list.length === 0) return "";
45
- const first = toolLine(list[0]);
46
- return list.length === 1 ? first : `${first} + ${list.length - 1} more`;
47
- }
48
-
49
41
  // The conversation with each run of text-less tool steps (a reply that
50
42
  // only called tools) folded into the next reply's rows, or one row group
51
43
  // when no reply follows: one "3 tools: …" line instead of three.
@@ -271,6 +263,26 @@ export function leftText(iso, now = Date.now()) {
271
263
  return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
272
264
  }
273
265
 
266
+ // What the page says a participant's turn does, by the session's mode
267
+ // (viewer.mode: ParticipantMode.live on the server): a review turn only
268
+ // reads; a pair turn (a working session's) acts with the session's tools
269
+ // under the owner's guardrails. +tag+ is a message's: its own stamp
270
+ // (participant_mode), a message without one a review's.
271
+ export const MODE_COPY = {
272
+ review: {
273
+ note: "You can comment and ask; the model answers in a turn that can only read. The owner sees everything.",
274
+ hint: "The model answers in a read-only turn; the owner sees everything.",
275
+ tag: "review",
276
+ },
277
+ pair: {
278
+ note: "You can ask the model to work in this session: your turns use its tools, writes included, under the owner's "
279
+ + "guardrails, and what needs approval goes to the owner. The owner sees everything.",
280
+ hint: "The model works in this session; the owner approves what needs it and sees everything.",
281
+ tag: "pair",
282
+ },
283
+ };
284
+ export const modeCopy = (mode) => MODE_COPY[mode] || MODE_COPY.review;
285
+
274
286
  export function usageItems(usage, now = Date.now()) {
275
287
  if (!usage || typeof usage !== "object") return [];
276
288
  const items = [];
@@ -25,15 +25,35 @@ export function participantName(author) {
25
25
 
26
26
  // A participant's badge: {author, name, color}, and unnamed: true when
27
27
  // neither the prompt nor the list named them yet; null for the owner's.
28
- export function authorBadge(author, name = null) {
28
+ // +mode+: the turn's participant_mode stamp ("review", "pair"; the
29
+ // message's, or turn_started's), kept as mode: when known.
30
+ export function authorBadge(author, name = null, mode = null) {
29
31
  if (!author || author === "owner") return null;
30
32
  const known = name || participantName(author);
31
- return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }) };
33
+ return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
32
34
  }
33
35
 
34
- // A badge's text and tooltip (turn_html.js userBubbleTemplate).
35
- export const authorBadgeText = (name) => `${name} · review`;
36
- export const authorBadgeTitle = (name) => `A review comment from ${name}, a participant: its turn could only read`;
36
+ // A badge's text and tooltip (turn_html.js userBubbleTemplate), by the
37
+ // turn's mode: a pair turn (a working session's) acted with the session's
38
+ // tools; a review turn, or one whose mode isn't known (a queued prompt, a
39
+ // message from before the stamp), could only read.
40
+ export const PAIR = "pair";
41
+ export const authorBadgeText = (name, mode = null) => (mode === PAIR ? `${name} · pair` : `${name} · review`);
42
+ export const authorBadgeTitle = (name, mode = null) => (mode === PAIR
43
+ ? `A turn from ${name}, a participant: it acted with this session's tools, its asks came to you`
44
+ : `A review comment from ${name}, a participant: its turn could only read`);
45
+
46
+ // The badge on +bubble+ (a prompt drawn before its turn started) takes the
47
+ // turn's +mode+ once turn_started names it.
48
+ export function setBadgeMode(bubble, mode) {
49
+ const badge = bubble?.querySelector?.(".author-badge");
50
+ if (!badge || !mode) return;
51
+ bubble.dataset.participantMode = mode;
52
+ if (badge.dataset.unnamed) return;
53
+ const name = participantName(bubble.dataset.author) || badge.textContent.replace(/ · (review|pair)$/, "");
54
+ badge.textContent = authorBadgeText(name, mode);
55
+ badge.title = authorBadgeTitle(name, mode);
56
+ }
37
57
 
38
58
  // The badges under +root+ drawn before the list named their participant
39
59
  // (data-unnamed) take the name now known. Bubbles are drawn once
@@ -42,10 +62,11 @@ export const authorBadgeTitle = (name) => `A review comment from ${name}, a part
42
62
  export function nameUnnamedBadges(root) {
43
63
  let named = 0;
44
64
  for (const badge of root?.querySelectorAll?.(".author-badge[data-unnamed]") || []) {
45
- const name = participantName(badge.closest("[data-author]")?.dataset.author);
65
+ const bubble = badge.closest("[data-author]");
66
+ const name = participantName(bubble?.dataset.author);
46
67
  if (!name) continue;
47
- badge.textContent = authorBadgeText(name);
48
- badge.title = authorBadgeTitle(name);
68
+ badge.textContent = authorBadgeText(name, bubble?.dataset.participantMode);
69
+ badge.title = authorBadgeTitle(name, bubble?.dataset.participantMode);
49
70
  badge.removeAttribute("data-unnamed");
50
71
  named += 1;
51
72
  }
@@ -181,22 +181,30 @@ function inviteTemplate(invite, { canShare }) {
181
181
  ${invite.qr_svg ? html`<div class="pp-qr" .innerHTML=${invite.qr_svg}></div>` : nothing}</div>`;
182
182
  }
183
183
 
184
+ // The empty list's line, by the session's mode.
185
+ export const EMPTY_REVIEW = "Nobody yet. Invite a person (or an agent) to review: their comments become read-only review turns; you see and approve everything.";
186
+ export const EMPTY_PAIR = "Nobody yet. Invite a person (or an agent) to work with you here: their turns act with this session's tools under your guardrails, and their asks come to you.";
187
+
184
188
  // The popover's content: the participants, the Invite form (or the link it
185
189
  // gave), the participant view.
186
190
  // +working+: the server's warning for a session that isn't a review session
187
191
  // (shown before the Invite, with Start a review of it), null for one.
192
+ // +pair+: a participant invited now gets pair turns (the list's mode: a
193
+ // working session on a worker with pair turns): the empty list says their
194
+ // turns act; otherwise (a review session, an older worker) that they read.
188
195
  // +identityHeader+: the header chi web's auth proxy names the signed-in
189
196
  // user in (the list's identity_header), null without one: then the Invite
190
197
  // has no email field (a bound link could never open).
191
198
  export function panelTemplate(grants, { now, sessionId, invite = null, error = null, capFor = null, canShare = false,
192
- busy = false, working = null, reviewing = false, identityHeader = null } = {}) {
199
+ busy = false, working = null, reviewing = false, identityHeader = null,
200
+ pair = false } = {}) {
193
201
  const rows = (grants || []).map((g) => rowModel(g, now));
194
202
  const active = rows.filter((row) => row.active);
195
203
  const ended = rows.filter((row) => !row.active);
196
204
  return html`<div class="ctx-pop-head"><strong>Participants</strong><span class="ctx-pop-kind">this session</span
197
205
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
198
206
  ${active.length ? html`<ul class="pp-list">${active.map((row) => rowTemplate(row, { capFor }))}</ul>`
199
- : html`<div class="ctx-pop-meta">Nobody yet. Invite a person (or an agent) to review: their comments become read-only review turns; you see and approve everything.</div>`}
207
+ : html`<div class="ctx-pop-meta">${pair ? EMPTY_PAIR : EMPTY_REVIEW}</div>`}
200
208
  ${ended.length ? html`<details class="pp-ended"><summary>Ended (${ended.length})</summary><ul class="pp-list">${
201
209
  ended.map((row) => rowTemplate(row, { capFor }))}</ul></details>` : nothing}
202
210
  <div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div>
@@ -233,6 +241,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
233
241
  // The open session's share warning (GET …/participants), null for a
234
242
  // review session.
235
243
  let working = null;
244
+ // What a participant invited now gets (GET …/participants mode: "pair" or
245
+ // "review", the server's ReviewStart.share_terms), never guessed from the warning.
246
+ let inviteMode = null;
236
247
  // The header chi web's proxy names the user in (GET …/participants), null
237
248
  // without one: the Invite's email field.
238
249
  let identityHeader = null;
@@ -268,7 +279,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
268
279
 
269
280
  function draw() {
270
281
  render(panelTemplate(grants, { now: clock(), sessionId: session(), invite, error, capFor, busy,
271
- working: startReview ? working : null, reviewing: reviewing(), identityHeader,
282
+ working: startReview ? working : null, pair: inviteMode === "pair", reviewing: reviewing(), identityHeader,
272
283
  canShare: typeof win.navigator?.share === "function" }), shell.pop);
273
284
  shell.replace();
274
285
  }
@@ -277,6 +288,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
277
288
  if (!id) {
278
289
  grants = null;
279
290
  working = null;
291
+ inviteMode = null;
280
292
  loadedFor = null;
281
293
  setParticipantNames([]);
282
294
  onChange();
@@ -287,6 +299,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
287
299
  if (id !== session()) return;
288
300
  grants = Array.isArray(body.participants) ? body.participants : [];
289
301
  working = typeof body.warning === "string" ? body.warning : null;
302
+ inviteMode = typeof body.mode === "string" ? body.mode : null;
290
303
  identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
291
304
  loadedFor = id;
292
305
  if (setParticipantNames(grants)) onNames();
@@ -296,6 +309,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
296
309
  if (loadedFor !== id) {
297
310
  grants = [];
298
311
  working = null;
312
+ inviteMode = null;
299
313
  setParticipantNames([]);
300
314
  }
301
315
  }
@@ -63,10 +63,19 @@ export function relayView(pq) {
63
63
  };
64
64
  }
65
65
 
66
- // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?}};
66
+ // Who asks when a participant's turn asks (approval.author, Approval.payload):
67
+ // their name, else their id; null for the owner's own turns.
68
+ export function approvalAsker(pq) {
69
+ const a = (pq && pq.approval) || {};
70
+ if (!a.author) return null;
71
+ return a.author_name ? String(a.author_name) : String(a.author).replace(/^p:/, "");
72
+ }
73
+
74
+ // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
67
75
  // paths are what +what+ joins when it shows paths (the card draws a ref
68
76
  // for each); preview is an edit/write's dry-run diff (diff_view.js draws
69
- // it); delegate is relayView's, for a delegate's approval
77
+ // it); delegate is relayView's, for a delegate's approval; asker is
78
+ // approvalAsker's, for a participant's turn's ask
70
79
  export function approvalView(pq) {
71
80
  if (!isApproval(pq)) return null;
72
81
  const a = pq.approval || {};
@@ -94,6 +103,7 @@ export function approvalView(pq) {
94
103
  why: `${reason} (${who})`,
95
104
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
96
105
  ...(relayView(pq) ? { delegate: relayView(pq) } : {}),
106
+ ...(approvalAsker(pq) ? { asker: approvalAsker(pq), askedBy: String(a.asked_by || "") } : {}),
97
107
  };
98
108
  }
99
109
 
@@ -320,6 +320,13 @@ export function createQuestionCards({
320
320
  box.className = "approval-details";
321
321
  // A delegate's approval: who asks, and its task.
322
322
  if (view.delegate) box.appendChild(renderDelegateLine(view.delegate));
323
+ // A participant's turn asks: the server's line (Approval::ASKED_BY), else their name.
324
+ if (view.asker) {
325
+ const who = doc.createElement("div");
326
+ who.className = "approval-delegate approval-asker";
327
+ who.textContent = view.askedBy || view.asker;
328
+ box.appendChild(who);
329
+ }
323
330
  const tool = doc.createElement("div");
324
331
  tool.className = "approval-tool";
325
332
  tool.textContent = view.tool;
@@ -53,6 +53,7 @@ import {
53
53
  } from "./turn_events.js";
54
54
  import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
55
55
  import { keepEarlyRestore, restoreAction } from "./restore.js";
56
+ import { PAIR, setBadgeMode } from "./participant_names.js";
56
57
 
57
58
  export function createStreamHandlers(deps) {
58
59
  const {
@@ -159,9 +160,12 @@ export function createStreamHandlers(deps) {
159
160
  liveTurn.cutAfter = data.cut_after ?? null;
160
161
  liveTurn.serverGen = null;
161
162
  // A participant's review turn: its id and author, for Apply at its end
162
- // (turn_completed names neither).
163
+ // (turn_completed names neither). A pair turn acted itself: no Apply.
163
164
  const author = data.author || data.origin?.author;
164
- liveTurn.review = author && author !== "owner" && data.turn_id ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
165
+ const pair = data.participant_mode === PAIR;
166
+ liveTurn.review = author && author !== "owner" && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
167
+ // Its prompt drawn while queued takes the turn's mode now.
168
+ if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
165
169
  // The owner's input queues behind it (the composer's delivery line).
166
170
  liveTurn.participantTurn = !!(author && author !== "owner");
167
171
  setTurnRunning(true);
@@ -30,9 +30,11 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
30
30
  }
31
31
 
32
32
  function draw(entry, text, action, sticky) {
33
- render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${action
34
- ? html`<button type="button" class="toast-action" @click=${() => { remove(entry); action.run(); }}>${action.label}</button>`
35
- : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>` : nothing}`,
33
+ const actions = Array.isArray(action) ? action : action ? [action] : [];
34
+ render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${actions.map((item) =>
35
+ html`<button type="button" class="toast-action" @click=${() => { remove(entry); item.run(); }}>${item.label}</button>`)}${sticky
36
+ ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>`
37
+ : nothing}`,
36
38
  entry.el);
37
39
  }
38
40
 
@@ -61,7 +63,7 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
61
63
  el.appendChild(duplicate.el);
62
64
  if (!duplicate.sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
63
65
  sync();
64
- return;
66
+ return duplicate;
65
67
  }
66
68
 
67
69
  if (entries.length === MAX_TOASTS) {
@@ -72,7 +74,13 @@ export function createToasts({ el, onChange = () => {}, doc = globalThis.documen
72
74
  el.appendChild(entry.el);
73
75
  if (!sticky) entry.timer = setTimeout(() => remove(entry), TOAST_MS);
74
76
  sync();
77
+ return entry;
78
+ }
79
+
80
+ // Takes down a toast show returned (gone already: nothing).
81
+ function dismiss(entry) {
82
+ if (entry) remove(entry);
75
83
  }
76
84
 
77
- return { show };
85
+ return { show, dismiss };
78
86
  }
@@ -0,0 +1,42 @@
1
+ // A turn's tool rows as one line: "read 12 files · ran 3 commands · edited
2
+ // 2 files (engine.rb, worker.rb)". Pure, names only (rows carry a tool name
3
+ // and a project path, never args or output); the participant page folds a
4
+ // turn's rows into it, and the owner's page may later.
5
+ const READERS = new Set(["read"]);
6
+ const COMMANDS = new Set(["execute", "bash"]);
7
+ const EDITORS = new Set(["write", "edit"]);
8
+ const FILE_NAMES = 3;
9
+
10
+ const rowsOf = (rows) => (Array.isArray(rows) ? rows : []);
11
+ const namedPath = (row) => typeof row?.path === "string" && row.path ? row.path : null;
12
+ const basename = (path) => path.split(/[\\/]/).at(-1);
13
+ const noun = (count, singular, plural) => `${count} ${count === 1 ? singular : plural}`;
14
+
15
+ function fileCount(rows) {
16
+ const paths = new Set(rows.map(namedPath).filter(Boolean));
17
+ return paths.size + rows.filter((row) => !namedPath(row)).length;
18
+ }
19
+
20
+ function fileNames(rows) {
21
+ const paths = [...new Set(rows.map(namedPath).filter(Boolean))];
22
+ const names = paths.slice(0, FILE_NAMES).map(basename);
23
+ if (paths.length > FILE_NAMES) names.push("…");
24
+ return names.length ? ` (${names.join(", ")})` : "";
25
+ }
26
+
27
+ export function toolsOverview(rows) {
28
+ const list = rowsOf(rows);
29
+ if (!list.length) return "";
30
+
31
+ const readers = list.filter((row) => READERS.has(row?.tool));
32
+ const commands = list.filter((row) => COMMANDS.has(row?.tool));
33
+ const editors = list.filter((row) => EDITORS.has(row?.tool));
34
+ const otherCount = list.length - readers.length - commands.length - editors.length;
35
+ const parts = [];
36
+
37
+ if (readers.length) parts.push(`read ${noun(fileCount(readers), "file", "files")}`);
38
+ if (commands.length) parts.push(`ran ${noun(commands.length, "command", "commands")}`);
39
+ if (editors.length) parts.push(`edited ${noun(fileCount(editors), "file", "files")}${fileNames(editors)}`);
40
+ if (otherCount) parts.push(`used ${noun(otherCount, "other tool", "other tools")}`);
41
+ return parts.join(" · ");
42
+ }
@@ -289,8 +289,8 @@ export function initWaitLine({ tasks } = {}) {
289
289
  // no "steered" badge, as a reload shows it).
290
290
  // A participant's prompt's badge, as an op's field ({author}); {} for the
291
291
  // owner's, so their ops stay as they were.
292
- function authorOf(author, name = null) {
293
- const badge = authorBadge(author, name);
292
+ function authorOf(author, name = null, mode = null) {
293
+ const badge = authorBadge(author, name, mode);
294
294
  return badge ? { author: badge } : {};
295
295
  }
296
296
 
@@ -314,7 +314,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
314
314
  // draws the bubble from this.
315
315
  return [withImages({ op: "start", enqueuedId: id, prompt: event.prompt }, event.images)];
316
316
  }
317
- return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id), ...authorOf(event.author || origin.author, event.author_name) }, event.images)];
317
+ return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id), ...authorOf(event.author || origin.author, event.author_name, event.participant_mode) }, event.images)];
318
318
  }
319
319
  case "turn_dequeued":
320
320
  return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
@@ -362,7 +362,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
362
362
  const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
363
363
  const turnId = turn.turn_id ? { turn_id: turn.turn_id } : {};
364
364
  // A participant's turn names them (TurnAccumulator keeps author, author_name).
365
- const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}) } : {};
365
+ const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}),
366
+ ...(turn.participant_mode ? { participant_mode: turn.participant_mode } : {}) } : {};
366
367
  // Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
367
368
  const at = typeof turn.at === "string" ? { at: turn.at } : {};
368
369
  events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...turnId, ...author }, turn.images));
@@ -311,9 +311,9 @@ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {})
311
311
  export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null, delivery = null, author = null } = {}) {
312
312
  const named = BADGES[state] ? state : null;
313
313
  const from = author
314
- ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
314
+ ? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name, author.mode)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name, author.mode)}</span>`
315
315
  : label ? html`<span class="origin-label">${label}</span>` : nothing;
316
- return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
316
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-participant-mode=${author?.mode || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-turn-id=${message?.turn_id || nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
317
317
  }
318
318
 
319
319
  // The notice of a turn that ended with no answer (the server's
@@ -20,7 +20,7 @@
20
20
  // A block that holds one gen with no thinking and no tool rows hides its
21
21
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
22
22
  // removed at the end: the answer bubble takes its place.
23
- import { authorBadge } from "./participant_names.js";
23
+ import { authorBadge, PAIR } from "./participant_names.js";
24
24
  import { applyRowTemplate } from "./review_apply.js";
25
25
  import { html, nothing, render } from "./vendor/lit-html.js";
26
26
  import { answerRefsOf, setAnswerRefs } from "./answer_refs.js";
@@ -870,7 +870,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
870
870
  const userTemplate = (m, opts = {}) => {
871
871
  const shell = shellCommandMessage(m?.content);
872
872
  if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
873
- return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
873
+ return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name, m?.participant_mode), ...opts });
874
874
  };
875
875
  // The first text after a delegate report merged mid-turn, unless it is
876
876
  // the turn's answer (shown anyway).
@@ -946,8 +946,10 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
946
946
  else if (group.answer === null) parts.push(timingTemplate(group));
947
947
  else parts.push(answerTemplate(group.answer, timingTemplate(group)));
948
948
  // A participant's review turn with a reply: Apply under it (the owner's
949
- // page only: the participant page has its own view).
950
- const reviewer = group.answer !== null && prompt.turn_id ? authorBadge(prompt.author, prompt.author_name) : null;
949
+ // page only: the participant page has its own view). Not under a pair
950
+ // turn: it acted itself.
951
+ const reviewer = group.answer !== null && prompt.turn_id && prompt.participant_mode !== PAIR
952
+ ? authorBadge(prompt.author, prompt.author_name) : null;
951
953
  if (reviewer) parts.push(applyRowTemplate(prompt.turn_id, reviewer));
952
954
  // A canceled turn ends with its cancel line, a failed one whose steps
953
955
  // stayed with its failure line, as the live view left them.