samagotchi 0.25.0 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/configuration.md +1 -0
  5. data/docs/hooks.md +1 -1
  6. data/docs/internals/engine.md +478 -0
  7. data/docs/internals/prompt-caching.md +33 -7
  8. data/docs/plugins.md +3 -2
  9. data/lib/samagotchi/bridge.rb +37 -1
  10. data/lib/samagotchi/bridge_client.rb +9 -0
  11. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -5
  13. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/cancellation_controller.rb +8 -0
  17. data/lib/samagotchi/client.rb +53 -3
  18. data/lib/samagotchi/config.rb +9 -2
  19. data/lib/samagotchi/engine.rb +50 -7
  20. data/lib/samagotchi/kernel_loop.rb +39 -3
  21. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  22. data/lib/samagotchi/llm/http.rb +5 -4
  23. data/lib/samagotchi/log.rb +19 -3
  24. data/lib/samagotchi/prompt_warmup.rb +95 -0
  25. data/lib/samagotchi/terminal_ui.rb +2 -0
  26. data/lib/samagotchi/thinking_tails.rb +4 -3
  27. data/lib/samagotchi/tool_activity.rb +3 -2
  28. data/lib/samagotchi/tool_declarations.rb +1 -1
  29. data/lib/samagotchi/tool_view.rb +14 -3
  30. data/lib/samagotchi/tools/task_get.rb +3 -2
  31. data/lib/samagotchi/tools/task_runtime.rb +49 -12
  32. data/lib/samagotchi/tools/task_stop.rb +1 -1
  33. data/lib/samagotchi/tools/task_wait.rb +22 -10
  34. data/lib/samagotchi/turn_note.rb +28 -2
  35. data/lib/samagotchi/turn_state.rb +6 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +25 -0
  38. data/lib/samagotchi/web/markdown_renderer.rb +21 -6
  39. data/lib/samagotchi/web/public/app.js +4 -0
  40. data/lib/samagotchi/web/public/command_view.js +6 -3
  41. data/lib/samagotchi/web/public/data.js +11 -0
  42. data/lib/samagotchi/web/public/index.html +39 -13
  43. data/lib/samagotchi/web/public/stage_model.js +8 -2
  44. data/lib/samagotchi/web/public/stage_view.js +14 -2
  45. data/lib/samagotchi/web/public/task_stop.js +101 -0
  46. data/lib/samagotchi/web/public/turn_view.js +24 -1
  47. data/lib/samagotchi/worker.rb +3 -0
  48. metadata +4 -1
@@ -5,14 +5,22 @@
5
5
  <title>Chi</title>
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
7
  <style>
8
- :root{--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
8
+ :root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
9
9
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
10
- --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc}
10
+ --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
11
+ --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
12
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25)}
13
+ /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
14
+ @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;
15
+ --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
16
+ --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
17
+ --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
18
+ --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
11
19
  *{box-sizing:border-box;margin:0;padding:0}
12
20
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
13
21
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
14
22
  #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
15
- #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:rgba(255,255,255,.14)}
23
+ #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}
16
24
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
17
25
  /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
18
26
  #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
@@ -23,7 +31,7 @@ body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-g
23
31
  body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
24
32
  body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
25
33
  button,input,textarea,select{font:inherit}
26
- button{background:var(--primary);border:0;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer;transition:filter .12s ease,background .12s ease}
34
+ button{background:var(--primary);border:0;color:var(--on-solid);padding:8px 12px;border-radius:8px;cursor:pointer;transition:filter .12s ease,background .12s ease}
27
35
  button:hover:not(:disabled){filter:brightness(1.12)}
28
36
  button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
29
37
  button.ghost{background:var(--bg-raised);color:var(--text-2)}
@@ -56,6 +64,18 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
56
64
  .bubble.output.markdown code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}
57
65
  .bubble.output.markdown :not(pre)>code{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:1px 5px;color:var(--text)}
58
66
  .bubble.output.markdown pre{font-size:13px;line-height:1.45;border:1px solid var(--border)}
