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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +10 -1
  4. data/docs/cli.md +16 -1
  5. data/docs/configuration.md +2 -0
  6. data/docs/hooks.md +1 -1
  7. data/docs/internals/engine.md +3 -2
  8. data/docs/sessions.md +3 -1
  9. data/docs/sub-agent.md +1 -1
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
  11. data/lib/samagotchi/bridge.rb +54 -1
  12. data/lib/samagotchi/bridge_client.rb +12 -0
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/engine.rb +1 -1
  15. data/lib/samagotchi/kernel_loop.rb +5 -1
  16. data/lib/samagotchi/llm/chat_loop.rb +5 -1
  17. data/lib/samagotchi/log_subscriber.rb +1 -0
  18. data/lib/samagotchi/parent_report.rb +5 -2
  19. data/lib/samagotchi/plugin/context.rb +11 -7
  20. data/lib/samagotchi/reply_wait.rb +8 -1
  21. data/lib/samagotchi/session_commands.rb +7 -4
  22. data/lib/samagotchi/session_inbox.rb +68 -0
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +89 -1
  24. data/lib/samagotchi/terminal_ui.rb +3 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +28 -0
  27. data/lib/samagotchi/web/public/app.js +17 -0
  28. data/lib/samagotchi/web/public/bubbles.js +9 -0
  29. data/lib/samagotchi/web/public/commands.js +1 -0
  30. data/lib/samagotchi/web/public/data.js +11 -0
  31. data/lib/samagotchi/web/public/index.html +9 -0
  32. data/lib/samagotchi/web/public/stage_view.js +6 -0
  33. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  34. data/lib/samagotchi/web/public/turn_events.js +4 -0
  35. data/lib/samagotchi/web/public/turn_html.js +9 -1
  36. data/lib/samagotchi/web/public/unqueue.js +56 -0
  37. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5e180d3ef3bc5dd32bd646d471b1ef88e11083aba16f6908a8c4588b0173b4c
4
- data.tar.gz: b337e5690f3094d5f3b0e8528aacbedab734211c9be60ed5ff314f6eb4299ea7
3
+ metadata.gz: d533709ed93dac5a1a65a9c023e83a50aef691542faffb1cdeefe6bfca51b5f1
4
+ data.tar.gz: 90cbb0c9256e751f64a36b854d6049098b694a8824a34230895e83eb383f8f74
5
5
  SHA512:
6
- metadata.gz: 4ce619eccbfc69729b160a2b19c65d3b8e91d0f2b641fb6d4eb288d8fd12e9a147529745a8a3b5272270a2fabec6e597f423086c6574cb06f689816b32550568
7
- data.tar.gz: 4a9922290d92cabab3aac30f17292119cc6e778243a7e1b57bd8260423a27446b34b978346056566619f8e358da8dabbd9e80f8bc3aa9fc198dab99e9b27b38c
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.84.0...HEAD
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; `cut in now` cuts, like `/cut TEXT` (by click only: it throws the
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
@@ -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 }` |
@@ -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. Stream one generation (:generation_started, :generation_chunk, :generation_completed)
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`, `card`,
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
@@ -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
- FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE].freeze
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).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.84.0
3
+ version: 0.85.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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), no_answer, error, worker_gone,
38
- # stopped or running
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
- merged = id ? overlay(@settings, session_values) : @settings
115
- @session_settings = [id, merged].freeze
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, or when
439
- # the Host can't tell them).
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
- @log.warn("session_settings_unread", error: "#{e.class}: #{e.message}")
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; nil when unknown. With
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. /queue is a real after-the-turn list only in the local
127
- # REPL (the attached TUI's worker doesn't skip queued files yet), so
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: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
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
- Array(snapshot[:queued]).each do |entry|
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.84.0"
4
+ VERSION = "0.85.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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
- return text ? html`<span class="state-badge">${text}</span>` : nothing;
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.84.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