samagotchi 0.115.0 → 0.117.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +33 -2
  5. data/docs/configuration.md +13 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/sessions.md +3 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  10. data/lib/samagotchi/bridge.rb +114 -14
  11. data/lib/samagotchi/bridge_client.rb +20 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +34 -3
  14. data/lib/samagotchi/engine.rb +6 -1
  15. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  16. data/lib/samagotchi/guardrails/approval.rb +20 -0
  17. data/lib/samagotchi/guardrails/targets.rb +23 -2
  18. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  19. data/lib/samagotchi/guardrails_command.rb +329 -0
  20. data/lib/samagotchi/hold_stop.rb +89 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  22. data/lib/samagotchi/prompt_warmup.rb +8 -3
  23. data/lib/samagotchi/session_access/contract.rb +1 -1
  24. data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
  25. data/lib/samagotchi/session_commands.rb +79 -2
  26. data/lib/samagotchi/session_manager.rb +3 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -1
  28. data/lib/samagotchi/system_one/answer.rb +78 -0
  29. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  30. data/lib/samagotchi/system_one/client.rb +89 -0
  31. data/lib/samagotchi/system_one/errors.rb +14 -0
  32. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  33. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  34. data/lib/samagotchi/system_one/log.rb +133 -0
  35. data/lib/samagotchi/system_one/question.rb +86 -0
  36. data/lib/samagotchi/system_one/record.rb +83 -0
  37. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  38. data/lib/samagotchi/system_one/similar.rb +69 -0
  39. data/lib/samagotchi/system_one.rb +25 -0
  40. data/lib/samagotchi/task_watch.rb +23 -5
  41. data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  43. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/web/app.rb +3 -0
  46. data/lib/samagotchi/web/public/activity.js +10 -0
  47. data/lib/samagotchi/web/public/app.js +20 -1
  48. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  49. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  50. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  51. data/lib/samagotchi/web/public/composer_size.js +40 -5
  52. data/lib/samagotchi/web/public/data.js +24 -0
  53. data/lib/samagotchi/web/public/favicon.svg +1 -1
  54. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  55. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  56. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  57. data/lib/samagotchi/web/public/index.html +21 -0
  58. data/lib/samagotchi/web/public/session_actions.js +39 -6
  59. data/lib/samagotchi/web/public/stop_sheet.js +126 -0
  60. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  61. data/lib/samagotchi/web/public/timing.js +9 -5
  62. data/lib/samagotchi/web/public/turn_events.js +1 -0
  63. data/lib/samagotchi/web/public/turn_html.js +13 -2
  64. data/lib/samagotchi/web/session_access_routes.rb +9 -6
  65. data/lib/samagotchi/web/session_control.rb +62 -3
  66. data/lib/samagotchi/web/session_show.rb +35 -5
  67. data/lib/samagotchi/worker.rb +57 -5
  68. data/lib/samagotchi/worker_idle_exit.rb +111 -9
  69. metadata +17 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.115.0"