67
+ /* Code highlighting (markdown_renderer.rb): syntect scope classes, hl-prefixed, coloured from the palette's --code-* tokens. More classes win over fewer (a function name is also "entity"). */
68
+ .syntax-highlighting{color:var(--code-text)}
69
+ .syntax-highlighting .hl-keyword,.syntax-highlighting .hl-storage{color:var(--code-keyword)}
70
+ .syntax-highlighting .hl-string{color:var(--code-string)}
71
+ .syntax-highlighting .hl-string .hl-embedded{color:var(--code-text)}
72
+ .syntax-highlighting .hl-constant{color:var(--code-constant)}
73
+ .syntax-highlighting .hl-entity.hl-name.hl-function,.syntax-highlighting .hl-support.hl-function{color:var(--code-func)}
74
+ .syntax-highlighting .hl-entity.hl-name.hl-class,.syntax-highlighting .hl-entity.hl-name.hl-type,.syntax-highlighting .hl-entity.hl-other.hl-inherited-class,.syntax-highlighting .hl-support.hl-class,.syntax-highlighting .hl-support.hl-type,.syntax-highlighting .hl-meta.hl-diff.hl-range{color:var(--code-type)}
75
+ .syntax-highlighting .hl-comment{color:var(--code-comment);font-style:italic}
76
+ .syntax-highlighting .hl-markup.hl-inserted{color:var(--diff-add-text)}
77
+ .syntax-highlighting .hl-markup.hl-deleted{color:var(--diff-del-text)}
78
+ .syntax-highlighting .hl-invalid{color:var(--danger-text)}
59
79
  .bubble.output.markdown h1,.bubble.output.markdown h2,.bubble.output.markdown h3,.bubble.output.markdown h4,.bubble.output.markdown h5,.bubble.output.markdown h6{font-weight:600;line-height:1.3;margin:18px 0 8px;color:var(--text-bright)}
60
80
  .bubble.output.markdown h1{font-size:19px}
61
81
  .bubble.output.markdown h2{font-size:17px}
@@ -91,8 +111,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
91
111
  .bubble.user.queued{border-left-style:dashed;opacity:.8}
92
112
  .bubble.user.steered{border-left-style:dashed;border-left-color:var(--accent);opacity:.9}
93
113
  .bubble.user.failed{border-left-color:var(--danger);opacity:.8}
94
- .bubble.user .state-badge{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:2px 7px;border-radius:999px;background:rgba(255,255,255,.08);color:var(--text);font-size:11px;line-height:1.2;white-space:nowrap;flex-shrink:0}
95
- .bubble.user .state-badge::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.8;box-shadow:0 0 0 1px rgba(255,255,255,.18)}
114
+ .bubble.user .state-badge{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:2px 7px;border-radius:999px;background:var(--badge-bg);color:var(--text);font-size:11px;line-height:1.2;white-space:nowrap;flex-shrink:0}
115
+ .bubble.user .state-badge::before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.8;box-shadow:0 0 0 1px var(--badge-ring)}
96
116
  .bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
97
117
  .bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
98
118
  .bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
@@ -116,6 +136,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
116
136
  .activity-status.stopped{color:var(--warn-text);opacity:.8}
117
137
  .activity-status.blocked{color:var(--danger-text)}
118
138
  .activity-tool{color:var(--accent);font-weight:600}
139
+ /* The stop-task button on a running task_wait (task_stop.js): a small ghost
140
+ button in the danger colours, at the row's end and in the stage's now slot. */
141
+ button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid color-mix(in srgb,var(--danger) 45%,transparent);border-radius:6px;background:transparent;color:var(--danger-text);font-size:11.5px;line-height:18px;white-space:nowrap}
142
+ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
143
+ .ts-now button.task-stop{align-self:center}
119
144
  .activity-params{color:var(--text-muted);word-break:break-all}
120
145
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
121
146
  .activity-output{margin:4px 0 2px 60px;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}
@@ -145,6 +170,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
145
170
  /* The command as its steps (CommandSteps): the op that links each to the one before, labels as small headings, limits and heredocs as chips; "raw" swaps in the command as written (CSS only). */
