samagotchi 0.62.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/cli.md +4 -1
  6. data/docs/configuration.md +1 -1
  7. data/docs/context.md +13 -3
  8. data/docs/hooks.md +6 -3
  9. data/docs/memory.md +1 -1
  10. data/docs/plugins.md +89 -2
  11. data/docs/releasing.md +1 -1
  12. data/docs/sub-agent.md +6 -0
  13. data/docs/testing.md +7 -4
  14. data/lib/samagotchi/answer_refs.rb +18 -26
  15. data/lib/samagotchi/bridge.rb +23 -1
  16. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  17. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  18. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  19. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  20. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/context_absorber.rb +25 -6
  27. data/lib/samagotchi/idle_recap.rb +2 -3
  28. data/lib/samagotchi/image_store.rb +8 -0
  29. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  30. data/lib/samagotchi/recap_setup.rb +3 -3
  31. data/lib/samagotchi/row_refs.rb +3 -4
  32. data/lib/samagotchi/session.rb +12 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  35. data/lib/samagotchi/terminal_ui.rb +31 -11
  36. data/lib/samagotchi/tools/execute.rb +22 -4
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +6 -4
  39. data/lib/samagotchi/web/public/activity.js +2 -2
  40. data/lib/samagotchi/web/public/app.js +47 -91
  41. data/lib/samagotchi/web/public/card.js +73 -23
  42. data/lib/samagotchi/web/public/chains.js +6 -4
  43. data/lib/samagotchi/web/public/command_view.js +39 -35
  44. data/lib/samagotchi/web/public/copy.js +7 -15
  45. data/lib/samagotchi/web/public/diff_view.js +20 -23
  46. data/lib/samagotchi/web/public/format.js +0 -48
  47. data/lib/samagotchi/web/public/images.js +12 -16
  48. data/lib/samagotchi/web/public/question_cards.js +3 -2
  49. data/lib/samagotchi/web/public/read_view.js +10 -9
  50. data/lib/samagotchi/web/public/scope.js +23 -0
  51. data/lib/samagotchi/web/public/timing.js +0 -7
  52. data/lib/samagotchi/web/public/turn_html.js +119 -59
  53. data/lib/samagotchi/web/public/turn_view.js +73 -116
  54. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  55. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  56. data/lib/samagotchi/worker.rb +46 -30
  57. data/lib/samagotchi/worker_inbound.rb +12 -11
  58. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68aeb876a7c4c04d7f2f5de4290a1c6089df3acd181bce7380ccb6bde9ef9de8
4
- data.tar.gz: ed308c360b3942d9f3d08233762396493a018585103e50661dd17ea4bb0d123c
3
+ metadata.gz: 20b05cd0da6dfdd621eba3b2add37f00b2abe96c2e3950d8ddd066bdf3078424
4
+ data.tar.gz: ecf5784f9f82c8a8c9e813185570d326a3f4d9ad3a404033f53abf1b62a20902
5
5
  SHA512:
6
- metadata.gz: 8a3ecca6a15d1c2d0e4acf5531491116139b3423ca0e6fe2b4ad558f5c99b332b50672a700437f9d37c1397d9ed92e527b843e6577b11198906ff75ab16b4ecd
7
- data.tar.gz: 949538fdd97549e09bd215353cbdf9dccbe42a21806b965ff828db5b708521791e939abd2d2cb93892c23660abbfabd3106bb492412eb505f52cc0cd742d6d99
6
+ metadata.gz: 302c8ccf54cc947c9165fe8e30f4d32b4054ee02a1e2d62cbb7d7942a30574af58af84a7eafc5b2daf24a8f167e98024e2798fcacccf597b919435019d498613
7
+ data.tar.gz: b6ac96ff05fb4e52a5705b55de3fa505054860b154cd6622c4badc232c6284bc5e80d57ffc94b13a7eae1384557acd5b697b630a0ed21be1a8e85d99610b3d04
data/CHANGELOG.md CHANGED
@@ -8,6 +8,37 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.63.0] - 2026-10-13
12
+
13
+ ### Added
14
+
15
+ - The claude-peer bundle (in the dev profile): pair a chi session with a Claude Code session you work with, and
16
+ the `claude_send` tool sends Claude a part of the work (a plan step, a diff, a finding) to validate, as context or
17
+ with a question; Claude's answer comes back as the `claude-review` context. `/claude validate|context <text>` and
18
+ `/claude last` send without the model, `/claude` shows whether a Claude is paired, and `/claude install-skill`
19
+ installs Claude's `chi-peer` skill, which pairs ("pair with chi <id>"). See docs/plugins.md, "The claude-peer
20
+ bundle".
21
+ - Plugins: `ctx.context.list` gives each source's last `summary:` too. See docs/plugins.md, "Attached context".
22
+
23
+ ### Fixed
24
+
25
+ - Attached context: a source added with `chi context add NAME --push` now wakes an idle session when its first
26
+ push asks to (`"wake": true`); before, only later pushes could.
27
+ - Plugins: `ctx.context.list` returns `[]` for a new session whose file isn't saved yet (a plugin command before
28
+ the first turn) instead of raising "Session not found".
29
+ - An after_turn hook's file refs on the answer (`event[:refs]`) must be under the session's folder, as a plugin
30
+ tool row's are: a file elsewhere is refused (and logged), and a stored one is dropped when the answer is shown.
31
+ - loop-guard (0.3.8): the card for a turn it stopped prints under the blocked call's row in the terminal, not
32
+ before it.
33
+ - Attached TUI: joining a session whose last turn was images alone (sent from the web) names each image once, not
34
+ twice.
35
+ - Attached TUI: text you had typed when the step-limit question opened comes back at the prompt once you answer
36
+ it, instead of being lost.
37
+ - `chi -p … --non-interactive` says so on stderr and exits 1 when the iteration limit cuts the turn short, instead
38
+ of exiting 0 with the partial text as if it were the answer.
39
+ - `chi --no-shared --resume` drops a question the session's last worker saved and died with, as an attached resume
40
+ does, instead of keeping it in the session file.
41
+
11
42
  ## [0.62.0] - 2026-10-13