4
+ VERSION = "0.117.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -236,6 +236,8 @@ module Samagotchi
236
236
  ["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
237
237
  ["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
238
238
  ["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
239
+ ["GET", %r{\A/api/sessions/([^/]+)/holds\z}, :handle_session_holds, :owner, :session],
240
+ ["POST", %r{\A/api/sessions/([^/]+)/holds/stop\z}, :handle_stop_holds, :owner, :session],
239
241
  ["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
240
242
  ["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
241
243
  ["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
@@ -247,6 +249,7 @@ module Samagotchi
247
249
  ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
248
250
  ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
249
251
  ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
252
+ ["POST", %r{\A/api/sessions/([^/]+)/guardrails/wrong\z}, :handle_system_one_wrong, :owner, :session],
250
253
  ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
251
254
  ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
252
255
  ["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
@@ -144,6 +144,16 @@ export function addCompleted(model, event = {}, now = null) {
144
144
  Object.assign(row, pickFields(event.activity, ACTIVITY_FIELDS));
145
145
  // A grep that found nothing (status ok): the row says "no match".
146
146
  if (event.activity?.no_match) row.no_match = true;
147
+ // System 1 allowed the call once instead of asking (the row's label); any
148
+ // other guardrail verdict draws nothing.
149
+ const guardrail = event.activity?.guardrail;
150
+ if (guardrail?.decided_by === "system one") {
151
+ row.guardrail = {
152
+ rule: guardrail.rule,
153
+ p: guardrail.p,
154
+ ...(guardrail.judgment_id ? { judgment_id: guardrail.judgment_id } : {}),
155
+ };
156
+ }
147
157
  // The images the tool returned (a reload in the turn view draws them from
148
158
  // the saved parts, turn_html.js toolRowTemplate).
149
159
  if (event.images?.length) row.images = event.images;
@@ -4,6 +4,8 @@ import {
4
4
  sendTurn,
5
5
  cancelTurn,
6
6
  stopSession,
7
+ sessionHolds,
8
+ stopSessionHolds,
7
9
  restartSession,
8
10
  deleteSession,
9
11
  archiveSession,
@@ -50,6 +52,7 @@ import { createGapClock, lastActivity } from "./time_gaps.js";
50
52
  import { createMarkdownPlace } from "./markdown_place.js";
51
53
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
52
54
  import { createTaskStopper } from "./task_stop.js";
55
+ import { createSystemOneWrongAction } from "./system_one_wrong.js";
53
56
  import { appendQuote } from "./annotations.js";
54
57
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
55
58
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
@@ -97,6 +100,7 @@ import { createToasts } from "./toasts.js";
97
100
  import { createUnqueue } from "./unqueue.js";
98
101
  import { createStartPage } from "./start_page.js";
99
102
  import { createSessionActions } from "./session_actions.js";
103
+ import { createStopSheet } from "./stop_sheet.js";
100
104
  import { createRename } from "./session_rename.js";
101
105
  import { createSendTurn } from "./send_turn.js";
102
106
  import { createMarkdownTip } from "./markdown_tip.js";
@@ -299,6 +303,8 @@ const { placeToast } = createDockReserve({
299
303
  function turnRoots() {
300
304
  return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
301
305
  }
306
+ const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => selected });
307
+ turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
302
308
  function findInTurnRoots(selector) {
303
309
  return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
304
310
  }
@@ -562,12 +568,21 @@ const lists = createListsView({
562
568
 
563
569
  // Continue, Archive, Delete and Stop, and the start page when the open
564
570
  // session is gone (session_actions.js).
571
+ // The Stop sheet first (stop_sheet.js): its buttons are the two ways to stop,
572
+ // and the sheet is only shown when the worker has something the user can stop.
573
+ const stopSheet = createStopSheet({
574
+ doc: document, win: window,
575
+ stopAll: (id) => sessionActions.stopSelected(id, { stopHolds: true }),
576
+ stopSession: (id) => sessionActions.stopSelected(id),
577
+ });
578
+
565
579
  const sessionActions = createSessionActions({
566
580
  selected: () => selected,
567
581
  state: () => current,
568
582
  sessions: () => allSessions,
569
583
  setSessions: (list) => { allSessions = list; },
570
- api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession },
584
+ api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession,
585
+ sessionHolds, stopSessionHolds },
571
586
  eventsLive: () => !!eventsControl,
572
587
  refresh,
573
588
  lists,
@@ -581,6 +596,7 @@ const sessionActions = createSessionActions({
581
596
  restoreDraft,
582
597
  closeLiveStream: () => streamControl?.close(),
583
598
  workerGone,
599
+ stopSheet,
584
600
  });
585
601
  // The title's ✎: /title through the chips' path (session_rename.js), the
586
602
  // input over the title row on screen: the top bar's when it shows the
@@ -1656,6 +1672,9 @@ attachInput.addEventListener("change", () => {
1656
1672
  attachInput.value = "";
1657
1673
  });
1658
1674
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1675
+ // The floor is saved as it was set; fitPrompt holds it to half the window it is
1676
+ // drawn in (composer_size.js), so a floor saved in a bigger window fits this one
1677
+ // and comes back when the window is big again.
1659
1678
  let promptFloor = Number(readStored(GRIP_KEY)) || null;
1660
1679
 
1661
1680
  // The empty box: one line in a session, three on the start page.
@@ -14,17 +14,17 @@ export function composerFolded({ saved, archivedVisit, visitOpen }) {
14
14
  return !!saved || !!archivedVisit;
15
15
  }
16
16
 
17
- // The composer's grip while it is the fold's handle. Expanded it only says
18
- // how to resize and hide; folded it is drawn as the handle on the info
19
- // bar's top edge, pulled up or clicked to open, and marked: `draft` (the
20
- // box holds text or images: folding never clears them: the accent),
21
- // `waiting` (the attention colour: the session waits on the user, and its
22
- // card may not take the focus while a draft is there). The title carries
23
- // the words.
17
+ // The composer's grip while it is the fold's handle. Expanded it says how to
18
+ // resize it and that a click hides it (a double-click resets the height);
19
+ // folded it is drawn as the handle on the info bar's top edge, pulled up or
20
+ // clicked to open, and marked: `draft` (the box holds text or images: folding
21
+ // never clears them: the accent), `waiting` (the attention colour: the session
22
+ // waits on the user, and its card may not take the focus while a draft is
23
+ // there). The title carries the words.
24
24
  // @return {title, folded, draft, waiting}
25
25
  export function foldHandle({ folded, draft, waiting }) {
26
26
  if (!folded) {
27
- return { title: "Drag to make the message box taller, down to hide it (double-click resets)", folded: false, draft: false, waiting: false };
27
+ return { title: "Drag to resize, click to hide (double-click resets)", folded: false, draft: false, waiting: false };
28
28
  }
29
29
  const marked = !!draft;
30
30
  let title = "Pull up or click to write";
@@ -1,9 +1,9 @@
1
- import { GRIP_OPEN_PX, gripFloor, gripFolds } from "./composer_size.js";
1
+ import { GRIP_CLICK_FOLD_MS, GRIP_OPEN_PX, gripFloor, gripFolds, gripGesture } from "./composer_size.js";
2
2
  import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
3
3
  import { readStored, removeStored, writeStored } from "./storage.js";
4
4
 
5
- // The composer's grip and fold (used by app.js): the grip's drag, tap and keys, the
6
- // fold's state per visit and its page-wide listeners. app.js keeps the box's
5
+ // The composer's grip and fold (used by app.js): the grip's drag, click and keys,
6
+ // the fold's state per visit and its page-wide listeners. app.js keeps the box's
7
7
  // height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
8
8
  // fitPrompt reads; the floor is saved under GRIP_KEY.
9
9
  export const GRIP_KEY = "chi_composer_floor";
@@ -24,22 +24,44 @@ export function createComposerGrip({
24
24
  // past the fold threshold.
25
25
  const gripStartTitle = composeGrip.title;
26
26
  let gripWillFold = false;
27
+ // A click on the expanded grip folds GRIP_CLICK_FOLD_MS after it, so a
28
+ // second click in that window can still mean "reset the height" (a
29
+ // double-click). A drag or a key acts at once and takes the wait back.
30
+ let pendingFold = null;
27
31
  // This visit's state (a visit is one select() call): opened by the page
28
32
  // (a focus, a restored line), archived (decided once, from the visit's
29
33
  // first getSession answer).
30
34
  const newFoldVisit = () => ({ open: false, archived: false, decided: false });
31
35
  let foldVisit = newFoldVisit();
32
36
 
33
- // A drag down past the box folds the composer on release (a session only: the
34
- // start page's grip only resizes); from folded, a pull up opens it and the box
35
- // follows the pointer in the same gesture, a tap opens it and focuses the box.
36
- // No click listener: a click still fires after pointerdown's preventDefault
37
- // and would toggle twice.
37
+ function cancelPendingFold() {
38
+ if (!pendingFold) return;
39
+ clearTimeout(pendingFold);
40
+ pendingFold = null;
41
+ }
42
+
43
+ // The grip's custom height, forgotten only by a double-click.
44
+ function resetFloor() {
45
+ setFloor(null);
46
+ removeStored(GRIP_KEY);
47
+ fitPrompt();
48
+ }
49
+
50
+ // One release of the grip is one gesture, decided by composer_size.js's
51
+ // gripGesture: a drag down past the box folds the composer on release (a
52
+ // session only: the start page's grip only resizes); from folded, a pull up
53
+ // opens it and the box follows the pointer in the same gesture, a tap opens it
54
+ // and focuses the box; a click on the expanded grip folds it too, GRIP_CLICK_FOLD_MS
55
+ // after the release so a second click in that window can still reset the height.
56
+ // No click listener: a click still fires after pointerdown's preventDefault,
57
+ // so the release is the one that acts.
38
58
  composeGrip.addEventListener("pointerdown", (e) => {
39
59
  e.preventDefault();
40
60
  composeGrip.setPointerCapture(e.pointerId);
41
61
  composeGrip.classList.add("dragging");
42
62
  const tapY = e.clientY;
63
+ // The height the box had before this press: a drag that folds brings it back.
64
+ const floorBefore = floor();
43
65
  let startY = tapY;
44
66
  let startH = promptEl.offsetHeight;
45
67
  let opened = false;
@@ -76,17 +98,35 @@ export function createComposerGrip({
76
98
  };
77
99
  const up = (ev) => {
78
100
  const folds = end();
79
- if (folds) {
80
- // The next opening starts at the minimum (or the text's height).
81
- setFloor(null);
82
- removeStored(GRIP_KEY);
101
+ const gesture = gripGesture({
102
+ session: !!session(),
103
+ folded: isComposerFolded(),
104
+ dy: ev.clientY - tapY,
105
+ willFold: folds,
106
+ pending: !!pendingFold,
107
+ });
108
+ if (gesture === "fold") {
109
+ cancelPendingFold();
110
+ // The drag asked for a smaller box, but opening it brings back the
111
+ // height it had before the press: only a double-click forgets it.
112
+ setFloor(floorBefore);
113
+ writeStored(GRIP_KEY, String(floorBefore || ""));
83
114
  setComposerFolded(true);
84
- } else if (session() && isComposerFolded()) {
85
- if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
86
- setComposerFolded(false);
87
- promptEl.focus();
88
- }
89
- } else {
115
+ } else if (gesture === "open") {
116
+ cancelPendingFold();
117
+ setComposerFolded(false);
118
+ promptEl.focus();
119
+ } else if (gesture === "reset") {
120
+ resetFloor();
121
+ } else if (gesture === "hide") {
122
+ writeStored(GRIP_KEY, String(floor() || ""));
123
+ pendingFold = setTimeout(() => {
124
+ pendingFold = null;
125
+ setComposerFolded(true);
126
+ }, GRIP_CLICK_FOLD_MS);
127
+ } else if (gesture === "resize") {
128
+ // A drag that stops short of folding: keep the box at the height it asks for.
129
+ cancelPendingFold();
90
130
  writeStored(GRIP_KEY, String(floor() || ""));
91
131
  // A pull that opened the box leaves the focus in it, as a tap does.
92
132
  if (opened) promptEl.focus();
@@ -103,15 +143,16 @@ export function createComposerGrip({
103
143
  });
104
144
  composeGrip.addEventListener("dblclick", () => {
105
145
  if (session() && isComposerFolded()) return;
106
- setFloor(null);
107
- removeStored(GRIP_KEY);
108
- fitPrompt();
146
+ cancelPendingFold();
147
+ resetFloor();
109
148
  });
110
149
  // Enter or Space on the focused handle folds, or opens and focuses the box
111
- // (a session only; preventDefault: Space would scroll the page).
150
+ // (a session only; preventDefault: Space would scroll the page). At once:
151
+ // the keyboard has no double-click to wait for.
112
152
  composeGrip.addEventListener("keydown", (e) => {
113
153
  if (!session() || (e.key !== "Enter" && e.key !== " ")) return;
114
154
  e.preventDefault();
155
+ cancelPendingFold();
115
156
  if (isComposerFolded()) {
116
157
  setComposerFolded(false);
117
158
  promptEl.focus();
@@ -135,6 +176,8 @@ export function createComposerGrip({
135
176
  }
136
177
  function applyComposerFolded() {
137
178
  const folded = isComposerFolded();
179
+ // Anything else opened the composer: the click's wait for its fold is over.
180
+ if (!folded) cancelPendingFold();
138
181
  const was = doc.body.classList.contains("composer-folded");
139
182
  doc.body.classList.toggle("composer-folded", folded);
140
183
  // Its height was measured while hidden.
@@ -1,24 +1,36 @@
1
- // The composer's height. It grows with its text up to a cap; the grip on
2
- // its top edge sets a taller floor (the cap then is at least that floor).
1
+ // The composer's height and the gestures of the grip on its top edge. The
2
+ // height grows with the text up to a cap; the grip sets a taller floor (the
3
+ // cap then is at least that floor), and no floor — saved in another window or
4
+ // dragged here — takes more than half of the window the box is shown in. A
5
+ // release of the grip is one gesture: a drag that resizes, a drag far enough
6
+ // down that folds the box away, or a click (which, expanded, folds too — after
7
+ // a delay, so a double-click can still reset the height).
3
8
  export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
4
9
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
5
10
  const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
11
  const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
7
12
  export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
8
13
  export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
14
+ export const GRIP_CLICK_FOLD_MS = 250; // how long a click on the expanded grip waits before folding, so a second click in that window can still mean a reset
9
15
 
10
16
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
11
17
  // grip's height, or null; minPx: the empty box's height.
12
18
  export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
13
- const floor = Math.max(minPx, floorPx || 0);
19
+ // The floor, as THIS window can hold it: a floor saved in a bigger window
20
+ // (a rotated phone, a resized window, another device on the same origin) is
21
+ // held down to the share a drag may set here, and comes back whole when the
22
+ // window is big again — only the height shown shrinks, never the saved floor.
23
+ const floor = gripFloor(floorPx, viewportPx, minPx);
14
24
  const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
15
25
  return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
16
26
  }
17
27
 
18
28
  // A grip drag: the floor it asks for, kept between the minimum and the
19
- // grip's share of the window.
29
+ // grip's share of the window. The same rule fits a floor saved earlier (see
30
+ // promptHeight), so the box never takes more of a window than a drag in it
31
+ // could have given it.
20
32
  export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
21
- return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
33
+ return Math.max(minPx, Math.min(px || 0, Math.round(viewportPx * PROMPT_GRIP_SHARE)));
22
34
  }
23
35
 
24
36
  // Whether a grip drag asks to fold the composer: it wants the box at least
@@ -28,3 +40,26 @@ export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
28
40
  export function gripFolds({ requestedPx, shownPx }) {
29
41
  return shownPx - requestedPx >= GRIP_FOLD_PX;
30
42
  }
43
+
44
+ // What a grip release asks for, decided from the press alone (the strip's
45
+ // grip's twin, strip.js's stripGripAction). +session+: is there a session (the
46
+ // start page's grip only resizes). +folded+: the composer is folded now.
47
+ // +dy+: the release y minus the press y, down positive. +willFold+: the drag is
48
+ // past GRIP_FOLD_PX below what the box shows. +pending+: an earlier click's fold
49
+ // is still waiting its GRIP_CLICK_FOLD_MS, so this release is the second click.
50
+ // fold – hide the box now
51
+ // open – a tap on the folded handle
52
+ // hide – the first click on the expanded grip: fold it after the delay
53
+ // reset – the second click in that delay: a double-click, so the height is reset
54
+ // resize – a drag that stops short: keep the box and save the floor it asks for
55
+ // none – a drag down on the folded handle (pulling up opens it while dragging)
56
+ export function gripGesture({ session, folded, dy, willFold, pending }) {
57
+ if (willFold) return "fold";
58
+ const tap = Math.abs(dy) < GRIP_OPEN_PX;
59
+ if (tap) {
60
+ if (folded) return "open";
61
+ if (!session) return "resize";
62
+ return pending ? "reset" : "hide";
63
+ }
64
+ return folded ? "none" : "resize";
65
+ }
@@ -180,10 +180,34 @@ export function dismissQuestion(id, questionId, opts = {}) {
180
180
  });
181
181
  }
182
182
 
183
+ // Flags a System 1 auto-allow in this session as one it should have asked
184
+ // about; the owner-only route returns 404 for an unknown judgment.
185
+ export function markSystemOneWrong(id, judgmentId, opts = {}) {
186
+ return api(`/api/sessions/${encodeURIComponent(id)}/guardrails/wrong`, {
187
+ method: "POST",
188
+ body: JSON.stringify({ id: judgmentId }),
189
+ ...opts,
190
+ });
191
+ }
192
+
183
193
  export function stopSession(id, opts = {}) {
184
194
  return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
185
195
  }
186
196
 
197
+ // What this session's worker still has that the user can stop (reminders, a
198
+ // background task running, a delegate with a live worker), for the Stop
199
+ // button's sheet: 200 {holds: []} always — no worker, or one that can't say,
200
+ // is an empty list, so Stop stays one tap.
201
+ export function sessionHolds(id, opts = {}) {
202
+ return api(`/api/sessions/${encodeURIComponent(id)}/holds`, { ...opts });
203
+ }
204
+
205
+ // The sheet's "Stop all": the worker stops everything the user can before the
206
+ // Stop TERMs it. 200 {status: "stopped", stopped: {reminders, tasks, delegates}}.
207
+ export function stopSessionHolds(id, opts = {}) {
208
+ return api(`/api/sessions/${encodeURIComponent(id)}/holds/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
209
+ }
210
+
187
211
  // Kills a background task the session's conversation started (the
188
212
  // stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
189
213
  // no live worker.
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7aa5ff"/><stop offset="1" stop-color="#8957e5"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f6feb"/><stop offset="1" stop-color="#6e40c9"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
@@ -184,6 +184,11 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
184
184
  .ts-now button.task-stop{align-self:center}
185
185
  .activity-params{color:var(--text-muted);overflow-wrap:anywhere}
186
186
  .activity-alias{color:var(--text-faint);white-space:nowrap}
187
+ .activity-auto{color:var(--accent);font-size:11px;white-space:nowrap;cursor:help}
188
+ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;border:1px solid var(--border-strong);border-radius:6px;background:transparent;color:var(--text-muted);font-size:10px;line-height:16px;white-space:nowrap}
189
+ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:var(--text)}
190
+ .system-one-wrong.marked{border-color:var(--accent-border);color:var(--text-3)}
191
+ .activity-row.guardrail-wrong .activity-auto{color:var(--text-muted)}
187
192
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
188
193
  .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
189
194
  .activity-output.tail{display:flex;flex-direction:column-reverse}
@@ -1221,6 +1226,22 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1221
1226
  .push-devices.sheet .pd-done{font-size:13px}
1222
1227
  .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1223
1228
  .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1229
+ /* The Stop button's sheet (stop_sheet.js): what still runs, and the three answers; a bottom sheet on a phone. */
1230
+ .stop-sheet .ctx-pop-head strong{font-family:inherit}
1231
+ .stop-sheet.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1232
+ .ss-lines{list-style:none;display:flex;flex-direction:column;gap:6px;margin:4px 0 10px;padding:0}
1233
+ .ss-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1234
+ .ss-line b{color:var(--text-bright)}
1235
+ .ss-items{flex-basis:100%;color:var(--text-2)}
1236
+ .ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
1237
+ .ss-acts{display:flex;gap:8px;flex-wrap:wrap}
1238
+ .ss-acts button{padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2)}
1239
+ .ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
1240
+ .ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
1241
+ .ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
1242
+ .ss-cancel{background:transparent}
1243
+ .stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
1244
+ .ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
1224
1245
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1225
1246
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1226
1247
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -10,7 +10,8 @@ import { inTree } from "./sessions_list.js";
10
10
  // Hooks: +selected()+ the open session's id or null; +state()+ its fields
11
11
  // (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
12
12
  // +api+: {continueSession, startReview, archiveSession, unarchiveSession,
13
- // deleteSession, stopSession} (data.js); +eventsLive()+ the hub's stream is open (else
13
+ // deleteSession, stopSession, sessionHolds, stopSessionHolds} (data.js);
14
+ // +eventsLive()+ the hub's stream is open (else
14
15
  // +refresh()+ fetches the list); +lists+ (lists_view.js: render);
15
16
  // +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
16
17
  // +toast(text, action, options)+, +addCreatedSession(created)+,
@@ -30,6 +31,10 @@ export function createSessionActions({
30
31
  selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
31
32
  select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
32
33
  alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
34
+ // The Stop sheet (stop_sheet.js): +open(id, holds)+ shows it and answers
35
+ // true; nothing to stop (or no sheet wired) is false, and Stop is the
36
+ // confirm.
37
+ stopSheet = { open: () => false },
33
38
  }) {
34
39
  // The session this tab is stopping: its stream may close before the stop
35
40
  // request returns (workerGone reads it).
@@ -64,11 +69,39 @@ export function createSessionActions({
64
69
  showNotice(goneSessionNotice(id, why));
65
70
  }
66
71
 
67
- // The info bar's Stop: the open session's worker, after a confirm.
68
- async function stopSelected() {
69
- if (!selected()) return;
70
- if (!confirm(`Stop session ${selected()}?`)) return;
71
- const id = selected();
72
+ // The info bar's Stop: the open session's worker. It first asks the worker
73
+ // what still runs that the user can stop (GET …/holds); when there is
74
+ // something, the sheet (stop_sheet.js) asks what to do with it; when there
75
+ // is nothing, or no worker to ask, it is today's confirm and one tap.
76
+ // +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
77
+ // the stop. A delegate or a background task outlives the worker either way
78
+ // (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
79
+ async function stopSelected(id = selected(), { stopHolds = false } = {}) {
80
+ if (!id) return;
81
+
82
+ if (!stopHolds) {
83
+ let holds = [];
84
+ try {
85
+ const answer = await api.sessionHolds(id);
86
+ holds = answer?.holds || [];
87
+ } catch {
88
+ holds = []; // a worker that can't say, or none: stop as before
89
+ }
90
+ if (stopSheet.open(id, holds)) return;
91
+ if (!confirm(`Stop session ${id}?`)) return;
92
+ }
93
+
94
+ // Stop all: the worker stops what it can first. A failure is said and
95
+ // doesn't hold the stop: the session goes either way, and the sheet's
96
+ // other button is exactly that.
97
+ if (stopHolds) {
98
+ try {
99
+ await api.stopSessionHolds(id);
100
+ } catch (e) {
101
+ toast(`${cantText("stop what the session runs", e)} — stopping the session anyway`, null, { sticky: true });
102
+ }
103
+ }
104
+
72
105
  stoppingId = id;
73
106
  try {
74
107
  await api.stopSession(id);
@@ -0,0 +1,126 @@
1
+ // The Stop button's sheet (session_actions.js asks the worker first): when
2
+ // the session's worker still has things the user can stop — reminders, a
3
+ // background task that runs, a delegate with a live worker — Stop asks what
4
+ // to do with them before TERMed-ing the worker. Nothing to stop, or no worker
5
+ // to ask, is today's one tap.
6
+ //
7
+ // "Stop all" runs the worker's stop-all step (POST …/holds/stop, the same
8
+ // HoldStop#stop_all the terminal's /exit ask uses) and then today's Stop;
9
+ // "Stop session only" is today's Stop exactly, so what survives keeps running
10
+ // (docs/sessions.md, "Stopping a worker"). Reminders are listed with a note:
11
+ // they live only in the worker, so they end with the session either way.
12
+ //
13
+ // A phone gets it as a bottom sheet, a wider window as a popover at the Stop
14
+ // button (chip_popover.js). Pure model and template plus createStopSheet, so
15
+ // the specs run them under node --test.
16
+
17
+ import { html, nothing, render } from "./vendor/lit-html.js";
18
+ import { createChipPopover } from "./chip_popover.js";
19
+
20
+ // At most this many of a kind named in the sheet; the rest are one "+N more".
21
+ export const MAX_NAMED = 3;
22
+
23
+ // The Stop button the sheet opens at.
24
+ export const STOP_ANCHOR = "#infoStopBtn";
25
+
26
+ const KIND_WORDS = {
27
+ reminders: ["reminder", "reminders"],
28
+ background_tasks: ["background task", "background tasks"],
29
+ delegates: ["delegate", "delegates"],
30
+ };
31
+
32
+ // One entry of a hold as the sheet writes it: the command, the reminder's
33
+ // name with its schedule, a delegate's short id and its title. A long command
34
+ // is cut short: the sheet must fit a phone without scrolling.
35
+ function itemWords(item, kind) {
36
+ const label = String(item?.label ?? item?.id ?? "").trim();
37
+ const detail = String(item?.detail ?? "").trim();
38
+ const cut = label.length > 42 ? `${label.slice(0, 42)}…` : label;
39
+ if (kind === "delegates") {
40
+ const id = String(item?.id ?? "").slice(0, 8);
41
+ return `${id} "${cut || "untitled"}"${detail ? ` ${detail}` : ""}`;
42
+ }
43
+ const name = cut || String(item?.id ?? "").slice(0, 8);
44
+ return detail ? `${name} (${detail})` : name;
45
+ }
46
+
47
+ // What the sheet says from the worker's holds (GET …/holds): null when there
48
+ // is nothing to ask about (no sheet: Stop stays one tap), else its lines, one
49
+ // per kind, and the total.
50
+ // @return {lines: [{kind, count, word, items, more, note}], total} | null
51
+ export function stopSheetModel(holds) {
52
+ const lines = [];
53
+ for (const hold of Array.isArray(holds) ? holds : []) {
54
+ const kind = String(hold?.kind ?? "");
55
+ const items = Array.isArray(hold?.items) ? hold.items : [];
56
+ if (!items.length) continue;
57
+
58
+ const [singular, plural] = KIND_WORDS[kind] ?? [kind || "hold", `${kind || "holds"}s`];
59
+ lines.push({
60
+ kind,
61
+ count: items.length,
62
+ word: items.length === 1 ? singular : plural,
63
+ items: items.slice(0, MAX_NAMED).map((item) => itemWords(item, kind)),
64
+ more: Math.max(0, items.length - MAX_NAMED),
65
+ note: kind === "reminders" ? "they end with the session" : "",
66
+ });
67
+ }
68
+ if (!lines.length) return null;
69
+
70
+ return { lines, total: lines.reduce((sum, line) => sum + line.count, 0) };
71
+ }
72
+
73
+ function lineTemplate(line) {
74
+ return html`<li class="ss-line" data-kind=${line.kind}>
75
+ <b>${line.count} ${line.word}</b>
76
+ ${line.note ? html` <span class="ss-note">(${line.note})</span>` : nothing}
77
+ ${line.items.length ? html` <span class="ss-items">${line.items.join(" · ")}${line.more ? html` <span class="ss-more">+${line.more} more</span>` : nothing}</span>` : nothing}
78
+ </li>`;
79
+ }
80
+
81
+ // The sheet's content (a lit-html template).
82
+ export function stopSheetTemplate(model) {
83
+ const things = model.total === 1 ? "1 thing" : `${model.total} things`;
84
+ return html`<div class="ctx-pop-head"><strong>This session still has</strong
85
+ ><span class="ctx-pop-kind">${things}</span
86
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
87
+ ><ul class="ss-lines">${model.lines.map(lineTemplate)}</ul
88
+ ><div class="ss-acts"><button type="button" class="ss-stop-all" data-act="all">Stop all</button
89
+ ><button type="button" class="ss-stop-only" data-act="session">Stop session only</button
90
+ ><button type="button" class="ss-cancel" data-act="cancel">Cancel</button></div
91
+ ><div class="ss-foot">Stopping the session ends its worker; a background task or a delegate runs on its own, so it stays.</div>`;
92
+ }
93
+
94
+ // The sheet at the Stop button. +stopAll(id)+ stops what the worker can and
95
+ // then the session; +stopSession(id)+ is today's Stop; +open(id, holds)+ shows
96
+ // it only when there is something (false otherwise, so the caller can fall
97
+ // back to the plain confirm). @return {open, close, isOpen, pop}
98
+ export function createStopSheet({ doc = globalThis.document, win = globalThis.window,
99
+ anchor = STOP_ANCHOR, el = doc.createElement("span"),
100
+ stopAll = () => {}, stopSession = () => {} }) {
101
+ let model = null;
102
+ let forId = null;
103
+
104
+ const shell = createChipPopover({
105
+ id: "stopSheet", className: "ctx-popover stop-sheet", width: 400, el, anchor, openers: anchor,
106
+ summary: () => model, sheet: true, label: "Stop the session?", doc, win,
107
+ show: (data) => render(stopSheetTemplate(data), shell.pop),
108
+ onClose: () => { model = null; forId = null; },
109
+ onAct: (act) => {
110
+ const id = forId;
111
+ if (act === "all") { shell.close(); stopAll(id); }
112
+ else if (act === "session") { shell.close(); stopSession(id); }
113
+ else if (act === "cancel") shell.close();
114
+ },
115
+ });
116
+
117
+ function open(id, holds) {
118
+ model = stopSheetModel(holds);
119
+ if (!model) return false;
120
+ forId = id;
121
+ shell.open(doc.querySelector(anchor) ?? el);
122
+ return true;
123
+ }
124
+
125
+ return { open, close: () => shell.close(), isOpen: shell.isOpen, pop: shell.pop };
126
+ }