samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -1
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  listSessions,
3
- createSession,
4
3
  getSession,
5
4
  sendTurn,
6
5
  cancelTurn,
@@ -37,14 +36,15 @@ 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";
42
+ import { updateNotice } from "./update_notice.js";
43
43
  import {
44
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
44
+ appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, 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, emptyAnswerLine, 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, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, startPageReply, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
48
48
 
49
49
  const $ = (s) => document.querySelector(s);
50
50
  const topStripEl = $("#topStrip");
@@ -102,6 +102,15 @@ function findInTurnRoots(selector) {
102
102
  let current = emptySessionState();
103
103
  let turnRunning = false;
104
104
  let liveTurnTimingEl = null;
105
+ // The running turn's id (turn_started's turn_id, the id of its timing
106
+ // record), so a turn-end re-read finishes that turn's line with that
107
+ // turn's record, whatever started since.
108
+ let liveTurnId = null;
109
+ // The turns this page saw end: the records of its last full render, +1 at
110
+ // each turn's end, never fewer than a merge brought. The live line numbers
111
+ // the next one, so a turn queued behind another reads its own number while
112
+ // the other's re-read is still out.
113
+ let turnsEnded = 0;
105
114
  // What the running turn waits on, after its live timing line: a provider
106
115
  // retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
107
116
  // it streams again.
@@ -170,6 +179,7 @@ async function refresh() {
170
179
  function openSessionEvents() {
171
180
  eventsControl = openEvents({
172
181
  snapshot: (data) => {
182
+ noticeUpdate(data.version);
173
183
  notifications.noticeSessions("snapshot", data);
174
184
  allSessions = applySessionEvent(allSessions, "snapshot", data);
175
185
  render();
@@ -200,6 +210,17 @@ function openSessionEvents() {
200
210
  });
201
211
  }
202
212
 
213
+ // chi web was upgraded while this tab stayed open (a snapshot on the
214
+ // reconnect names another version than the one that served the page):
215
+ // a toast offers the reload, never forces it.
216
+ let updateShownFor = null;
217
+ function noticeUpdate(served) {
218
+ const text = updateNotice(document.body.dataset.version, served, updateShownFor);
219
+ if (!text) return;
220
+ updateShownFor = served;
221
+ showToast(text, { label: "Reload", run: () => location.reload() }, { sticky: true });
222
+ }
223
+
203
224
  // The selected session's card changed: its owner and, without a live
204
225
  // stream, its status show in the info bar; a worker whose bridge is up
205
226
  // (a turn another client sent woke one, or a replacement after a kill)
@@ -285,7 +306,7 @@ function cardHtml(s, { deletable = false } = {}) {
285
306
  const ctx = cardCtxText(s.ctx_pct);
286
307
  const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
287
308
  const waiting = waitingBadge(s);
288
- const waitingHtml = waiting ? `<span class="attn ${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
309
+ const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
289
310
  const dotWord = waiting ? waiting.title : s.status;
290
311
  return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
291
312
  }
@@ -606,6 +627,7 @@ function clearSelection() {
606
627
  resetSessionState();
607
628
  clearTimingInterval();
608
629
  liveTurnTimingEl = null;
630
+ liveTurnId = null;
609
631
  setTurnRunning(false);
610
632
  closeStream();
611
633
  setMarkdownWarning(null);
@@ -659,6 +681,7 @@ function resetTurnView() {
659
681
  closeStream();
660
682
  clearTimingInterval();
661
683
  liveTurnTimingEl = null;
684
+ liveTurnId = null;
662
685
  questionCards.removeQuestion();
663
686
  questionCards.forgetContinue();
664
687
  unmatchedMerge = false;
@@ -716,6 +739,8 @@ function renderSessionView(data) {
716
739
  current.dir = s.working_directory;
717
740
  current.owner = s.owner || null;
718
741
  current.timing = normalizeTiming(data.timing);
742
+ turnsEnded = current.timing.turnRecords.length;
743
+ const activeTurnId = current.timing.activeTurn?.id || null;
719
744
  // The saved fill, so a reloaded or stopped session shows it before its
720
745
  // next turn (a running turn's replay below streams over it).
721
746
  const savedPct = savedCtxPct(data.timing?.context);
@@ -768,7 +793,10 @@ function renderSessionView(data) {
768
793
  replay.forEach((event) => streamHandlers[event.type]?.(event));
769
794
  if (!data.current_turn) {
770
795
  setTurnRunning(current.status === "running");
771
- if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at);
796
+ if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at, activeTurnId);
797
+ } else if (turnRunning) {
798
+ // The replayed turn_started carries no id: the running turn's record's.
799
+ liveTurnId = activeTurnId;
772
800
  }
773
801
  turnDom.flush();
774
802
  snapshotCards.current.forEach((card) => upsertCard(card));
@@ -819,13 +847,18 @@ let turnEndRead = Promise.resolve();
819
847
  let displayLanded = null;
820
848
  async function readTurnEnd() {
821
849
  if (!selected) return;
850
+ const id = selected;
851
+ // The line and the turn this read is for, before the await: a turn
852
+ // queued behind this one may start (a new live line) before it lands.
853
+ const line = { el: liveTurnTimingEl, turnId: liveTurnId };
822
854
  try {
823
- const d = await getSession(selected, { tail: true });
855
+ const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
856
+ if (id !== selected) return;
824
857
  if (d.session) {
825
858
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
826
859
  if (d.session.status) current.status = d.session.status;
827
- applyTiming(d.timing);
828
- finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
860
+ if (!(await mergeTurnTiming(id, d.timing))) return;
861
+ finishTurnLine(line);
829
862
  setMarkdownWarning(d.markdown_warning);
830
863
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
831
864
  updateInfoBar();
@@ -914,6 +947,9 @@ function emptyAnswerEl(emptyAnswer) {
914
947
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
915
948
  function endTurnView(kind, endText = null, { emptyAnswer = null } = {}) {
916
949
  setTurnRunning(false);
950
+ // Every ended turn has a record (completed, canceled, failed); a worker
951
+ // gone mid-turn is re-rendered (resync), which counts again.
952
+ if (kind !== "gone") turnsEnded += 1;
917
953
  // A completed turn with no answer: its notice where the answer goes
918
954
  // (the turn view places it), else after the turn; before the timing.
919
955
  const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
@@ -1357,11 +1393,12 @@ function clearTimingInterval() {
1357
1393
 
1358
1394
  let liveTurnNumber = null;
1359
1395
 
1360
- function startLiveTurnTiming(startedAt = new Date().toISOString()) {
1396
+ function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1361
1397
  clearTimingInterval();
1362
- current.timing.activeTurn = { started_at: startedAt };
1363
- // The turn records hold the finished turns, so this one comes next.
1364
- liveTurnNumber = current.timing.turnRecords.length + 1;
1398
+ current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
1399
+ liveTurnId = turnId || null;
1400
+ // The turns ended so far, so this one comes next.
1401
+ liveTurnNumber = turnsEnded + 1;
1365
1402
  liveTurnNote = "";
1366
1403
  liveTurnTimingEl = document.createElement("div");
1367
1404
  liveTurnTimingEl.className = "turn-timing live";
@@ -1411,11 +1448,46 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1411
1448
  updateInfoBar();
1412
1449
  }
1413
1450
 
1414
- function applyTiming(data) {
1415
- current.timing = normalizeTiming(data);
1416
- if (turnRunning && current.timing.activeTurn?.started_at) {
1417
- startLiveTurnTiming(current.timing.activeTurn.started_at);
1451
+ // A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
1452
+ // and turn_count) merged into the page's (mergeTiming); when the merge comes
1453
+ // up short, the whole timing (?timing=1). Never (re)starts live timing: a
1454
+ // turn that started meanwhile keeps its line and its own active turn.
1455
+ // @return false when the session changed meanwhile
1456
+ async function mergeTurnTiming(id, data) {
1457
+ const keepActive = () => (turnRunning ? current.timing.activeTurn : null);
1458
+ const merged = mergeTiming(current.timing, data);
1459
+ current.timing = { ...merged.timing, activeTurn: keepActive() };
1460
+ if (merged.short) {
1461
+ try {
1462
+ const full = await getSession(id, { timing: true });
1463
+ if (id !== selected) return false;
1464
+ current.timing = { ...normalizeTiming(full.timing), activeTurn: keepActive() };
1465
+ } catch (_) {
1466
+ if (id !== selected) return false;
1467
+ }
1418
1468
  }
1469
+ turnsEnded = Math.max(turnsEnded, current.timing.turnRecords.length);
1470
+ return true;
1471
+ }
1472
+
1473
+ // Finish the timing line a turn-end read captured (+line+: {el, turnId})
1474
+ // with that turn's record: its place in the records and its duration (a
1475
+ // canceled one says so). No id (an older worker's events): the last
1476
+ // record. No record: the line stays as the turn's end left it. The ticker
1477
+ // stops only if that line is still the live one.
1478
+ function finishTurnLine(line) {
1479
+ const records = current.timing.turnRecords;
1480
+ const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
1481
+ if (line.el === liveTurnTimingEl) {
1482
+ current.timing.activeTurn = null;
1483
+ clearTimingInterval();
1484
+ }
1485
+ const duration = Number(record?.duration_ms);
1486
+ if (line.el && record && Number.isFinite(duration)) {
1487
+ line.el.classList.remove("live");
1488
+ line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
1489
+ }
1490
+ updateInfoBar();
1419
1491
  }
1420
1492
 
1421
1493
  function updateFirstPeek() {
@@ -1539,7 +1611,7 @@ const streamHandlers = {
1539
1611
  clearTimingInterval();
1540
1612
  liveTurnTimingEl = null;
1541
1613
  turnDom.turnStarted({ ...data, promptBubble });
1542
- startLiveTurnTiming(data.started_at);
1614
+ startLiveTurnTiming(data.started_at, data.turn_id || null);
1543
1615
  }),
1544
1616
  turn_completed: async (data) => {
1545
1617
  endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
@@ -1564,7 +1636,7 @@ const streamHandlers = {
1564
1636
  try {
1565
1637
  await turnEndRead;
1566
1638
  if (!selected || !data.display) return;
1567
- const d = await getSession(selected, { tail: true });
1639
+ const d = await getSession(selected, { tail: true, recent: true });
1568
1640
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1569
1641
  } catch (_) {
1570
1642
  } finally {
@@ -1621,7 +1693,9 @@ const streamHandlers = {
1621
1693
  updateInfoBar();
1622
1694
  }
1623
1695
  if (view.resync) await resync();
1624
- const queued = view.anytime && commandBubble(view.commandId);
1696
+ // A card's action that isn't anytime (the step-limit question's
1697
+ // /continue) has no bubble: nothing to remove.
1698
+ const queued = view.anytime ? commandBubble(view.commandId) : null;
1625
1699
  if (view.hidden) queued?.remove();
1626
1700
  else if (queued) drawCommandBubble(queued, view);
1627
1701
  else addCommandBubble(view);
@@ -1701,11 +1775,13 @@ const streamHandlers = {
1701
1775
 
1702
1776
  async function refreshTimingFromSession() {
1703
1777
  if (!selected) return;
1778
+ const id = selected;
1779
+ const line = { el: liveTurnTimingEl, turnId: liveTurnId };
1704
1780
  try {
1705
- const data = await getSession(selected, { tail: true });
1781
+ const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
1782
+ if (id !== selected) return;
1706
1783
  if (data.session?.status) current.status = data.session.status;
1707
- applyTiming(data.timing);
1708
- finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
1784
+ if (await mergeTurnTiming(id, data.timing)) finishTurnLine(line);
1709
1785
  } catch (_) {}
1710
1786
  }
1711
1787
 
@@ -1732,38 +1808,27 @@ function addCreatedSession(created) {
1732
1808
  render();
1733
1809
  }
1734
1810
 
1735
- // A first message: an idle session, then the turn into it as for any
1736
- // later one (handleSendTurn), so a failed first turn's prompt comes back
1737
- // into the composer too (sentIds). A first command line starts the session
1738
- // with it.
1739
- async function handleCreate() {
1740
- const prompt = promptEl.value.trim();
1741
- if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
1742
- if (!prompt) return;
1743
- actionBtn.disabled = true;
1744
- try {
1745
- const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
1746
- addCreatedSession(s);
1747
- select(s.id);
1748
- promptEl.value = "";
1749
- fitPrompt();
1750
- refreshHistory();
1751
- } catch (e) {
1752
- alert(e.message);
1753
- } finally {
1754
- actionBtn.disabled = false;
1755
- }
1811
+ // A first message: an idle session, then the turn (or the command) into it
1812
+ // as for any later one (handleSendTurn), so a failed first turn's prompt
1813
+ // comes back into the composer too (sentIds).
1814
+ function handleCreate() {
1815
+ return handleSendTurn();
1756
1816
  }
1757
1817
 
1758
1818
  async function handleSendTurn() {
1759
- if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
1760
1819
  if (current.owner === "tui") return;
1761
1820
  const prompt = turnText(promptEl.value, chips);
1762
1821
  if (!prompt) return;
1763
1822
  // An image-only send's text is its placeholders: not for ↑.
1764
1823
  const typed = promptEl.value.trim() !== "";
1765
- if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
1766
1824
  if (!selected) {
1825
+ const local = startPageReply(prompt, { images: chips.length });
1826
+ if (local) {
1827
+ promptEl.value = "";
1828
+ fitPrompt();
1829
+ showNotice(local);
1830
+ return;
1831
+ }
1767
1832
  actionBtn.disabled = true;
1768
1833
  try {
1769
1834
  const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
@@ -1776,6 +1841,19 @@ async function handleSendTurn() {
1776
1841
  actionBtn.disabled = false;
1777
1842
  }
1778
1843
  }
1844
+ // A session command by the session's list (the new one's too: select
1845
+ // read it); an unknown /word is a prompt, as in a terminal — but a typo
1846
+ // of a command (/modle) gets the hint, not a turn.
1847
+ if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
1848
+ if (!chips.length) {
1849
+ const typo = unknownCommandHint(prompt, current.commands);
1850
+ if (typo) {
1851
+ addCommandBubble({ label: null, line: prompt, text: typo });
1852
+ promptEl.value = "";
1853
+ fitPrompt();
1854
+ return;
1855
+ }
1856
+ }
1779
1857
  const hint = historyEl.querySelector(".hint");
1780
1858
  if (hint) hint.remove();
1781
1859
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -1795,6 +1873,15 @@ async function handleSendTurn() {
1795
1873
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1796
1874
  }
1797
1875
  const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
1876
+ if (ack?.command_id) {
1877
+ // The worker knew it as a command (its list was newer than ours):
1878
+ // its command_ran shows it, not a prompt bubble.
1879
+ echo.remove();
1880
+ promptEl.value = "";
1881
+ fitPrompt();
1882
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1883
+ return;
1884
+ }
1798
1885
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1799
1886
  refreshHistory();
1800
1887
  // A turn that failed fast was restored before this ack: its prompt
@@ -2197,17 +2284,20 @@ async function setArchived(id, archive, { undo = true } = {}) {
2197
2284
  showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2198
2285
  }
2199
2286
 
2287
+ // sticky: stays until its action or its ✕ (else gone after 6 s).
2200
2288
  let toastTimer = null;
2201
- function showToast(text, action = null) {
2289
+ function showToast(text, action = null, { sticky = false } = {}) {
2202
2290
  const el = $("#toast");
2203
- el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}`;
2291
+ el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2292
+ (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
2204
2293
  el.hidden = false;
2205
2294
  el.querySelector(".toast-action")?.addEventListener("click", () => {
2206
2295
  el.hidden = true;
2207
2296
  action.run();
2208
2297
  });
2298
+ el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
2209
2299
  clearTimeout(toastTimer);
2210
- toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2300
+ if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2211
2301
  }
2212
2302
 
2213
2303
  $("#includeArchived").addEventListener("change", () => {
@@ -2247,11 +2337,27 @@ function stripHiddenSaved() {
2247
2337
  return false;
2248
2338
  }
2249
2339
  }
2340
+ // A short window hides the strip by itself, so the stage keeps room for a
2341
+ // card that waits (its question and choices); the sessions pill brings it
2342
+ // back until the window is tall again. Not saved: the saved choice is the
2343
+ // user's own hide ▴ / show.
2344
+ const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2345
+ let stripShownWhileShort = false;
2346
+ function applyStripHidden() {
2347
+ document.body.classList.toggle("strip-hidden", stripAutoHidden({
2348
+ saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2349
+ }));
2350
+ }
2250
2351
  function setStripHidden(hidden) {
2251
2352
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2252
- document.body.classList.toggle("strip-hidden", hidden);
2353
+ stripShownWhileShort = !hidden && shortWindow.matches;
2354
+ applyStripHidden();
2253
2355
  }
2254
- setStripHidden(stripHiddenSaved());
2356
+ applyStripHidden();
2357
+ shortWindow.addEventListener("change", () => {
2358
+ if (!shortWindow.matches) stripShownWhileShort = false;
2359
+ applyStripHidden();
2360
+ });
2255
2361
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2256
2362
 
2257
2363
  $("#filter").addEventListener("input", renderAll);
@@ -23,17 +23,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
23
23
  );
24
24
  }
25
25
 
26
- // opts.dir: the folder the chat starts in (else the server's cwd).
27
- // dir: the folder the chat starts in; model: the model it starts on (as
28
- // GET /api/models spells it); either left out means the server's default.
29
- export function createSession(prompt, { dir, model, ...opts } = {}) {
30
- const body = { prompt, ...(dir ? { dir } : {}), ...(model ? { model } : {}) };
31
- return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
32
- }
33
-
34
26
  // A session with no first turn: the page's first message is sent into it
35
- // as a turn (with its images, once they are uploaded). preview: that
36
- // message, which names the session until its turn is saved.
27
+ // as a turn (with its images, once they are uploaded). dir: the folder the
28
+ // chat starts in; model: the model it starts on (as GET /api/models spells
29
+ // it); either left out means the server's default. preview: that message,
30
+ // which names the session until its turn is saved.
37
31
  export function createIdleSession({ dir, model, preview, ...opts } = {}) {
38
32
  const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
39
33
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
@@ -61,11 +55,16 @@ export function uploadImage(id, file, name, opts = {}) {
61
55
  }
62
56
 
63
57
  // opts.tail: the lighter answer for the end of a turn (the session, its
64
- // timing and the last answer only), not the whole history.
58
+ // timing and the last answer only), not the whole history; with
59
+ // opts.recent its timing holds the newest turn's records and turn_count
60
+ // (mergeTiming), and opts.turnId asks for that turn's answer.
61
+ // opts.timing: the whole timing alone (when the merge came up short).
65
62
  // opts.parts: each saved message with what it did (thinking, tool calls
66
63
  // and their output), for the turn view's reload.
67
- export function getSession(id, { tail, parts, cards, ...opts } = {}) {
68
- const query = cards ? "?cards=1" : tail ? "?tail=1" : parts ? "?parts=1" : "";
64
+ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
65
+ let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
66
+ if (tail && recent) query += "&recent=1";
67
+ if (tail && turnId) query += `&turn_id=${encodeURIComponent(turnId)}`;
69
68
  return api(`/api/sessions/${id}${query}`, opts);
70
69
  }
71
70
 
@@ -224,6 +224,9 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
224
224
  .continue-context{font-size:12px;color:var(--text-3);margin-bottom:8px;white-space:pre-wrap}
225
225
  .continue-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
226
226
  .continue-reason{flex:1;min-width:140px;background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:8px;color:var(--text);padding:6px 8px}
227
+ /* The step-limit question (kind continue): the continue card's buttons inside a question card; the picked one stays marked once answered. */
228
+ .bubble.question.step-limit .continue-actions{margin-top:2px}
229
+ .bubble.question.step-limit .question-choice.selected{outline:2px solid var(--ok);outline-offset:1px;opacity:1}
227
230
  .bubble.question.submitting{opacity:.7}
228
231
  .bubble.question.answered{border-left-color:var(--ok)}
229
232
  .bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
@@ -666,9 +669,11 @@ body.all-view #center, body.all-view #topStrip{display:none}
666
669
  .card.archived{opacity:.6}
667
670
  .card.archived:hover{opacity:.85}
668
671
  .card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
669
- #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
672
+ #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
670
673
  #toast[hidden]{display:none}
671
674
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
675
+ #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
676
+ #toast .toast-close:hover{color:var(--text)}
672
677
  #allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
673
678
  .card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
674
679
  .card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
@@ -15,13 +15,15 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
15
15
  // A finished turn shorter than this was watched: no "done".
16
16
  export const DONE_MIN_SECONDS = 10;
17
17
 
18
- const questionReason = (q) => (q.kind === "approval" ? "approval" : "question");
18
+ // The step-limit question (kind "continue": a turn ran out of iterations
19
+ // and asks whether to go on) has its own reason: "out of steps".
20
+ const questionReason = (q) => (q.kind === "approval" ? "approval" : q.kind === "continue" ? "continue" : "question");
19
21
 
20
22
  // What a session waits on the user for right now, from one summary: an open
21
23
  // question or approval, or a running turn's card with actions (check-in's
22
24
  // Nudge / Keep going / Stop asks as a question does). The session cards
23
25
  // mark and lift these; attentionFor notifies when one opens.
24
- // @return null | {reason: "question"|"approval"|"card", id}
26
+ // @return null | {reason: "question"|"approval"|"continue"|"card", id}
25
27
  export function waitingOn(summary) {
26
28
  const q = summary?.pending_question;
27
29
  if (q && q.id) return { reason: questionReason(q), id: q.id };
@@ -31,9 +33,13 @@ export function waitingOn(summary) {
31
33
  }
32
34
 
33
35
  // 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;
36
+ // parent relays it when it waits on its children, and then the parent's
37
+ // card notifies instead (one bell, not two). A child the parent isn't
38
+ // waiting on directly is seen by its look at the others (DelegateRelay
39
+ // OTHERS_EVERY, 2 s), and the relay reaches the page with the hub's next
40
+ // scan (SessionHub SCAN_INTERVAL, 1 s): up to ~3 s. Only these approvals
41
+ // wait; a child's approval nobody relays rings this late.
42
+ export const RELAY_HOLD_MS = 3500;
37
43
 
38
44
  // What changed between two summaries of one session that needs the user.
39
45
  // @param prev the summary held before (null for a session not seen yet)
@@ -133,6 +139,7 @@ export function attentionText(session, attention) {
133
139
  const body = {
134
140
  question: "needs an answer",
135
141
  approval: "needs approval",
142
+ continue: "hit its step limit",
136
143
  card: "needs you",
137
144
  failed: "turn failed",
138
145
  done: `done in ${seconds} s`
@@ -8,6 +8,23 @@ export function isApproval(pq) {
8
8
  return !!pq && pq.kind === "approval";
9
9
  }
10
10
 
11
+ // The step-limit question (kind "continue"): a turn ran out of iterations.
12
+ // Its card has a button per option (Continue, Stop) and a reason that goes
13
+ // with Stop; it can't be dismissed.
14
+ export function isContinue(pq) {
15
+ return !!pq && pq.kind === "continue";
16
+ }
17
+
18
+ export const CONTINUE_REASON_PLACEHOLDER = "Stop, because… (the model reads it)";
19
+
20
+ // Why a step-limit question closed unanswered (its question_cancelled reason).
21
+ const CONTINUE_CLOSED = {
22
+ dropped: "Dropped: a new prompt came",
23
+ answered: "Answered with /continue",
24
+ superseded: "Set aside for another question",
25
+ replaced: "Asked again",
26
+ };
27
+
11
28
  // A delegate's approval relayed to this session's user (the approval
12
29
  // relay): who asks (the chain, "ab12 → cd34" for a grandchild's) and its
13
30
  // task, or null.
@@ -79,6 +96,7 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
79
96
  const approval = isApproval(pq);
80
97
  const relay = relayView(pq);
81
98
  if (cancelled && relay && reason !== "dismissed") return relayClosedText(relay, reason);
99
+ if (isContinue(pq)) return continueResultText({ answer, cancelled, reason });
82
100
  if (cancelled) {
83
101
  const label = approval ? "Denied" : "Cancelled";
84
102
  return reason ? `${label} (${reason})` : label;
@@ -97,6 +115,14 @@ export function resultText(pq, { answer = null, cancelled = false, reason = "" }
97
115
  return parts.length ? `Answered: ${parts.join(" · ")}` : "Answered";
98
116
  }
99
117
 
118
+ function continueResultText({ answer, cancelled, reason }) {
119
+ if (cancelled) return CONTINUE_CLOSED[reason] || (reason ? `Closed (${reason})` : "Closed");
120
+ const sel = answer && Array.isArray(answer.selected) ? answer.selected : [];
121
+ const freeform = answer && answer.freeform ? String(answer.freeform) : "";
122
+ if (sel[0] === "Continue") return "Continued";
123
+ return freeform ? `Stopped: ${freeform}` : "Stopped";
124
+ }
125
+
100
126
  // Shorten a line of text for the card's summary (the collapsed one-liner).
101
127
  export function truncate(text, n = 80) {
102
128
  const s = String(text ?? "");
@@ -115,7 +141,8 @@ export function summaryText(pq, { answer = null, cancelled = false, reason = ""
115
141
  const text = asked ? `${truncate(asked, 60)} → ${result}` : result;
116
142
  return truncate(relay ? `${relay.who}: ${text}` : text, 120);
117
143
  }
118
- const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?` : "Question";
144
+ const label = isApproval(pq) ? `Approve ${pq.approval?.label || pq.approval?.tool || "call"}?`
145
+ : isContinue(pq) ? "Step limit" : "Question";
119
146
  return truncate(pq.header || label);
120
147
  }
121
148