samagotchi 0.15.0 → 0.17.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +23 -0
  5. data/docs/cli.md +7 -4
  6. data/docs/configuration.md +7 -5
  7. data/docs/guardrails.md +16 -2
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/memory.md +39 -0
  10. data/docs/plugins.md +2 -0
  11. data/docs/sessions.md +10 -4
  12. data/docs/sub-agent.md +16 -3
  13. data/docs/testing.md +47 -0
  14. data/lib/samagotchi/answer_display.rb +6 -2
  15. data/lib/samagotchi/bridge/card_store.rb +16 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
  17. data/lib/samagotchi/bridge.rb +122 -7
  18. data/lib/samagotchi/bridge_client.rb +33 -6
  19. data/lib/samagotchi/bundle_command.rb +13 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  22. data/lib/samagotchi/bundles/system/self_map.md +3 -1
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  24. data/lib/samagotchi/engine.rb +82 -18
  25. data/lib/samagotchi/kernel_loop.rb +16 -5
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  27. data/lib/samagotchi/llm/model_result.rb +12 -7
  28. data/lib/samagotchi/log_subscriber.rb +1 -0
  29. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  30. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  31. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  32. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  33. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  34. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  36. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  37. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  38. data/lib/samagotchi/parent_report.rb +8 -0
  39. data/lib/samagotchi/plugin/context.rb +14 -2
  40. data/lib/samagotchi/question_desk.rb +97 -18
  41. data/lib/samagotchi/relay.rb +88 -0
  42. data/lib/samagotchi/relay_desk.rb +73 -0
  43. data/lib/samagotchi/relay_verifier.rb +113 -0
  44. data/lib/samagotchi/relay_watcher.rb +46 -0
  45. data/lib/samagotchi/reply_wait.rb +10 -1
  46. data/lib/samagotchi/self_report.rb +49 -3
  47. data/lib/samagotchi/send_command.rb +45 -6
  48. data/lib/samagotchi/session.rb +15 -2
  49. data/lib/samagotchi/session_manager.rb +3 -2
  50. data/lib/samagotchi/session_metrics.rb +154 -48
  51. data/lib/samagotchi/sessions_command.rb +10 -4
  52. data/lib/samagotchi/system_prompt.rb +45 -4
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
  55. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
  56. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  57. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  58. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  59. data/lib/samagotchi/terminal_ui.rb +24 -0
  60. data/lib/samagotchi/tool_declarations.rb +2 -2
  61. data/lib/samagotchi/tools/builtins.rb +11 -3
  62. data/lib/samagotchi/tools/delegate_relay.rb +220 -0
  63. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  64. data/lib/samagotchi/tools/peers.rb +7 -4
  65. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  66. data/lib/samagotchi/turn_note.rb +30 -2
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +22 -1
  69. data/lib/samagotchi/web/public/app.js +60 -33
  70. data/lib/samagotchi/web/public/index.html +7 -0
  71. data/lib/samagotchi/web/public/notify.js +22 -5
  72. data/lib/samagotchi/web/public/question_card.js +45 -3
  73. data/lib/samagotchi/web/public/question_cards.js +49 -1
  74. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  75. data/lib/samagotchi/web/public/stage_model.js +8 -3
  76. data/lib/samagotchi/web/public/stage_view.js +20 -3
  77. data/lib/samagotchi/web/public/strip.js +11 -0
  78. data/lib/samagotchi/web/public/timing.js +37 -15
  79. data/lib/samagotchi/web/public/turn_events.js +22 -3
  80. data/lib/samagotchi/web/public/turn_view.js +34 -12
  81. data/lib/samagotchi/worker.rb +28 -2
  82. data/lib/samagotchi/worker_sidecar.rb +4 -1
  83. metadata +8 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.15.0"
4
+ VERSION = "0.17.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1059,7 +1059,8 @@ module Samagotchi
1059
1059
  port && BridgeClient.new(session_id: session_id, port: port, host: DEFAULT_HOST)
1060
1060
  end
1061
1061
 
1062
- # Read the live Engine state over the bridge (raw GET /session/:id/state).
1062
+ # GET a read path of the session's live Bridge (+path+: "state",
1063
+ # "snapshot", "stats", ...).
1063
1064
  # @return [Hash, nil] parsed JSON body, or nil when no live bridge / timeout.
