samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -0,0 +1,103 @@
1
+ import { easeScrollTo, FOLLOW_MS } from "./scroll.js";
2
+ import { FOLD_MS } from "./stage_view.js";
3
+ import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
4
+ import { toastBottom } from "./toast_place.js";
5
+
6
+ // What floats over the history (used by app.js): the composer card, and in
7
+ // the stage view the stage above it. Two measures follow it.
8
+ //
9
+ // The reserve: the card floats over the history, so the history's bottom
10
+ // padding (--dock-h on #center, index.html) is kept at the card's height,
11
+ // and a reader at the end stays pinned there, since the card grows (text,
12
+ // chips, Cancel, a wrapping footer) under a reader at the end. In the stage
13
+ // view the stage floats above the card, out of the dock's flow
14
+ // (stage_view.js): the reserve counts it too, with its STAGE_GAP, while it
15
+ // shows. A leaving stage (the hand-off's slide) no longer counts: the
16
+ // reserve eases down over the slide (#center.reserve-easing).
17
+ //
18
+ // The toast: it sits above the session's composer card (measured: it grows
19
+ // with a wrapping footer or a taller box), so it never covers the card's
20
+ // controls, and above the stage while it shows and a fresh link's open
21
+ // model panel (toast_place.js); with no card in sight (the start page, all
22
+ // sessions) the CSS's 24 px from the bottom (--toast-bottom unset).
23
+ //
24
+ // +stageEl+: the stage (stage view) or null (turn view). +atHistoryEnd()+:
25
+ // the reader is at the history's end (app.js, scroll.js's threshold).
26
+ // +reduce()+ / +ease+: prefers-reduced-motion and the eased follow
27
+ // (scroll.js), defaults the page's.
28
+ // The observers that call these are installed here: the dock's and the
29
+ // stage's size, the stage showing, hiding or leaving, the window's size and
30
+ // the model panel's. app.js calls placeToast() when the page enters or
31
+ // leaves a session and when a toast shows or goes (toasts.js onChange).
32
+ export function createDockReserve({
33
+ dockEl, centerEl, stageEl, historyEl, toastEl, modelPanelEl, atHistoryEnd,
34
+ reduce = reducedMotion, ease = easeScrollTo,
35
+ }) {
36
+ const doc = dockEl.ownerDocument;
37
+ const win = doc.defaultView;
38
+
39
+ function dockReserve() {
40
+ const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
41
+ return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
42
+ }
43
+ const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
44
+
45
+ // A reader at the end stays there when the reserve changes: at once for the
46
+ // composer card, eased for the stage (+fromStage+: it appeared, grew or
47
+ // leaves), so the history glides rather than jumps. An eased follow running
48
+ // counts as at the end.
49
+ function updateReserve({ fromStage = false } = {}) {
50
+ const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
51
+ const leaving = !!stageEl?.classList.contains("leaving");
52
+ centerEl.classList.toggle("reserve-easing", leaving);
53
+ const reserve = `${dockReserve()}px`;
54
+ const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
55
+ if (changed) centerEl.style.setProperty("--dock-h", reserve);
56
+ if (changed && follow) {
57
+ if (fromStage) ease(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: reduce() });
58
+ else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
59
+ }
60
+ placeToast();
61
+ }
62
+
63
+ function placeToast() {
64
+ const r = dockEl.getBoundingClientRect();
65
+ const shownTop = (el) => {
66
+ const box = el && !el.hidden ? el.getBoundingClientRect() : null;
67
+ return box && box.height > 0 ? box.top : null;
68
+ };
69
+ const px = toastBottom({
70
+ inSession: doc.body.classList.contains("has-session") && r.height > 0,
71
+ viewportHeight: win.innerHeight,
72
+ dockTop: r.top,
73
+ tops: [shownTop(stageEl), shownTop(modelPanelEl)],
74
+ toastHeight: toastEl.offsetHeight,
75
+ });
76
+ const bottom = px === null ? "" : `${px}px`;
77
+ const root = doc.documentElement.style;
78
+ if (root.getPropertyValue("--toast-bottom") !== bottom) {
79
+ if (bottom) root.setProperty("--toast-bottom", bottom);
80
+ else root.removeProperty("--toast-bottom");
81
+ }
82
+ }
83
+
84
+ const dockObserver = new win.ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
85
+ dockObserver.observe(dockEl);
86
+ if (stageEl) {
87
+ dockObserver.observe(stageEl);
88
+ // Only showing, hiding and leaving change the reserve (not the stage's
89
+ // other classes: waiting, in-use…).
90
+ let stageShown = "";
91
+ new win.MutationObserver(() => {
92
+ const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
93
+ if (shown === stageShown) return;
94
+ stageShown = shown;
95
+ updateReserve({ fromStage: true });
96
+ }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
97
+ }
98
+ win.addEventListener("resize", () => placeToast());
99
+ // The model panel opening, closing or changing height (its list filters).
100
+ new win.ResizeObserver(() => placeToast()).observe(modelPanelEl);
101
+
102
+ return { updateReserve, placeToast };
103
+ }
@@ -547,7 +547,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
547
547
  @media (hover:none){.copy-btn{opacity:.55}}
