samagotchi 0.14.0 → 0.16.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/plugins.md +3 -3
  8. data/docs/sessions.md +7 -2
  9. data/docs/sub-agent.md +15 -3
  10. data/lib/samagotchi/answer_display.rb +6 -2
  11. data/lib/samagotchi/bridge/card_store.rb +16 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  13. data/lib/samagotchi/bridge.rb +84 -0
  14. data/lib/samagotchi/bridge_client.rb +33 -6
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  17. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  18. data/lib/samagotchi/engine.rb +58 -17
  19. data/lib/samagotchi/kernel_loop.rb +16 -5
  20. data/lib/samagotchi/llm/chat_loop.rb +17 -9
  21. data/lib/samagotchi/llm/model_result.rb +12 -7
  22. data/lib/samagotchi/log_subscriber.rb +1 -0
  23. data/lib/samagotchi/parent_report.rb +8 -0
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/question_desk.rb +97 -18
  26. data/lib/samagotchi/relay.rb +88 -0
  27. data/lib/samagotchi/relay_desk.rb +73 -0
  28. data/lib/samagotchi/relay_verifier.rb +113 -0
  29. data/lib/samagotchi/relay_watcher.rb +46 -0
  30. data/lib/samagotchi/reply_wait.rb +10 -1
  31. data/lib/samagotchi/self_report.rb +24 -4
  32. data/lib/samagotchi/session.rb +15 -2
  33. data/lib/samagotchi/session_manager.rb +5 -2
  34. data/lib/samagotchi/session_metrics.rb +18 -1
  35. data/lib/samagotchi/session_retention.rb +6 -1
  36. data/lib/samagotchi/sessions_command.rb +10 -4
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +88 -12
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  43. data/lib/samagotchi/terminal_ui.rb +11 -0
  44. data/lib/samagotchi/tool_declarations.rb +2 -2
  45. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  46. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  47. data/lib/samagotchi/tools/peers.rb +6 -4
  48. data/lib/samagotchi/turn_note.rb +30 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +24 -0
  51. data/lib/samagotchi/web/public/app.js +32 -11
  52. data/lib/samagotchi/web/public/index.html +9 -0
  53. data/lib/samagotchi/web/public/notify.js +18 -5
  54. data/lib/samagotchi/web/public/question_card.js +45 -3
  55. data/lib/samagotchi/web/public/question_cards.js +49 -1
  56. data/lib/samagotchi/web/public/sessions_list.js +16 -1
  57. data/lib/samagotchi/web/public/stage_model.js +8 -3
  58. data/lib/samagotchi/web/public/stage_view.js +7 -2
  59. data/lib/samagotchi/web/public/strip.js +8 -1
  60. data/lib/samagotchi/web/public/timing.js +22 -5
  61. data/lib/samagotchi/web/public/turn_events.js +9 -0
  62. data/lib/samagotchi/web/public/turn_view.js +38 -13
  63. metadata +7 -1
@@ -21,7 +21,7 @@ import { emptySessionState } from "./session_state.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
23
  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";
24
+ import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
25
25
  import { createStageView } from "./stage_view.js";
26
26
  import { appendQuote } from "./annotations.js";
27
27
  import { parsePresets } from "./annotate_presets.js";
@@ -38,13 +38,13 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
38
38
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
39
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
40
40
  import { stripColumns, stripShowParts } from "./strip.js";
41
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, withChildrenAfterParents } from "./sessions_list.js";
41
+ import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
42
42
  import { createNotifications } from "./notifications.js";
43
43
  import {
44
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
45
  } from "./timing.js";
46
46
  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";
47
+ import { clientLabel, commandView, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
48
 
49
49
  const $ = (s) => document.querySelector(s);
50
50
  const topStripEl = $("#topStrip");
@@ -262,7 +262,8 @@ function filteredSessions(list) {
262
262
  (s.first_preview || "").toLowerCase().includes(q) ||
263
263
  s.short_id.toLowerCase().includes(q) ||
264
264
  s.id.toLowerCase().includes(q) ||
265
- s.status.toLowerCase().includes(q),
265
+ s.status.toLowerCase().includes(q) ||
266
+ waitingSearchText(s).includes(q),
266
267
  );
267
268
  }
268
269
 
@@ -385,11 +386,13 @@ function renderTop(sessions) {
385
386
  $("#stripHide").addEventListener("click", () => setStripHidden(true));
386
387
  }
387
388
 
388
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
389
+ // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
390
+ // following the list.
389
391
  function renderStripShow() {
390
- const { count, live } = stripShowParts(shownSessions());
392
+ const { count, live, waiting } = stripShowParts(shownSessions());
391
393
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
392
- $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
394
+ const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
395
+ $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
393
396
  }