1064
1065
  def bridge_get_json(session_id, path)
1065
1066
  bridge_client(session_id)&.get_json(path)
@@ -1252,6 +1253,13 @@ module Samagotchi
1252
1253
  filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
1253
1254
  next
1254
1255
  end
1256
+ # A turn that ended with no answer (TurnNote.empty's marker): its
1257
+ # empty steps (with ?parts=1) and the item the page draws its
1258
+ # notice from; the note itself stays hidden.
1259
+ if (marker = Samagotchi::TurnNote.empty_answer(m))
1260
+ filtered.concat(empty_answer_items(marker, parts: parts, cwd: cwd))
1261
+ next
1262
+ end
1255
1263
  next if role == "system"
1256
1264
  next if role == "tool_response"
1257
1265
 
@@ -1293,6 +1301,19 @@ module Samagotchi
1293
1301
  []
1294
1302
  end
1295
1303
 
1304
+ # A no-answer turn's marker as display items: each empty step that
1305
+ # has something to show (its thinking) as a text-less assistant step
1306
+ # (only with +parts+, as other text-less steps), then
1307
+ # { role: "empty_answer", retries: }.
1308
+ def empty_answer_items(marker, parts:, cwd:)
1309
+ steps = parts ? Array(marker[:steps] || marker["steps"]) : []
1310
+ items = steps.filter_map do |step|
1311
+ did = step.is_a?(Hash) ? MessageParts.for_message(step, [], cwd: cwd) : nil
1312
+ { role: "assistant", content: "", parts: did } if did
1313
+ end
1314
+ items << { role: "empty_answer", content: "", retries: (marker[:retries] || marker["retries"]).to_i }
1315
+ end
1316
+
1296
1317
  # The last turn failed and its prompt went back to the user: the
1297
1318
  # prompt (session.last_prompt) and why, for the page to show after the
1298
1319
  # conversation as it did live. nil otherwise.
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  listSessions,
3
- createSession,
4
3
  getSession,
5
4
  sendTurn,
6
5
  cancelTurn,
@@ -21,7 +20,7 @@ import { emptySessionState } from "./session_state.js";
21
20
  import { createModelPicker } from "./model_picker.js";
22
21
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
22
  import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
- import { createTurnView, turnHistoryHtml } from "./turn_view.js";
23
+ import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
24
  import { createStageView } from "./stage_view.js";
26
25
  import { appendQuote } from "./annotations.js";
27
26
  import { parsePresets } from "./annotate_presets.js";
@@ -37,14 +36,14 @@ import { createPromptHistory } from "./prompt_history.js";
37
36
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
37
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
38
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
- import { stripColumns, stripShowParts } from "./strip.js";
39
+ import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
41
40
  import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
41
  import { createNotifications } from "./notifications.js";
43
42
  import {
44
43
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
44
  } from "./timing.js";
46
45
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
46
+ import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
47
 
49
48
  const $ = (s) => document.querySelector(s);
50
49
  const topStripEl = $("#topStrip");
@@ -776,6 +775,9 @@ function renderSessionView(data) {
776
775
  // A question the worker is blocked on (also after a reload).
777
776
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
778
777
  questionCards.renderQuestion(data.pending_question);
778
+ // The turn's replay drew it as asked; the snapshot knows whether it is
779
+ // relayed to the parent now.
780
+ questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
779
781
  }
780
782
  // A turn ran out of iterations and waits for a yes or no.
781
783
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
@@ -897,12 +899,25 @@ function addEndBubble(text) {
897
899
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
898
900
  }
899
901
 
902
+ // The notice of a turn that ended with no answer (turn_summary.empty_answer),
903
+ // as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
904
+ function emptyAnswerEl(emptyAnswer) {
905
+ const el = document.createElement("div");
906
+ el.className = EMPTY_ANSWER_CLASS;
907
+ el.textContent = emptyAnswerLine(emptyAnswer);
908
+ return el;
909
+ }
910
+
900
911
  // Everything a turn's end (completed, canceled, failed) closes: then its
901
912
  // end line (+endText+, the cancel or failed line), then the step cards that
