samagotchi 0.123.0 → 0.124.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 +20 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +19 -1
- data/docs/cli.md +20 -5
- data/docs/desktop.md +51 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/update_command.rb +40 -13
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +198 -2
- data/lib/samagotchi/web/public/app.js +40 -1
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/update_notice.js +6 -6
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/server.rb +116 -7
- data/lib/samagotchi/web/session_hub.rb +28 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +14 -7
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e85ff88270cf7569d21ff95bba739199ceb3c53d32675b1648a18363ca5482cd
|
|
4
|
+
data.tar.gz: d9f539982d7c41b94ad40d17368041f2debed8040ebc48a27776486fe2476dc9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3496206b25f1d329a3a915c075cefe5e0c3e1fc40b71694128c7d3fee633fc65447686efbb4c8f4f19e0872998cecb2f269db67a7428b9b519a9cc5b6469bd3
|
|
7
|
+
data.tar.gz: 3ed82d4bb9b01288f391e2d68d9eba5b71492fa66f411d7a5fd25276ac68425f8432979fd0d4d59882b741a2dfa79bcb0810fc74e3b2c7e66bb590e0c5bdbccc
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ and commands may change between minor versions. How releases are made:
|
|
|
7
7
|
[docs/releasing.md](docs/releasing.md).
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.124.0] - 2026-10-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The desktop helper's panel lists and sends to the sessions of the remotes `chi remote add` keeps: a section per
|
|
15
|
+
remote ("on studio") after this Mac's rows, each row tagged with its machine, "→ studio" in the hint while one is
|
|
16
|
+
ticked, one call per machine at once; a remote that doesn't answer within 4 s or refuses shows one line, and this
|
|
17
|
+
Mac's rows don't wait for it. A remote with a session also gets a "New session on studio in <folder>" row, in its
|
|
18
|
+
newest session's folder, on that remote's default model. The Everyone row, the model chooser and agents stay on
|
|
19
|
+
this Mac.
|
|
20
|
+
- chi web updates chi from its page: the notice's **Update** installs the release it names in a progress sheet
|
|
21
|
+
that lists kept edits, then offers **Restart workers** (each worker on an older chi; a busy one says what holds
|
|
22
|
+
it) and **Restart chi web** (in place: same port, token, flags and Tailscale Serve). `chi update --to VERSION`
|
|
23
|
+
installs an exact release; `--json` prints its rows as JSON.
|
|
24
|
+
|
|
25
|
+
Update with `chi update` (it rebuilds the desktop helper), then restart `chi web` and the session workers
|
|
26
|
+
(`chi sessions restart`). From the next release on, chi web's Update button does this from its page.
|
|
27
|
+
|
|
10
28
|
## [0.123.0] - 2026-10-27
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -4695,7 +4713,8 @@ and long-lived sessions.
|
|
|
4695
4713
|
- A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
|
|
4696
4714
|
hotkey panel that send selected text or the clipboard to your sessions.
|
|
4697
4715
|
|
|
4698
|
-
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.
|
|
4716
|
+
[Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.124.0...HEAD
|
|
4717
|
+
[0.124.0]: https://github.com/dm1try/samagotchi/compare/v0.123.0...v0.124.0
|
|
4699
4718
|
[0.123.0]: https://github.com/dm1try/samagotchi/compare/v0.122.0...v0.123.0
|
|
4700
4719
|
[0.122.0]: https://github.com/dm1try/samagotchi/compare/v0.121.1...v0.122.0
|
|
4701
4720
|
[0.121.1]: https://github.com/dm1try/samagotchi/compare/v0.121.0...v0.121.1
|
data/bin/chi
CHANGED
|
@@ -18,6 +18,15 @@ rescue Samagotchi::ConfigFile::RemovedApi => e
|
|
|
18
18
|
end
|
|
19
19
|
require "samagotchi"
|
|
20
20
|
|
|
21
|
+
# A web self-restart re-enters through the installed wrapper: its marker and
|
|
22
|
+
# the worker starters to adopt leave ENV before anything spawns with it
|
|
23
|
+
# (Server.take_restart_env!); a restarted chi web skips the first-run hint.
|
|
24
|
+
web_restarted = false
|
|
25
|
+
if ENV.key?("SAMAGOTCHI_WEB_RESTARTED") || ENV.key?("SAMAGOTCHI_WEB_ADOPT_PIDS")
|
|
26
|
+
require "samagotchi/web/server"
|
|
27
|
+
web_restarted = Samagotchi::Web::Server.take_restart_env!
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
# `chi self`: print version, source dir, config/memory/session paths, model and
|
|
22
31
|
# bundles (read-only, offline) — lets the agent find its own code and state.
|
|
23
32
|
if ARGV[0] == "self"
|
|
@@ -221,7 +230,7 @@ chi_update_hint = lambda do
|
|
|
221
230
|
end
|
|
222
231
|
|
|
223
232
|
if options[:web]
|
|
224
|
-
chi_update_hint.call
|
|
233
|
+
chi_update_hint.call unless web_restarted
|
|
225
234
|
require "samagotchi/web/server"
|
|
226
235
|
port = options[:web_port] || Samagotchi::Config.get("web.port")
|
|
227
236
|
markdown = options[:web_markdown]
|
|
@@ -233,7 +242,9 @@ if options[:web]
|
|
|
233
242
|
annotate_presets: Samagotchi::Config.get("web.annotate_presets"),
|
|
234
243
|
editor: Samagotchi::Config.get("web.editor"),
|
|
235
244
|
start_dir: options[:start_dir] || Samagotchi::Config.get("web.start_dir"),
|
|
236
|
-
start_dir_from: options[:start_dir] ? :flag : :config
|
|
245
|
+
start_dir_from: options[:start_dir] ? :flag : :config,
|
|
246
|
+
restarted: web_restarted,
|
|
247
|
+
restart_argv: Samagotchi::RunOptions.web_restart_argv(options))
|
|
237
248
|
end
|
|
238
249
|
|
|
239
250
|
# --resume and --attach take a unique id prefix too, like git.
|
data/docs/architecture.md
CHANGED
|
@@ -551,6 +551,24 @@ version than the server itself still means restart chi web (its workers already
|
|
|
551
551
|
web runs in) it names `start_dir`, the folder it starts in (`web.start_dir`, `--start-dir`;
|
|
552
552
|
`Web::StartDir`), which a second `chi web` given another one mentions.
|
|
553
553
|
|
|
554
|
+
The owner's update routes publish progress and results as `chi_update` events with an
|
|
555
|
+
`operation` (`install` or `workers`), `state`, and `rows`; install events also name the target
|
|
556
|
+
`version` and may include a progress line. The hub retains the latest event for a reconnecting
|
|
557
|
+
tab's snapshot. `POST /api/update` decides itself whether this install can take a gem
|
|
558
|
+
(`UpdateCheck.blocked_reason`: a checkout, Bundler, an unwritable gem dir → 409 `update_blocked`), so
|
|
559
|
+
it holds with `update.notify: off`; a child that prints no rows fails with its own last line, and a
|
|
560
|
+
done install refreshes the hub's installed version (`SessionHub#refresh_installed`) before its `done`.
|
|
561
|
+
Worker restarts target every worker older than the version installed now (refreshed the same way)
|
|
562
|
+
and publish per-session results. Only one of an install, a worker restart pass and a web restart runs
|
|
563
|
+
at a time (409 `update_running`). `POST /api/update/restart_web` (409 `restart_unavailable` without a
|
|
564
|
+
chi wrapper to exec) schedules WEBrick shutdown only after its response is sent; after the hub and
|
|
565
|
+
listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web was started with, as
|
|
566
|
+
`RunOptions`' parser recorded them (`RunOptions.web_restart_argv`), but the one-shot `--open` and
|
|
567
|
+
`--new-token`. The re-entry (`SAMAGOTCHI_WEB_RESTARTED`, taken out of ENV first by
|
|
568
|
+
`Server.take_restart_env!`) skips the first-run update hint, adopts live worker-starter PIDs (workers
|
|
569
|
+
themselves are detached), and adopts an exact Tailscale Serve handler. A failed exec, or a restarted
|
|
570
|
+
chi web that doesn't start for any reason, turns off only that exact adopted handler.
|
|
571
|
+
|
|
554
572
|
The page's scope is in its URL, and the server picks the default: `?dir=<folder>` is that folder's
|
|
555
573
|
project (the list, the events stream and a new chat follow it), `?scope=all` every session, and a bare
|
|
556
574
|
`/` the start folder's project when that folder is in a repo (`App#index_data_attributes` writes
|
|
@@ -786,4 +804,4 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
|
|
|
786
804
|
| Stores | `reminder_store.rb` (`ReminderStore`), `recap_store.rb` (`RecapStore`) | A session's reminders, and its idle recap (`recap.json`). |
|
|
787
805
|
| Setup and update | `bootstrap_command.rb`, `bootstrap/`, `update_command.rb`, `gem_update.rb` | `chi bootstrap` (first config.yml, system bundle, core profile) and `chi update` (the gem, shipped bundles, the macOS helper). |
|
|
788
806
|
| Process groups | `process_group.rb` (`ProcessGroup`) | A command in a process group of its own, stopped as a group (TERM, grace, KILL): execute, tasks, context fetches, the mcp bundle's servers. |
|
|
789
|
-
| Desktop | `desktop.rb`, `desktop/macos.rb`, `desktop_command.rb`, `desktop/macos/*.swift` | `chi desktop`, the macOS "Send to chi" helper ([desktop.md](desktop.md)). The helper's Swift side: the panel (`Panel.swift`), the chi CLI runner (`ChiRunner.swift`), agent terminals behind `AgentTerminal` (`AgentTerminal.swift`, `Kitty.swift`, `Tmux.swift`; `ChiHelper --agents` is their CLI seam). |
|
|
807
|
+
| Desktop | `desktop.rb`, `desktop/macos.rb`, `desktop_command.rb`, `desktop/macos/*.swift` | `chi desktop`, the macOS "Send to chi" helper ([desktop.md](desktop.md)). The helper's Swift side: the panel (`Panel.swift`), the chi CLI runner (`ChiRunner.swift`), the session rows and the chi calls a send makes (`Sessions.swift`: `SendPlan`; `ChiHelper --sessions` and `--send-plan` are their CLI seams), agent terminals behind `AgentTerminal` (`AgentTerminal.swift`, `Kitty.swift`, `Tmux.swift`; `ChiHelper --agents` is their CLI seam). |
|
data/docs/cli.md
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
- `chi desktop install|upgrade|uninstall|status` — the macOS "Send to chi" helper: a Service and a ⌃⌥⌘N hotkey that send text or images to a session (live, stopped or new) as a message (⏎, a turn runs) or a context note (⌘⏎) (see [Desktop helper](desktop.md))
|
|
35
35
|
- `chi models [--format text|json] [--timeout S] [TEXT]` — list the models every configured host offers, as the names `--model` takes (see [Listing the models](#listing-the-models))
|
|
36
36
|
- `chi self` — print version, source dir (checkout or installed gem), config/memory/session paths, model/host and bundles. Run by a session's `execute`, the `model` row (and host, loop, profile, thinking, served model) is **that session's** model, labelled `(this session <id8>; default …)`; elsewhere it is the default, `(default)`. `model key` is the memory overlay key (`<name>.<key>.md`), and `model notes` the model notes a prompt of that model loads (`model_notes_deepseek (system, 612 chars)`, `none`; inside a session its `--mute` list is left out). `chi self --model` prints only the model a new session starts on, an alias resolved to its target (the desktop helper's hint)
|
|
37
|
-
- `chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
|
|
37
|
+
- `chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, in one table (see [Updating](#updating))
|
|
38
38
|
- `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Plugins](plugins.md#the-btw-bundle), [the mcp bundle](plugins.md#the-mcp-bundle) [the loop-guard bundle](plugins.md#the-loop-guard-bundle), [the check-in bundle](plugins.md#the-check-in-bundle) and [the skills bundle](plugins.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
|
|
39
39
|
|
|
40
40
|
- `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
|
|
@@ -233,13 +233,26 @@ Also shipped, not installed: dev (mcp, skills, source-links) (chi bundle install
|
|
|
233
233
|
done
|
|
234
234
|
```
|
|
235
235
|
|
|
236
|
+
- `--to VERSION` installs exactly that gem version instead of checking for the
|
|
237
|
+
newest release. It must be newer than the running chi; an equal or older
|
|
238
|
+
version is refused.
|
|
239
|
+
- `--json` writes the component rows as one JSON array on stdout. Each row has
|
|
240
|
+
`component`, `from`, `to`, `status`, `failed` and `kept` fields; `kept` is an
|
|
241
|
+
array of edited filenames. Install progress is written to stderr.
|
|
242
|
+
|
|
236
243
|
- **chi web update notices.** By default, chi web checks RubyGems about 30
|
|
237
244
|
seconds after it starts and every 24 hours after that. Once a stable release
|
|
238
245
|
is at least 24 hours old and newer than both the running chi and the newest
|
|
239
|
-
installed one, the page shows its version
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
installed one, the owner's page shows its version and a link to that
|
|
247
|
+
RubyGems release. When the install is unblocked, **Update** installs that
|
|
248
|
+
exact release and opens a progress sheet with component results and any
|
|
249
|
+
edited files kept. A blocked install instead shows the reason and a command
|
|
250
|
+
to copy. After installation, the notice and the sheet's footer offer
|
|
251
|
+
**Restart workers** and **Restart chi web**; the sheet reports worker results
|
|
252
|
+
(a busy worker's refusal says what holds it) and waits for chi web to
|
|
253
|
+
reconnect, and keeps the other restart in reach. The notice is once per version per browser: a reload or a chi
|
|
254
|
+
web restart doesn't repeat it. `update.notify: off` disables checking;
|
|
255
|
+
`update.check_hours` and
|
|
243
256
|
`update.min_age_hours` tune the interval and release age (see
|
|
244
257
|
[configuration](configuration.md#all-settings)). If the gem can't be updated
|
|
245
258
|
in place, the notice gives the blocker and its command instead.
|
|
@@ -1325,6 +1338,8 @@ CHI_REMOTE=studio chi context push claude-outbox ab12 < answer.json
|
|
|
1325
1338
|
studio (http://192.168.1.29:4567): connection refused`, exit 1; with
|
|
1326
1339
|
`--format json` an `error` object).
|
|
1327
1340
|
- Plain http, the same as the phone's: use it on your home network.
|
|
1341
|
+
- The macOS desktop helper's panel lists and sends to a remote's sessions too: see
|
|
1342
|
+
[Desktop helper: Remote sessions](desktop.md#remote-sessions).
|
|
1328
1343
|
|
|
1329
1344
|
A session there in this terminal (the attached TUI, through that machine's
|
|
1330
1345
|
chi web; its worker stays there):
|
data/docs/desktop.md
CHANGED
|
@@ -41,7 +41,7 @@ alias as the model it names).
|
|
|
41
41
|
|
|
42
42
|
### Everyone it concerns
|
|
43
43
|
|
|
44
|
-
The second row, **Everyone it concerns** (⌘B), shares a note with every session it may concern instead of the ones
|
|
44
|
+
The second row, **Everyone it concerns (this Mac)** (⌘B), shares a note with every session it may concern instead of the ones
|
|
45
45
|
you pick: ⌘⏎ (the **Broadcast** button) runs [`chi broadcast`](broadcast.md) with the line, then the text, on stdin.
|
|
46
46
|
chi picks the sessions (a shared ticket, PR or link, else a small triage model), so the preselected session doesn't
|
|
47
47
|
count: the row is ticked alone, like the new row. Write it the way you'd tell a colleague: a first line such as
|
|
@@ -51,6 +51,8 @@ count: the row is ticked alone, like the new row. Write it the way you'd tell a
|
|
|
51
51
|
skipped 2 · 1 unchecked: triage deadline`), and **stays open**: Esc closes. Which sessions got it and why:
|
|
52
52
|
`chi broadcast log` in a terminal.
|
|
53
53
|
- A broadcast is a text note: ⏎ and images beep. The source field and Send are hidden (its source is `broadcast`).
|
|
54
|
+
- It reaches this Mac's sessions only (`chi broadcast` runs on this machine only), never a
|
|
55
|
+
[remote's](#remote-sessions).
|
|
54
56
|
- It may take `broadcast.triage_deadline` (20 s) and 10 s more before the helper stops it ("the note may be partly
|
|
55
57
|
delivered"). The helper reads that from its launch file: after changing the deadline, run `chi update` (or
|
|
56
58
|
`chi desktop upgrade`).
|
|
@@ -106,6 +108,46 @@ thumbnails sit under the message line, 48 px high, the file name as a tooltip; h
|
|
|
106
108
|
the panel closes; files from Finder are sent as they are, never touched. A send with images may take up to 30 s
|
|
107
109
|
(converting, a worker starting) before the panel gives up.
|
|
108
110
|
|
|
111
|
+
## Remote sessions
|
|
112
|
+
|
|
113
|
+
The panel also lists the sessions of the other machines `chi remote add` keeps (see
|
|
114
|
+
[Remote sessions](cli.md#remote-sessions)), say the Studio's from a laptop: select text or take a screenshot here,
|
|
115
|
+
and it lands in a session running there.
|
|
116
|
+
|
|
117
|
+
- **Which:** every remote in `chi remote ls` that has a token, asked at each open (a remote you don't want in the
|
|
118
|
+
panel: `chi remote rm` it). No `chi update` or `chi desktop upgrade` after adding one.
|
|
119
|
+
- **Where:** after this Mac's rows, one section per remote under an **on studio** divider: its live sessions, then up
|
|
120
|
+
to 3 recent ones, as for this Mac. Each row carries a grey **studio** tag and its whole folder path (that machine's
|
|
121
|
+
home may differ from this one's). ⌘1…⌘9 number this Mac's rows first, then the remotes' in order; past nine, a row
|
|
122
|
+
is clicked.
|
|
123
|
+
- **Not twice:** a session shows once, where it is listed first (this Mac, then the remotes in `chi remote ls` order),
|
|
124
|
+
so two remotes for the same Studio (its LAN address and its ts.net name), or a remote that is this Mac, don't
|
|
125
|
+
list or send to it twice.
|
|
126
|
+
- **Wrong machine:** both machines likely have the same paths. While a remote's row is ticked the message line's hint
|
|
127
|
+
ends in **→ studio**, and **→ studio** shows beside Send (it stays there while you type). The last choice is
|
|
128
|
+
preselected while it is still live, a remote's row too; the "only one live session" rule picks this Mac's only,
|
|
129
|
+
never a remote's. A remote that answers after this Mac's list never changes what is ticked: it is preselected only
|
|
130
|
+
when nothing is (so a remembered remote row is ticked when the remote answers first, or when this Mac has live
|
|
131
|
+
sessions but none was remembered).
|
|
132
|
+
- **Keys:** ⏎ and ⌘⏎ work as for this Mac's sessions (`chi --remote studio send …`, `chi --remote studio note …`;
|
|
133
|
+
a note to a stopped session there waits for its next start). Images go along (`--image`: chi uploads them). With
|
|
134
|
+
rows of several machines ticked, the helper runs one call per machine at once and shows one line: `sent · studio:
|
|
135
|
+
sent`. ⌥⏎ (paste) beeps on a remote's row as on this Mac's.
|
|
136
|
+
- **Down or refused:** this Mac's rows show as soon as they are in; each remote's fill in as it answers. A remote's
|
|
137
|
+
two lists run at once and get 4 s together: one that doesn't answer shows **studio: no answer within 4 s** under
|
|
138
|
+
its divider, one that chi gives up on by itself (connection refused, the token refused) the first line of chi's
|
|
139
|
+
error. The previous open's rows for it are never reused: a stale row could send to a stopped worker.
|
|
140
|
+
- **New session there:** a remote with a session has its own new row under its divider, **New session on studio in
|
|
141
|
+
proj**: the folder is that remote's most recently updated session's (live first, then recent), whole and not
|
|
142
|
+
checked on this Mac. A remote with no session has no new row (never a guess at its home). It is ticked alone like
|
|
143
|
+
this Mac's new row (ticking it clears the sessions, and the reverse), is never preselected and has no ⌘ number (⌘0
|
|
144
|
+
stays this Mac's). ⏎ runs `chi --remote studio send --new --dir <that folder>` with the message and images, and
|
|
145
|
+
shows `studio: started 1a2b3c4d…` for 3 s. It starts on the remote's default model, never `--model`: its hint
|
|
146
|
+
says **remote default**, and ⌘M or a click on it beeps with "Model choice is for this Mac's sessions" (`chi models`
|
|
147
|
+
answers for this Mac's hosts).
|
|
148
|
+
- **What stays on this Mac:** the model chooser, the **Everyone it concerns** row (`chi broadcast` is local only)
|
|
149
|
+
and the agents in kitty and tmux.
|
|
150
|
+
|
|
109
151
|
## Agents in kitty and tmux
|
|
110
152
|
|
|
111
153
|
The panel can also send to agent CLIs (claude, codex, gemini, aider, …) running in
|
|
@@ -276,8 +318,14 @@ The helper uses only these commands, so it could ship on its own later:
|
|
|
276
318
|
(`delivered N · skipped M…`); exit 0 when it ran, 1 when refused or a delivery failed.
|
|
277
319
|
- `chi self --model` → the model a new session starts on (the panel's hint).
|
|
278
320
|
- `chi models --format json` → the models the hosts offer (the new-session model picker).
|
|
279
|
-
|
|
280
|
-
|
|
321
|
+
- `chi remote ls --format json` → `[{name, url, token}]` (never the token itself): the remotes whose sessions the
|
|
322
|
+
panel lists. A name goes on only when it has a token and passes chi's own rule (letters, digits, `_`, `-`, not
|
|
323
|
+
starting with `-`). An older chi without `chi remote` lists none.
|
|
324
|
+
- `chi --remote NAME sessions list …`, `chi --remote NAME send …` and `chi --remote NAME note …`: the same calls on a
|
|
325
|
+
remote's sessions. `CHI_REMOTE` is taken out of the helper's environment, so this Mac's calls never go remote.
|
|
326
|
+
|
|
327
|
+
Each call is stopped after 10 s, a remote's two lists after 4 s, a send of text to a remote after 15 s (waking a
|
|
328
|
+
stopped worker there), a send with images after 30 s, a broadcast after the launch file's
|
|
281
329
|
`broadcast_timeout` (`broadcast.triage_deadline` + 10 s; 30 s when it has none). A stopped `chi note` or `chi
|
|
282
330
|
broadcast` says the note may be partly delivered.
|
|
283
331
|
|
data/docs/testing.md
CHANGED
|
@@ -301,7 +301,8 @@ to install). It refuses when the folder or the branch already exists.
|
|
|
301
301
|
|
|
302
302
|
`SAMAGOTCHI_MACOS_BUILD=1 bundle exec rspec spec/desktop` also compiles the macOS helper (`:macos_build`, needs the
|
|
303
303
|
Command Line Tools) and runs `ChiHelper` against a fake kitty and, when a `tmux` is on PATH, a real one (private
|
|
304
|
-
`-L` servers under a temp `TMUX_TMPDIR`, killed after)
|
|
304
|
+
`-L` servers under a temp `TMUX_TMPDIR`, killed after), and its `--sessions`, `--send-plan` and `--broadcast` seams
|
|
305
|
+
against a fake chi (a `/bin/sh` script as the launch file's argv). Without the variable they are filtered out (CI's runs), and
|
|
305
306
|
`script/affected-specs` doesn't pick them for a Swift change: run them by hand after editing
|
|
306
307
|
`lib/samagotchi/desktop/macos/`.
|
|
307
308
|
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// directly by chi desktop) manages the login item and exits;
|
|
6
6
|
// `ChiHelper --agents list|send` is the agent terminals' seam (AgentTerminal.swift),
|
|
7
7
|
// `ChiHelper --model-pick` the model chooser's (Models.swift),
|
|
8
|
-
// `ChiHelper --broadcast` the Everyone row's (Broadcast.swift)
|
|
8
|
+
// `ChiHelper --broadcast` the Everyone row's (Broadcast.swift),
|
|
9
|
+
// `ChiHelper --sessions` and `--send-plan` the session rows' and their
|
|
10
|
+
// sends' (Sessions.swift).
|
|
9
11
|
import AppKit
|
|
10
12
|
import ServiceManagement
|
|
11
13
|
|
|
@@ -111,6 +113,12 @@ struct ChiHelperMain {
|
|
|
111
113
|
if args.count >= 2, args[1] == "--broadcast" {
|
|
112
114
|
exit(broadcastCommand(Array(args.dropFirst(2))))
|
|
113
115
|
}
|
|
116
|
+
if args.count >= 2, args[1] == "--sessions" {
|
|
117
|
+
exit(sessionsCommand(Array(args.dropFirst(2))))
|
|
118
|
+
}
|
|
119
|
+
if args.count >= 2, args[1] == "--send-plan" {
|
|
120
|
+
exit(sendPlanCommand(Array(args.dropFirst(2))))
|
|
121
|
+
}
|
|
114
122
|
let app = NSApplication.shared
|
|
115
123
|
let delegate = AppDelegate()
|
|
116
124
|
app.delegate = delegate
|
|
@@ -36,7 +36,8 @@ enum ChiError: Error, CustomStringConvertible {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/// One session from `chi sessions list --format json`: a live one, or a
|
|
39
|
-
/// recent stopped one (listed below the live ones; a message wakes it)
|
|
39
|
+
/// recent stopped one (listed below the live ones; a message wakes it), on
|
|
40
|
+
/// this Mac or on a remote (`chi --remote NAME sessions list`).
|
|
40
41
|
struct LiveSession: Decodable, Identifiable, Equatable {
|
|
41
42
|
let id: String
|
|
42
43
|
let desc: String?
|
|
@@ -45,6 +46,9 @@ struct LiveSession: Decodable, Identifiable, Equatable {
|
|
|
45
46
|
let owner: String?
|
|
46
47
|
let updatedAt: String?
|
|
47
48
|
var recent = false
|
|
49
|
+
/// The remote it was listed on (set by the helper, never decoded); nil:
|
|
50
|
+
/// this Mac.
|
|
51
|
+
var remote: String?
|
|
48
52
|
|
|
49
53
|
enum CodingKeys: String, CodingKey {
|
|
50
54
|
case id, desc, cwd, busy, owner
|
|
@@ -54,7 +58,13 @@ struct LiveSession: Decodable, Identifiable, Equatable {
|
|
|
54
58
|
/// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
|
|
55
59
|
var valid: Bool { UUID(uuidString: id) != nil }
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
/// The row's key in the selection, ⌘ numbers and the remembered choice:
|
|
62
|
+
/// "studio:<id>" on a remote, the plain id on this Mac (so a choice
|
|
63
|
+
/// remembered before remotes still counts).
|
|
64
|
+
var key: String { remote.map { "\($0):\(id)" } ?? id }
|
|
65
|
+
|
|
66
|
+
/// A remote's home may differ from this Mac's: its path stays whole.
|
|
67
|
+
var shortCwd: String { remote == nil ? cwd.map(Self.shorten) ?? "" : cwd ?? "" }
|
|
58
68
|
|
|
59
69
|
/// A path with the home folder as "~".
|
|
60
70
|
static func shorten(_ path: String) -> String {
|
|
@@ -83,7 +93,43 @@ struct LiveSession: Decodable, Identifiable, Equatable {
|
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
/// The remotes `chi remote ls` keeps (`chi remote add`): a name goes into
|
|
97
|
+
/// argv only when it came from that answer and passes chi's own rule.
|
|
98
|
+
enum Remotes {
|
|
99
|
+
/// One row of `chi remote ls --format json`; the token itself never
|
|
100
|
+
/// leaves chi, only whether there is one.
|
|
101
|
+
struct Entry: Decodable {
|
|
102
|
+
let name: String
|
|
103
|
+
let token: Bool?
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// chi's `Remotes::NAME`: no leading "-", so never a flag.
|
|
107
|
+
static func valid(_ name: String) -> Bool {
|
|
108
|
+
name.range(of: "\\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\\z", options: .regularExpression) != nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// The names with a token (one without can't reach that chi web), in
|
|
112
|
+
/// chi's order.
|
|
113
|
+
static func names(_ entries: [Entry]) -> [String] {
|
|
114
|
+
var seen = Set<String>()
|
|
115
|
+
return entries.filter { $0.token == true && valid($0.name) && seen.insert($0.name).inserted }.map(\.name)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// A remote's part of the panel: its rows, or why there are none.
|
|
120
|
+
struct RemoteSection: Equatable {
|
|
121
|
+
let name: String
|
|
122
|
+
var sessions: [LiveSession] = []
|
|
123
|
+
/// "studio: no answer within 4 s", or the first line of chi's stderr.
|
|
124
|
+
var error: String?
|
|
125
|
+
var loading = true
|
|
126
|
+
}
|
|
127
|
+
|
|
86
128
|
final class ChiRunner {
|
|
129
|
+
/// A remote's two lists together: chi's own open timeout is 5 s, so a
|
|
130
|
+
/// down host is stopped here before chi says anything.
|
|
131
|
+
static let remoteBudget: TimeInterval = 4
|
|
132
|
+
|
|
87
133
|
static let supportDir = NSString(string: "~/Library/Application Support/Chi Helper").expandingTildeInPath
|
|
88
134
|
static let launchPath = supportDir + "/launch.json"
|
|
89
135
|
|
|
@@ -125,30 +171,75 @@ final class ChiRunner {
|
|
|
125
171
|
completion(.failure(.failed("\(error)"))); return
|
|
126
172
|
}
|
|
127
173
|
|
|
174
|
+
var env = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
|
|
175
|
+
// chi reads CHI_REMOTE for every call without --remote: one from the
|
|
176
|
+
// shell would send this Mac's rows to a remote. --remote is the only way.
|
|
177
|
+
env["CHI_REMOTE"] = nil
|
|
128
178
|
ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
|
|
129
|
-
env:
|
|
130
|
-
stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
|
|
179
|
+
env: env, stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
|
|
131
180
|
}
|
|
132
181
|
|
|
133
182
|
/// The live sessions, oldest started first, then up to +recentCount+
|
|
134
183
|
/// stopped ones, newest first. The live order is by creation so ⌘1…⌘9
|
|
135
184
|
/// stay on their sessions as turns update them; a new one comes last.
|
|
136
185
|
/// A session a chi REPL holds is in neither: it takes no notes or
|
|
137
|
-
/// messages. A failed second call just leaves "recent" empty.
|
|
138
|
-
|
|
139
|
-
|
|
186
|
+
/// messages. A failed second call just leaves "recent" empty. Calls
|
|
187
|
+
/// back on +queue+ (a headless seam waits on another one).
|
|
188
|
+
func sessions(recentCount: Int = 3, queue: DispatchQueue = .main,
|
|
189
|
+
completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
190
|
+
list(Self.liveFlags, queue: queue) { result in
|
|
140
191
|
guard case .success(let live) = result else { completion(result); return }
|
|
141
|
-
self.list(
|
|
142
|
-
|
|
143
|
-
let stopped = ((try? recent.get()) ?? [])
|
|
144
|
-
.filter { $0.owner == nil && !liveIds.contains($0.id) }
|
|
145
|
-
.prefix(recentCount)
|
|
146
|
-
.map { session -> LiveSession in var session = session; session.recent = true; return session }
|
|
147
|
-
completion(.success(live + stopped))
|
|
192
|
+
self.list(Self.recentFlags, queue: queue) { recent in
|
|
193
|
+
completion(.success(Self.rows(live: live, recent: recent, recentCount: recentCount)))
|
|
148
194
|
}
|
|
149
195
|
}
|
|
150
196
|
}
|
|
151
197
|
|
|
198
|
+
private static let liveFlags = ["--live", "--sort", "created_at", "--order", "asc"]
|
|
199
|
+
private static let recentFlags = ["--limit", "20"]
|
|
200
|
+
|
|
201
|
+
/// The live rows, then up to +recentCount+ of the recent list that are
|
|
202
|
+
/// neither live nor a REPL's; a failed recent list adds none.
|
|
203
|
+
private static func rows(live: [LiveSession], recent: Result<[LiveSession], ChiError>, recentCount: Int) -> [LiveSession] {
|
|
204
|
+
let liveIds = Set(live.map(\.id))
|
|
205
|
+
let stopped = ((try? recent.get()) ?? [])
|
|
206
|
+
.filter { $0.owner == nil && !liveIds.contains($0.id) }
|
|
207
|
+
.prefix(recentCount)
|
|
208
|
+
.map { session -> LiveSession in var session = session; session.recent = true; return session }
|
|
209
|
+
return live + stopped
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// `chi remote ls --format json`: the remotes with a token, in chi's
|
|
213
|
+
/// order; none when it fails (an older chi has no `remote`).
|
|
214
|
+
func remotes(queue: DispatchQueue = .main, completion: @escaping ([String]) -> Void) {
|
|
215
|
+
run(["remote", "ls", "--format", "json"], queue: queue) { result in
|
|
216
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut,
|
|
217
|
+
let entries = try? JSONDecoder().decode([Remotes.Entry].self, from: Data(r.stdout.utf8)) else {
|
|
218
|
+
completion([]); return
|
|
219
|
+
}
|
|
220
|
+
completion(Remotes.names(entries))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// A remote's rows as sessions(...) lists this Mac's, each tagged with
|
|
225
|
+
/// +name+. Its two lists run at once under one budget (each is a chi boot
|
|
226
|
+
/// and two requests); the failure is the panel's line for that remote.
|
|
227
|
+
func sessions(remote name: String, recentCount: Int = 3, queue: DispatchQueue = .main,
|
|
228
|
+
completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
229
|
+
var live: Result<[LiveSession], ChiError>?
|
|
230
|
+
var recent: Result<[LiveSession], ChiError>?
|
|
231
|
+
// Both answer on +queue+, one at a time.
|
|
232
|
+
let finish = {
|
|
233
|
+
guard let live, let recent else { return }
|
|
234
|
+
completion(live.map { rows in
|
|
235
|
+
Self.rows(live: rows, recent: recent, recentCount: recentCount)
|
|
236
|
+
.map { session -> LiveSession in var session = session; session.remote = name; return session }
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
list(Self.liveFlags, remote: name, queue: queue) { live = $0; finish() }
|
|
240
|
+
list(Self.recentFlags, remote: name, queue: queue) { recent = $0; finish() }
|
|
241
|
+
}
|
|
242
|
+
|
|
152
243
|
/// The model a new session starts on (`chi self --model`: the config's
|
|
153
244
|
/// default, as chi resolves it), or nil when none is configured or chi
|
|
154
245
|
/// can't say. Only a single line counts: nothing that looks like a whole
|
|
@@ -180,17 +271,38 @@ final class ChiRunner {
|
|
|
180
271
|
}
|
|
181
272
|
}
|
|
182
273
|
|
|
183
|
-
|
|
274
|
+
/// @param remote a remote's name: `--remote NAME` first, the remote
|
|
275
|
+
/// budget, and its two failure lines (no answer in time, or the first
|
|
276
|
+
/// line of chi's stderr: it names the remote and why)
|
|
277
|
+
private func list(_ flags: [String], remote: String? = nil, queue: DispatchQueue = .main,
|
|
278
|
+
completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
|
|
279
|
+
let prefix = remote.map { ["--remote", $0] } ?? []
|
|
280
|
+
let timeout = remote == nil ? self.timeout : Self.remoteBudget
|
|
184
281
|
// Every project's sessions: the helper isn't in any one project (an
|
|
185
282
|
// older chi ignores the flag).
|
|
186
|
-
run(["sessions", "list"] + flags + ["--scope=all", "--format", "json"]
|
|
283
|
+
run(prefix + ["sessions", "list"] + flags + ["--scope=all", "--format", "json"], timeout: timeout,
|
|
284
|
+
queue: queue) { result in
|
|
187
285
|
switch result {
|
|
188
286
|
case .failure(let error): completion(.failure(error))
|
|
189
287
|
case .success(let r):
|
|
190
|
-
if r.timedOut {
|
|
288
|
+
if r.timedOut {
|
|
289
|
+
let message = remote.map { "\($0): no answer within \(Int(timeout)) s" } ?? "chi sessions list took over \(Int(timeout)) s"
|
|
290
|
+
completion(.failure(.failed(message)))
|
|
291
|
+
return
|
|
292
|
+
}
|
|
191
293
|
guard r.status == 0, let data = r.stdout.data(using: .utf8),
|
|
192
294
|
let sessions = try? JSONDecoder().decode([LiveSession].self, from: data) else {
|
|
193
295
|
let detail = r.stderr.isEmpty ? r.stdout : r.stderr
|
|
296
|
+
if let remote {
|
|
297
|
+
// chi's own line names the remote and why; Ruby's warnings
|
|
298
|
+
// ("…: warning: …") before it aren't the reason.
|
|
299
|
+
let line = r.status == 0 ? nil : detail.split(whereSeparator: \.isNewline).map(String.init)
|
|
300
|
+
.first { !$0.contains("warning:") && !$0.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
301
|
+
let fallback = r.status == 0 ? "\(remote): chi sessions list answered no session list"
|
|
302
|
+
: "\(remote): chi sessions list failed (exit \(r.status))"
|
|
303
|
+
completion(.failure(.failed(line ?? fallback)))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
194
306
|
completion(.failure(.failed("chi sessions list failed (exit \(r.status)): \(detail.prefix(400))")))
|
|
195
307
|
return
|
|
196
308
|
}
|
|
@@ -88,9 +88,10 @@ enum ImageIntake {
|
|
|
88
88
|
static let sentMaxAge: TimeInterval = 7 * 86_400
|
|
89
89
|
|
|
90
90
|
/// The paths an agent paste names: an image the helper wrote is copied to
|
|
91
|
-
/// sentDir first (the
|
|
92
|
-
///
|
|
93
|
-
///
|
|
91
|
+
/// sentDir first (the helper deletes the original after the send; chi's
|
|
92
|
+
/// send copies what it reads and leaves it), with no spaces in its name;
|
|
93
|
+
/// a file of the user's is named as it is. Call before any send job
|
|
94
|
+
/// starts. An image that can't be copied is left out.
|
|
94
95
|
static func keepForPaste(_ images: [PanelImage]) -> [String] {
|
|
95
96
|
images.compactMap { image in
|
|
96
97
|
guard image.temp else { return image.url.path }
|