548
548
  /* The dock is the composer card: the text field, a toolbar row, the info bar as its footer. */
549
549
  #dock{display:flex;flex-direction:column;flex-shrink:0;margin:0 12px 12px;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
550
- /* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from app.js). */
550
+ /* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from dock_reserve.js). */
551
551
  /* The session panel lets the page's glow through (the history's own layers go clear), so the card and its footer read as glass over it, not as a flat box on black. */
552
552
  body.has-session #center{background:color-mix(in srgb,var(--bg) 35%,transparent);border-color:var(--glass-border);position:relative}
553
553
  body.has-session #stage,body.has-session #history{background:transparent}
@@ -646,7 +646,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
646
646
  body.has-session #composer textarea{min-height:0}
647
647
  body.has-session #composeHint{display:none}
648
648
  body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
649
- /* The model picker (the start page, and a fresh chain link: app.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
649
+ /* The model picker (the start page, and a fresh chain link: start_page.js syncModelPick): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
650
650
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
651
651
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
652
652
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
@@ -719,7 +719,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
719
719
  .turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
720
720
  /* Out of the dock's flow: it floats 10 px above the composer card, so its rise, growth and leaving never move #dock (a
721
721
  bottom-pinned box changing height is a layout shift, animated or not). Painted before .dock-card (no z-index): the command
722
- list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (app.js, --dock-h). */
722
+ list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (dock_reserve.js, --dock-h). */
723
723
  body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-bottom:10px}
724
724
  .turn-stage[hidden]{display:none}
725
725
  .turn-stage.ended{border-left-color:var(--border-strong)}
@@ -1183,7 +1183,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
1183
1183
  .card.archived{opacity:.6}
1184
1184
  .card.archived:hover{opacity:.85}
1185
1185
  .card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
1186
- /* bottom: above the session's composer card when one shows (--toast-bottom, app.js placeToast). */
1186
+ /* bottom: above the session's composer card when one shows (--toast-bottom, dock_reserve.js placeToast). */
1187
1187
  #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1188
1188
  #toast[hidden]{display:none}
1189
1189
  #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
