samagotchi 0.84.0 → 0.85.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/docs/architecture.md +10 -1
- data/docs/cli.md +16 -1
- data/docs/configuration.md +2 -0
- data/docs/hooks.md +1 -1
- data/docs/internals/engine.md +3 -2
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +54 -1
- data/lib/samagotchi/bridge_client.rb +12 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +5 -1
- data/lib/samagotchi/llm/chat_loop.rb +5 -1
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/parent_report.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +11 -7
- data/lib/samagotchi/reply_wait.rb +8 -1
- data/lib/samagotchi/session_commands.rb +7 -4
- data/lib/samagotchi/session_inbox.rb +68 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
- data/lib/samagotchi/terminal_ui.rb +3 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -0
- data/lib/samagotchi/web/public/app.js +17 -0
- data/lib/samagotchi/web/public/bubbles.js +9 -0
- data/lib/samagotchi/web/public/commands.js +1 -0
- data/lib/samagotchi/web/public/data.js +11 -0
- data/lib/samagotchi/web/public/index.html +9 -0
- data/lib/samagotchi/web/public/stage_view.js +6 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/turn_events.js +4 -0
- data/lib/samagotchi/web/public/turn_html.js +9 -1
- data/lib/samagotchi/web/public/unqueue.js +56 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d533709ed93dac5a1a65a9c023e83a50aef691542faffb1cdeefe6bfca51b5f1
|
|
4
|
+
data.tar.gz: 90cbb0c9256e751f64a36b854d6049098b694a8824a34230895e83eb383f8f74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 90149bcdee28e2c2aa74c011d361aee78b652f4a0ef03c966a2fa11324181b67b8a926e3a1fe94d6d7cc8b92e62edf5d7fc67f444ebec0923fe58f7338c3c65d
|
|
7
|
+
data.tar.gz: 8884e65ab2e6a0506e15dab8ea701dda33f3a149dc6168e770ad2b885dadf7789f0d4cf2df8c22a258239b74f3da8d384c356cd72db2c66ff8154eb31819acea
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.85.0] - 2026-10-18
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- A message queued for after the running turn can be taken back before it runs: the × on its bubble in the web,
|
|
16
|
+
`/unqueue` in an attached terminal (its newest queued line). Its text comes back into the input to change and send
|
|
17
|
+
again. Every UI drops it, and a `chi send --queue --wait` on it ends with `removed` (exit 1). `/queue` is now listed
|
|
18
|
+
in the attached terminal's Tab and `/help` too.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- On a phone, a queued message's text gets the bubble's full width, with its origin and after-turn badge above it.
|
|
23
|
+
- A plugin's `ctx.settings` reads a session's `--set` values again after a failed read, instead of keeping config.yml's
|
|
24
|
+
alone until the worker restarts.
|
|
25
|
+
- A `before_generation` hook now runs before the generation starts (`:generation_started`) in both loops: a question
|
|
26
|
+
it asks (the cost-cap's) no longer counts in the generation's time or leaves an empty generation on Stop.
|
|
27
|
+
|
|
28
|
+
Update with `chi update` (the system bundle 0.85.0), then restart `chi web`.
|
|
29
|
+
|
|
11
30
|
## [0.84.0] - 2026-10-18
|
|
12
31
|
|
|
13
32
|
### Added
|
|
@@ -3389,7 +3408,8 @@ and long-lived sessions.
|
|
|
3389
3408
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
3390
3409
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
3391
3410
|
|
|
3392
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
3411
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.85.0...HEAD
|
|
3412
|
+
[0.85.0]: https://github.com/dm1try/samagotchi/compare/v0.84.0...v0.85.0
|
|
3393
3413
|
[0.84.0]: https://github.com/dm1try/samagotchi/compare/v0.83.0...v0.84.0
|
|
3394
3414
|
[0.83.0]: https://github.com/dm1try/samagotchi/compare/v0.82.0...v0.83.0
|
|
3395
3415
|
[0.82.0]: https://github.com/dm1try/samagotchi/compare/v0.81.0...v0.82.0
|
data/docs/architecture.md
CHANGED
|
@@ -281,6 +281,15 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
|
|
|
281
281
|
saved messages, or the running turn's accumulator); answers once the
|
|
282
282
|
task is gone: `200 {status, stop_reason, task_id}`, `404 task_not_found`, `409 not_running`.
|
|
283
283
|
The worker names it in its sidecar features (`task_stop`).
|
|
284
|
+
- `POST /session/:id/turn/dequeue` — `{enqueued_id, client_id?}`: take back a queued message
|
|
285
|
+
(one that waits for the running turn's end: `--queue`, Alt+Enter, `/queue TEXT`, or a message
|
|
286
|
+
with images) before a turn takes it (web route `POST /api/sessions/:id/turn/:enqueued_id/dequeue`,
|
|
287
|
+
the × on a queued bubble; `/unqueue` in the attached TUI). Under the event log, like its enqueue:
|
|
288
|
+
`SessionInbox.take_queued_input` claims the file as the drain does (one rename wins), deletes it,
|
|
289
|
+
leaves a tombstone (`input/removed/<enqueued_id>`, for `ReplyWait`'s `turn:`), and the Bridge
|
|
290
|
+
announces `:turn_dequeued`. `200 {status: "removed", enqueued_id, prompt}`, `409 already_started`
|
|
291
|
+
(a turn took it), `409 not_queued` (a next-step message: a drain may merge it any moment), `404
|
|
292
|
+
unknown_enqueued_id`. It never wakes the worker. The worker names it in its sidecar features (`unqueue`).
|
|
284
293
|
- `POST /session/:id/relay` — the approval relay, on a delegated child's worker
|
|
285
294
|
(`{action, relay_id, question_id}`): `opened` / `closed` (with a `reason`) set or clear the
|
|
286
295
|
pending question's `relayed_to` mark (`QuestionDesk#annotate`, `:question_relay`); `answered`
|
|
@@ -404,7 +413,7 @@ session every 10 s, so a `kill -9` shows within a second. The summary (`Web::Ses
|
|
|
404
413
|
shared with `/api/sessions` and the session view) carries `owner`, `project_root` and
|
|
405
414
|
`bridge_up` (the sidecar is there *and* the lock is held: the page attaches its stream on it), and
|
|
406
415
|
from that sidecar `worker_version` (the chi the worker runs) and `worker_features`
|
|
407
|
-
(`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`,
|
|
416
|
+
(`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`, `unqueue`,
|
|
408
417
|
`delivery`, a worker that queues a message for after the turn; `SessionManager.deliver_turn` and
|
|
409
418
|
the attached TUI refuse a queue to a worker without it, which would steer; `turn_ids`, a worker whose running
|
|
410
419
|
turn's mark, continue turn and Stop carry the turn's `enqueued_id`, which `chi answer`, `chi send --wait` with no
|
data/docs/cli.md
CHANGED
|
@@ -607,6 +607,17 @@ REPL alike:
|
|
|
607
607
|
sent after it still go in at the next step.
|
|
608
608
|
At the open prompt (no turn running) `/cut TEXT` and `/queue TEXT` run TEXT
|
|
609
609
|
as a normal prompt.
|
|
610
|
+
- `/unqueue` (attached terminal) takes back the newest line this terminal
|
|
611
|
+
queued that hasn't started: `removed: <its first 60 characters>`, and its text
|
|
612
|
+
goes back into the input to change and send again (not over typed text: then
|
|
613
|
+
it is in the input history, ↑; not with input from a pipe; its images don't
|
|
614
|
+
come back), then the one before it on the next `/unqueue`; `(nothing queued: …)` when none waits,
|
|
615
|
+
`(not removed: it already started)` when its turn won the race, `(not removed:
|
|
616
|
+
it is already gone)` when another client removed it. Other terminals that
|
|
617
|
+
showed it say `(a queued message was removed)`; the web drops its bubble. A
|
|
618
|
+
worker older than it is not asked: `(/unqueue needs a newer worker: …)`. In
|
|
619
|
+
the plain REPL it answers with a note (its own queue is not touched), and the
|
|
620
|
+
web's composer points at the × on the bubble.
|
|
610
621
|
- **The web composer** has a row above the text field in a session (always
|
|
611
622
|
there, so the composer never changes height): the keys while idle, and while
|
|
612
623
|
the turn runs the delivery line, where a message sent now goes in (`↳ goes in after this
|
|
@@ -616,7 +627,11 @@ REPL alike:
|
|
|
616
627
|
**Alt+Enter** (Option+Enter) or `queue for after the turn` queues it, like
|
|
617
628
|
`/queue TEXT`, and the bubble is dashed (`after this turn`), below the
|
|
618
629
|
running turn's card with the commands queued for its end, until its turn
|
|
619
|
-
starts;
|
|
630
|
+
starts; its **×** (`Remove from queue`, once the worker acked it) takes it
|
|
631
|
+
back before it runs, in every tab (a toast says why when it can't: it
|
|
632
|
+
already started, it is gone, no live worker), and puts its text back into an
|
|
633
|
+
empty composer to change and send again (a draft stays: the text is then in
|
|
634
|
+
the toast; images don't come back); `cut in now` cuts, like `/cut TEXT` (by click only: it throws the
|
|
620
635
|
thinking away). Cut is enabled only while the step has streamed nothing but
|
|
621
636
|
thinking and cutting is on for the model (the line says `cuts at about 0:25
|
|
622
637
|
if you choose cut in now` until the thinking passes `steer_cut_after`, and
|
data/docs/configuration.md
CHANGED
|
@@ -1145,6 +1145,8 @@ Bridge acks `delivery: "queue"` while a turn runs (to an idle session the messag
|
|
|
1145
1145
|
`next_step`). A worker whose sidecar doesn't name the `delivery` feature (older than queueing) would steer it, so the
|
|
1146
1146
|
sender refuses it (`409 delivery_unavailable`, "restart it"), and so does a worker that answers the request with a
|
|
1147
1147
|
400 `delivery_unavailable`.
|
|
1148
|
+
Until its turn starts, a queued message can be taken back: `/unqueue` in an attached terminal, the × on its bubble
|
|
1149
|
+
in the web ([sessions.md](sessions.md), *Removing a queued message*).
|
|
1148
1150
|
|
|
1149
1151
|
## Server errors
|
|
1150
1152
|
|
data/docs/hooks.md
CHANGED
|
@@ -52,7 +52,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
|
|
|
52
52
|
| `:session_start` | The first turn this chi process runs for the session (so again after `--resume`, a worker that idle-exited and woke, or a restart) | `{ type: :session_start, session_id: "..." }` |
|
|
53
53
|
| `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), resumed: true \| false (true on a step-limit Continue, which resumes the run before it; false on every other turn, a reminder's or a wake's too), messages: [...] (the history before this turn) }` |
|
|
54
54
|
| `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), refs: (see [Refs on the answer](#refs-on-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
|
|
55
|
-
| `:before_generation` | Before each LLM API call (both loops) | `{ type: :before_generation, iteration: N }` |
|
|
55
|
+
| `:before_generation` | Before each LLM API call (both loops), before its `:generation_started`: the hook's time (a question it waits on) isn't the generation's | `{ type: :before_generation, iteration: N }` |
|
|
56
56
|
| `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
|
|
57
57
|
| `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
|
|
58
58
|
| `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
|
data/docs/internals/engine.md
CHANGED
|
@@ -179,7 +179,7 @@ KernelLoop#run(messages, max_iterations:, on_stream_event:, cancel_controller:,
|
|
|
179
179
|
│ 1. Queued input (user lines, plugin steers) goes in at the boundary (Steer.inject!)
|
|
180
180
|
│ 2. Format the conversation as prompt text (Prompt.format_with_images); a context
|
|
181
181
|
│ guidance line goes on the tail when the window fills past a threshold
|
|
182
|
-
│ 3.
|
|
182
|
+
│ 3. Fire :before_generation, then stream one generation (:generation_started, :generation_chunk, :generation_completed)
|
|
183
183
|
│ 4. Parse tool calls (ToolCallParser for the profile):
|
|
184
184
|
│ → Gemma 4: <|tool_call>call:NAME{params}<tool_call|>
|
|
185
185
|
│ → Qwen 3.6: <tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>
|
|
@@ -466,7 +466,8 @@ persistent observers (`Engine#subscribe`), which get a copy with a monotonic `ev
|
|
|
466
466
|
metrics, the debug log, the thinking tails, and in a worker the Bridge, which streams to the web and the attached
|
|
467
467
|
TUI. Errors in the sink or an observer are caught; they never break the turn.
|
|
468
468
|
|
|
469
|
-
`Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `
|
|
469
|
+
`Engine#announce` puts an event that belongs to no turn (`ANNOUNCEABLE_EVENTS`: `turn_enqueued`, `turn_dequeued`
|
|
470
|
+
(a queued message taken back before it ran, `{enqueued_id, client_id}`: the remover's), `card`,
|
|
470
471
|
`hook_notice`, `guardrail_warning`, `plugin_init_started`, and more) to the observers only.
|
|
471
472
|
|
|
472
473
|
Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
|
data/docs/sessions.md
CHANGED
|
@@ -4,6 +4,8 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
|
|
|
4
4
|
|
|
5
5
|
**Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
|
|
6
6
|
|
|
7
|
+
**Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
|
|
8
|
+
|
|
7
9
|
**Retention (file-based, opt-out via env):**
|
|
8
10
|
|
|
9
11
|
| Env | Default | Purpose |
|
|
@@ -207,7 +209,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
|
|
|
207
209
|
- `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
|
|
208
210
|
- `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; wait or attach`, exit 1): the message would merge into it, and its reply would come back as the answer. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
|
|
209
211
|
- Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi --attach ID`). There is no default timeout.
|
|
210
|
-
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
212
|
+
- `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
|
|
211
213
|
- `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
|
|
212
214
|
- `--set BUNDLE.KEY=VALUE` (with `--new`, repeatable) gives the new session a bundle setting of its own, as `chi --set` does (see *Bundle settings* below): `chi send --new --set cost-cap.cap=3 -m "…"` starts it with a $3 cap. A bundle or key the installed bundles don't offer is a usage error (exit 2), as is `--set` without `--new`.
|
|
213
215
|
- `--continues ID` (with `--new`) starts the next link of that session's chain instead (see *Session chains*): in its folder, on its model (or the one `--model` names), LLM context, thinking level, toolkit and bundle settings, so `--dir`, `--llm-context*`, `--thinking`, `--toolkit`, `--tool`, `--no-tool` and `--set` are refused beside it; `ID` may be a prefix or `last:ID`, the chain's latest link. Without a message the new link starts idle; `--wait` then has nothing to wait for (a usage error). It prints `<full id> started (continues 3fa2c1d0)`, with `; 2 delegates moved` inside the parentheses when open delegates moved to it; a refusal (continued already, naming the next link; an open delegate that can't move; a turn running) is `chi send: refused: …`, exit 1, and a `--model` it can't start on is `chi send: could not start a session: …`, exit 1, nothing archived. A fallback to the default model (below) prints `chi send: warning: …`.
|
data/docs/sub-agent.md
CHANGED
|
@@ -59,7 +59,7 @@ Exit codes, both commands:
|
|
|
59
59
|
| 0 | `not_continued` | (`chi answer`) a Stop answered the step-limit question; the turn's work so far stays |
|
|
60
60
|
| 3 | `question` | chi waits for an answer: `question`, `answer_with` |
|
|
61
61
|
| 4 | `running` | `--timeout` passed; the turn goes on, wait again |
|
|
62
|
-
| 1 | `failed`, `canceled`, `limit`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
62
|
+
| 1 | `failed`, `canceled`, `limit`, `removed`, `no_answer`, `error`, `worker_gone`, `stopped` | `detail` says what happened (`limit`: the turn ran out of steps and nobody can be asked, e.g. an older worker; `chi send ID -m '/continue yes'` continues it; `removed`: the `--queue` message waited for was taken back before it ran, no turn ran for it). `failed` adds `error_kind` (the provider error's kind: `credits`, `credits_held`, `rate_limited`, `server`, `auth`, `connection`, …) and `retryable` when known; `canceled` adds `cancel_reason` (`user`: a Stop in the web; `session_stopped`: a session stop, `chi sessions stop` or the web's; `hook`; `ctrl_c`; `manual`) and `stopped_by` (the hook that stopped it, e.g. `loop-guard`) when known |
|
|
63
63
|
| 2 | (no JSON) | usage error, or an option the question doesn't offer |
|
|
64
64
|
| 130 | `running` | Ctrl-C; the turn goes on |
|
|
65
65
|
|
|
@@ -232,6 +232,9 @@ module Samagotchi
|
|
|
232
232
|
queued[:images] = event[:images] if event[:images]
|
|
233
233
|
queued[:delivery] = event[:delivery] if event[:delivery]
|
|
234
234
|
@queued << queued
|
|
235
|
+
# Taken back before it ran (Bridge#handle_dequeue_turn).
|
|
236
|
+
when :turn_dequeued
|
|
237
|
+
dequeue([{ enqueued_id: event[:enqueued_id] }])
|
|
235
238
|
when :recap_ready
|
|
236
239
|
# One collected just as a turn started describes the chat before it.
|
|
237
240
|
@recap = event[:recap] unless @turn
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -517,7 +517,10 @@ module Samagotchi
|
|
|
517
517
|
# turn's end (Delivery::QUEUE_FEATURE). A worker without it would steer.
|
|
518
518
|
# turn_ids: the running turn's checkpoint mark, a continue turn and a
|
|
519
519
|
# Stop's last_turn carry the turn's enqueued_id (ReplyWait.own_turn).
|
|
520
|
-
|
|
520
|
+
# unqueue: POST turn/dequeue (#handle_dequeue_turn) takes a queued
|
|
521
|
+
# message back before it runs.
|
|
522
|
+
FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
|
|
523
|
+
SessionInbox::UNQUEUE_FEATURE].freeze
|
|
521
524
|
|
|
522
525
|
# The routes, by method and what follows /session/:id/: a handler takes
|
|
523
526
|
# the session id and the request body and returns [headers, status, body].
|
|
@@ -528,6 +531,7 @@ module Samagotchi
|
|
|
528
531
|
%w[POST answer] => :handle_answer,
|
|
529
532
|
%w[POST question/dismiss] => :handle_dismiss_question,
|
|
530
533
|
%w[POST turn] => :handle_post_turn,
|
|
534
|
+
%w[POST turn/dequeue] => :handle_dequeue_turn,
|
|
531
535
|
%w[POST command] => :handle_command,
|
|
532
536
|
%w[POST exit] => :handle_exit_request,
|
|
533
537
|
%w[POST recap] => :handle_recap,
|
|
@@ -932,6 +936,55 @@ module Samagotchi
|
|
|
932
936
|
[{}, 202, ack]
|
|
933
937
|
end
|
|
934
938
|
|
|
939
|
+
# POST /session/:id/turn/dequeue {enqueued_id, client_id?, deadline?}:
|
|
940
|
+
# take back a queued message (one that waits for the running turn's
|
|
941
|
+
# end: a queue or an image message) before a turn takes it. With the
|
|
942
|
+
# event log held, as its enqueue was, so :turn_dequeued always follows
|
|
943
|
+
# its :turn_enqueued; the file is claimed as the drain claims it (one
|
|
944
|
+
# rename wins), deleted, and its tombstone left for a wait on its turn
|
|
945
|
+
# (SessionInbox.take_queued_input). It never wakes the worker.
|
|
946
|
+
# 200 {status: "removed", enqueued_id, prompt}; 409 already_started (a
|
|
947
|
+
# turn took it, or the snapshot still lists it with no file left: taken,
|
|
948
|
+
# not announced yet), 409 not_queued (a next-step message: a drain may
|
|
949
|
+
# merge it any moment); 404 unknown_enqueued_id (never not_found: that is
|
|
950
|
+
# the unrouted path's, what a worker older than the route answers);
|
|
951
|
+
# 408 past its +deadline+ (see #handle_post_turn), nothing removed.
|
|
952
|
+
def handle_dequeue_turn(session_id, body)
|
|
953
|
+
parsed = parse_json(body)
|
|
954
|
+
return [{ "Allow" => "POST" }, 400, { error: "invalid_json" }] unless parsed.is_a?(Hash)
|
|
955
|
+
|
|
956
|
+
id = fetched(parsed, "enqueued_id").to_s.strip
|
|
957
|
+
return [{ "Allow" => "POST" }, 400, { error: "missing_fields", detail: "enqueued_id required" }] if id.empty?
|
|
958
|
+
|
|
959
|
+
deadline = fetched(parsed, "deadline")
|
|
960
|
+
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
961
|
+
|
|
962
|
+
client_id = fetched(parsed, "client_id")
|
|
963
|
+
taken, input = @engine.synchronize_events do
|
|
964
|
+
raise IntakeClosed if stopped?
|
|
965
|
+
next [:expired, nil] if expired?("dequeue_expired", deadline, sid: session_id, enqueued_id: id)
|
|
966
|
+
|
|
967
|
+
SessionInbox.take_queued_input(session_dir, id).tap do |status, _|
|
|
968
|
+
@engine.announce(type: :turn_dequeued, enqueued_id: id, client_id: client_id) if status == :removed
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
dequeue_reply(taken, id, input)
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
def dequeue_reply(taken, id, input)
|
|
975
|
+
case taken
|
|
976
|
+
when :removed then [{}, 200, { status: "removed", enqueued_id: id, prompt: input.prompt.to_s, session_id: @session_id }]
|
|
977
|
+
when :expired then deadline_passed("removal")
|
|
978
|
+
when :not_queued
|
|
979
|
+
[{}, 409, { error: "not_queued", detail: "only a message queued for after the turn can be removed", enqueued_id: id }]
|
|
980
|
+
when :claimed then [{}, 409, { error: "already_started", enqueued_id: id }]
|
|
981
|
+
else
|
|
982
|
+
return [{}, 409, { error: "already_started", enqueued_id: id }] if @accumulator.queued.any? { |q| q[:enqueued_id] == id }
|
|
983
|
+
|
|
984
|
+
[{}, 404, { error: "unknown_enqueued_id", enqueued_id: id }]
|
|
985
|
+
end
|
|
986
|
+
end
|
|
987
|
+
|
|
935
988
|
# Read-only snapshot surface. AC #4: too-old reconnects re-derive state
|
|
936
989
|
# from here.
|
|
937
990
|
def handle_state(_session_id, _body = nil)
|
|
@@ -132,6 +132,18 @@ module Samagotchi
|
|
|
132
132
|
post("question/dismiss", { id: id, deadline: deadline }, read_body: true)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# POST /session/:id/turn/dequeue: take back a queued message (one that
|
|
136
|
+
# waits for the running turn's end) before it runs. 200 {status:
|
|
137
|
+
# "removed", enqueued_id, prompt}; 409 already_started (a turn took
|
|
138
|
+
# it) or not_queued (a next-step message); 404 unknown_enqueued_id; a
|
|
139
|
+
# 404 not_found: a worker older than the route (its sidecar lacks
|
|
140
|
+
# "unqueue"); 408 = too late.
|
|
141
|
+
# @param client_id [String, nil] the removing UI, in its :turn_dequeued
|
|
142
|
+
# @return [Response]
|
|
143
|
+
def dequeue(enqueued_id:, client_id: nil)
|
|
144
|
+
post("turn/dequeue", { enqueued_id: enqueued_id, client_id: client_id, deadline: deadline }, read_body: true)
|
|
145
|
+
end
|
|
146
|
+
|
|
135
147
|
# POST /session/:id/turn. 202 = queued (body carries the enqueued_id the
|
|
136
148
|
# Bridge also announced in :turn_enqueued), 408 deadline_passed = the
|
|
137
149
|
# Bridge read it too late and dropped it (see DEADLINE_SHARE).
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -762,7 +762,7 @@ module Samagotchi
|
|
|
762
762
|
# Event types #announce accepts: facts about the session's input queue,
|
|
763
763
|
# a failed turn's prompt handed back and the continue offer, which live
|
|
764
764
|
# UIs need in the event log, emitted outside any turn's stream.
|
|
765
|
-
ANNOUNCEABLE_EVENTS = %i[turn_enqueued input_merged prompt_restored continue_offered continue_resolved
|
|
765
|
+
ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
|
|
766
766
|
command_queued command_ran context_added card hook_notice guardrail_warning
|
|
767
767
|
plugin_init_started plugin_init_finished].freeze
|
|
768
768
|
|
|
@@ -372,6 +372,11 @@ module Samagotchi
|
|
|
372
372
|
# Ends with its :generation_completed (and, unless cut, the
|
|
373
373
|
# after_generation hook).
|
|
374
374
|
def generate(turn, request)
|
|
375
|
+
# Before the generation starts: a hook that waits (a question) isn't
|
|
376
|
+
# the generation's time, and a Stop there leaves none started.
|
|
377
|
+
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
378
|
+
raise Client::RequestCancelled, turn.cancel_controller.reason if turn.cancel_controller&.cancelled?
|
|
379
|
+
|
|
375
380
|
# Each ask splits its own stream (run_generation rebuilds the
|
|
376
381
|
# splitter), so one that ended inside a thought or a tool call doesn't
|
|
377
382
|
# leave the next one "inside" it.
|
|
@@ -383,7 +388,6 @@ module Samagotchi
|
|
|
383
388
|
# streamed, for the log (a stuck thinking generation shows as
|
|
384
389
|
# thinking_chars=N content_length=…).
|
|
385
390
|
generation = Generation.new(usage: nil, served_model: nil, streamed_thinking: 0)
|
|
386
|
-
fire_hook(:before_generation, { type: :before_generation, iteration: turn.iteration }) if @hooks
|
|
387
391
|
buffer_mark = generation.buffer_mark = turn.buffer.length
|
|
388
392
|
# A cancel salvages only this generation's thinking: the earlier
|
|
389
393
|
# steps' is on their own messages already.
|
|
@@ -655,10 +655,14 @@ module Samagotchi
|
|
|
655
655
|
@loop.apply_llm_context!(@conversation, context: @context, emit: method(:emit))
|
|
656
656
|
window = @window = @loop.context_window(@model_name)
|
|
657
657
|
observe_context(iteration, window)
|
|
658
|
+
# Before the generation starts: a hook that waits (a question)
|
|
659
|
+
# isn't the generation's time, and a Stop there leaves none started.
|
|
660
|
+
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
661
|
+
return [@cancel_controller.reason, "", ""] if @cancel_controller&.cancelled?
|
|
662
|
+
|
|
658
663
|
retry_generation = @empty_retry.take_sampling!
|
|
659
664
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
660
665
|
context_window_source: window&.source, price: @loop.price&.to_h)
|
|
661
|
-
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
662
666
|
streamed = +""
|
|
663
667
|
thought = +""
|
|
664
668
|
generation_controller = nil
|
|
@@ -16,6 +16,7 @@ module Samagotchi
|
|
|
16
16
|
# One line each, with a few of their own fields (never text).
|
|
17
17
|
ANNOUNCED = {
|
|
18
18
|
turn_enqueued: %i[enqueued_id client_id],
|
|
19
|
+
turn_dequeued: %i[enqueued_id client_id],
|
|
19
20
|
command_queued: %i[command_id client_id],
|
|
20
21
|
command_ran: %i[command_id client_id status],
|
|
21
22
|
input_merged: %i[count],
|
|
@@ -34,8 +34,9 @@ module Samagotchi
|
|
|
34
34
|
# @param result [ReplyWait::Result]
|
|
35
35
|
# @return [String] answered, question, failed, canceled, limit (the
|
|
36
36
|
# turn ran out of iterations, nobody asked), not_continued (a Stop
|
|
37
|
-
# answered the step-limit question),
|
|
38
|
-
#
|
|
37
|
+
# answered the step-limit question), removed (the queued message was
|
|
38
|
+
# taken back before it ran), no_answer, error, worker_gone, stopped
|
|
39
|
+
# or running
|
|
39
40
|
def status(result)
|
|
40
41
|
case result.status
|
|
41
42
|
when :done then "answered"
|
|
@@ -46,6 +47,7 @@ module Samagotchi
|
|
|
46
47
|
when "canceled" then "canceled"
|
|
47
48
|
when "exhausted" then "limit"
|
|
48
49
|
when "not_continued" then "not_continued"
|
|
50
|
+
when "removed" then "removed"
|
|
49
51
|
else "no_answer"
|
|
50
52
|
end
|
|
51
53
|
when :error then "error"
|
|
@@ -258,6 +260,7 @@ module Samagotchi
|
|
|
258
260
|
when "canceled" then "the turn was canceled"
|
|
259
261
|
when "completed" then "the turn ended with no visible answer"
|
|
260
262
|
when "not_continued" then "the turn was not continued (Stop); its work so far stays"
|
|
263
|
+
when "removed" then "the queued message was removed before it ran; no turn ran for it"
|
|
261
264
|
when "exhausted"
|
|
262
265
|
"the turn ran out of iterations#{" (#{result.limit} steps)" if result.limit} before it answered; " \
|
|
263
266
|
"chi send #{session_id} -m '/continue yes' continues it, a message drops it"
|
|
@@ -111,8 +111,9 @@ module Samagotchi
|
|
|
111
111
|
cached = @session_settings
|
|
112
112
|
return cached.last if cached && cached.first == id
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
values = session_values(id) if id
|
|
115
|
+
merged = id ? overlay(@settings, values || {}) : @settings
|
|
116
|
+
@session_settings = [id, merged].freeze unless id && values.nil?
|
|
116
117
|
merged
|
|
117
118
|
end
|
|
118
119
|
|
|
@@ -435,14 +436,17 @@ module Samagotchi
|
|
|
435
436
|
nil
|
|
436
437
|
end
|
|
437
438
|
|
|
438
|
-
# The session's own values for this bundle ({} without any
|
|
439
|
-
# the Host
|
|
440
|
-
def session_values
|
|
439
|
+
# The session's own values for this bundle ({} without any; nil when
|
|
440
|
+
# the Host couldn't tell them, so #settings can retry).
|
|
441
|
+
def session_values(id)
|
|
441
442
|
values = @host.setup&.call&.bundle_settings
|
|
442
443
|
values.is_a?(Hash) ? values.fetch(@bundle, {}) : {}
|
|
443
444
|
rescue StandardError => e
|
|
444
|
-
@
|
|
445
|
-
|
|
445
|
+
unless (@session_settings_unread ||= {}).key?(id)
|
|
446
|
+
@session_settings_unread[id] = true
|
|
447
|
+
@log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
|
|
448
|
+
end
|
|
449
|
+
nil
|
|
446
450
|
end
|
|
447
451
|
|
|
448
452
|
def overlay(config, values)
|
|
@@ -35,7 +35,9 @@ module Samagotchi
|
|
|
35
35
|
# reply (:no_reply): "failed", "canceled", "completed" (empty),
|
|
36
36
|
# "exhausted" (it ran out of iterations, with nobody asked whether to
|
|
37
37
|
# continue) or "not_continued" (a Stop answered the step-limit
|
|
38
|
-
# question; no turn ran), from the session's last_turn
|
|
38
|
+
# question; no turn ran), from the session's last_turn, or "removed"
|
|
39
|
+
# (turn:'s queued input was taken back before it ran, its tombstone:
|
|
40
|
+
# SessionInbox.removed?; no turn ran); nil when unknown. With
|
|
39
41
|
# "failed", text is the failure's summary when the turn note has one.
|
|
40
42
|
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
41
43
|
# turn ran out at
|
|
@@ -132,6 +134,11 @@ module Samagotchi
|
|
|
132
134
|
claimed = waiting.nil?
|
|
133
135
|
end
|
|
134
136
|
session = Session.load(id, state_dir: state_dir)
|
|
137
|
+
# Taken back before it ran (POST turn/dequeue): no turn will come.
|
|
138
|
+
if turn && claimed && !seen_own && SessionInbox.removed?(session_dir, turn)
|
|
139
|
+
return Result.new(status: :no_reply, outcome: "removed", session: session)
|
|
140
|
+
end
|
|
141
|
+
|
|
135
142
|
if turn && !seen_own && session.status == Session::STATUS_RUNNING && session.interrupted_turn&.enqueued_id == turn
|
|
136
143
|
seen_own = true
|
|
137
144
|
own_base = session.last_turn&.ended_at
|
|
@@ -123,13 +123,16 @@ module Samagotchi
|
|
|
123
123
|
# /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
|
|
124
124
|
# themselves (ComposerRoute.steer_choice) before any command check; the
|
|
125
125
|
# entries exist for Tab completion and /help. The web composer sends
|
|
126
|
-
# /cut too
|
|
127
|
-
#
|
|
128
|
-
# it is offered there.
|
|
126
|
+
# /cut too (its queue is Alt+Enter). Both terminals queue: the REPL in
|
|
127
|
+
# its own list, the attached TUI's worker in its input files.
|
|
129
128
|
registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
|
|
130
129
|
local: true, uis: %i[repl attached web], match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
|
|
131
130
|
registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
|
|
132
|
-
local: true, uis: [
|
|
131
|
+
local: true, uis: %i[repl attached], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
|
|
132
|
+
# Takes back the newest message this attached TUI queued, before it
|
|
133
|
+
# runs (the worker's POST turn/dequeue); the web has × on the bubble.
|
|
134
|
+
registry.register("/unqueue", "remove your newest queued message before it runs", local: true, uis: [:attached],
|
|
135
|
+
match: ->(text) { text.casecmp?("/unqueue") })
|
|
133
136
|
registry
|
|
134
137
|
end
|
|
135
138
|
|
|
@@ -6,6 +6,7 @@ require "time"
|
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
8
|
require_relative "delivery"
|
|
9
|
+
require_relative "log"
|
|
9
10
|
require_relative "session"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
@@ -276,5 +277,72 @@ module Samagotchi
|
|
|
276
277
|
rescue Errno::ENOENT, Errno::EACCES
|
|
277
278
|
nil
|
|
278
279
|
end
|
|
280
|
+
|
|
281
|
+
# Where a removed queued input leaves its tombstone: input/removed/<id>,
|
|
282
|
+
# an empty file outside the *.json glob, so a wait for that input's
|
|
283
|
+
# turn (ReplyWait's turn:) learns it will never run (#removed?).
|
|
284
|
+
REMOVED_DIR = "removed"
|
|
285
|
+
# The worker's sidecar feature for POST turn/dequeue (Bridge::FEATURES).
|
|
286
|
+
UNQUEUE_FEATURE = "unqueue"
|
|
287
|
+
|
|
288
|
+
# Take back the unclaimed input file carrying +enqueued_id+ before a
|
|
289
|
+
# turn takes it (the Bridge's POST turn/dequeue, under the event log):
|
|
290
|
+
# only one that waits for the turn's end (a queue or an image message,
|
|
291
|
+
# #waits_for_turn_end?); a next-step file may be merged at any boundary.
|
|
292
|
+
# The file is claimed first, as the drain does (the rename is the race's
|
|
293
|
+
# one winner), then deleted, and its tombstone written.
|
|
294
|
+
# @return [Array(Symbol, Input)] [:removed, the input it held],
|
|
295
|
+
# [:not_queued, nil] for a next-step file, [:claimed, nil] when a
|
|
296
|
+
# turn took it (its *.json.processing is there), [:none, nil] for
|
|
297
|
+
# none
|
|
298
|
+
def self.take_queued_input(session_dir, enqueued_id)
|
|
299
|
+
id = enqueued_id.to_s
|
|
300
|
+
return [:none, nil] if id.empty?
|
|
301
|
+
|
|
302
|
+
find_new_input_files(session_dir).sort.each do |file|
|
|
303
|
+
data = JSON.parse(File.read(file))
|
|
304
|
+
next unless data.is_a?(Hash) && data["enqueued_id"] == id
|
|
305
|
+
return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
|
|
306
|
+
|
|
307
|
+
claimed = claim_input_file(file) or return [:claimed, nil]
|
|
308
|
+
input = read_input(claimed)
|
|
309
|
+
mark_removed(session_dir, id)
|
|
310
|
+
FileUtils.rm_f(claimed)
|
|
311
|
+
return [:removed, input]
|
|
312
|
+
rescue JSON::ParserError, SystemCallError
|
|
313
|
+
next
|
|
314
|
+
end
|
|
315
|
+
claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Whether a claimed input file (*.json.processing: a turn's, now or
|
|
319
|
+
# left by a crash) carries +id+.
|
|
320
|
+
def self.claimed_input?(session_dir, id)
|
|
321
|
+
Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
|
|
322
|
+
data = JSON.parse(File.read(file))
|
|
323
|
+
data.is_a?(Hash) && data["enqueued_id"] == id
|
|
324
|
+
rescue JSON::ParserError, SystemCallError
|
|
325
|
+
false
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
private_class_method :claimed_input?
|
|
329
|
+
|
|
330
|
+
def self.mark_removed(session_dir, id)
|
|
331
|
+
return unless ENQUEUED_ID_NAME.match?(id)
|
|
332
|
+
|
|
333
|
+
dir = File.join(session_dir, INPUT_DIR, REMOVED_DIR)
|
|
334
|
+
FileUtils.mkdir_p(dir)
|
|
335
|
+
AtomicFile.write(File.join(dir, id), "")
|
|
336
|
+
rescue SystemCallError => e
|
|
337
|
+
Log.warn(:bridge, "removed_tombstone_write_failed", error: e.class.name, msg: e.message)
|
|
338
|
+
end
|
|
339
|
+
private_class_method :mark_removed
|
|
340
|
+
|
|
341
|
+
# @return [Boolean] whether the queued input +enqueued_id+ was removed
|
|
342
|
+
# before it ran (#take_queued_input's tombstone)
|
|
343
|
+
def self.removed?(session_dir, enqueued_id)
|
|
344
|
+
id = enqueued_id.to_s
|
|
345
|
+
ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
|
|
346
|
+
end
|
|
279
347
|
end
|
|
280
348
|
end
|
|
@@ -5,6 +5,7 @@ require_relative "../composer_route"
|
|
|
5
5
|
require_relative "event_renderer"
|
|
6
6
|
require_relative "formatting"
|
|
7
7
|
require_relative "../prompt_history"
|
|
8
|
+
require_relative "../session_inbox"
|
|
8
9
|
require_relative "attached_view"
|
|
9
10
|
require_relative "status_row"
|
|
10
11
|
require_relative "input_support"
|
|
@@ -149,6 +150,10 @@ module Samagotchi
|
|
|
149
150
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
150
151
|
# the input when their turn fails (a replayed event must not).
|
|
151
152
|
@sent_ids = Set.new
|
|
153
|
+
# This run's prompts queued for after the running turn (their ack
|
|
154
|
+
# said queue), oldest first, until their turn starts or they are
|
|
155
|
+
# removed: /unqueue takes back the newest.
|
|
156
|
+
@my_queued = []
|
|
152
157
|
@wait_at_eof = wait_at_eof
|
|
153
158
|
# Sent and not ended yet (by enqueued_id), the ones merged into the
|
|
154
159
|
# running turn, whether the input has ended, and whether one of this
|
|
@@ -260,6 +265,7 @@ module Samagotchi
|
|
|
260
265
|
take_session_state(event[:session_state_snapshot] || {})
|
|
261
266
|
return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
|
|
262
267
|
when :turn_enqueued then show_enqueued(event)
|
|
268
|
+
when :turn_dequeued then turn_dequeued(event)
|
|
263
269
|
when :turn_started
|
|
264
270
|
@status.take_event(event)
|
|
265
271
|
start_turn(event)
|
|
@@ -419,6 +425,8 @@ module Samagotchi
|
|
|
419
425
|
show_stats
|
|
420
426
|
elsif local == :recap
|
|
421
427
|
show_recap
|
|
428
|
+
elsif local == :unqueue
|
|
429
|
+
unqueue_newest
|
|
422
430
|
elsif route.kind == :command
|
|
423
431
|
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
424
432
|
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
@@ -620,6 +628,58 @@ module Samagotchi
|
|
|
620
628
|
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
621
629
|
# /queue to a worker from before queueing, which would steer it.
|
|
622
630
|
QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
|
|
631
|
+
UNQUEUE_NEEDS_NEWER_WORKER = "(/unqueue needs a newer worker: restart it with chi sessions restart)"
|
|
632
|
+
NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
|
|
633
|
+
UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
|
|
634
|
+
"unknown_enqueued_id" => "(not removed: it is already gone)",
|
|
635
|
+
"not_queued" => "(not removed: it goes in at the next step)" }.freeze
|
|
636
|
+
|
|
637
|
+
# /unqueue: take back the newest prompt this run queued for after the
|
|
638
|
+
# turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
|
|
639
|
+
# to every UI; here the reply says what became of it. A worker whose
|
|
640
|
+
# sidecar lacks "unqueue" is not asked.
|
|
641
|
+
def unqueue_newest
|
|
642
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
|
|
643
|
+
|
|
644
|
+
id = @my_queued.last
|
|
645
|
+
return @screen.commit(NOTHING_QUEUED) unless id
|
|
646
|
+
|
|
647
|
+
reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
|
|
648
|
+
error = reply.json&.fetch("error", nil)
|
|
649
|
+
return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
|
|
650
|
+
|
|
651
|
+
# Removed, started or gone: no longer this run's to take back.
|
|
652
|
+
@my_queued.delete(id) if [200, 404, 409].include?(reply.status)
|
|
653
|
+
return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
|
|
654
|
+
|
|
655
|
+
@screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
|
|
656
|
+
rescue SystemCallError, IOError => e
|
|
657
|
+
@screen.commit("could not remove the queued message (#{worker_down(e)})")
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
# The removal's line, and its text back in the input to change and
|
|
661
|
+
# send again (as a failed turn's prompt comes back, #restore_prompt):
|
|
662
|
+
# not over typed text, and not for lines from a pipe. Its images are
|
|
663
|
+
# not put back.
|
|
664
|
+
def unqueued(prompt)
|
|
665
|
+
@screen.commit("removed: #{first_line_clip(prompt)}")
|
|
666
|
+
return if @wait_at_eof || prompt.strip.empty?
|
|
667
|
+
|
|
668
|
+
@screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def first_line_clip(text, max = 60)
|
|
672
|
+
line = text.to_s.strip.lines.first.to_s.strip
|
|
673
|
+
line.length > max ? "#{line[0, max]}…" : line
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Whether the attached worker takes queued messages back (its sidecar
|
|
677
|
+
# names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
678
|
+
# answers (an older one's 404 not_found says so).
|
|
679
|
+
def worker_unqueues?
|
|
680
|
+
sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
|
|
681
|
+
sidecar.nil? || sidecar.features.include?(SessionInbox::UNQUEUE_FEATURE)
|
|
682
|
+
end
|
|
623
683
|
|
|
624
684
|
# A command queued for after the running turn (command_queued with
|
|
625
685
|
# waits): its line now (another client's) and a note; its command_ran
|
|
@@ -760,6 +820,7 @@ module Samagotchi
|
|
|
760
820
|
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
761
821
|
@sent_ids << enqueued_id if enqueued_id
|
|
762
822
|
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
823
|
+
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
763
824
|
note = sent_note(reply.json, delivery: delivery, images: images, running: running)
|
|
764
825
|
@screen.commit(note) if note
|
|
765
826
|
return
|
|
@@ -1095,6 +1156,12 @@ module Samagotchi
|
|
|
1095
1156
|
|
|
1096
1157
|
def render_snapshot(snapshot, reset:)
|
|
1097
1158
|
@view.finish_thinking_spinner
|
|
1159
|
+
queued = Array(snapshot[:queued])
|
|
1160
|
+
known_queued = @my_queued.to_set
|
|
1161
|
+
@my_queued = queued.filter_map do |entry|
|
|
1162
|
+
id = entry[:enqueued_id]
|
|
1163
|
+
id if id && (known_queued.include?(id) || (entry.key?(:client_id) && own?(entry[:client_id])))
|
|
1164
|
+
end
|
|
1098
1165
|
# A new worker's first snapshot is the session as it now stands.
|
|
1099
1166
|
moved = @worker_changed
|
|
1100
1167
|
reset ||= moved
|
|
@@ -1131,7 +1198,7 @@ module Samagotchi
|
|
|
1131
1198
|
# A question between turns (the step-limit one) belongs to no turn.
|
|
1132
1199
|
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
1133
1200
|
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
1134
|
-
|
|
1201
|
+
queued.each do |entry|
|
|
1135
1202
|
next if own?(entry[:client_id])
|
|
1136
1203
|
|
|
1137
1204
|
@shown_enqueued << entry[:enqueued_id]
|
|
@@ -1453,10 +1520,31 @@ module Samagotchi
|
|
|
1453
1520
|
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1454
1521
|
end
|
|
1455
1522
|
|
|
1523
|
+
# A queued prompt was taken back before it ran (the web's ×, /unqueue
|
|
1524
|
+
# in a terminal). One of this run's (wait_at_eof) will have no turn:
|
|
1525
|
+
# it ends the wait, as a failed one does. Another client's shown
|
|
1526
|
+
# line says so.
|
|
1527
|
+
def turn_dequeued(event)
|
|
1528
|
+
id = event[:enqueued_id]
|
|
1529
|
+
@my_queued.delete(id)
|
|
1530
|
+
if @open_ids.delete?(id)
|
|
1531
|
+
if own?(event[:client_id])
|
|
1532
|
+
@own_empty = true
|
|
1533
|
+
@screen.commit("(removed before it ran)")
|
|
1534
|
+
else
|
|
1535
|
+
@own_failed = true
|
|
1536
|
+
@screen.commit("(your queued message was removed before it ran)")
|
|
1537
|
+
end
|
|
1538
|
+
elsif @shown_enqueued.delete?(id)
|
|
1539
|
+
@screen.commit("(a queued message was removed)")
|
|
1540
|
+
end
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1456
1543
|
def start_turn(event)
|
|
1457
1544
|
@running = true
|
|
1458
1545
|
@joined_mid_turn = false
|
|
1459
1546
|
origin = event[:origin] || {}
|
|
1547
|
+
@my_queued.delete(origin[:enqueued_id])
|
|
1460
1548
|
if event[:prompt].nil?
|
|
1461
1549
|
# A continue turn (after the offer's yes) has no prompt to show; a
|
|
1462
1550
|
# reminder turn shows its reminders (:reminder_injected).
|
|
@@ -67,6 +67,8 @@ module Samagotchi
|
|
|
67
67
|
COMMAND_QUEUED = "(queued: runs after this turn)"
|
|
68
68
|
# /detach is the attached terminal's; the REPL owns its session.
|
|
69
69
|
REPL_DETACH_NOTE = "(not attached: this session runs in this terminal; /exit ends it)"
|
|
70
|
+
# /unqueue is the attached terminal's (its worker's queued input files).
|
|
71
|
+
REPL_UNQUEUE_NOTE = "(/unqueue is the attached terminal's; here a line queued with /queue TEXT runs after the turn)"
|
|
70
72
|
IMAGE_LINE_WAITS = "(a line with images runs as the next turn)"
|
|
71
73
|
|
|
72
74
|
# Raised when another process (a `chi web` worker or another chi) owns the
|
|
@@ -1159,6 +1161,7 @@ module Samagotchi
|
|
|
1159
1161
|
case local_command(input)
|
|
1160
1162
|
when :stats then @surface.commit("\nmodel> session stats:\n#{format_session_metrics(@engine.stats_snapshot)}")
|
|
1161
1163
|
when :recap then @surface.commit("\nmodel> #{handle_recap_command}")
|
|
1164
|
+
when :unqueue then @surface.commit(REPL_UNQUEUE_NOTE)
|
|
1162
1165
|
else return false
|
|
1163
1166
|
end
|
|
1164
1167
|
true
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -166,6 +166,7 @@ module Samagotchi
|
|
|
166
166
|
["GET", %r{\A/api/sessions/([^/]+)/output\z}, :handle_output],
|
|
167
167
|
["POST", %r{\A/api/sessions/([^/]+)/cancel\z}, :handle_cancel],
|
|
168
168
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop],
|
|
169
|
+
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn],
|
|
169
170
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop],
|
|
170
171
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart],
|
|
171
172
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive],
|
|
@@ -1339,6 +1340,33 @@ module Samagotchi
|
|
|
1339
1340
|
error_response(404, "not_found", e.message)
|
|
1340
1341
|
end
|
|
1341
1342
|
|
|
1343
|
+
# POST /api/sessions/:id/turn/:enqueued_id/dequeue {client_id?}: the ×
|
|
1344
|
+
# on a queued bubble takes the message back before it runs
|
|
1345
|
+
# (Bridge#handle_dequeue_turn). Straight to the live worker, never
|
|
1346
|
+
# through deliver_turn: a removal must not wake one, and a stopped
|
|
1347
|
+
# worker's queued file is the next worker's (503 not_live). 200
|
|
1348
|
+
# {status, enqueued_id, prompt}; 409 already_started / not_queued and
|
|
1349
|
+
# 404 unknown_enqueued_id pass through; the rest as every relay.
|
|
1350
|
+
def handle_dequeue_turn(req, id, enqueued_id)
|
|
1351
|
+
@session_class.load(id, state_dir: default_state_dir)
|
|
1352
|
+
body = request_body(req)
|
|
1353
|
+
parsed = body.nil? || body.strip.empty? ? nil : parse_json(body)
|
|
1354
|
+
client_id = (parsed["client_id"] if parsed.is_a?(Hash))
|
|
1355
|
+
|
|
1356
|
+
request = ->(client) { client.dequeue(enqueued_id: enqueued_id, client_id: client_id) }
|
|
1357
|
+
relay(id, bridge_client(id), request, what: "the message was not removed", cant: "remove queued messages") do |reply|
|
|
1358
|
+
body = reply.json || {}
|
|
1359
|
+
case reply.status
|
|
1360
|
+
when 200 then json_response(200, { status: body["status"], enqueued_id: enqueued_id, prompt: body["prompt"].to_s, session_id: id })
|
|
1361
|
+
when 409 then json_response(409, { error: body["error"] || "already_started", detail: body["detail"], enqueued_id: enqueued_id, session_id: id }.compact)
|
|
1362
|
+
when 404
|
|
1363
|
+
json_response(404, { error: "unknown_enqueued_id", enqueued_id: enqueued_id, session_id: id }) if body["error"] == "unknown_enqueued_id"
|
|
1364
|
+
end
|
|
1365
|
+
end
|
|
1366
|
+
rescue ArgumentError => e
|
|
1367
|
+
error_response(404, "not_found", e.message)
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1342
1370
|
def handle_stop(_req, id)
|
|
1343
1371
|
# Bounded, so a Rack thread isn't held long; a worker still exiting
|
|
1344
1372
|
# after it just means an immediate resume finds it (rare).
|
|
@@ -92,6 +92,7 @@ import { currentGen } from "./turn_model.js";
|
|
|
92
92
|
import { toolName } from "./activity.js";
|
|
93
93
|
import { toastBottom } from "./toast_place.js";
|
|
94
94
|
import { createToasts } from "./toasts.js";
|
|
95
|
+
import { createUnqueue } from "./unqueue.js";
|
|
95
96
|
|
|
96
97
|
const $ = (s) => document.querySelector(s);
|
|
97
98
|
const topStripEl = $("#topStrip");
|
|
@@ -1801,6 +1802,22 @@ composerEl.addEventListener("drop", (e) => {
|
|
|
1801
1802
|
e.preventDefault();
|
|
1802
1803
|
addImageFiles(files);
|
|
1803
1804
|
});
|
|
1805
|
+
// The × on a message queued for after the turn takes it back (unqueue.js).
|
|
1806
|
+
const unqueue = createUnqueue({
|
|
1807
|
+
sessionId: () => selected,
|
|
1808
|
+
clientId,
|
|
1809
|
+
toast: (text) => showToast(text),
|
|
1810
|
+
// Edit: the removed text back into an empty composer (never over a draft).
|
|
1811
|
+
putBack: (text) => {
|
|
1812
|
+
if (promptEl.value.trim() || chips.length) return false;
|
|
1813
|
+
openForVisit();
|
|
1814
|
+
promptEl.value = text;
|
|
1815
|
+
fitPrompt();
|
|
1816
|
+
promptEl.focus();
|
|
1817
|
+
return true;
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
turnRoots().forEach((root) => root.addEventListener("click", (e) => { unqueue.onClick(e); }));
|
|
1804
1821
|
turnRoots().forEach((root) => root.addEventListener("click", (e) => {
|
|
1805
1822
|
const img = e.target.closest("img.thumb");
|
|
1806
1823
|
if (!img) return;
|
|
@@ -13,6 +13,13 @@ import { linkedLine } from "./notice_links.js";
|
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
15
|
|
|
16
|
+
export function removeQueuedBubble(bubble) {
|
|
17
|
+
if (!bubble) return;
|
|
18
|
+
const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
|
|
19
|
+
bubble.remove();
|
|
20
|
+
if (slot && slot.childElementCount === 0) slot.remove();
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
// A prompt that started a turn: not a line steered into a running one.
|
|
17
24
|
export const TURN_PROMPT = ".bubble.user:not(.steered)";
|
|
18
25
|
|
|
@@ -205,6 +212,8 @@ export function createBubbles({
|
|
|
205
212
|
setBubbleState(bubble, null);
|
|
206
213
|
started = bubble;
|
|
207
214
|
}
|
|
215
|
+
} else if (op.op === "remove") {
|
|
216
|
+
removeQueuedBubble(bubbleById(op.enqueuedId));
|
|
208
217
|
} else if (op.op === "steer") {
|
|
209
218
|
const bubble = bubbleById(op.enqueuedId);
|
|
210
219
|
if (bubble) setBubbleState(bubble, "steered");
|
|
@@ -110,6 +110,7 @@ const WEB_LOCAL_REPLIES = {
|
|
|
110
110
|
"/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
|
|
111
111
|
"/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
|
|
112
112
|
"/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
|
|
113
|
+
"/unqueue": "/unqueue: a terminal's command; here the × on a message queued for after the turn removes it",
|
|
113
114
|
"/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
|
|
114
115
|
};
|
|
115
116
|
|
|
@@ -169,6 +169,17 @@ export function stopTask(id, taskId, opts = {}) {
|
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Takes back a message queued for after the running turn (the × on its
|
|
173
|
+
// bubble): 200 {status, enqueued_id, prompt}; 409 already_started /
|
|
174
|
+
// not_queued, 404 unknown_enqueued_id, 503 no live worker.
|
|
175
|
+
export function dequeueTurn(id, enqueuedId, { clientId = null } = {}, opts = {}) {
|
|
176
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/turn/${encodeURIComponent(enqueuedId)}/dequeue`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
body: JSON.stringify(clientId ? { client_id: clientId } : {}),
|
|
179
|
+
...opts,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
// Hands the session to a new worker on the newest chi installed: 200
|
|
173
184
|
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
174
185
|
export function restartSession(id, opts = {}) {
|
|
@@ -120,6 +120,12 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
120
120
|
.bubble.user.queued .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
121
121
|
.bubble.user.steered .state-badge::before{background:var(--accent);box-shadow:0 0 0 1px var(--accent-ring)}
|
|
122
122
|
.bubble.user.failed .state-badge::before{background:var(--danger);box-shadow:0 0 0 1px color-mix(in srgb,var(--danger) 40%,transparent)}
|
|
123
|
+
/* The × on a message queued for after the turn (unqueue.js): only once the bubble has its enqueued_id; a 32px tap target around a small mark */
|
|
124
|
+
.bubble.user .state-badge button.unqueue{position:relative;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;margin:0 -3px 0 0;padding:0;border:0;border-radius:50%;background:transparent;color:var(--text-muted);font-size:14px;line-height:1;cursor:pointer}
|
|
125
|
+
.bubble.user .state-badge button.unqueue::before{content:"\00d7"}
|
|
126
|
+
.bubble.user .state-badge button.unqueue::after{content:"";position:absolute;inset:-8px}
|
|
127
|
+
.bubble.user .state-badge button.unqueue:hover:not(:disabled){background:var(--danger-bg-dim);color:var(--danger-text);filter:none}
|
|
128
|
+
.bubble.user:not([data-enqueued-id]) .state-badge button.unqueue{display:none}
|
|
123
129
|
.bubble.user .origin-label{font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}
|
|
124
130
|
.bubble.user .user-message{flex:1;min-width:0}
|
|
125
131
|
.bubble.user blockquote{border-left:3px solid var(--border-strong);padding-left:10px;color:var(--text-3);margin:2px 0 6px}
|
|
@@ -847,6 +853,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
|
|
|
847
853
|
.turn-stage .turn-work.outline>.gen.folded{display:block}
|
|
848
854
|
.turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
|
|
849
855
|
@media(max-width:600px){
|
|
856
|
+
.bubble.user.queued[data-delivery="queue"]{flex-wrap:wrap}
|
|
857
|
+
.bubble.user.queued[data-delivery="queue"] .origin-label,.bubble.user.queued[data-delivery="queue"] .state-badge{order:0}
|
|
858
|
+
.bubble.user.queued[data-delivery="queue"] .user-message{order:1;flex-basis:100%}
|
|
850
859
|
.ts-status{padding-left:10px}
|
|
851
860
|
.ts-scroll{padding:8px 10px 10px}
|
|
852
861
|
.ts-foot{padding:0 10px 10px}
|
|
@@ -410,6 +410,10 @@ export function createStageView({
|
|
|
410
410
|
}
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
function ensureQueuedSlot() {
|
|
414
|
+
if (!queuedSlot.isConnected) stage.appendChild(queuedSlot);
|
|
415
|
+
}
|
|
416
|
+
|
|
413
417
|
function emptySlots() {
|
|
414
418
|
for (const slot of [promptSlot, extras, answer, tail, cloudBody, queuedSlot, timingSlot]) slot.replaceChildren();
|
|
415
419
|
slotsView.reset();
|
|
@@ -535,6 +539,7 @@ export function createStageView({
|
|
|
535
539
|
// The lines still queued behind this turn went into the history with
|
|
536
540
|
// the turn before (the hand-off at this turn_started, or an earlier
|
|
537
541
|
// one): back below this turn's card, as a reload places them.
|
|
542
|
+
ensureQueuedSlot();
|
|
538
543
|
queuedSlot.append(...waiting.filter((el) => !el.isConnected), ...historyEl.querySelectorAll(QUEUED_CHILDREN));
|
|
539
544
|
view.turnStarted(data || {});
|
|
540
545
|
show();
|
|
@@ -616,6 +621,7 @@ export function createStageView({
|
|
|
616
621
|
const where = placeFor({ live: holding, handingOff: false, kind: placeKind(el) });
|
|
617
622
|
if (where === "history") return false;
|
|
618
623
|
if (where === "queued") {
|
|
624
|
+
ensureQueuedSlot();
|
|
619
625
|
queuedSlot.appendChild(el);
|
|
620
626
|
scheduleRefresh();
|
|
621
627
|
return true;
|
|
@@ -116,6 +116,9 @@ export function createStreamHandlers(deps) {
|
|
|
116
116
|
showSteers(data.steers);
|
|
117
117
|
},
|
|
118
118
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
119
|
+
// A queued message taken back before it ran (the × here or in another
|
|
120
|
+
// tab, /unqueue in a terminal): its bubble goes, in every tab.
|
|
121
|
+
turn_dequeued: (data) => applyPromptOps(data),
|
|
119
122
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
120
123
|
if (stream.takeGuess()) {
|
|
121
124
|
// A replay of a turn the render drew: re-read and stream from there.
|
|
@@ -247,6 +247,8 @@ export function initWaitLine({ tasks } = {}) {
|
|
|
247
247
|
// tag — this tab's own local echo gets its enqueued_id
|
|
248
248
|
// start — the bubble's turn started (drops the "queued" badge)
|
|
249
249
|
// steer — the bubble was merged into the running turn
|
|
250
|
+
// remove — the queued message was taken back before it ran (turn_dequeued,
|
|
251
|
+
// whoever removed it): its bubble goes
|
|
250
252
|
// report — the merge brought a delegate child's report (origin
|
|
251
253
|
// child:<id8>, no bubble anywhere): its text gets a labelled one
|
|
252
254
|
// `known(enqueuedId)` says whether a bubble carries that id already;
|
|
@@ -276,6 +278,8 @@ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStar
|
|
|
276
278
|
}
|
|
277
279
|
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
|
|
278
280
|
}
|
|
281
|
+
case "turn_dequeued":
|
|
282
|
+
return event.enqueued_id ? [{ op: "remove", enqueuedId: event.enqueued_id }] : [];
|
|
279
283
|
case "input_merged": {
|
|
280
284
|
const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
|
|
281
285
|
known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
|
|
@@ -184,9 +184,17 @@ export function badgeText(state, delivery = null) {
|
|
|
184
184
|
return (state === "queued" && QUEUED_BADGES[delivery]) || BADGES[state];
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// A message queued for after the turn (delivery queue) can be taken back
|
|
188
|
+
// while it waits: the badge carries its ×, shown once the bubble has its
|
|
189
|
+
// enqueued_id (style.css; an echo before its ack has none) and handled by
|
|
190
|
+
// unqueue.js. Not a next-step line: a step boundary may merge it any moment.
|
|
187
191
|
export function stateBadgeTemplate(state, delivery = null) {
|
|
188
192
|
const text = badgeText(state, delivery);
|
|
189
|
-
|
|
193
|
+
if (!text) return nothing;
|
|
194
|
+
const remove = state === "queued" && delivery === "queue"
|
|
195
|
+
? html`<button type="button" class="unqueue" title="Remove from queue" aria-label="Remove from queue"></button>`
|
|
196
|
+
: nothing;
|
|
197
|
+
return html`<span class="state-badge">${text}${remove}</span>`;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
200
|
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The × on a message queued for after the running turn (its badge,
|
|
2
|
+
// turn_html.js stateBadgeTemplate): POST …/turn/:enqueued_id/dequeue
|
|
3
|
+
// (Bridge#handle_dequeue_turn) takes it back before it runs. The bubble
|
|
4
|
+
// goes with the turn_dequeued every tab gets (bubbles.js op "remove"), and
|
|
5
|
+
// here at the 200 already. Its text comes back (edit): into an empty
|
|
6
|
+
// composer, to change and send again; over a draft only in a toast (the
|
|
7
|
+
// draft stays). Its images don't come back. A refusal keeps it and says why
|
|
8
|
+
// in a toast. No confirm: nothing is lost. Pure but for the
|
|
9
|
+
// injected calls, so `node --test` covers it; app.js binds onClick to the
|
|
10
|
+
// turn roots.
|
|
11
|
+
import { dequeueTurn as postDequeue } from "./data.js";
|
|
12
|
+
import { removeQueuedBubble } from "./bubbles.js";
|
|
13
|
+
|
|
14
|
+
// Why a removal didn't happen, in words.
|
|
15
|
+
export function unqueueFailureText(error) {
|
|
16
|
+
if (error?.code === "already_started") return "Not removed: it already started";
|
|
17
|
+
if (error?.code === "not_queued") return "Not removed: it goes in at the running turn's next step";
|
|
18
|
+
if (error?.status === 404 && error?.code === "unknown_enqueued_id") return "Not removed: it is already gone";
|
|
19
|
+
return `Not removed: ${error?.message || "the worker did not answer"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The removed text in a toast, clipped.
|
|
23
|
+
const SHOWN = 200;
|
|
24
|
+
function clipped(text) {
|
|
25
|
+
const line = String(text || "").replace(/\s+/g, " ").trim();
|
|
26
|
+
return line.length > SHOWN ? `${line.slice(0, SHOWN)}\u2026` : line;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// +sessionId+ is read at each click (the open session); +clientId+ is this
|
|
30
|
+
// tab's (the turn_dequeued names who removed it); +putBack(text)+ fills the
|
|
31
|
+
// composer when it is empty and says whether it did.
|
|
32
|
+
export function createUnqueue({ sessionId, clientId = null, dequeue = postDequeue, toast = () => {}, putBack = () => false }) {
|
|
33
|
+
// A click on a queued bubble's ×; any other click is left alone.
|
|
34
|
+
async function onClick(e) {
|
|
35
|
+
const button = e.target?.closest?.("button.unqueue");
|
|
36
|
+
if (!button || button.disabled) return null;
|
|
37
|
+
const bubble = button.closest(".bubble.user");
|
|
38
|
+
const enqueuedId = bubble?.dataset.enqueuedId;
|
|
39
|
+
const id = sessionId();
|
|
40
|
+
if (!enqueuedId || !id) return null;
|
|
41
|
+
e.stopPropagation?.();
|
|
42
|
+
button.disabled = true;
|
|
43
|
+
try {
|
|
44
|
+
const result = await dequeue(id, enqueuedId, { clientId });
|
|
45
|
+
removeQueuedBubble(bubble);
|
|
46
|
+
const text = String(result?.prompt || "");
|
|
47
|
+
if (text.trim() && !putBack(text)) toast(`Removed (your draft stays): ${clipped(text)}`);
|
|
48
|
+
return result;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (button.isConnected) button.disabled = false;
|
|
51
|
+
toast(unqueueFailureText(error));
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { onClick };
|
|
56
|
+
}
|
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.
|
|
4
|
+
version: 0.85.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmitry Dedov
|
|
@@ -691,6 +691,7 @@ files:
|
|
|
691
691
|
- lib/samagotchi/web/public/turn_html.js
|
|
692
692
|
- lib/samagotchi/web/public/turn_model.js
|
|
693
693
|
- lib/samagotchi/web/public/turn_view.js
|
|
694
|
+
- lib/samagotchi/web/public/unqueue.js
|
|
694
695
|
- lib/samagotchi/web/public/update_notice.js
|
|
695
696
|
- lib/samagotchi/web/public/vendor/directive-helpers.js
|
|
696
697
|
- lib/samagotchi/web/public/vendor/directive.js
|