146
171
  .activity-command.has-steps{background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:4px 8px 5px}
147
172
  .activity-command-head{display:flex;align-items:center;gap:8px;min-height:20px;padding-right:30px}
173
+ .activity-command-head.bare{float:right;min-height:0;margin-left:8px}
148
174
  .has-steps .activity-command-cwd{flex:1 1 auto;min-width:0;margin:0;overflow-wrap:anywhere}
149
175
  .activity-command-cwd>.activity-command-sep{font-family:inherit;color:var(--text-faint)}
150
176
  .activity-command-raw{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 7px;border:1px solid var(--border-strong);border-radius:9px;font-size:11px;color:var(--text-muted);cursor:pointer;user-select:none;-webkit-user-select:none}
@@ -366,7 +392,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
366
392
  /* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
367
393
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
368
394
  #annotateBar[hidden]{display:none}
369
- #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
395
+ #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
370
396
  #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
371
397
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
372
398
  .bubble.output,.bubble.user{position:relative}
@@ -407,7 +433,7 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
407
433
  .thumbs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
408
434
  .thumb{max-height:120px;max-width:200px;border-radius:6px;border:1px solid var(--border-strong);cursor:zoom-in;object-fit:contain;background:var(--bg)}
409
435
  .activity-row .thumbs{flex-basis:100%}
410
- #lightbox{position:fixed;inset:0;z-index:50;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
436
+ #lightbox{position:fixed;inset:0;z-index:50;background:var(--backdrop);display:flex;align-items:center;justify-content:center;cursor:zoom-out}
411
437
  #lightbox[hidden]{display:none}
412
438
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
413
439
  #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
@@ -598,7 +624,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
598
624
  #infoBar .meta{font-size:11px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
599
625
  #infoBar .meta .id{font-family:ui-monospace,monospace;font-weight:700;font-size:12px;color:var(--accent)}
600
626
  #infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
601
- #infoBar .meta .copy-attach:hover{color:#fff;border-color:var(--border-strong)}
627
+ #infoBar .meta .copy-attach:hover{color:var(--text-hover);border-color:var(--border-strong)}
602
628
  #infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
603
629
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
604
630
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
@@ -610,7 +636,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
610
636
  #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
611
637
  #infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
612
638
  #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
613
- #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
639
+ #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
614
640
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
615
641
  #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
616
642
  #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
@@ -621,7 +647,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
621
647
  #infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
622
648
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
623
649
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
624
- #infoBar #infoDeleteBtn:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
650
+ #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
625
651
  /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
626
652
  @media(max-width:600px){
627
653
  /* The key hint: cut off at this width, and phones have no Shift+Enter. */
@@ -743,7 +769,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
743
769
  .card.archived{opacity:.6}
744
770
  .card.archived:hover{opacity:.85}
745
771
  .card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
746
- #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}
772
+ #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:var(--toast-shadow);font-size:13px}
747
773
  #toast[hidden]{display:none}
748
774
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
749
775
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
@@ -761,7 +787,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
761
787
  #allList .card{position:relative}
762
788
  .card-del{position:absolute;top:6px;right:6px;padding:2px 7px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:12px;line-height:1.2;cursor:pointer;opacity:0;transition:opacity .15s}
763
789
  #allList .card:hover .card-del, .card-del:focus-visible{opacity:1}
764
- .card-del:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
790
+ .card-del:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
765
791
  @media (hover:none){.card-del{opacity:1}}
766
792
  .card.active{background:var(--accent-bg);border-color:var(--accent-border)}
767
793
  .card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -85,6 +85,12 @@ function withCommand(slot, row) {
85
85
  return row.view?.command ? { ...slot, command: row.view.command } : slot;
86
86
  }
87
87
 
88
+ // A running task_wait's slot: the task it waits on and the row's key, for
89
+ // the stop-task button (task_stop.js).
90
+ function withTask(slot, row) {
91
+ return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
92
+ }
93
+
88
94
  // While the first step has no thinking and no tool rows the turn is plain:
89
95
  // its text streams in the stage's answer slot, as the turn view's
90
96
  // chrome-less block streams like a bubble.