394
397
 
395
398
  // A delegated session sits right after its parent: applied on every
@@ -773,6 +776,9 @@ function renderSessionView(data) {
773
776
  // A question the worker is blocked on (also after a reload).
774
777
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
775
778
  questionCards.renderQuestion(data.pending_question);
779
+ // The turn's replay drew it as asked; the snapshot knows whether it is
780
+ // relayed to the parent now.
781
+ questionCards.markRelayed(data.pending_question.id, data.pending_question.relayed_to || null);
776
782
  }
777
783
  // A turn ran out of iterations and waits for a yes or no.
778
784
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
@@ -894,12 +900,25 @@ function addEndBubble(text) {
894
900
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
895
901
  }
896
902
 
903
+ // The notice of a turn that ended with no answer (turn_summary.empty_answer),
904
+ // as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
905
+ function emptyAnswerEl(emptyAnswer) {
906
+ const el = document.createElement("div");
907
+ el.className = EMPTY_ANSWER_CLASS;
908
+ el.textContent = emptyAnswerLine(emptyAnswer);
909
+ return el;
910
+ }
911
+
897
912
  // Everything a turn's end (completed, canceled, failed) closes: then its
898
913
  // end line (+endText+, the cancel or failed line), then the step cards that
899
914
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
900
- function endTurnView(kind, endText = null) {
915
+ function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
901
916
  setTurnRunning(false);
902
- const kept = turnDom.turnEnded({ kind }) || [];
917
+ // A completed turn with no answer: its notice where the answer goes
918
+ // (the turn view places it), else after the turn; before the timing.
919
+ const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
920
+ const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
921
+ if (endNote && !endNote.isConnected) appendToHistory(endNote);
903
922
  finishLiveTurnTiming(null, { canceled: kind === "canceled" });
904
923
  if (endText) addEndBubble(endText);
905
924
  if (kept.length) {
@@ -1200,7 +1219,7 @@ function reloadedTurnEls(before) {
1200
1219
  // What goes after a reloaded turn's own rows and cards: its answer, else
1201
1220
  // its timing line; null when it has neither.
1202
1221
  function turnAnswerEl(turnEls) {
1203
- return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1222
+ return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1204
1223
  }
1205
1224
 
1206
1225
  // A finished turn's question (the snapshot's cards keep it with its
@@ -1523,7 +1542,7 @@ const streamHandlers = {
1523
1542
  startLiveTurnTiming(data.started_at);
1524
1543
  }),
1525
1544
  turn_completed: async (data) => {
1526
- endTurnView("completed");
1545
+ endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
1527
1546
  const out = turnOutput(data);
1528
1547
  if (out) appendChunk(out);
1529
1548
  // after_turn hooks are running and may present the answer: its
@@ -1640,6 +1659,8 @@ const streamHandlers = {
1640
1659
  question_answered: (data) => {
1641
1660
  questionCards.resolveQuestion(data.id, { answer: data.answer });
1642
1661
  },
1662
+ // A delegate's question relayed to its parent's card, or no longer.
1663
+ question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
1643
1664
  question_cancelled: (data) => {
1644
1665
  questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
1645
1666
  },
@@ -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}
@@ -636,6 +643,8 @@ body.has-session #scope .scope-where{display:none}
636
643
  #stripShow{display:none;white-space:nowrap;flex-shrink:0}
637
644
  #stripShow .live{color:var(--ok-text);white-space:nowrap}
638
645
  #stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
646
+ #stripShow .waiting{color:var(--warn-text);white-space:nowrap}
647
+ #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
639
648
  #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
640
649
  #stripHide:hover{text-decoration:underline;color:var(--text-2)}
641
650
  body.strip-hidden #stripShow{display:block}
@@ -30,16 +30,25 @@ 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 within a poll or two when it waits on the child, and
35
+ // then the parent's card notifies instead (one bell, not two).
36
+ export const RELAY_HOLD_MS = 1500;
37
+
33
38
  // What changed between two summaries of one session that needs the user.
34
39
  // @param prev the summary held before (null for a session not seen yet)
35
40
  // @param next the new summary
36
41
  // @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
37
- // key names this one event, the same in every tab (the Notification tag)
42
+ // key names this one event, the same in every tab (the Notification tag);
43
+ // holdMs, when there, delays it (a delegated child's approval)
38
44
  export function attentionFor(prev, next) {
39
45
  if (!next || !next.id) return null;
40
46
  const q = next.pending_question;
41
47
  if (q && q.id && q.id !== prev?.pending_question?.id) {
42
- return { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
48
+ // Relayed to the parent already: the parent's card notifies.
49
+ if (q.relayed_to) return null;
50
+ const attention = { reason: questionReason(q), sessionId: next.id, key: `${next.id}:${q.id}` };
51
+ return next.parent_id && q.kind === "approval" ? { ...attention, holdMs: RELAY_HOLD_MS } : attention;
43
52
  }
44
53
  const card = next.pending_card;
45
54
  if (card && card.id && card.id !== prev?.pending_card?.id) {
@@ -74,7 +83,9 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
74
83
  export function closedKeys(prev, next) {
75
84
  const keys = [];
76
85
  const q = prev?.pending_question;
77
- if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
86
+ const nq = next?.pending_question;
87
+ // Gone, or relayed to the parent (its card notifies and badges instead).
88
+ if (q && q.id && (nq?.id !== q.id || (nq.relayed_to && !q.relayed_to))) keys.push(`${prev.id}:${q.id}`);
78
89
  const card = prev?.pending_card;
79
90
  if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
80
91
  return keys;
@@ -178,10 +189,12 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
178
189
  },
179
190
  // A new attention in a tab behind: +deliver+ (notify, badge) unless
180
191
  // another tab has it in front. @return whether it may still be delivered
192
+ // An attention's own holdMs (a delegated child's approval) holds it
193
+ // longer, alone too.
181
194
  offer(attention, deliver) {
182
195
  const key = attention.key;
183
196
  if (seen.has(key)) return false;
184
- if (!channel) {
197
+ if (!channel && !attention.holdMs) {
185
198
  deliver();
186
199
  return true;
187
200
  }
@@ -189,7 +202,7 @@ export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSe
189
202
  pending.set(key, schedule(() => {
190
203
  pending.delete(key);
191
204
  if (!seen.has(key)) deliver();
192
- }, holdMs));
205
+ }, Math.max(holdMs, attention.holdMs || 0)));
193
206
  return true;
194
207
  },
195
208
  // 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,22 @@ 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] };
89
+ }
90
+
91
+ // What the all view's search matches for a session that waits on the user:
92
+ // "waiting" and its badge's word ("question", "approval", "needs you");
93
+ // "" for one that doesn't.
94
+ export function waitingSearchText(summary) {
95
+ const badge = waitingBadge(summary);
96
+ return badge ? `waiting ${badge.text}` : "";
82
97
  }
83
98
 
84
99
  // The sessions that wait on the user (waitingOn) first, the rest after,
@@ -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);
@@ -452,6 +454,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
452
454
  clearTimeout(settleTimer);
453
455
  holding = false;
454
456
  endKind = null;
457
+ emptyAnswer = false;
455
458
  settled = true;
456
459
  view.reset();
457
460
  emptySlots();
@@ -517,6 +520,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
517
520
  }
518
521
  holding = true;
519
522
  endKind = null;
523
+ emptyAnswer = false;
520
524
  settled = true;
521
525
  startedAt = Date.parse(data?.started_at) || Date.now();
522
526
  emptySlots();
@@ -542,6 +546,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
542
546
  kept.push(...warn);
543
547
  view.chronological();
544
548
  endKind = args.kind;
549
+ emptyAnswer = !!args.emptyAnswer;
545
550
  endedAt = Date.now();
546
551
  // A completed turn's end read (markdown, answer_display) is pending
547
552
  // until answerReady; the others have none.
@@ -1,3 +1,5 @@
1
+ import { waitingOn } from "./notify.js";
2
+
1
3
  // How many session cards the top strip shows for its width in px: a card
2
4
  // needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
3
5
  // never fewer than 3 (narrow screens scroll them), never more than 6.
@@ -13,13 +15,18 @@ export function stripColumns(width) {
13
15
  // The hidden strip's "▾ sessions" pill carries the list's count, and how
14
16
  // many of those sessions a worker runs (the cards' green "live" badge), so a
15
17
  // user who hid the strip still sees there is a live session to go back to.
16
- // Plain text parts; live is "" when none. Zero sessions keep the bare label.
18
+ // And how many wait on the user (notify.js waitingOn: a question, an
19
+ // approval, a card), so a hidden strip still says one needs an answer.
20
+ // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
+ // the bare label.
17
22
  export function stripShowParts(sessions) {
18
23
  const list = sessions || [];
19
24
  const n = list.length;
20
25
  const liveN = list.filter((s) => s && s.owner === "worker").length;
26
+ const waitingN = list.filter((s) => waitingOn(s)).length;
21
27
  return {
22
28
  count: n ? `${n} ${n === 1 ? "session" : "sessions"}` : "sessions",
23
29
  live: liveN ? `${liveN} live` : "",
30
+ waiting: waitingN ? `${waitingN} waiting` : "",
24
31
  };
25
32
  }
@@ -88,7 +88,9 @@ export function timedTurnIndexes(items) {
88
88
  // the last, in order; a text-less iteration leaves no message, so a text
89
89
  // can sit one step early: inspection, not truth) and its answer (the last
90
90
  // assistant message). Assistant messages before any prompt are answers on
91
- // their own. The turn index counts prompts, as timedTurnIndexes does. The
91
+ // their own. A turn that ended with no answer (the server's empty_answer
92
+ // item) has no answer: its texts are steps, and `emptyAnswer` is that
93
+ // item's index. The turn index counts prompts, as timedTurnIndexes does. The
92
94
  // user's lines merged into the running turn (`merged`, the server's
93
95
  // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
94
96
  export function turnGroups(items, timing) {
@@ -105,18 +107,23 @@ export function turnGroups(items, timing) {
105
107
  turn.merged.push(i);
106
108
  } else if (item?.role === "user") {
107
109
  turnIndex += 1;
108
- turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
110
+ turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
109
111
  groups.push(turn);
110
112
  } else if (item?.role === "assistant") {
111
113
  if (turn) turn.texts.push(i);
112
114
  else groups.push({ kind: "answer", answer: i });
115
+ } else if (item?.role === "empty_answer" && turn) {
116
+ turn.emptyAnswer = i;
113
117
  }
114
118
  });
115
119
  return groups.map((g) => {
116
120
  if (g.kind !== "turn") return g;
117
121
  const group = withSteers(turnGroup(g, items, timing), g.steers, items);
122
+ // A turn that ended with no answer: its notice (the item's index) in
123
+ // the answer's place.
124
+ const ended = g.emptyAnswer == null ? group : { ...group, emptyAnswer: g.emptyAnswer };
118
125
  // A turn with no merged lines keeps its shape.
119
- return g.merged.length ? { ...group, merged: g.merged } : group;
126
+ return g.merged.length ? { ...ended, merged: g.merged } : ended;
120
127
  });
121
128
  }
122
129
 
@@ -138,10 +145,20 @@ function withSteers(group, steerIndexes, items) {
138
145
  return { ...group, steps, steers: left };
139
146
  }
140
147
 
148
+ // A prompt's turn record: the one with its turn id (the engine saves it
149
+ // on the prompt), so a turn whose prompt left the conversation (a failed
150
+ // one's went back to the user, !rollback) shifts no other; a prompt saved
151
+ // before turn ids pairs by its place among the prompts.
152
+ function promptRecord(timing, g) {
153
+ if (typeof g.turnId !== "string" || !g.turnId) return turnRecordAt(timing, g.turnIndex);
154
+ return (timing?.turnRecords || []).find((r) => r.id === g.turnId) || null;
155
+ }
156
+
141
157
  function turnGroup(g, items, timing) {
142
- const record = turnRecordAt(timing, g.turnIndex);
158
+ const record = promptRecord(timing, g);
143
159
  const tools = record ? (timing?.toolRecords || []).filter((r) => r.turn_id === record.id) : [];
144
- const stepsOnly = canceledSteps(g, record, tools);
160
+ // A turn with no answer has none of its texts as one: they are steps.
161
+ const stepsOnly = canceledSteps(g, record, tools) || g.emptyAnswer != null;
145
162
  if (g.texts.some((i) => items[i].parts)) return partsTurn(g, items, record, tools, stepsOnly);
146
163
  const maxIteration = tools.reduce((m, r) => Math.max(m, Number(r.iteration) || 0), 0);
147
164
  const stepTexts = stepsOnly ? g.texts : g.texts.slice(0, -1);
@@ -64,6 +64,15 @@ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
64
64
  return `↻ ${what}, asking again (${attempt}/${of})`;
65
65
  }
66
66
 
67
+ // The one muted line for a turn that ended with no answer (turn_completed's
68
+ // turn_summary.empty_answer, or a reloaded turn's marker): the TUI words
69
+ // it the same (TurnNote.empty_answer_line; spec/shared/labels_matrix.json).
70
+ export function emptyAnswerLine({ retries = 0 } = {}) {
71
+ const count = Number(retries) || 0;
72
+ const after = count > 0 ? ` (after ${count} ${count === 1 ? "retry" : "retries"})` : "";
73
+ return `no answer: the model returned nothing${after}`;
74
+ }
75
+
67
76
  // The text of a turn's row from a snapshot entry (CardStore): a hook's
68
77
  // notice as "<label>: text", the loop's asking-again row as its line.
69
78
  export function noticeLine(entry = {}) {