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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b284b838ef7182e859040f2955be576abe43fda373a9a96c094bd701ef8837c
4
- data.tar.gz: d776a399ac831dd9a62405f58f5eb9c9843b83b021f82a52361fe32d4f49aba9
3
+ metadata.gz: e85ff88270cf7569d21ff95bba739199ceb3c53d32675b1648a18363ca5482cd
4
+ data.tar.gz: d9f539982d7c41b94ad40d17368041f2debed8040ebc48a27776486fe2476dc9
5
5
  SHA512:
6
- metadata.gz: 1a590e517591c610712641c7496a916afad37502d3784669bbb58a582a5e8522246dc9c6a2bdcaad8b5fc9ad4d8c7b7bab95e52eeea012bf075af9d481e52680
7
- data.tar.gz: 4e7917cc27174b89e870f6f6d768001de752b35f332f942f7c9223fab22a0614c2bb32839e79c6038282ed5a86f994e038a4cd8b2a1a3a83b3c2f52320309827
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.123.0...HEAD
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, a link to that RubyGems release,
240
- and a button to copy `chi update`. The notice is once per version per
241
- browser: a reload or a chi web restart doesn't repeat it.
242
- `update.notify: off` disables checking; `update.check_hours` and
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
- Each call is stopped after 10 s, a send with images after 30 s, a broadcast after the launch file's
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). Without the variable they are filtered out (CI's runs), and
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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.123.0
3
+ version: 0.124.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -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
- var shortCwd: String { cwd.map(Self.shorten) ?? "" }
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: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
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
- func sessions(recentCount: Int = 3, completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
139
- list(["--live", "--sort", "created_at", "--order", "asc"]) { result in
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(["--limit", "20"]) { recent in
142
- let liveIds = Set(live.map(\.id))
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
- private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
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"]) { result in
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 { completion(.failure(.failed("chi sessions list took over \(Int(self.timeout)) s"))); return }
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 send deletes the original), with no spaces in its
92
- /// name; a file of the user's is named as it is. Call before any send
93
- /// job starts. An image that can't be copied is left out.
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 }