@@ -106,7 +112,7 @@ const NO_ANSWER = "no answer";
106
112
  // feed), else the newest thinking one (+thinking+, its ticker),
107
113
  // else "…";
108
114
  // tool the running call ({name, title, kind}, and command: its full
109
- // command when it has one) or null;
115
+ // command when it has one; taskId and key: a task_wait's) or null;
110
116
  // trail the last three finished calls, newest last (a file's title
111
117
  // without its dir; command as for tool);
112
118
  // ticks one per call: {kind, status, step (the gen's index)}.
@@ -137,7 +143,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
137
143
  phase,
138
144
  step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
139
145
  headline,
140
- tool: running ? withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running) : null,
146
+ tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
141
147
  trail,
142
148
  ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
143
149
  };
@@ -13,6 +13,7 @@
13
13
  import { createTurnView } from "./turn_view.js";
14
14
  import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
+ import { taskCommand, taskStopButton } from "./task_stop.js";
16
17
 
17
18
  const COLLAPSE_KEY = "chi_stage_collapsed";
18
19
  const WATCH_MS = 200;
@@ -45,7 +46,9 @@ function readCollapsed() {
45
46
  // +historyNearBottom+: the history's own follow check (the hand-off scrolls
46
47
  // the history only for a reader at its end). +dockEl+, +centerEl+ and
47
48
  // +composerEl+ size the stage's cap.
48
- export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl }) {
49
+ // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
50
+ // on a running task_wait.
51
+ export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl, taskStop = null }) {
49
52
  // The dock becomes a clear column: the stage's card, then the composer's
50
53
  // (its rows moved into .dock-card, which wears the glass and the focus ring).
51
54
  document.body.classList.add("stage-view");
@@ -131,6 +134,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
131
134
  },
132
135
  useHold: false,
133
136
  newestFirst: true,
137
+ taskStop,
134
138
  onChange: () => scheduleRefresh(),