12
43
 
13
44
  ### Added
@@ -2719,7 +2750,8 @@ and long-lived sessions.
2719
2750
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
2720
2751
  hotkey panel that send selected text or the clipboard to your sessions.
2721
2752
 
2722
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.62.0...HEAD
2753
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.63.0...HEAD
2754
+ [0.63.0]: https://github.com/dm1try/samagotchi/compare/v0.62.0...v0.63.0
2723
2755
  [0.62.0]: https://github.com/dm1try/samagotchi/compare/v0.61.0...v0.62.0
2724
2756
  [0.61.0]: https://github.com/dm1try/samagotchi/compare/v0.60.0...v0.61.0
2725
2757
  [0.60.0]: https://github.com/dm1try/samagotchi/compare/v0.59.0...v0.60.0
data/README.md CHANGED
@@ -150,7 +150,10 @@ task to a child session that runs in parallel and reports back only its final
150
150
  reply (a normal session: `chi --attach <id>` steers it). With the `coordinator`
151
151
  bundle (in `dev`), `/coordinate <goal>` has chi split the work into tasks, each
152
152
  in a child session in its own git worktree, check each report and ask you
153
- before each merge; `/children` shows the children and their state.
153
+ before each merge; `/children` shows the children and their state. With the
154
+ `claude-peer` bundle (in `dev`), chi sends a part of its work to a Claude Code
155
+ session you pair it with, to validate or as context, and Claude answers back
156
+ into chi (`/claude`).
154
157
 
155
158
  `chi send` is the other half: the text goes in as your message, the same as
156
159
  typing it in the attached terminal or the web composer, and a turn runs. Piped
data/bin/chi CHANGED
@@ -386,8 +386,9 @@ begin
386
386
  scratch: scratch
387
387
  ).run
388
388
  # `chi -p X </dev/null`: the turn failed (nothing was sent again); -p
389
- # --non-interactive: the turn ended with no answer, or Ctrl-C cancelled it.
390
- exit 1 if %i[turn_failed empty_answer].include?(ended)
389
+ # --non-interactive: the turn ended with no answer or at the iteration
390
+ # limit, or Ctrl-C cancelled it.
391
+ exit 1 if %i[turn_failed empty_answer iteration_limit].include?(ended)
391
392
  exit Samagotchi::CLI::Exit::INTERRUPTED if ended == :interrupted
392
393
  rescue Samagotchi::TerminalUI::SessionBusy, Samagotchi::TerminalUI::SessionNotFound, Samagotchi::ModelProfile::MissingModel => e
393
394
  warn "Error: #{e.message}"
data/docs/cli.md CHANGED
@@ -250,7 +250,10 @@ Notes:
250
250
  retry lines and hooks' notices as they come, the after_turn hooks' ones