902
913
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
903
- function endTurnView(kind, endText = null) {
914
+ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
904
915
  setTurnRunning(false);
905
- const kept = turnDom.turnEnded({ kind }) || [];
916
+ // A completed turn with no answer: its notice where the answer goes
917
+ // (the turn view places it), else after the turn; before the timing.
918
+ const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
919
+ const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
920
+ if (endNote && !endNote.isConnected) appendToHistory(endNote);
906
921
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
907
922
  if (endText) addEndBubble(endText);
908
923
  if (kept.length) {
@@ -1203,7 +1218,7 @@ function reloadedTurnEls(before) {
1203
1218
  // What goes after a reloaded turn's own rows and cards: its answer, else
1204
1219
  // its timing line; null when it has neither.
1205
1220
  function turnAnswerEl(turnEls) {
1206
- return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1221
+ return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1207
1222
  }
1208
1223
 
1209
1224
  // A finished turn's question (the snapshot's cards keep it with its
@@ -1526,7 +1541,7 @@ const streamHandlers = {
1526
1541
  startLiveTurnTiming(data.started_at);
1527
1542
  }),
1528
1543
  turn_completed: async (data) => {
1529
- endTurnView("completed");
1544
+ endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
1530
1545
  const out = turnOutput(data);
1531
1546
  if (out) appendChunk(out);
1532
1547
  // after_turn hooks are running and may present the answer: its
@@ -1643,6 +1658,8 @@ const streamHandlers = {
1643
1658
  question_answered: (data) => {
1644
1659
  questionCards.resolveQuestion(data.id, { answer: data.answer });
1645
1660
  },
1661
+ // A delegate's question relayed to its parent's card, or no longer.
1662
+ question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
1646
1663
  question_cancelled: (data) => {
1647
1664
  questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
1648
1665
  },
@@ -1714,37 +1731,19 @@ function addCreatedSession(created) {
1714
1731
  render();
1715
1732
  }
1716
1733
 
1717
- // A first message: an idle session, then the turn into it as for any
1718
- // later one (handleSendTurn), so a failed first turn's prompt comes back
1719
- // into the composer too (sentIds). A first command line starts the session
1720
- // with it.
1721
- async function handleCreate() {
1722
- const prompt = promptEl.value.trim();
1723
- if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
1724
- if (!prompt) return;
1725
- actionBtn.disabled = true;
1726
- try {
1727
- const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
1728
- addCreatedSession(s);
1729
- select(s.id);
1730
- promptEl.value = "";
1731
- fitPrompt();
1732
- refreshHistory();
1733
- } catch (e) {
1734
- alert(e.message);
1735
- } finally {
1736
- actionBtn.disabled = false;
1737
- }
1734
+ // A first message: an idle session, then the turn (or the command) into it
1735
+ // as for any later one (handleSendTurn), so a failed first turn's prompt
1736
+ // comes back into the composer too (sentIds).
1737
+ function handleCreate() {
1738
+ return handleSendTurn();
1738
1739
  }
1739
1740
 
1740
1741
  async function handleSendTurn() {
1741
- if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
1742
1742
  if (current.owner === "tui") return;
1743
1743
  const prompt = turnText(promptEl.value, chips);
1744
1744
  if (!prompt) return;
1745
1745
  // An image-only send's text is its placeholders: not for ↑.
1746
1746
  const typed = promptEl.value.trim() !== "";
1747
- if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
1748
1747
  if (!selected) {
1749
1748
  actionBtn.disabled = true;
1750
1749
  try {
@@ -1758,6 +1757,9 @@ async function handleSendTurn() {
1758
1757
  actionBtn.disabled = false;
1759
1758
  }
1760
1759
  }
1760
+ // A session command by the session's list (the new one's too: select
1761
+ // read it); an unknown /word is a prompt, as in a terminal.
1762
+ if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
1761
1763
  const hint = historyEl.querySelector(".hint");
1762
1764
  if (hint) hint.remove();
1763
1765
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -1777,6 +1779,15 @@ async function handleSendTurn() {
1777
1779
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1778
1780
  }
1779
1781
  const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
1782
+ if (ack?.command_id) {
1783
+ // The worker knew it as a command (its list was newer than ours):
1784
+ // its command_ran shows it, not a prompt bubble.
1785
+ echo.remove();
1786
+ promptEl.value = "";
1787
+ fitPrompt();
1788
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1789
+ return;
1790
+ }
1780
1791
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1781
1792
  refreshHistory();
1782
1793
  // A turn that failed fast was restored before this ack: its prompt
@@ -2229,11 +2240,27 @@ function stripHiddenSaved() {
2229
2240
  return false;
2230
2241
  }
2231
2242
  }
2243
+ // A short window hides the strip by itself, so the stage keeps room for a
2244
+ // card that waits (its question and choices); the sessions pill brings it
2245
+ // back until the window is tall again. Not saved: the saved choice is the
2246
+ // user's own hide ▴ / show.
2247
+ const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2248
+ let stripShownWhileShort = false;
2249
+ function applyStripHidden() {
2250
+ document.body.classList.toggle("strip-hidden", stripAutoHidden({
2251
+ saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2252
+ }));
2253
+ }
2232
2254
  function setStripHidden(hidden) {
2233
2255
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2234
- document.body.classList.toggle("strip-hidden", hidden);
2256
+ stripShownWhileShort = !hidden && shortWindow.matches;
2257
+ applyStripHidden();
2235
2258
  }
2236
- setStripHidden(stripHiddenSaved());
2259
+ applyStripHidden();
2260
+ shortWindow.addEventListener("change", () => {
2261
+ if (!shortWindow.matches) stripShownWhileShort = false;
2262
+ applyStripHidden();
2263
+ });
2237
2264
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2238
2265
 
2239
2266
  $("#filter").addEventListener("input", renderAll);
@@ -253,6 +253,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
253
253
  .bubble.question.approval.answered.denied .question-result{color:var(--danger)}
254
254
  .bubble.question.approval.answered.denied .question-option.selected{border-color:var(--danger)}
255
255
  .approval-details{margin-bottom:8px;font-size:13px;color:var(--text-2)}
256
+ .approval-delegate{font-size:12px;color:var(--text-muted);margin-bottom:4px;overflow-wrap:anywhere}
257
+ .approval-delegate a{color:var(--text);font-weight:600}
258
+ .question-relayed{font-size:12px;color:var(--text-muted);margin-bottom:6px}
259
+ .question-relayed.hidden{display:none}
260
+ .question-relayed a{color:var(--text);font-weight:600}
256
261
  .approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
257
262
  .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
258
263
  .approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
@@ -429,6 +434,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
429
434
  .turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
430
435
  .turn-stage[data-phase="canceled"],.turn-stage[data-phase="waiting for you"]{border-left-color:var(--warn)}
431
436
  .turn-stage[data-phase="failed"]{border-left-color:var(--danger)}
437
+ .turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
432
438
  .turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
433
439
  @keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
434
440
  .turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
@@ -441,6 +447,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
441
447
  .turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
442
448
  .turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
443
449
  .turn-stage[data-phase="failed"] .ts-mark,.turn-stage[data-phase="failed"] .ts-phase{color:var(--danger-text)}
450
+ .turn-stage[data-phase="no answer"] .ts-mark,.turn-stage[data-phase="no answer"] .ts-phase{color:var(--text-muted)}
444
451
  .turn-stage[data-phase="waiting for you"] .ts-mark{background:var(--warn)}
445
452
  .ts-phase{color:var(--accent);font-weight:600}
446
453
  .ts-timing{min-width:0;overflow:hidden;text-overflow:ellipsis}
@@ -30,16 +30,29 @@ export function waitingOn(summary) {
30
30
  return null;
31
31
  }
32
32
 
33
+ // How long a delegated child's new approval waits before it notifies: its
34
+ // parent relays it when it waits on its children, and then the parent's
35
+ // card notifies instead (one bell, not two). A child the parent isn't
36
+ // waiting on directly is seen by its look at the others (DelegateRelay
37
+ // OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
38
+ // scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
39
+ // wait; a child's approval nobody relays rings this late.
40
+ export const RELAY_HOLD_MS = 3500;
41
+
33
42
  // What changed between two summaries of one session that needs the user.
34
43
  // @param prev the summary held before (null for a session not seen yet)
35
44
  // @param next the new summary
36
45
  // @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
37
- // key names this one event, the same in every tab (the Notification tag)
46
+ // key names this one event, the same in every tab (the Notification tag);
47
+ // holdMs, when there, delays it (a delegated child's approval)
38
48
  export function attentionFor(prev, next) {
39
49
  if (!next || !next.id) return null;
40
50
  const q = next.pending_question;
41
51
  if (q && q.id && q.id !== prev?.pending_question?.id) {
42
- return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
52
+ // Relayed to the parent already: the parent's card notifies.
53
+ if (q.relayed_to) return null;
54
+ const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
55
+ return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
43
56
  }
44
57
  const card = next.pending_card;
45
58
  if (card && card.id && card.id !== prev?.pending_card?.id) {
@@ -74,7 +87,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
74
87
  export function closedKeys(prev, next) {
75
88
  const keys = [];
76
89
  const q = prev?.pending_question;
77
- if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
90
+ const nq = next?.pending_question;
91
+ // Gone, or relayed to the parent (its card notifies and badges instead).
92
+ if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
78
93
  const card = prev?.pending_card;
79
94
  if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
80
95
  return keys;
@@ -178,10 +193,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
178
193
  },
179
194
  // A new attention in a tab behind: +deliver+ (notify, badge) unless
180
195
  // another tab has it in front. @return whether it may still be delivered
196
+ // An attention's own holdMs (a delegated child's approval) holds it
197
+ // longer, alone too.
181
198
  offer(attention, deliver) {
182
199
  const key = attention.key;
183
200
  if (seen.has(key)) return false;
184
- if (!channel) {
201
+ if (!channel && !attention.holdMs) {
185
202
  deliver();
186
203
  return true;
187
204
  }
@@ -189,7 +206,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
189
206
  pending.set(key, schedule(() => {
190
207
  pending.delete(key);
191
208
  if (!seen.has(key)) deliver();
192
- }, holdMs));
209
+ }, Math.max(holdMs, attention.holdMs || 0)));
193
210
  return true;
194
211
  },
195
212
  // Closed before its hold was up (a question answered at once).
@@ -8,8 +8,26 @@ export function isApproval(pq) {
8
8
  return !!pq && pq.kind === "approval";
9
9
  }
10
10
 
11
- // @return {null | {tool, what, isCommand, where, why, preview?}}; preview is
12
- // an edit/write's dry-run diff (diff_view.js draws it)
11
+ // A delegate's approval relayed to this session's user (the approval
12
+ // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
13
+ // task, or null.
14
+ // @return {null | {who, childId, childShort, task, more}}
15
+ export function relayView(pq) {
16
+ const r = pq && pq.relay;
17
+ if (!r || typeof r !== "object" || !r.child_id) return null;
18
+ const chain = Array.isArray(r.chain) && r.chain.length ? r.chain : [String(r.child_id).slice(0, 8)];
19
+ return {
20
+ who: [...chain].reverse().join(" → "),
21
+ childId: String(r.child_id),
22
+ childShort: r.child_short || String(r.child_id).slice(0, 8),
23
+ task: r.task ? String(r.task) : "",
24
+ more: Number(r.more) || 0,
25
+ };
26
+ }
27
+
28
+ // @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
29
+ // preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
30
+ // is relayView's, for a delegate's approval
13
31
  export function approvalView(pq) {
14
32
  if (!isApproval(pq)) return null;
15
33
  const a = pq.approval || {};
@@ -33,12 +51,34 @@ export function approvalView(pq) {
33
51
  where,
34
52
  why: `${reason} (${who})`,
35
53
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
54
+ ...(relayView(pq) ? { delegate: relayView(pq) } : {}),
36
55
  };
37
56
  }
38
57
 
58
+ // This session's question waits in its parent's card too (the parent
59
+ // relayed it): the parent's id and short id, or null.
60
+ // @return {null | {parentId, parentShort}}
61
+ export function relayedToView(pq) {
62
+ const r = pq && pq.relayed_to;
63
+ if (!r || typeof r !== "object" || !r.parent_id) return null;
64
+ return { parentId: String(r.parent_id), parentShort: r.parent_short || String(r.parent_id).slice(0, 8) };
65
+ }
66
+
67
+ // How a relayed approval's card closed without an answer here: the
68
+ // delegate's question was answered there, its worker went, or this turn
69
+ // stopped (the question still waits in the delegate). A dismiss here
70
+ // denied it.
71
+ function relayClosedText(relay, reason) {
72
+ if (reason === "answered_on_child") return `Answered in ${relay.who}`;
73
+ if (reason === "child_gone") return `${relay.who}'s worker is gone`;
74
+ return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
75
+ }
76
+
39
77
  // The line a resolved card shows.
40
78
  export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
41
79
  const approval = isApproval(pq);
80
+ const relay = relayView(pq);
81
+ if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
42
82
  if (cancelled) {
43
83
  const label = approval ? "Denied" : "Cancelled";
44
84
  return reason ? `${label} (${reason})` : label;
@@ -70,8 +110,10 @@ export function truncate(text, n = 80) {
70
110
  export function summaryText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
71
111
  if (cancelled || answer) {
72
112
  const result = resultText(pq, { answer, cancelled, reason }).replace(/^Answered: /, "");
113
+ const relay = relayView(pq);
73
114
  const asked = summaryAsked(pq);
74
- return truncate(asked ? `${truncate(asked, 60)} → ${result}` : result, 120);
115
+ const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
116
+ return truncate(relay ? `${relay.who}: ${text}` : text, 120);
75
117
  }
76
118
  const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
77
119
  return truncate(pq.header || label);
@@ -14,7 +14,8 @@
14
14
 
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
16
  import { diffHtml } from "./diff_view.js";
17
- import { approvalAllowed, approvalView, nextCardAction, resultText, summaryText } from "./question_card.js";
17
+ import { approvalAllowed, approvalView, nextCardAction, relayedToView, resultText, summaryText } from "./question_card.js";
18
+ import { sessionHash } from "./route.js";
18
19
  import { clientLabel, continueLine, isOwn } from "./turn_events.js";
19
20
 
20
21
  // @param sessionId () => the open session's id (answers go there)
@@ -64,6 +65,8 @@ export function createQuestionCards({
64
65
  // left it (rememberToggle-style, as turn_view.js does).
65
66
  summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
66
67
 
68
+ // Relayed to the parent: say where else it waits (QuestionDesk#annotate).
69
+ card.appendChild(renderRelayedTo(pq));
67
70
  if (approval) {
68
71
  card.appendChild(renderApprovalDetails(approval));
69
72
  } else {
@@ -170,6 +173,23 @@ export function createQuestionCards({
170
173
  function renderApprovalDetails(view) {
171
174
  const box = doc.createElement("div");
172
175
  box.className = "approval-details";
176
+ // A delegate's approval: who asks, and its task.
177
+ if (view.delegate) {
178
+ const who = doc.createElement("div");
179
+ who.className = "approval-delegate";
180
+ const label = doc.createElement("span");
181
+ label.className = "approval-label";
182
+ label.textContent = "delegate ";
183
+ who.appendChild(label);
184
+ const name = doc.createElement("a");
185
+ name.className = "approval-delegate-link";
186
+ name.href = sessionHash(view.delegate.childId);
187
+ name.title = "Open the delegate's session";
188
+ name.textContent = view.delegate.who;
189
+ who.appendChild(name);
190
+ if (view.delegate.task) who.appendChild(doc.createTextNode(` · ${view.delegate.task}`));
191
+ box.appendChild(who);
192
+ }
173
193
  const tool = doc.createElement("div");
174
194
  tool.className = "approval-tool";
175
195
  tool.textContent = view.tool;
@@ -203,6 +223,32 @@ export function createQuestionCards({
203
223
  return box;
204
224
  }
205
225
 
226
+ // "Waiting for approval in parent ab12: answering here works too", or
227
+ // an empty (hidden) line while the question isn't relayed.
228
+ function renderRelayedTo(pq) {
229
+ const line = doc.createElement("div");
230
+ line.className = "question-relayed";
231
+ const view = relayedToView(pq);
232
+ if (!view) {
233
+ line.classList.add("hidden");
234
+ return line;
235
+ }
236
+ line.appendChild(doc.createTextNode(`Waiting for ${pq.kind === "approval" ? "approval" : "an answer"} in parent `));
237
+ const link = doc.createElement("a");
238
+ link.href = sessionHash(view.parentId);
239
+ link.textContent = view.parentShort;
240
+ line.appendChild(link);
241
+ line.appendChild(doc.createTextNode(": answering here works too"));
242
+ return line;
243
+ }
244
+
245
+ // The parent relayed the open question, or no longer does (question_relay).
246
+ function markRelayed(id, relayedTo) {
247
+ if (!questionEl || !pending || String(pending.id) !== String(id)) return;
248
+ pending = { ...pending, relayed_to: relayedTo || null };
249
+ questionEl.querySelector(".question-relayed")?.replaceWith(renderRelayedTo(pending));
250
+ }
251
+
206
252
  function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
207
253
  if (!questionEl) return;
208
254
  if (id && pending && String(pending.id) !== String(id)) return;
@@ -225,6 +271,7 @@ export function createQuestionCards({
225
271
  questionEl.appendChild(note);
226
272
  questionEl.classList.remove("submitting");
227
273
  disableQuestionInputs();
274
+ questionEl.querySelector(".question-relayed")?.classList.add("hidden");
228
275
  // The summary becomes the result; the card collapses to that one line
229
276
  // unless the user opened or closed it by hand.
230
277
  const summary = questionEl.querySelector("summary");
@@ -343,6 +390,7 @@ export function createQuestionCards({
343
390
  return {
344
391
  renderQuestion: renderQuestionCard,
345
392
  resolveQuestion: resolveQuestionCard,
393
+ markRelayed,
346
394
  removeQuestion: removeQuestionCard,
347
395
  questionCard: () => questionEl,
348
396
  pendingQuestion: () => pending,
@@ -78,7 +78,14 @@ const WAITING_BADGES = {
78
78
  // @return null | {kind, text, title}
79
79
  export function waitingBadge(summary) {
80
80
  const waiting = waitingOn(summary);
81
- return waiting ? { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] } : null;
81
+ if (!waiting) return null;
82
+ // A delegate's approval waiting in its parent's card (the approval relay).
83
+ const parent = summary.pending_question?.relayed_to;
84
+ if (parent && waiting.reason !== "card") {
85
+ return { kind: waiting.reason, text: `in parent ${parent}`,
86
+ title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
87
+ }
88
+ return { kind: waiting.reason, ...WAITING_BADGES[waiting.reason] };
82
89
  }
83
90
 
84
91
  // What the all view's search matches for a session that waits on the user:
@@ -88,6 +88,8 @@ export function isPlain(turn) {
88
88
  }
89
89
 
90
90
  const END_PHASES = { completed: "answered", canceled: "canceled" };
91
+ // A completed turn whose model returned nothing (the turn view's notice).
92
+ const NO_ANSWER = "no answer";
91
93
 
92
94
  // What the live slots show for +turn+:
93
95
  // phase the status row's word;
@@ -100,14 +102,17 @@ const END_PHASES = { completed: "answered", canceled: "canceled" };
100
102
  // trail the last three finished calls, newest last (a file's title
101
103
  // without its dir);
102
104
  // ticks one per call: {kind, status, step (the gen's index)}.
103
- // +pending+: a card waits for the user; +ended+: the turn's end kind.
104
- export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null } = {}) {
105
+ // +pending+: a card waits for the user; +ended+: the turn's end kind;
106
+ // +emptyAnswer+: a completed turn ended with no answer
107
+ // (turn_summary.empty_answer), so it reads "no answer", not "answered".
108
+ export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
105
109
  const gens = turn?.gens || [];
106
110
  const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
107
111
  const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
108
112
  const current = gens[gens.length - 1];
109
113
  let phase;
110
- if (ended) phase = END_PHASES[ended] || "failed";
114
+ if (ended === "completed" && emptyAnswer) phase = NO_ANSWER;
115
+ else if (ended) phase = END_PHASES[ended] || "failed";
111
116
  else if (pending) phase = "waiting for you";
112
117
  else if (running) phase = "running a tool";
113
118
  else if (current?.text && isPlain(turn)) phase = "writing the answer";
@@ -26,7 +26,7 @@ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.contin
26
26
  // asks the user too, but the turn goes on: it waits only while the turn runs
27
27
  // and never holds the hand-off (a turn cut short can leave it open).
28
28
  const ASKS = ".plugin-card[data-in-turn] .card-action";
29
- const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
29
+ const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
30
30
  // The mark of a flash in the trail: a warn notice, a hook's info notice, a
31
31
  // plugin's nudge (steer).
32
32
  const FLASH_MARKS = { warn: "⚠", info: "ℹ", steer: "↪" };
@@ -93,6 +93,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
93
93
  // The turn in the stage, from turnStarted until the hand-off (or a reset).
94
94
  let holding = false;
95
95
  let endKind = null;
96
+ // The ended turn had no answer (turn_summary.empty_answer).
97
+ let emptyAnswer = false;
96
98
  let settled = true;
97
99
  let settleTimer = null;
98
100
  let startedAt = 0;
@@ -175,7 +177,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
175
177
  // A question blocks the turn: its card takes the live slots' room. An
176
178
  // asking card's turn goes on: the status waits, the slots stay.
177
179
  const blocked = pendingCard();
178
- const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
180
+ const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
179
181
 
180
182
  stage.classList.toggle("plain", plain);
181
183
  stage.classList.toggle("ended", !!endKind);
@@ -358,6 +360,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
358
360
  // never above the session panel (the dock is absolute: a tall composer or
359
361
  // a phone's keyboard would push the stage's top out) ────────────────────
360
362
 
363
+ // Scroll the stage so +slot+'s end shows (it is the scroller's child).
364
+ function revealSlot(slot) {
365
+ if (stage.hidden || slot.hidden) return;
366
+ const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
367
+ if (end > scroller.scrollTop) scroller.scrollTop = end;
368
+ }
369
+
361
370
  function fitCap() {
362
371
  if (stage.hidden) return;
363
372
  const rest = dockEl.offsetHeight - stage.offsetHeight;
@@ -452,6 +461,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
452
461
  clearTimeout(settleTimer);
453
462
  holding = false;
454
463
  endKind = null;
464
+ emptyAnswer = false;
455
465
  settled = true;
456
466
  view.reset();
457
467
  emptySlots();
@@ -517,6 +527,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
517
527
  }
518
528
  holding = true;
519
529
  endKind = null;
530
+ emptyAnswer = false;
520
531
  settled = true;
521
532
  startedAt = Date.parse(data?.started_at) || Date.now();
522
533
  emptySlots();
@@ -542,6 +553,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
542
553
  kept.push(...warn);
543
554
  view.chronological();
544
555
  endKind = args.kind;
556
+ emptyAnswer = !!args.emptyAnswer;
545
557
  endedAt = Date.now();
546
558
  // A completed turn's end read (markdown, answer_display) is pending
547
559
  // until answerReady; the others have none.
@@ -588,8 +600,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
588
600
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
589
601
  slot.appendChild(el);
590
602
  slot.hidden = false;
591
- if (stick || el.matches?.(PENDING) || el.querySelector?.(ASKS)) slot.scrollTop = slot.scrollHeight;
603
+ const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
604
+ if (stick || waits) slot.scrollTop = slot.scrollHeight;
592
605
  scheduleRefresh();
606
+ // A card that waits for the user is brought into the stage's view: in
607
+ // a short window the prompt and headline fill it and the card sat
608
+ // below, out of sight (after the refresh, which hides the live slots).
609
+ if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
593
610
  return true;
594
611
  },
595
612
  // Where the live timing line goes (the status row), null: the history.
@@ -30,3 +30,14 @@ export function stripShowParts(sessions) {
30
30
  waiting: waitingN ? `${waitingN} waiting` : "",
31
31
  };
32
32
  }
33
+
34
+ // A window this short hides the strip by itself: at ~500-700 px the stage
35
+ // is capped low and a card that waits there (a question, an approval) is
36
+ // cut off below the strip.
37
+ export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
38
+
39
+ // Whether the strip is hidden: the user's own hide (saved), or a short
40
+ // window unless the user showed it (the pill) since it got short.
41
+ export function stripAutoHidden({ saved, short, shownWhileShort }) {
42
+ return !!saved || (!!short && !shownWhileShort);
43
+ }