@@ -0,0 +1,173 @@
1
+ import { routeComposerLine, routeSessionLine } from "./composer.js";
2
+ import { chipsAfterSend, restoredChips, turnRefs, turnText, uploadChips } from "./images.js";
3
+ import { cantText, firstPreview } from "./format.js";
4
+ import { setBubbleDelivery } from "./bubbles.js";
5
+ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
6
+
7
+ // The composer's send (used by app.js): a line into the open session, or
8
+ // on the start page a new session first (an idle one, then the line as for
9
+ // any later one, so a failed first turn's prompt comes back into the
10
+ // composer too). The route (composer.js), the echo, the images' upload, the
11
+ // /turn and its ack, a fast failure's restore (restore.js) and the error's
12
+ // words.
13
+ //
14
+ // Hooks: +session()+ the open session's id or null; +state()+ its fields
15
+ // (session_state.js: owner, commands, firstPreview); +running()+ a turn
16
+ // runs; +clientId+; +sentIds+ / +earlyRestores+ (restore.js' bookkeeping);
17
+ // +api+: {createIdleSession, uploadImage, sendTurn} (data.js); +dir+: the
18
+ // page's scope folder (new sessions start there); +startPage+
19
+ // (start_page.js: pendingPick(), chosenModel(), newChoices(),
20
+ // clearChoices()); +takeDelivery()+ the delivery line's armed choice;
21
+ // +composer+: {el (the text box), button (Send/Start), chips(),
22
+ // setChips(list), fit(), clear(), openForVisit(), focus()}; +historyEl+,
23
+ // +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
24
+ // +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
25
+ // +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
26
+ // +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
27
+ // +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
28
+ // +alert+: the browser's.
29
+ export function createSendTurn({
30
+ session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
31
+ historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
32
+ addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
33
+ leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
34
+ }) {
35
+ const promptEl = composer.el;
36
+ const actionBtn = composer.button;
37
+
38
+ // +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
39
+ // a delivery-line link: cut or queue); else the armed one; a typed /cut or
40
+ // /queue wins over both.
41
+ async function send({ delivery: chosen = null } = {}) {
42
+ if (state().owner === "tui") return;
43
+ // A fresh link's pick first: its /model is queued in the worker once
44
+ // acked, and the worker runs queued commands before a turn's input.
45
+ const pick = startPage.pendingPick();
46
+ if (pick) await pick;
47
+ let prompt = turnText(promptEl.value, composer.chips());
48
+ if (!prompt) return;
49
+ const armed = takeDelivery();
50
+ // An image-only send's text is its placeholders: not for ↑.
51
+ const typed = promptEl.value.trim() !== "";
52
+ // The composer holds the line as typed until the ack (composerAfterAck).
53
+ const line = prompt;
54
+ // Where the line goes (composer.js): the page's own reply, a session to
55
+ // make first (the start page), a command, or a turn.
56
+ let route = routeComposerLine(prompt, {
57
+ session: !!session(), running: running(), commands: state().commands, images: composer.chips().length,
58
+ });
59
+ if (route.create) {
60
+ actionBtn.disabled = true;
61
+ try {
62
+ const s = await api.createIdleSession({ dir, model: startPage.chosenModel(), preview: route.send,
63
+ ...startPage.newChoices() });
64
+ // The choice was for this chat only.
65
+ startPage.clearChoices();
66
+ addCreatedSession(s);
67
+ await select(s.id);
68
+ } catch (e) {
69
+ alert(e.message);
70
+ return;
71
+ } finally {
72
+ actionBtn.disabled = false;
73
+ }
74
+ // In the new session, by its command list (select read it).
75
+ route = routeSessionLine(route.send, { commands: state().commands, images: composer.chips().length, delivery: route.delivery });
76
+ }
77
+ if (route.reply) {
78
+ // The start page's reply is a notice: no session is made for it (2.29).
79
+ if (session()) addCommandBubble({ label: null, line: route.line, text: route.reply });
80
+ else showNotice(route.reply);
81
+ composer.clear();
82
+ return;
83
+ }
84
+ if (route.command) return runCommand(route.command);
85
+ prompt = route.send;
86
+ const isRunning = running() && !!session();
87
+ const delivery = route.delivery || (isRunning ? chosen || armed : null);
88
+ const hint = historyEl.querySelector(".hint");
89
+ if (hint) hint.remove();
90
+ // Sending moves a finished turn out of the stage at once (else the echo
91
+ // would land among its rows); into a running one it is a queued row there.
92
+ turnDom.handOff?.({ immediate: true });
93
+ const sending = composer.chips().slice();
94
+ // Into a running turn the echo says what it waits for (its delivery); a
95
+ // line with images always waits for the turn's end (the worker queues it).
96
+ const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
97
+ const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
98
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
99
+ if (!state().firstPreview || state().firstPreview === "—") {
100
+ state().firstPreview = firstPreview(prompt);
101
+ updateInfoBar();
102
+ }
103
+ actionBtn.disabled = true;
104
+ // The session the line goes to: the page may switch to another while an
105
+ // image uploads or the turn is sent, and what follows stays with this one.
106
+ const sentTo = session();
107
+ try {
108
+ await uploadChips(sending, sentTo, { upload: api.uploadImage });
109
+ const ack = await api.sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
110
+ if (ack?.command_id) {
111
+ // The worker knew it as a command (its list was newer than ours):
112
+ // its command_ran shows it, not a prompt bubble.
113
+ echo.remove();
114
+ composer.clear();
115
+ if (sentTo === session()) followWokenWorker();
116
+ return;
117
+ }
118
+ if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
119
+ // A cut the worker didn't take (no thinking-only step: cut off) goes in
120
+ // at the next step; a queue whose turn ended meanwhile is the next turn.
121
+ if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
122
+ setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
123
+ }
124
+ refreshHistory();
125
+ // A turn that failed fast was restored before this ack: its prompt
126
+ // (and images) go back into the composer, without replacing text typed
127
+ // since (4.13). The composer still holds the text just sent: it goes.
128
+ const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
129
+ // A restored line must be seen: the composer opens for this visit.
130
+ if (restored?.refill && sentTo === session()) composer.openForVisit();
131
+ promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
132
+ composer.fit();
133
+ // Only the sent chips go: images added since stay (they go up at their own send).
134
+ const kept = chipsAfterSend(composer.chips(), sending);
135
+ composer.setChips(restored?.images?.length ? kept.concat(restoredChips(sentTo, restored.images)) : kept);
136
+ // Its turn_enqueued usually tagged the echo already; the ack covers a
137
+ // worker that wasn't live (no turn_enqueued then).
138
+ if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
139
+ echo.dataset.enqueuedId = ack.enqueued_id;
140
+ }
141
+ // A live stream carries this turn already; restarting it would drop the
142
+ // events in between. Without one, POST /turn just woke a worker: its
143
+ // events start after the seq the session was rendered at (0 for a new
144
+ // worker process). A switch since then opened the other session's own.
145
+ if (sentTo === session()) followWokenWorker();
146
+ } catch (e) {
147
+ echo.remove();
148
+ // The turn never reached the worker's queue: an early restore waiting
149
+ // for its ack is never taken back (4.13), so drop it here. The prompt
150
+ // is still in the composer, as the echo was removed.
151
+ dropEarlyRestores(earlyRestores);
152
+ // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
153
+ // image refused (bad_image, too_large, bad_images) says why below.
154
+ if (e.code === "owned_by_tui") {
155
+ if (sentTo !== session()) return toast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
156
+ state().owner = "tui";
157
+ updateComposerMode();
158
+ return;
159
+ }
160
+ // A stale tab sent into a session deleted meanwhile: the start page
161
+ // says so, and the line stays in the composer.
162
+ if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
163
+ // A /queue to a worker that can't queue (it would steer): its words, the text kept.
164
+ if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
165
+ toast(cantText("send", e), null, { sticky: true });
166
+ } finally {
167
+ actionBtn.disabled = state().owner === "tui";
168
+ composer.focus();
169
+ }
170
+ }
171
+
172
+ return { send };
173
+ }
@@ -0,0 +1,203 @@
1
+ import { continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
2
+ import { cantText, deleteConfirmText, goneSessionNotice } from "./format.js";
3
+ import { sessionHash } from "./route.js";
4
+ import { inTree } from "./sessions_list.js";
5
+
6
+ // What the page does to a whole session (used by app.js): Continue, Archive
7
+ // and Unarchive, Delete and Stop, from the info bar's buttons, a card's or a
8
+ // batch; and the start page when the open session is gone.
9
+ //
10
+ // Hooks: +selected()+ the open session's id or null; +state()+ its fields
11
+ // (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
12
+ // +api+: {continueSession, archiveSession, unarchiveSession, deleteSession,
13
+ // stopSession} (data.js); +eventsLive()+ the hub's stream is open (else
14
+ // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
15
+ // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
16
+ // +toast(text, action, options)+, +addCreatedSession(created)+,
17
+ // +fillOpener(created)+ (the new link's composer), +closeLiveStream()+ (the live
18
+ // stream's close, the stream left as it is) and +workerGone(id)+ the page's.
19
+ // +alert+ / +confirm+: the browser's.
20
+ export function createSessionActions({
21
+ selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
22
+ select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, closeLiveStream, workerGone,
23
+ alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
24
+ }) {
25
+ // The session this tab is stopping: its stream may close before the stop
26
+ // request returns (workerGone reads it).
27
+ let stoppingId = null;
28
+
29
+ // Delete a session (the info bar's button, an all-sessions card's ✕) after
30
+ // a confirm; the list drops it without a reload. The server stops a live
31
+ // worker first and refuses a session a terminal chi has open.
32
+ async function confirmDelete(id) {
33
+ const sess = sessions().find((s) => s.id === id) || { id };
34
+ if (sess.owner === "tui") {
35
+ alert(`Session ${id.slice(0, 8)} is open in a terminal chi; close it there first.`);
36
+ return;
37
+ }
38
+ if (!confirm(deleteConfirmText(sess))) return;
39
+ try {
40
+ await api.deleteSession(id);
41
+ } catch (e) {
42
+ alert(`Could not delete session ${id.slice(0, 8)}: ${e.message}`);
43
+ if (!eventsLive()) refresh();
44
+ return;
45
+ }
46
+ setSessions(sessions().filter((s) => s.id !== id));
47
+ if (selected() === id) clearSelection();
48
+ lists.render();
49
+ }
50
+
51
+ // The open session no longer exists: back to the start page, saying why.
52
+ function leaveGoneSession(id, why) {
53
+ if (selected() !== id) return;
54
+ clearSelection();
55
+ showNotice(goneSessionNotice(id, why));
56
+ }
57
+
58
+ // The info bar's Stop: the open session's worker, after a confirm.
59
+ async function stopSelected() {
60
+ if (!selected()) return;
61
+ if (!confirm(`Stop session ${selected()}?`)) return;
62
+ const id = selected();
63
+ stoppingId = id;
64
+ try {
65
+ await api.stopSession(id);
66
+ if (id === selected()) stoppedWorker(id);
67
+ } catch (e) {
68
+ // A refusal (a terminal holds it) or a worker that won't stop: said, as
69
+ // a failed restart is.
70
+ toast(cantText(`stop ${id.slice(0, 8)}`, e), null, { sticky: true });
71
+ } finally {
72
+ stoppingId = null;
73
+ }
74
+ // The list hears of the stop from the hub (the stop route rescans it).
75
+ if (!eventsLive()) refresh();
76
+ }
77
+
78
+ // ── Continue (session chains) ─────────────────────────────────────────────
79
+ // Continue → starts the next link of the open session's chain (the server
80
+ // waits up to 30 s for its recap, archives it, starts the new one with the
81
+ // recap as a note) and opens it with the opener in the composer, not sent
82
+ // (fillOpener: the previous link's own first message when it continues one).
83
+ // A link continued already opens that next link; its open delegates move
84
+ // to the new link (the toast counts them); a refusal for open delegates
85
+ // that can't move names them, each a link.
86
+ // One continue per session at a time (the server locks it too); another
87
+ // session's continue may run beside it.
88
+ const continuingIds = new Set();
89
+
90
+ async function continueSelected() {
91
+ if (!selected() || continuingIds.has(selected())) return;
92
+ const id = selected();
93
+ const short = id.slice(0, 8);
94
+ continuingIds.add(id);
95
+ updateInfoBar();
96
+ try {
97
+ const created = await api.continueSession(id);
98
+ markArchived([id], true);
99
+ addCreatedSession(created);
100
+ const next = created.id;
101
+ // A model fallback's warning stays until closed.
102
+ const sticky = !!created.model_warning;
103
+ if (selected() !== id) {
104
+ toast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) }, { sticky });
105
+ return;
106
+ }
107
+ await select(next);
108
+ if (selected() === next) fillOpener(created);
109
+ toast(continuedText(id, created), null, { sticky });
110
+ } catch (e) {
111
+ const refusal = continueRefusal(e, sessions());
112
+ if (refusal.kind === "open") {
113
+ if (selected() === id) await select(refusal.id);
114
+ toast(`${short} is continued already: ${selected() === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
115
+ } else if (refusal.kind === "children") {
116
+ toast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
117
+ } else {
118
+ toast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
119
+ }
120
+ } finally {
121
+ continuingIds.delete(id);
122
+ updateInfoBar();
123
+ }
124
+ }
125
+
126
+ // ── Archive ───────────────────────────────────────────────────────────────
127
+ // Archive hides a session (and its delegates) from the strip and the list,
128
+ // kept for good; "include archived" in all sessions finds it. No confirm:
129
+ // the toast's Undo takes it back. Archiving the open session stays on it
130
+ // (the button flips to unarchive); its worker was stopped by the server.
131
+
132
+ function selectedArchived() {
133
+ return !!sessions().find((s) => s.id === selected())?.archived;
134
+ }
135
+
136
+ // The ids in the server's answer flip here at once; the hub's events
137
+ // follow with the same.
138
+ function markArchived(ids, archived) {
139
+ const set = new Set(ids);
140
+ setSessions(sessions().map((s) => (set.has(s.id) ? { ...s, archived } : s)));
141
+ }
142
+
143
+ // The open session's worker was stopped from here (the stop button, an
144
+ // archive): don't wait for its stream to notice.
145
+ function stoppedWorker(id) {
146
+ closeLiveStream();
147
+ state().status = "stopped";
148
+ workerGone(id);
149
+ }
150
+
151
+ // One archive / unarchive request, the core the info bar, a card's button
152
+ // and a batch share. The ids in the answer flip in the list at once. When
153
+ // the open session's worker was stopped (it, or a delegate of the archived
154
+ // one), its stream ending is a stop, not a lost worker.
155
+ // @return {ok: true, result} | {ok: false, error}
156
+ async function archiveOne(id, archive) {
157
+ // A live worker in the tree is stopped by the archive: the open
158
+ // session's stream ending then is a stop.
159
+ const open = selected();
160
+ const mine = archive && open && inTree(sessions(), id, open) ? open : null;
161
+ if (mine) stoppingId = mine;
162
+ try {
163
+ const result = archive ? await api.archiveSession(id) : await api.unarchiveSession(id);
164
+ markArchived(archive ? result.archived : result.unarchived, archive);
165
+ if (mine && mine === selected() && (result.stopped || []).includes(mine)) stoppedWorker(mine);
166
+ return { ok: true, result };
167
+ } catch (error) {
168
+ return { ok: false, error };
169
+ } finally {
170
+ // Another call may have taken it meanwhile: clear only our own.
171
+ if (mine && stoppingId === mine) stoppingId = null;
172
+ }
173
+ }
174
+
175
+ async function setArchived(id, archive, { undo = true } = {}) {
176
+ const short = id.slice(0, 8);
177
+ const answer = await archiveOne(id, archive);
178
+ if (!answer.ok) {
179
+ alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
180
+ return;
181
+ }
182
+ lists.render();
183
+ updateInfoBar();
184
+ if (!eventsLive()) refresh();
185
+ const what = archive ? "Archived" : "Unarchived";
186
+ toast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
187
+ }
188
+
189
+ // The info bar's Archive / Unarchive: the open session's, by its state.
190
+ function toggleArchiveSelected() {
191
+ if (!selected()) return;
192
+ if (selectedArchived()) setArchived(selected(), false);
193
+ else setArchived(selected(), true);
194
+ }
195
+
196
+ return {
197
+ confirmDelete, leaveGoneSession, stopSelected, continueSelected, archiveOne, setArchived, toggleArchiveSelected,
198
+ // The sessions whose Continue is under way (the info bar's button).
199
+ continuing: continuingIds,
200
+ // The session this tab is stopping, or null.
201
+ stoppingId: () => stoppingId,
202
+ };
203
+ }