251
251
  (source-links' `sources:`) after the answer; and the error of a failed turn,
252
252
  then `chi: the session is kept with your prompt; continue it with: chi
253
- --resume ID` (exit 1). Ctrl-C saves the session with the prompt and a
253
+ --resume ID` (exit 1). A turn the iteration limit (`turn.max_iterations`)
254
+ cut short prints what the model wrote so far, then `chi: the iteration limit
255
+ (N) was reached before the turn finished; continue it with: chi --resume ID`
256
+ on stderr (exit 1). Ctrl-C saves the session with the prompt and a
254
257
  cancel note and exits 130.
255
258
  Nobody can answer there: an approval is denied, and a question the model
256
259
  asks (`ask_user_question`) is dismissed at once (one line on stderr), never
@@ -1350,7 +1350,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
1350
1350
  | `memory.index_warn_tokens` | `2500` | yes | The tokens one scope's memory index (project or system; sent with every prompt) may hold before a `memory_write`, `write` or `edit` that takes it over gets a note asking the model to tighten long index descriptions; `0`: no note. Estimated as characters / `context.chars_per_token`. `memory:` is its own section, not the `memories:` preload list. See [Memory](memory.md). |
1351
1351
  | `context.status_thresholds` | `20,40,60,80` | yes | Percentages that put the estimate into a bucket; the rise into a guidance bucket (from the second up) leaves the model its own `[CONTEXT: …]` line, and the bucket decides the guidance. |
1352
1352
  | `context.every_seconds` | `300` | | How often an attached context source's command runs when it has no `--every` (seconds, at least 30). See [Attached context](context.md). |
1353
- | `context.wake` | `true` | yes | A source whose update says `wake: true` may start a turn in a live, idle session (one per source per 10 minutes, within `session.max_wakes`); `false`: updates wait as notes for the next turn. See [Waking](context.md#waking). |
1353
+ | `context.wake` | `true` | yes | A source whose update says `wake: true` may start a turn in a live, idle session (one per source per 10 minutes, within `session.max_wakes`); so may a session's pushed source's first push. `false`: updates wait as notes for the next turn. See [Waking](context.md#waking). |
1354
1354
  | `thinking.turn_preamble` | `true` | yes | Ask a `qwen36` model to open its thinking with a short `TURN:` line (the step label). |
1355
1355
  | `reasoning_history` | `turn` | | Which of its reasoning a chat host's model gets back: `drop`, `turn`, `all`; environment only (`SAMAGOTCHI_REASONING_HISTORY`), below a session's own level (`/reasoning-history`), above the `models:`/`hosts:` entries. See "Reasoning history". |
1356
1356
  | `thinking.level` | `default` | `--thinking` | `off`, `low`, `medium`, `high` or `default` for every model; the flag and env outrank the `models:`/`hosts:` entries, the file's value doesn't; a session's own level outranks them all, and `--thinking` sets it on a session run (on `chi web` it sets this). See "Thinking". |
data/docs/context.md CHANGED
@@ -123,6 +123,11 @@ chi context add clip --push --project
123
123
  pbpaste | chi context push clip --project
124
124
  ```
125
125
 
126
+ A Claude Code session can be a pushed source too: the
127
+ [claude-peer bundle](plugins.md#the-claude-peer-bundle)'s `pair` script attaches
128
+ `claude-inbox` (Claude's inbox socket as the hint) and `claude-review`, where
129
+ Claude pushes its answers as JSON with `wake: true`.
130
+
126
131
  ### A stream: chat, a log, a feed
127
132
 
128
133
  A source's text is a snapshot, not a log: each run replaces it. For
@@ -233,6 +238,10 @@ you're away from the terminal. All of these must hold:
233
238
  - the source asked (`"wake": true`; a plain-text source never does), in
234
239
  any change since the session last heard of it: a later change that doesn't
235
240
  ask doesn't take the wake back;
241
+ - it's a change, not the source's first text, except for a session's pushed
242
+ source (`chi context add NAME --push`, attached empty): its first push can
243
+ wake too. A command or URL source's first fetch runs as you attach it, and a
244
+ project source's first text may predate the session, so neither wakes;
236
245
  - `context.wake` is on (default `true`);
237
246
  - the session's worker is up and idle: no turn running, nothing queued, no
238
247
  question or continue offer waiting, not just started;
@@ -242,15 +251,16 @@ you're away from the terminal. All of these must hold:
242
251
  - the source hasn't woken the session in the last 10 minutes.
243
252
 
244
253
  Otherwise the change arrives as a note, as above. The turn starts with the
245
- update note, whose last line becomes:
254
+ update note, or, for a session's pushed source, its attached note, whose last
255
+ line becomes:
246
256
 
247
257
  > chi started this turn because the source changed; your user didn't write anything. Tell your user in a sentence or two what changed and what it may mean for their work. Don't run commands, change files or reply on the source for it: wait for your user to ask.
248
258
 
249
259
  Every UI labels the turn `context <name> changed`, and the web's
250
260
  notifications count it as a finished turn however short.
251
261
 
252
- A wake turn that fails goes back to before it and keeps the update as a
253
- plain note; the model reads `[SYSTEM: the previous turn failed before any
262
+ A wake turn that fails goes back to before it and keeps its note as a
263
+ plain one; the model reads `[SYSTEM: the previous turn failed before any
254
264
  answer: … The wake turn for the change in attached context <name> was not
255
265
  answered; chi starts no other wake turn until the user writes.]`.
256
266
 
data/docs/hooks.md CHANGED
@@ -301,9 +301,12 @@ end
301
301
  keywords only (String or Symbol keys inside `target` and `actions`).
302
302
  - `match`: the text in the answer the ref stands for (at most 500 characters).
303
303
  - `kind`: a short lowercase word. `"file"` takes `target: {path:, line:, end_line:}`
304
- (the lines optional); a relative path is resolved against the session's
305
- folder, an absolute one is kept as given. Any other kind (`"issue"`,
306
- `"commit"`, …) takes `target: {id:}`.
304
+ (the lines optional); the path is absolute or relative to the session's
305
+ folder, and must be **under** it (its own spelling or its real path:
306
+ `/tmp/x` or `/private/tmp/x`), as a plugin tool row's file refs
307
+ ([plugins.md](plugins.md)): a file elsewhere is refused. It is kept under
308
+ the folder's real path. Any other kind (`"issue"`, `"commit"`, …) takes
309
+ `target: {id:}`.
307
310
  - `label`: what a UI shows for it (optional, at most 200 characters).
308
311
  - `actions`: links a UI offers on top of the kind's own, each
309
312
  `{id:, label:, url:}`; http(s) URLs only, without userinfo (`user:pass@`),
data/docs/memory.md CHANGED
@@ -270,7 +270,7 @@ profiles, meta bundles that install a set of them:
270
270
  | profile | bundles |
271
271
  |---|---|
272
272
  | `core` | loop-guard, check-in, guardrails, slow-commands, agents-md: the recommended safety set |
273
- | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator |
273
+ | `dev` | known-names, mcp, btw, skills, source-links, github-pr, coordinator, claude-peer |
274
274
 
275
275
  `chi bootstrap` installs `core` and, on a terminal, offers `dev`. An existing
276
276
  install gets neither by itself: `chi bundle install core` (or `dev`).
data/docs/plugins.md CHANGED
@@ -722,7 +722,7 @@ ctx.sessions.stop(id) # one of this session's own children
722
722
  ```ruby
723
723
  ctx.context.attach(url: "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
724
724
  ctx.context.attach(name: "ci", cmd: "bin/ci-status", why: "this branch's CI", every_seconds: 120)
725
- ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:}]
725
+ ctx.context.list # => [{name:, scope:, why:, hint:, fetched_at:, error:, provider:, summary:}]
726
726
  ctx.context.decline(url: "https://github.com/acme/app/pull/42") # => "pr-42"
727
727
  ctx.context.mark_offered("pr-42", "https://github.com/acme/app/pull/42", why: "branch feat/x has open PR #42")
728
728
  ctx.context.offered("pr-42") # => #<data Offer name="pr-42", hint="https://…", why="branch…", at="2026-10-08T…Z">, or nil
@@ -756,7 +756,10 @@ gets a note when its text changes.
756
756
  - The source is the session's (not the project's), marked
757
757
  `added_by: plugin:<bundle>`. It is plugin code, so it isn't asked about as
758
758
  the agent's `chi context add --cmd` is.
759
- - `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).
760
763
  - Two attaches of one source at once (a double click) both return its
761
764
  name.
762
765
  - `attach`, `decline` and `mark_offered` raise
@@ -1504,6 +1507,90 @@ by themselves there.
1504
1507
  reason: merging a child's branch
1505
1508
  ```
1506
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
+
1507
1594
  ## Shutdown
1508
1595
 
1509
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/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,10 +15,13 @@ 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`, `thinking_chip.js`, `llm_context_chip.js`,
19
- `info_bar.js`, `lists_view.js`, `context_chips.js`, `families_view.js`, `chains.js`, `vendor/lit-html.js`) needs a DOM: `import "./dom_shim.js";` as its first import (happy-dom, a
20
- devDependency only, never shipped; about 0.3 s per spec file that loads it). The shim's `htmlOf(nodes)` and `canon(html)` compare markup
21
- without lit's marker comments. happy-dom 20 picks the wrong option of a `<select>` whose options lit inserted with
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.
23
26
  A select bound with `.value` instead reads right in both, but lit sets it before the options exist on a first render:
24
27
  `llm_context_chip.js`'s `renderForm` draws twice then. Assert a kept node with `assert.ok(a === b)`: a failing
@@ -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's relative
31
- # path is resolved against +cwd+, so the stored path is absolute; with
32
- # no +cwd+ it is refused. An absolute path stays as given.
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, cwd: nil)
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: absolute(ref, cwd))
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 (there is no folder to resolve it).
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: nil).to_a
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
- @cwd = cwd
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, cwd: @cwd).to_h
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). Duplicates and what is over the
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, cwd: nil).to_h
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)
@@ -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"