135
139
  onThinkingLine: (text) => {
136
140
  const { turn } = view.current();
@@ -223,7 +227,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
223
227
 
224
228
  function drawNow(tool, gen) {
225
229
  const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
226
- const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}` : `quiet|${quiet}`;
230
+ // A task_wait's stop button, as the row's (task_stop.js): in the key
231
+ // with its in-flight state, so a redraw keeps a clicked one disabled.
232
+ const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
233
+ const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
234
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
227
235
  if (shown.now === key) return;
228
236
  shown.now = key;
229
237
  now.replaceChildren();
@@ -236,6 +244,10 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
236
244
  title.textContent = tool.title;
237
245
  // The full command when the call has one (the title is its first step +N).
238
246
  title.title = tool.command || tool.title;
247
+ if (stopId) {
248
+ const command = () => taskCommand(view.current().turn?.gens, stopId);
249
+ now.appendChild(taskStopButton(taskStop, stopId, { command, onChange: scheduleRefresh }));
250
+ }
239
251
  } else {
240
252
  const word = node("span", "ts-title quiet", now);
241
253
  word.textContent = quiet;
@@ -0,0 +1,101 @@
1
+ // The stop-task button on a running task_wait (POST …/tasks/:task_id/stop,
2
+ // Bridge#handle_task_stop): the worker kills the task as the user, the wait
3
+ // returns, and the turn goes on. Which row gets one, what the confirm says,
4
+ // and the stops in flight. The in-flight set lives here, not in the DOM:
5
+ // the stage rebuilds its "now" slot, and a redraw must keep a clicked
6
+ // button disabled. Pure but for the injected calls, so `node --test`
7
+ // covers it; turn_view.js and stage_view.js draw the button.
8
+ import { stopTask as postStopTask } from "./data.js";
9
+
10
+ // What a worker that has the route names in its sidecar features.
11
+ export const TASK_STOP_FEATURE = "task_stop";
12
+ const COMMAND_SHOWN = 120;
13
+
14
+ // The task a row's button would stop: a running task_wait's (its server
15
+ // view's task_id), on a worker that has the route; else null.
16
+ export function stopTarget(row, features) {
17
+ if (!row || row.tool !== "task_wait" || row.status !== "running") return null;
18
+ if (!Array.isArray(features) || !features.includes(TASK_STOP_FEATURE)) return null;
19
+ return row.view?.task_id || null;
20
+ }
21
+
22
+ // The command a task_create of this turn started +taskId+ with (its row's
23
+ // view), or null: a task from an earlier turn has no row here.
24
+ export function taskCommand(gens, taskId) {
25
+ for (const gen of gens || []) {
26
+ for (const row of gen.tools || []) {
27
+ if (row.tool !== "task_create") continue;
28
+ const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
29
+ if (id === taskId) return row.view?.command || null;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function confirmText(taskId, command) {
36
+ const line = String(command || "").replace(/\s+/g, " ").trim();
37
+ const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
38
+ return `Stop task ${what}?`;
39
+ }
40
+
41
+ // A failed stop said nothing about: 409 (the task ended meanwhile), 503
42
+ // (no live worker: it can't be stopped now).
43
+ export function quietFailure(error) {
44
+ return error?.status === 409 || error?.status === 503;
45
+ }
46
+
47
+ // +sessionId+ and +features+ are read at each call (the selected session,
48
+ // its worker's sidecar features).
49
+ export function createTaskStopper({
50
+ sessionId,
51
+ features,
52
+ stopTask = postStopTask,
53
+ confirm = (text) => globalThis.confirm(text),
54
+ alert = (text) => globalThis.alert(text),
55
+ }) {
56
+ const inFlight = new Set();
57
+ const keyOf = (taskId) => `${sessionId()}|${taskId}`;
58
+ return {
59
+ target: (row) => stopTarget(row, features()),
60
+ busy: (taskId) => inFlight.has(keyOf(taskId)),
61
+ // true once the worker stopped it. A stop that went through stays in
62
+ // flight: the button goes with its row's completion a moment later.
63
+ async stop(taskId, command = null) {
64
+ const id = sessionId();
65
+ const key = keyOf(taskId);
66
+ if (!id || inFlight.has(key) || !confirm(confirmText(taskId, command))) return false;
67
+ inFlight.add(key);
68
+ try {
69
+ await stopTask(id, taskId);
70
+ return true;
71
+ } catch (e) {
72
+ inFlight.delete(key);
73
+ if (!quietFailure(e)) alert(e.message);
74
+ return false;
75
+ }
76
+ },
77
+ };
78
+ }
79
+
80
+ // The button itself (the one DOM part): a click asks, posts, and keeps it
81
+ // disabled while the stop is in flight. +command+ is read at the click;
82
+ // +onChange+ lets a view redraw (the stage's slot).
83
+ export function taskStopButton(stopper, taskId, { command = () => null, onChange = () => {} } = {}) {
84
+ const btn = document.createElement("button");
85
+ btn.type = "button";
86
+ btn.className = "ghost task-stop";
87
+ btn.textContent = "stop task";
88
+ btn.title = "Kill this background task; the model is told you stopped it";
89
+ btn.disabled = stopper.busy(taskId);
90
+ btn.addEventListener("click", (e) => {
91
+ e.stopPropagation();
92
+ const pending = stopper.stop(taskId, command());
93
+ btn.disabled = stopper.busy(taskId);
94
+ onChange();
95
+ pending.then(() => {
96
+ btn.disabled = stopper.busy(taskId);
97
+ onChange();
98
+ });
99
+ });
100
+ return btn;
101
+ }
@@ -23,6 +23,7 @@ import { createSentenceFeed } from "./sentences.js";
23
23
  import { createTicker } from "./thinking_ticker.js";
24
24
  import * as model from "./turn_model.js";
25
25
  import { createHold } from "./hold.js";
26
+ import { taskCommand, taskStopButton } from "./task_stop.js";
26
27
 
27
28
  // A row's status (live, or a saved tool record's on reload): running,
28
29
  // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
@@ -90,7 +91,9 @@ function fillActivityRow(el, row, sessionId) {
90
91
  // (chronological() restores the order);
91
92
  // onChange() after anything in the turn changed (the stage redraws
92
93
  // its slots from current());
93
- // onThinkingLine(sentence) the live step's ticker showed a new line.
94
+ // onThinkingLine(sentence) the live step's ticker showed a new line;
95
+ // taskStop the page's stopper (task_stop.js): a running task_wait
96
+ // row gets its stop-task button (none: no button).
94
97
  export function createTurnView({
95
98
  historyEl, appendToHistory, isNearBottom, sawText, sessionId,
96
99
  blockHost = appendToHistory,
@@ -100,6 +103,7 @@ export function createTurnView({
100
103
  newestFirst = false,
101
104
  onChange = () => {},
102
105
  onThinkingLine = () => {},
106
+ taskStop = null,
103
107
  }) {
104
108
  let turn = null;
105
109
  let blockEl = null;
@@ -407,14 +411,33 @@ export function createTurnView({
407
411
  const stick = isNearBottom();
408
412
  body.appendChild(el);
409
413
  fillActivityRow(el, r.row, sessionId());
414
+ syncTaskStop(el, r.row);
410
415
  if (stick) follow();
411
416
  } else {
412
417
  fillActivityRow(el, r.row, sessionId());
418
+ syncTaskStop(el, r.row);
413
419
  }
414
420
  updateBlockSummary();
415
421
  onChange();
416
422
  }
417
423
 
424
+ // The stop-task button after a running task_wait's params; gone once the
425
+ // row completes.
426
+ function syncTaskStop(el, row) {
427
+ const taskId = taskStop?.target(row);
428
+ const btn = el.querySelector(":scope > .task-stop");
429
+ if (!taskId) {
430
+ btn?.remove();
431
+ return;
432
+ }
433
+ if (btn) {
434
+ btn.disabled = taskStop.busy(taskId);
435
+ return;
436
+ }
437
+ const command = () => taskCommand(turn?.gens, taskId);
438
+ el.querySelector(".activity-params").after(taskStopButton(taskStop, taskId, { command }));
439
+ }
440
+
418
441
  // A hook's line (hook_notice) as a row of the current step, above the row
419
442
  // of the call it judged; false with no running turn (app.js shows the
420
443
  // bubble then). data.line is a whole row of chi's own (an empty-answer
@@ -112,6 +112,9 @@ module Samagotchi
112
112
  # stream gets the session's history and status in its snapshot, not
113
113
  # an empty session until the first turn.
114
114
  @engine.session = @session
115
+ # Input already waiting in the inbox starts the next turn at once: no
116
+ # turn-end warm-up then.
117
+ @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
115
118
  @turn_flow = TurnFlow.new(engine: @engine)
116
119
  # Its question's answer is queued as the matching /continue (the
117
120
  # Bridge exists by the time anyone answers).
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -130,6 +130,7 @@ files:
130
130
  - docs/hooks.md
131
131
  - docs/internals/background-tasks.md
132
132
  - docs/internals/context-telemetry.md
133
+ - docs/internals/engine.md
133
134
  - docs/internals/gemma4-contract.md
134
135
  - docs/internals/prompt-caching.md
135
136
  - docs/internals/tool-guardrails.md
@@ -324,6 +325,7 @@ files:
324
325
  - lib/samagotchi/prompt.rb
325
326
  - lib/samagotchi/prompt_history.rb
326
327
  - lib/samagotchi/prompt_literal_guard.rb
328
+ - lib/samagotchi/prompt_warmup.rb
327
329
  - lib/samagotchi/question_desk.rb
328
330
  - lib/samagotchi/recap_setup.rb
329
331
  - lib/samagotchi/recap_store.rb
@@ -469,6 +471,7 @@ files:
469
471
  - lib/samagotchi/web/public/stage_view.js
470
472
  - lib/samagotchi/web/public/strip.js
471
473
  - lib/samagotchi/web/public/tally.js
474
+ - lib/samagotchi/web/public/task_stop.js
472
475
  - lib/samagotchi/web/public/thinking_ticker.js
473
476
  - lib/samagotchi/web/public/timing.js
474
477
  - lib/samagotchi/web/public/turn_events.js