samagotchi 0.61.0 → 0.63.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 +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
data/docs/plugins.md
CHANGED
|
@@ -144,7 +144,7 @@ chi's own, each bundle's with the bundle's name, and the terminal UIs' own
|
|
|
144
144
|
`attached only`, `repl only`, or `terminal and web only` (`/cut`).
|
|
145
145
|
It works in all three UIs.
|
|
146
146
|
|
|
147
|
-
### `chi.tool(name, description, params:, schema:, label:, preview:, targets:) { |args, ctx| … }`
|
|
147
|
+
### `chi.tool(name, description, params:, schema:, label:, preview:, targets:, refs:) { |args, ctx| … }`
|
|
148
148
|
|
|
149
149
|
This adds a tool the model can call. It is declared in the system prompt and
|
|
150
150
|
in the chat path's `tools:`, after chi's own tools.
|
|
@@ -166,6 +166,8 @@ in the chat path's `tools:`, after chi's own tools.
|
|
|
166
166
|
plugins.
|
|
167
167
|
- `targets`: `->(args) { { paths: [...], command: "…", cwd: "…", acts_as: "…", args: {…}, label: "…" } }`,
|
|
168
168
|
each key optional, says what a call acts on, for [guardrails](#guardrails).
|
|
169
|
+
- `refs`: `->(args) { [{ kind:, target:, label:, actions: [] }, …] }`, what the call's row traces to in the web
|
|
170
|
+
(display only, the model never sees them). See [Refs on a tool's row](#refs-on-a-tools-row).
|
|
169
171
|
|
|
170
172
|
The block returns the result text. If it starts with `Error:`, it counts as
|
|
171
173
|
a failure. If it raises, the model gets `Error: <message>`. To return images
|
|
@@ -174,7 +176,7 @@ too, see [Returning images](#returning-images).
|
|
|
174
176
|
#### `args`
|
|
175
177
|
|
|
176
178
|
`args` is a frozen Hash with **string keys**: the arguments the model gave,
|
|
177
|
-
by name (`args["text"]`). The same Hash goes to `preview` and `
|
|
179
|
+
by name (`args["text"]`). The same Hash goes to `preview`, `targets` and `refs`.
|
|
178
180
|
|
|
179
181
|
Each parser gives them structured: Gemma's native values (strings, numbers,
|
|
180
182
|
booleans, lists, nested objects), Qwen's `<parameter=…>` text, and the chat
|
|
@@ -261,6 +263,42 @@ does loop-guard's `ignore_tools`.
|
|
|
261
263
|
A tool without `targets:` is matched by its name only. A `targets:` that
|
|
262
264
|
raises counts as nothing (it is logged). See [guardrails.md](guardrails.md).
|
|
263
265
|
|
|
266
|
+
#### Refs on a tool's row
|
|
267
|
+
|
|
268
|
+
`refs:` gives the call's web row its refs: a ticket the tool looked up, the PR it opened, a file it wrote.
|
|
269
|
+
Each is a ref as on the answer ([Refs on the answer](hooks.md#refs-on-the-answer-display-only), without
|
|
270
|
+
`match:`):
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
chi.tool "ticket", "Look up a Jira ticket", params: { id: { type: "string", required: true } },
|
|
274
|
+
refs: ->(args) {
|
|
275
|
+
[{ kind: "issue", target: { id: args["id"] }, label: "#{args["id"]} in Jira",
|
|
276
|
+
actions: [{ id: "open", label: "Open in Jira", url: "https://jira.example/browse/#{args["id"]}" }] }]
|
|
277
|
+
} do |args, ctx|
|
|
278
|
+
# …
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- `kind`: `"file"` with `target: { path:, line:, end_line: }`, or any short lowercase word (`issue`,
|
|
283
|
+
`commit`, …) with `target: { id: }`.
|
|
284
|
+
- A file's `path` is absolute or relative to the session's folder, and must be **under** it (its own
|
|
285
|
+
spelling or its real path: `/tmp/x` or `/private/tmp/x`): a file elsewhere is dropped. It is kept under
|
|
286
|
+
the folder's real path.
|
|
287
|
+
- `actions`: up to 8 `{ id:, label:, url: }`, each URL `http(s)` only. A file ref keeps the editor, show in
|
|
288
|
+
turn and copy; its actions add to them.
|
|
289
|
+
- The web puts the row's **first** ref on its title (a click runs its first action) and the others in the
|
|
290
|
+
title's `▾` menu, each as `<its label> · <action>` with the URL on hover. URL actions show for every viewer,
|
|
291
|
+
the phone too; a file's "Open in editor" only where the editor opens (this machine).
|
|
292
|
+
- It runs **once per call, on the tool path**, before the call runs: keep it cheap, with no I/O (no
|
|
293
|
+
network, no file reads); build the refs from `args` alone.
|
|
294
|
+
- If it raises or returns something other than a list, the row gets no refs and the tool still runs; a bad
|
|
295
|
+
ref (an unknown kind, a `javascript:` URL, a file outside the folder) is dropped. Both are logged (tag
|
|
296
|
+
`plugins`, `tool_refs_failed` / `tool_refs_rejected`).
|
|
297
|
+
- At most 20 refs per row (64 KiB of them). They are saved with the result (`tool_refs`), so a reload shows them without the
|
|
298
|
+
plugin, checked again.
|
|
299
|
+
- `chi.replace_tools` swaps a kept tool's block, `preview:`, `targets:` and `refs:` in place: none of them is a
|
|
300
|
+
change to the prompt.
|
|
301
|
+
|
|
264
302
|
#### Returning images
|
|
265
303
|
|
|
266
304
|
A tool can hand the model images too. The block returns a
|
|
@@ -684,7 +722,7 @@ ctx.sessions.stop(id) # one of this session's own children
|
|
|
684
722
|
```ruby
|
|
685
723
|
ctx.context.attach(url: "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
|
|
686
724
|
ctx.context.attach(name: "ci", cmd: "bin/ci-status", why: "this branch's CI", every_seconds: 120)
|
|
687
|
-
ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:}]
|
|
725
|
+
ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}]
|
|
688
726
|
ctx.context.decline(url: "https://github.com/acme/app/pull/42") # => "pr-42"
|
|
689
727
|
ctx.context.mark_offered("pr-42", "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
|
|
690
728
|
ctx.context.offered("pr-42") # => #<data Offer name="pr-42", hint="https://…", why="branch…", at="2026-10-08T…Z">, or nil
|
|
@@ -718,7 +756,10 @@ gets a note when its text changes.
|
|
|
718
756
|
- The source is the session's (not the project's), marked
|
|
719
757
|
`added_by: plugin:<bundle>`. It is plugin code, so it isn't asked about as
|
|
720
758
|
the agent's `chi context add --cmd` is.
|
|
721
|
-
- `list` gives the session's sources, its own then its project's
|
|
759
|
+
- `list` gives the session's sources, its own then its project's (`[]` before
|
|
760
|
+
the session has an id or its file is saved: a command before the first turn). `hint` is the
|
|
761
|
+
one the last text gave (else the source's), `summary` that text's summary
|
|
762
|
+
(nil before any text; chi 0.63.0).
|
|
722
763
|
- Two attaches of one source at once (a double click) both return its
|
|
723
764
|
name.
|
|
724
765
|
- `attach`, `decline` and `mark_offered` raise
|
|
@@ -1381,7 +1422,8 @@ and a summary of what changed in counts, authors and states only; it wakes
|
|
|
1381
1422
|
the session for a review requesting changes, checks turning red, or the PR
|
|
1382
1423
|
merged or closed. In the web, a `path:line` in a worker's answer links to
|
|
1383
1424
|
that line of the session's PR ([Line links](context.md#line-links); `line_links:
|
|
1384
|
-
always` for the REPL and `chi -p` too)
|
|
1425
|
+
always` for the REPL and `chi -p` too), and its ref menu offers the file
|
|
1426
|
+
here and a "PR diff" action (`event[:refs]`). `auto_attach:
|
|
1385
1427
|
offer` asks with a card first (`/pr-attach N`, `/pr-decline N`), `off`
|
|
1386
1428
|
attaches nothing ([Attach, offer or off](context.md#attach-offer-or-off)).
|
|
1387
1429
|
See [Attached context](context.md#github-prs-the-github-pr-bundle).
|
|
@@ -1465,6 +1507,90 @@ by themselves there.
|
|
|
1465
1507
|
reason: merging a child's branch
|
|
1466
1508
|
```
|
|
1467
1509
|
|
|
1510
|
+
## The claude-peer bundle
|
|
1511
|
+
|
|
1512
|
+
```sh
|
|
1513
|
+
chi bundle install claude-peer # in the dev profile
|
|
1514
|
+
```
|
|
1515
|
+
|
|
1516
|
+
A live link between a chi session and a Claude Code session you already work
|
|
1517
|
+
with: chi sends it a part of the work (a plan step, a diff, a finding) to
|
|
1518
|
+
validate or as context, and Claude's answer comes back into chi. This is
|
|
1519
|
+
Claude with the context it has, not a fresh review run. Nothing runs until a
|
|
1520
|
+
Claude pairs; without one the bundle costs one tool declaration.
|
|
1521
|
+
|
|
1522
|
+
- **Pairing is Claude's.** chi can't see Claude's inbox, so Claude attaches
|
|
1523
|
+
two pushed sources to the chi session: `claude-inbox`, whose hint is its
|
|
1524
|
+
per-session inbox socket (`CLAUDE_CODE_MESSAGING_SOCKET`, Claude Code
|
|
1525
|
+
2.1.224 or later), and `claude-review`, where its answers come. You never
|
|
1526
|
+
run that yourself:
|
|
1527
|
+
1. `/claude install-skill` (once) shows a card with the target,
|
|
1528
|
+
`$CLAUDE_CONFIG_DIR/skills/chi-peer/SKILL.md` (else `~/.claude/…`),
|
|
1529
|
+
whether one is there and, when it differs, a short diff; its Install (or
|
|
1530
|
+
Replace) button runs `/claude install-skill --yes`, which copies the
|
|
1531
|
+
bundle's `scripts/chi-peer-SKILL.md` there with the installed
|
|
1532
|
+
`scripts/pair` path written in. The copy happens inside chi, where no
|
|
1533
|
+
guardrail sees it: the button is your consent. The card also offers the
|
|
1534
|
+
Claude Code permission rule `Read(~/.local/state/samagotchi/plugins/claude-peer/**)`
|
|
1535
|
+
(your state folder), so Claude isn't asked before reading a long part's
|
|
1536
|
+
file; chi never writes Claude's settings.
|
|
1537
|
+
2. In Claude Code you say "pair with chi <id or chi web URL>". The skill runs
|
|
1538
|
+
`sh <bundle>/scripts/pair <id or URL>`, which attaches `claude-inbox` (the
|
|
1539
|
+
socket as its hint, pushed as JSON so a re-pair after a Claude restart
|
|
1540
|
+
refreshes it) and `claude-review`, and lists the session's context. A
|
|
1541
|
+
re-pair keeps Claude's last answer.
|
|
1542
|
+
3. Claude answers a validate or a question with a JSON push to
|
|
1543
|
+
`claude-review` (`text`, `summary`, `wake: true`), which wakes chi, the
|
|
1544
|
+
first answer too (on chi 0.62 the first one arrives as a note without
|
|
1545
|
+
waking); the model reads it with `context_read claude-review`.
|
|
1546
|
+
- **The tool `claude_send(kind, title, text)`**, `kind` being `validate`,
|
|
1547
|
+
`context` or `question`, writes one JSON line to that socket. The message
|
|
1548
|
+
starts with a header, `chi <id> · <kind> · <title>`. A text up to 4,000
|
|
1549
|
+
characters follows inline; a longer one goes to a file,
|
|
1550
|
+
`$XDG_STATE_HOME/samagotchi/plugins/claude-peer/sessions/<id>/<HHMMSS>-<slug>.md`
|
|
1551
|
+
(removed with the session), and the message names it (`payload: <path>`).
|
|
1552
|
+
The tool is declared whether or not a Claude is paired, so pairing doesn't
|
|
1553
|
+
change the prompt. Its errors say what to do: no `claude-inbox` (pair from
|
|
1554
|
+
Claude), the socket gone or refusing (that Claude session ended or
|
|
1555
|
+
restarted: pair again), a text over 200,000 characters.
|
|
1556
|
+
- **`/claude`** (an anytime command, so it works mid-turn) sends without
|
|
1557
|
+
the model:
|
|
1558
|
+
- `/claude` alone shows a card: paired or not, the socket and whether its
|
|
1559
|
+
file is there (it goes when that Claude session ends), the last send, and
|
|
1560
|
+
Claude's last answer (the `claude-review` summary).
|
|
1561
|
+
- `/claude validate <text>` and `/claude context <text>` send the text as it
|
|
1562
|
+
is, titled by its first line: paste a selection after them.
|
|
1563
|
+
- `/claude last` sends the model's last completed answer as context. Mid-turn
|
|
1564
|
+
it is the answer before the running turn, never the turn's partial text;
|
|
1565
|
+
a cancelled or failed turn has no answer, so the one before it goes.
|
|
1566
|
+
- **The skill.** `skill_claude_peer` (system scope) tells the model to send
|
|
1567
|
+
only when you ask, to pick the part rather than the session, not to wait
|
|
1568
|
+
or poll, and how to read the `claude-review` answer (a validate ends with
|
|
1569
|
+
`VERDICT: OK`, `CHANGES` or `UNSURE`).
|
|
1570
|
+
- **Asking first.** The tool has no `targets:`, so guardrails match it by name
|
|
1571
|
+
only: to be asked before each send, add a rule ([Rules in
|
|
1572
|
+
config.yml](guardrails.md#rules-in-configyml)):
|
|
1573
|
+
|
|
1574
|
+
```yaml
|
|
1575
|
+
guardrails:
|
|
1576
|
+
rules:
|
|
1577
|
+
- id: claude-send
|
|
1578
|
+
tool: claude_send
|
|
1579
|
+
verdict: ask
|
|
1580
|
+
reason: sends session content to Claude Code
|
|
1581
|
+
```
|
|
1582
|
+
|
|
1583
|
+
- **Limits.** The socket's format (`{"type":"user","message":{"role":"user","content":"…"}}`)
|
|
1584
|
+
is undocumented: it was found in, and checked on, Claude Code 2.1.296. A
|
|
1585
|
+
line Claude Code can't parse is dropped silently, so a Claude Code update
|
|
1586
|
+
could break this without an error. No auth line is sent: it is optional on
|
|
1587
|
+
macOS and Linux, and native Windows, which needs it, isn't supported.
|
|
1588
|
+
Claude's `crossSessionInbound` setting decides what it does with a peer
|
|
1589
|
+
message: an interactive auto-mode session delivers it; a session that
|
|
1590
|
+
bypasses permissions holds it for approval (in `-p` it expires after 5
|
|
1591
|
+
minutes). It is local only (a Unix socket), but it does hand session
|
|
1592
|
+
content to another agent: only what the model or you picked.
|
|
1593
|
+
|
|
1468
1594
|
## Shutdown
|
|
1469
1595
|
|
|
1470
1596
|
When the REPL exits, or a session's worker exits (an idle exit, `/exit`, a
|
data/docs/releasing.md
CHANGED
|
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
|
|
|
13
13
|
and `rake release:check` enforce it). The system bundle upgrades itself when
|
|
14
14
|
chi starts.
|
|
15
15
|
- **Every other shipped bundle** (btw, check-in, guardrails, known-names,
|
|
16
|
-
loop-guard, mcp, skills, source-links, github-pr, coordinator, and the profiles core and dev) has its own semver in its
|
|
16
|
+
loop-guard, mcp, skills, source-links, github-pr, coordinator, claude-peer, and the profiles core and dev) has its own semver in its
|
|
17
17
|
`manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
|
|
18
18
|
`chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
|
|
19
19
|
- a change to a bundle's files bumps that bundle's `version:`
|
data/docs/sessions.md
CHANGED
|
@@ -23,9 +23,9 @@ A session is deleted if **expired by age OR overflow by count** (unless `keep_st
|
|
|
23
23
|
|
|
24
24
|
**A worker outlives whoever started it:** a spawned worker leaves its spawner's process tree (it is forked again, in a session of its own, and reparented to init), so closing the terminal of a `chi web`, or a tool tearing down a background `chi send --wait` and its children, no longer takes the worker and its turn with it.
|
|
25
25
|
|
|
26
|
-
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration}`, written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight
|
|
26
|
+
**A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration}`, written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `--no-shared` REPL resuming it, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once). The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
|
|
27
27
|
|
|
28
|
-
**Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first; the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
|
|
28
|
+
**Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first (so a command it was running says "stopped by the user"; only a bare signal to the worker makes it "stopped by a signal to chi"); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's `llm ctx` chip, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
|
|
29
29
|
|
|
30
30
|
**Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a plain REPL has open (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id` (200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
|
|
31
31
|
|
|
@@ -72,9 +72,9 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
|
|
|
72
72
|
**Ordering:**
|
|
73
73
|
|
|
74
74
|
- `Session.list` / `SessionManager.list_sessions` / `GET /api/sessions?sort=&order=&limit=&offset=` default to `updated_at desc` (newest activity first). Also supports `created_at`, `asc`. `X-Total-Count` header when paginated.
|
|
75
|
-
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
75
|
+
- Web UI (`chi web`): the page's scope is in its URL. Started in a git repo, `chi web` opens `/?dir=<that folder>`: that project's sessions, and new chats start in that folder; the header chip says `<project> · all`, and `all` opens the same place without `?dir` (every session; a new chat there starts in the server's own folder, shown on the start page, and cards name their folder; `← <project>` goes back to the project view it came from, or to the server's own project). One server serves every project: a second `chi web` (from another repo) finds it through `GET /api/info` and prints (with `--open`, opens) its page for its own folder instead of starting another. The 3 latest sessions sit above the chat (`hide ▴` puts them away behind a `▾ N sessions` pill, remembered; in a window under 900 px wide, where the strip scrolls sideways, `▴ hide` sits in the top bar where the pill comes back; a window under 700 px tall hides them by itself, so a question or approval card in the stage keeps room, and the pill shows them until the window is tall again). A parent and its delegates are one card there: its `▸ 2 delegates · 1 live · 1 waiting` chip (the waiting part in the attention colour, the card's border too) opens their rows in a popover under it (a row per delegate at any depth, indented; a click opens that one, Esc or a click outside closes it, and the strip's order holds while it is open). A fork is a card of its own, and so is a delegate whose parent isn't listed (archived, another project), with its `↳ <parent>` chip; the pill counts cards, and every live or waiting session. "All sessions" (or `/`) opens every session at `#/sessions`, with a search over preview, id and status (Esc or Back returns). There a parent's card holds its delegates the same way, the chip opening their rows inside the card (each with an archive button on hover), open by itself while one of them waits on you; the count says `14 (+7 delegates)`, a search that matches a delegate shows its parent's card with the list open and the matching rows marked, and select mode picks cards (archiving a parent takes its delegates). The open session is in the URL (`#/s/<id>`), so a reload or a copied link opens it again; the chi logo top left goes back to the empty start for a new chat. The message box grows with its text; drag its top edge to keep it taller (double-click resets). A file in a tool row (a read, edit or write's title) and its diff lines are refs: hover one and click to open the current file in your editor at that line, on this machine only (`web.editor`; the browser's "always allow" for the page's port and VS Code's "allow opening local files" are asked once; see [Opening files from chi web](configuration.md#opening-files-from-chi-web)). So are an edit or write approval card's paths (at the first line the dry run changes) and its diff lines, before you approve, and the numbered lines of a read's output: `lines 40–60` under it opens the text as read, numbered, when the output is the file as-is (not the head/tail preview of a big file, an error or an image; no numbers after a cut). A `path:line` (or `path:line-line`) in an answer or a step's text is one too once the turn has ended, and after a reload, when it names a file a row or card on the page touched: the path under the session's folder, else the one touched file it ends with (a bare `app.js:12` when only one touched file is named so; two such files make it none); a path without a line counts only when it is a touched file exactly. Code blocks, links, bare URLs and inline code with more than the ref in it are left alone, and a file the session didn't touch is never linked; a link whose text is such a ref alone (github-pr's link to the PR diff) becomes a ref too, and its plain click still goes to the PR. A plugin's refs on an answer (an `after_turn` hook's, see [Refs on the answer](hooks.md#refs-on-the-answer-display-only)) are paired with its links and text whatever the rows touched: they can link a file the session didn't touch, or an issue or a commit, and add their own actions to the ref's menu. A plugin tool's row carries the refs its `refs:` gives (see [Refs on a tool's row](plugins.md#refs-on-a-tools-row)): its title opens the first, and its `▾` menu adds the others as `<ref> · <action>` (the URL on hover); their URL actions work on a phone too. A ref with more than one target shows a small `▾` on hover: it opens a menu of them, in order **PR diff** (a link's), **Open in editor**, **Show in turn** and **Copy path**, and a plain click runs the first (a link's own, else the editor, else show in turn). **Show in turn** scrolls to the tool row that touched the file (looked for in the ref's own step, then its own turn, then the rows before it, then the nearest after it; there, the last one showing the ref's line, else the last one), opens the steps, the stage's steps and the row's diff (or the read showing the line) and flashes the row and the line; an answer's or a step's ref and an approval card's path have it, not a row's own title or lines. It needs no editor, so a viewer without one (a phone on the LAN) gets an answer's refs with **Show in turn** as their click; a row's title and diff lines stay plain text there. On a touch screen a tap opens the menu. A ref with a target is a tab stop: Enter runs its first target, ↓ opens the menu (↑/↓ move, Enter picks, Esc closes back to the ref). Diff and read lines have their click only. The info bar copies `chi --attach <id>` for a terminal. A session whose prompt carried model notes has a `notes: deepseek` chip there (the names without `model_notes_`; the tooltip lists each with its scope and size; `session.prompt_notes` in `GET /api/sessions/:id`, the live worker's, else the session file's; see [Memory](memory.md#model-notes)). Its `llm ctx` chip names the LLM context strategy the next turn runs under (`llm ctx stale,forget · session`, in the accent colour when the session set its own; the tooltip gives each value's source), and its popover sets the session's own strategy, apply rule and budget, or follows the model again, by running `/llm-context` in the worker (the reply shows as a command bubble); `GET /api/sessions/:id`'s `session.llm_context` carries the values (the live worker's, else resolved from the session file), and a `command_ran` that changed them carries the new ones. Its `think` chip does the same for the thinking level (`/thinking`), and on a chat host its `reasoning` chip for the reasoning history (`reasoning all · session`, `/reasoning-history`; `session.reasoning_history`, null on a native host). A `/thinking` or `/reasoning-history` change that restarts the prompt cache asks first: the reply bubble has an **Apply (restarts the cache)** button that sends its `--yes` line (the chips' Set sends that line itself; see [Changes that restart the prompt cache](configuration.md#changes-that-restart-the-prompt-cache)). It shows the context used (`ctx 42%`) and the last generation's decode speed (`87 tok/s`, `~64 tok/s` when estimated; see `/stats` in [cli.md](cli.md#session-commands)); hovering `ctx` there or on a session card lists the session's tokens in (summed over every request, with the cached share), out (with reasoning) and the cost when the provider reports one, for this session only (one estimated from a `hosts.<name>.models` price shows with `~`: `cost: ~$0.54 ($0.42 reported, ~$0.12 estimated)`), and the memory indexes its prompt holds (`memory index: ~3.4k tokens in this session's prompt (system 2.0k, project 1.4k)`; see [Memory](memory.md#the-indexs-size)). The start page's model picker (bottom left of the composer, the chosen name `▾`) chooses the model a new chat starts on; it opens a search list over the conversation (↓ or a click; downward when the start page leaves no room above): an empty search shows the last 5 picks (Recent) and then each host's model ids A–Z under the host, the default host first (an id the host declares under `hosts.<name>.models` first, noted `config`, its tooltip `served by config (hosts.<name>.models)`; a host that failed to list its models keeps those ids, greyed and noted `host down`, still pickable; a remembered or default model stays chosen even then, greyed on the button); typed words must each match the host or the id (a substring, else for 3+ characters the letters in order, so `deepseek4.1 fla` finds `openrouter · deepseek/deepseek-v4.1-flash`), ⏎ picks, Esc/Tab close, a pick puts the focus in the message box: `GET /api/models` lists the hosts' models as chi spells them (`{default, models: [{name, host, id, configured?, unavailable?, sampling?, llm_context?}], warning?}`; `configured: true` marks an id declared under `hosts.<name>.models`, listed first under its host, `unavailable: true` one whose host failed to list its models; `sampling` is the model's configured request parameters as `/model` words them, the row's tooltip in the list; bare for the default host, `host:model` for the others, from the same cached lists as `/models`, a bounded wait, a host that is down noted in `warning`), and `POST /api/sessions` takes `model` (blank means the default). Beside it, an `llm ctx` chip shows the LLM context the picked model starts under (each `GET /api/models` row's `llm_context`, as the info bar's); its form sets one for the new chat only, sent as `POST /api/sessions`'s `llm_context` and never remembered (see [configuration.md](configuration.md#llm-context-a-sessions-own-strategy)). The start page's first message goes as later ones do: `POST /api/sessions` with `idle: true` and `preview` (the message, which names the session until its turn is saved), then `POST /api/sessions/:id/turn`, so a failed first turn puts its prompt back in the composer too; a first line that is one of the new session's commands (`/model …`) goes to `POST /api/sessions/:id/command` instead, as later ones do. The browser remembers the last model picked; a running session's model is in the info bar and changes only with `/model`. In the composer a line is a command when it is `!…` or starts with one of the session's command names (its `commands` list; `/stats`, `/exit` and the other terminal words get the page's own reply); an unknown `/word` (`/usr/bin/env is missing`) goes to the model as a prompt, as in a terminal; a one-word `/word` no command answers (`/modle`) is not sent: the page shows `Unknown command /modle. Did you mean /model? /help lists the commands.` (the "Did you mean" part only when a name is close). A `POST /turn` (or a session's first `prompt`) that is one of the worker's commands runs as that command too (`202 {status, command_id, session_id}`), so `chi send`, `chi -p` and a page whose command list lags all get the command. ↑/↓ in the composer walk the prompt history the TUI's ↑ walks ([Persistent Prompt History](cli.md#persistent-prompt-history)): ↑ with the caret on the first line, ↓ from the last line while browsing, ↓ past the newest brings the draft back; `GET /api/history` returns `{entries: [...]}`, oldest first (the page fetches it on focus and after each send). The server records a sent line in the routes once the worker took it (`POST /turn` accepted, a `!command` through `POST /command` or as a session's first line); `"history": false` in the body keeps it out (an image-only send).
|
|
76
76
|
- Web notifications: when a session needs you while the tab is hidden or behind another window (a question or a guardrail approval, a plugin card with buttons during a turn such as check-in's, a failed turn, a turn done after 10 s or more; a delegated session only for its questions, a canceled turn never), the page title counts it, `(N) Chi`, until you come back to the tab. The bell at the right of the top bar also turns on OS notifications ("needs an answer", "needs approval", "needs you", "turn failed", "done in 42 s", under the session's first message; a click opens the session): its first click asks the browser for the permission, and the choice is kept per browser. Any session of the page's scope counts, not only the open one. What was already so when the page loaded never counts, and several chi tabs show one notification per event. A chi tab in front tells the others (a `BroadcastChannel`), so while you look at one, the ones behind neither notify nor count what it shows; coming back to a tab clears those events in the other tabs' titles too. The hub's `session` frames carry what this needs: `pending_question` (`{id, kind}`, kind `question` or `approval`), `pending_card` (`{id, bundle}`: the running turn's card with actions, from `pending_card.json` that the worker keeps in the session's folder while one is open; a card without actions, such as loop-guard's, never counts) and `last_turn` (`{outcome, ended_at, seconds, origin}`, saved in the session's JSON as the engine ends each turn).
|
|
77
|
-
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
77
|
+
- The web frontend is a zero-build ES-module stack in `lib/samagotchi/web/public/`: `data.js` (retrieval, typed SSE `openStream` for a session and `openEvents` for the session list), `sessions_list.js` (the list as a pure reducer over `GET /api/events`: a snapshot replaces it, an upsert keeps a known card in place, a removal drops it; `families()` groups delegates under their parent), `families_view.js` (a parent's family chip, its rows and the strip's popover; the chip and rows as lit-html templates), `keep_focus.js` (lit reuses a list's nodes by position: a redraw keeps the focus on the same item by its `data-id` / `data-name`), `chains.js` (session chains: the links from `continues`, the cards folded into the latest link's, its `day N` chip and rows as lit-html templates, the info bar's ← → links, a refused continue), `notify.js` (which of those events needs the user, for the notifications), `app.js` (presentation/state; a session with no live stream gets one when its `session` event says `bridge_up`, after one re-read: `event_seq` starts over in each worker, so a dropped stream is never resumed with its old cursor), `format.js` (pure formatters such as `previewOf`), `prompt_history.js` (the composer's ↑/↓ over the shared prompt history), `commands.js` (session commands: which composer lines are commands, the page's own replies, a `command_ran`'s bubble, the page's command bubbles kept across a re-read), `composer.js` (where a composer line goes, `routeComposerLine`: /cut and /queue, the start page's own replies, a session command or `!cmd`, a typo hint, else a turn; and a session command sent from the composer or a chip), `restore.js` (a failed turn's prompt handed back into this tab's composer, before or after its `/turn` ack), `lists_view.js` (the session lists: the strip and its pill, all sessions with its search, count and select mode, their cards, the family and chain popovers; the cards, tile and pill as lit-html templates, drawn as new nodes each time, and the lists' orders as pure functions), `stream_handlers.js` (the open session's stream event handlers, by event type, over the page's views and hooks; a snapshot replays through them too), `info_bar.js` (the session bar's info line as a lit-html template over a plain model: id, status, model, chain links, chips, ctx, speed, clock, worker badge), `session_state.js` (what the page knows about the open session: its empty state, and `sessionStateFrom`, the fields a `GET /sessions/:id` payload sets), `refs.js` (refs: a kind and a target on the element, a plugin's ref data (label, http(s) URL actions) bound in a WeakMap by `bindRef`, the targets per kind, show in turn, one delegated set of click, key and hover listeners), `ref_menu.js` (a ref's `▾` and its menu), `ref_scan.js` (the `path:line` refs in an answer's or a step's text that name a file the session's rows touched), `answer_refs.js` (the refs a plugin attached to an answer, its `display_refs`: bound to the answer's bubble, paired with its links and text, bound with `bindRef`). Unit-tested via `npm test` (`node --test spec/web/public/*.test.js`).
|
|
78
78
|
- Selecting a session in the web UI is read-only: `GET /api/sessions/:id` never spawns a worker (it reads a live worker's snapshot when one runs, else the session file). A prompt (`POST /turn`) or a command (`POST /command`) wakes the worker, and `/stream` briefly waits for a freshly-spawned bridge before answering. A caught-up SSE reconnect holds the stream open; `reset` markers are only sent for reconnects behind the ring window, or with a cursor from another worker (event ids are `<event_seq>-<epoch>`, one epoch per worker).
|
|
79
79
|
|
|
80
80
|
**Test-session hygiene:**
|
data/docs/sub-agent.md
CHANGED
|
@@ -13,6 +13,12 @@ worker keeps the turn paused. The parent answers with `chi answer`, from what it
|
|
|
13
13
|
knows or after asking its own user, and chi goes on from there. The model gets
|
|
14
14
|
the answer as its tool result, as if you had answered in the web.
|
|
15
15
|
|
|
16
|
+
The other direction, chi sending a part of its work to a Claude Code session
|
|
17
|
+
you already work with (to validate, as context, or with a question), is the
|
|
18
|
+
[claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with the
|
|
19
|
+
chi session, chi's `claude_send` tool or `/claude` writes to Claude's inbox,
|
|
20
|
+
and Claude's answer comes back as attached context.
|
|
21
|
+
|
|
16
22
|
## The commands
|
|
17
23
|
|
|
18
24
|
```sh
|
data/docs/testing.md
CHANGED
|
@@ -15,11 +15,17 @@ bug) or a broken spec: fix it, or disable that line with the reason (`# rubocop:
|
|
|
15
15
|
No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
|
|
16
16
|
clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
|
|
17
17
|
|
|
18
|
-
The web specs run in plain node. One that loads lit-html (`turn_view.js`,
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
|
|
19
|
+
`turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
|
|
20
|
+
`question_cards.js`, `stream_handlers.js`, the chips, `info_bar.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
|
|
21
|
+
`chains.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a devDependency only,
|
|
22
|
+
never shipped; about 0.3 s per spec file that loads it; lit reads `document` as it loads). The shim's `markupOf(template)`,
|
|
23
|
+
`htmlOf(nodes)` and `canon(html)` compare markup without lit's marker comments; the DOM writes text with only `&`, `<`, `>`
|
|
24
|
+
escaped (a `"` in text stays `"`) and an attribute with `&` and `"` escaped. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
|
|
22
25
|
`?selected`: read the option marked `selected` (`option[selected]`), not `select.value`; the browser follows the mark.
|
|
26
|
+
A select bound with `.value` instead reads right in both, but lit sets it before the options exist on a first render:
|
|
27
|
+
`llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
|
|
28
|
+
`assert.equal` on two DOM nodes inspects the whole DOM (huge output, can stall the run).
|
|
23
29
|
|
|
24
30
|
## While you work: affected specs
|
|
25
31
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "log"
|
|
5
5
|
require_relative "ref"
|
|
6
|
+
require_relative "row_refs"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# One ref on the answer: the text it stands for (+match+, what the page
|
|
@@ -27,30 +28,20 @@ module Samagotchi
|
|
|
27
28
|
MAX_MATCH = 500
|
|
28
29
|
|
|
29
30
|
# A hook's {match:, kind:, target:, label:, actions:}, or a stored
|
|
30
|
-
# entry (String or Symbol keys, nested ones too). A file ref
|
|
31
|
-
#
|
|
32
|
-
#
|
|
31
|
+
# entry (String or Symbol keys, nested ones too). A file ref is held to
|
|
32
|
+
# the session's folder as a plugin row's is (RowRefs.inside, plan D7):
|
|
33
|
+
# +roots+ is RowRefs.roots of it; a relative path is resolved against
|
|
34
|
+
# it (+relative+; a stored one was stored absolute, so it is dropped),
|
|
35
|
+
# a path outside it refused, and the path kept under its real
|
|
36
|
+
# spelling. With no folder (no +roots+), an absolute path stays.
|
|
33
37
|
# @raise [ArgumentError] anything invalid
|
|
34
|
-
def self.from(value,
|
|
38
|
+
def self.from(value, roots: [], relative: true)
|
|
35
39
|
raise ArgumentError, "a ref is a Hash {match:, kind:, target:, label:, actions:}" unless value.is_a?(Hash)
|
|
36
40
|
|
|
37
41
|
value = value.transform_keys(&:to_sym)
|
|
38
42
|
ref = Ref.new(kind: value[:kind], target: value[:target], label: value[:label], actions: value[:actions] || [])
|
|
39
|
-
new(match: value[:match], ref:
|
|
43
|
+
new(match: value[:match], ref: RowRefs.inside(ref, roots, relative))
|
|
40
44
|
end
|
|
41
|
-
|
|
42
|
-
def self.absolute(ref, cwd)
|
|
43
|
-
return ref unless ref.kind == Ref::FILE
|
|
44
|
-
|
|
45
|
-
path = ref.target[:path]
|
|
46
|
-
return ref if File.absolute_path?(path)
|
|
47
|
-
raise ArgumentError, "relative path, no session folder" if cwd.to_s.empty?
|
|
48
|
-
|
|
49
|
-
# absolute_path, not expand_path: a leading ~ is a name, never a home.
|
|
50
|
-
Ref.new(kind: ref.kind, target: ref.target.merge(path: File.absolute_path(path, cwd.to_s)), label: ref.label,
|
|
51
|
-
actions: ref.actions)
|
|
52
|
-
end
|
|
53
|
-
private_class_method :absolute
|
|
54
45
|
end
|
|
55
46
|
|
|
56
47
|
# The refs after_turn hooks attach to a turn's answer (event[:refs]),
|
|
@@ -71,12 +62,13 @@ module Samagotchi
|
|
|
71
62
|
# Stored refs (+raw+, as read from session.json or a snapshot), each
|
|
72
63
|
# rebuilt and checked again, the caps applied; what isn't valid any
|
|
73
64
|
# more is dropped. Never raises: a reload must show the conversation.
|
|
74
|
-
# A relative file path is dropped (
|
|
65
|
+
# A relative file path is dropped (it was stored absolute), and with
|
|
66
|
+
# +cwd+ (the session's folder) a file outside it.
|
|
75
67
|
# @return [Array<Hash>] the refs to send ({match:, kind:, target:, …})
|
|
76
|
-
def self.for_display(raw)
|
|
68
|
+
def self.for_display(raw, cwd: nil)
|
|
77
69
|
return [] if raw.nil?
|
|
78
70
|
|
|
79
|
-
kept = new([{ role: "model", KEY => raw }], cwd:
|
|
71
|
+
kept = new([{ role: "model", KEY => raw }], cwd: cwd).to_a
|
|
80
72
|
dropped = raw.is_a?(Array) ? raw.length - kept.length : 1
|
|
81
73
|
Log.debug(:web, "display_refs_dropped", count: dropped) if dropped.positive?
|
|
82
74
|
kept
|
|
@@ -92,7 +84,7 @@ module Samagotchi
|
|
|
92
84
|
def initialize(messages, cwd:)
|
|
93
85
|
last = Array(messages).last
|
|
94
86
|
@target = last if last.is_a?(Hash) && (last[:role] || last["role"]).to_s == "model"
|
|
95
|
-
@
|
|
87
|
+
@roots = RowRefs.roots(cwd)
|
|
96
88
|
@refs = []
|
|
97
89
|
@seen = {}
|
|
98
90
|
@bytes = 2 # the list's []
|
|
@@ -149,7 +141,7 @@ module Samagotchi
|
|
|
149
141
|
unknown = value.keys - KEYS
|
|
150
142
|
return reject(hook, "has unknown keys #{unknown.map(&:to_s).inspect}") unless unknown.empty?
|
|
151
143
|
|
|
152
|
-
entry = AnswerRef.from(value,
|
|
144
|
+
entry = AnswerRef.from(value, roots: @roots).to_h
|
|
153
145
|
return true if @seen.key?(entry)
|
|
154
146
|
return over_cap(hook, "#{MAX_REFS} refs") if @refs.length >= MAX_REFS
|
|
155
147
|
|
|
@@ -163,11 +155,11 @@ module Samagotchi
|
|
|
163
155
|
end
|
|
164
156
|
|
|
165
157
|
# A stored entry back in the list, checked again (a relative path is
|
|
166
|
-
# dropped: it was stored absolute
|
|
167
|
-
# caps go quietly.
|
|
158
|
+
# dropped: it was stored absolute; so is a file outside the folder).
|
|
159
|
+
# Duplicates and what is over the caps go quietly.
|
|
168
160
|
# @return [Boolean] kept
|
|
169
161
|
def keep(raw)
|
|
170
|
-
entry = AnswerRef.from(raw,
|
|
162
|
+
entry = AnswerRef.from(raw, roots: @roots, relative: false).to_h
|
|
171
163
|
return false if @seen.key?(entry) || @refs.length >= MAX_REFS
|
|
172
164
|
|
|
173
165
|
size = JSON.generate(entry).bytesize + (@refs.empty? ? 0 : 1)
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -180,6 +180,25 @@ module Samagotchi
|
|
|
180
180
|
raise e
|
|
181
181
|
end
|
|
182
182
|
|
|
183
|
+
# Raised on a connection thread whose request would queue something
|
|
184
|
+
# (a turn, a command, an exit) after #close_intake: the connection
|
|
185
|
+
# closes unanswered, as #stop's kill of a request still waiting did.
|
|
186
|
+
class IntakeClosed < StandardError; end
|
|
187
|
+
|
|
188
|
+
# With the event log held (the worker leaving: Worker#leave_idle,
|
|
189
|
+
# #leave_on_request): no connection is accepted and no request queues
|
|
190
|
+
# a turn, a command or an exit from here on. #stop, called once the log
|
|
191
|
+
# is let go, does the rest: a request that waited on the log then ends
|
|
192
|
+
# at once instead of holding #stop for its REQUEST_GRACE_SECONDS.
|
|
193
|
+
def close_intake
|
|
194
|
+
@mutex.synchronize { @stopped = true }
|
|
195
|
+
begin
|
|
196
|
+
@server&.close
|
|
197
|
+
rescue StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
183
202
|
# Stop the acceptor, release the socket and remove the sidecar, so clients
|
|
184
203
|
# don't have to find it stale. Does not stop the owning turn. Joins the
|
|
185
204
|
# acceptor thread so the process can exit cleanly (Ruby waits for a thread
|
|
@@ -394,7 +413,7 @@ module Samagotchi
|
|
|
394
413
|
log_request(method, request[:path], started)
|
|
395
414
|
break if close_after_request?(headers)
|
|
396
415
|
end
|
|
397
|
-
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
416
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, IntakeClosed
|
|
398
417
|
nil
|
|
399
418
|
rescue StandardError => e
|
|
400
419
|
# The thread doesn't report (report_on_exception = false): log it.
|
|
@@ -728,6 +747,7 @@ module Samagotchi
|
|
|
728
747
|
# A card's action: the UIs leave its line out (the card is the echo).
|
|
729
748
|
command = QueuedCommand.mint(line, client_id: client_id, card: card)
|
|
730
749
|
queued = @engine.synchronize_events do
|
|
750
|
+
raise IntakeClosed if stopped?
|
|
731
751
|
next false if expired?("command_expired", deadline, sid: session_id, client_id: command.client_id)
|
|
732
752
|
|
|
733
753
|
queue_command_locked(command)
|
|
@@ -777,6 +797,7 @@ module Samagotchi
|
|
|
777
797
|
return BAD_DEADLINE unless deadline_valid?(deadline)
|
|
778
798
|
|
|
779
799
|
reason = @engine.synchronize_events do
|
|
800
|
+
raise IntakeClosed if stopped?
|
|
780
801
|
next :expired if expired?("exit_expired", deadline, sid: session_id, client_id: client_id)
|
|
781
802
|
|
|
782
803
|
@on_exit_request.call(client_id, **options)
|
|
@@ -851,6 +872,7 @@ module Samagotchi
|
|
|
851
872
|
# this turn's :turn_started (or merge it mid-turn) before
|
|
852
873
|
# :turn_enqueued, and a failed write announces nothing.
|
|
853
874
|
enqueued = @engine.synchronize_events do
|
|
875
|
+
raise IntakeClosed if stopped?
|
|
854
876
|
next :expired if expired?("turn_expired", deadline, sid: sid, client_id: client_id)
|
|
855
877
|
|
|
856
878
|
enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: claude-peer
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
5
|
+
description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-review context; /claude install-skill installs Claude's chi-peer skill, which pairs"
|
|
6
|
+
trust_level: reviewed
|
|
7
|
+
files:
|
|
8
|
+
skill_claude_peer.md:
|
|
9
|
+
sha256: sha256:dff0175d0370f43920daf5ecbb81315fdd2c7d8063a33fbefc75c7d0e7312993
|
|
10
|
+
description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks; validate, context or question; Claude's answer comes as the claude-review context"
|
|
11
|
+
plugin:
|
|
12
|
+
file: plugin.rb
|
|
13
|
+
sha256: sha256:bf5a95eb125ba4831466e177295bdaba1f625e255b027cea1e59992310d8300e
|
|
14
|
+
scripts:
|
|
15
|
+
pair: sha256:033f1fc6a644950b57e71865253526a0a487bb4c9fd5a61bb89d031b65f40417
|
|
16
|
+
chi-peer-SKILL.md: sha256:f35f726b1a86091ff69d289e8dd58794ac6f44879a63630fcc88d69867c56e20
|
|
17
|
+
# Two things need chi 0.63.0, and the bundle works without them on 0.62:
|
|
18
|
+
# ctx.context.list's summary: (the /claude card's last answer; without it
|
|
19
|
+
# the card shows no summary), and a pushed source's first wake: true push
|
|
20
|
+
# waking the session (on 0.62 Claude's first answer arrives as a note
|
|
21
|
+
# without waking; later ones wake).
|
|
22
|
+
requires_chi: ">= 0.62.0"
|