samagotchi 0.159.0 → 0.161.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +125 -1
  3. data/docs/architecture.md +12 -9
  4. data/docs/cli.md +7 -5
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +33 -5
  7. data/docs/internals/attached-context.md +4 -2
  8. data/docs/internals/bridge.md +28 -6
  9. data/docs/internals/bundles.md +24 -5
  10. data/docs/internals/engine.md +7 -1
  11. data/docs/internals/objectives.md +8 -2
  12. data/docs/internals/remote-sessions.md +2 -1
  13. data/docs/memory.md +43 -13
  14. data/docs/plugins.md +13 -4
  15. data/docs/releasing.md +7 -4
  16. data/docs/scheduling.md +53 -8
  17. data/docs/sessions.md +17 -12
  18. data/docs/testing.md +9 -1
  19. data/lib/samagotchi/bridge/card_store.rb +31 -3
  20. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  21. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  22. data/lib/samagotchi/bridge.rb +103 -44
  23. data/lib/samagotchi/bridge_client.rb +9 -6
  24. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  25. data/lib/samagotchi/broadcast_command.rb +24 -8
  26. data/lib/samagotchi/bundle_command.rb +57 -6
  27. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  29. data/lib/samagotchi/child_reports.rb +29 -23
  30. data/lib/samagotchi/client_id.rb +6 -0
  31. data/lib/samagotchi/command_intake.rb +56 -0
  32. data/lib/samagotchi/compat.rb +32 -0
  33. data/lib/samagotchi/context_absorber.rb +6 -3
  34. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  35. data/lib/samagotchi/context_candidates.rb +11 -8
  36. data/lib/samagotchi/context_candidates_log.rb +10 -1
  37. data/lib/samagotchi/context_command.rb +10 -4
  38. data/lib/samagotchi/context_move.rb +6 -1
  39. data/lib/samagotchi/context_poller.rb +5 -2
  40. data/lib/samagotchi/context_sources.rb +6 -4
  41. data/lib/samagotchi/engine/events.rb +25 -2
  42. data/lib/samagotchi/engine/guardrails.rb +3 -4
  43. data/lib/samagotchi/engine/plugins.rb +1 -1
  44. data/lib/samagotchi/engine/steering.rb +2 -2
  45. data/lib/samagotchi/engine.rb +1 -0
  46. data/lib/samagotchi/engine_peers.rb +1 -0
  47. data/lib/samagotchi/events.rb +71 -0
  48. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  49. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  50. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  51. data/lib/samagotchi/guardrails/targets.rb +25 -6
  52. data/lib/samagotchi/hook_routes.rb +0 -1
  53. data/lib/samagotchi/live_versions.rb +7 -2
  54. data/lib/samagotchi/memory_bundle/asset_installer.rb +86 -18
  55. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  56. data/lib/samagotchi/memory_bundle/file_actions.rb +3 -4
  57. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  58. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  59. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  60. data/lib/samagotchi/memory_bundle/installer.rb +93 -23
  61. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +7 -4
  64. data/lib/samagotchi/memory_bundle/status.rb +3 -3
  65. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +29 -13
  67. data/lib/samagotchi/memory_orphans.rb +54 -13
  68. data/lib/samagotchi/memory_paths.rb +29 -6
  69. data/lib/samagotchi/memory_usage.rb +23 -12
  70. data/lib/samagotchi/note_command.rb +9 -1
  71. data/lib/samagotchi/note_delivery.rb +34 -24
  72. data/lib/samagotchi/note_move.rb +205 -0
  73. data/lib/samagotchi/objectives_command.rb +3 -1
  74. data/lib/samagotchi/owner_lock.rb +7 -1
  75. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  76. data/lib/samagotchi/plugin/sessions.rb +4 -1
  77. data/lib/samagotchi/project_command.rb +58 -20
  78. data/lib/samagotchi/project_id.rb +102 -0
  79. data/lib/samagotchi/project_id_clash.rb +62 -0
  80. data/lib/samagotchi/project_init.rb +214 -10
  81. data/lib/samagotchi/project_scope.rb +11 -2
  82. data/lib/samagotchi/review_start.rb +5 -3
  83. data/lib/samagotchi/schedule_command.rb +12 -0
  84. data/lib/samagotchi/schedules.rb +374 -76
  85. data/lib/samagotchi/send_command.rb +2 -15
  86. data/lib/samagotchi/session.rb +59 -7
  87. data/lib/samagotchi/session_access/local.rb +53 -22
  88. data/lib/samagotchi/session_access/remote.rb +13 -5
  89. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  90. data/lib/samagotchi/session_access.rb +20 -4
  91. data/lib/samagotchi/session_commands.rb +7 -3
  92. data/lib/samagotchi/session_continue.rb +162 -24
  93. data/lib/samagotchi/session_delete_command.rb +3 -1
  94. data/lib/samagotchi/session_inbox.rb +41 -1
  95. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  96. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  97. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  98. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  99. data/lib/samagotchi/session_manager/listing.rb +4 -1
  100. data/lib/samagotchi/session_manager/removal.rb +130 -26
  101. data/lib/samagotchi/session_manager/spawn.rb +167 -100
  102. data/lib/samagotchi/session_manager/worker_command.rb +7 -19
  103. data/lib/samagotchi/session_manager.rb +1 -0
  104. data/lib/samagotchi/session_retention.rb +53 -23
  105. data/lib/samagotchi/session_setup.rb +27 -7
  106. data/lib/samagotchi/starting_marker.rb +93 -24
  107. data/lib/samagotchi/state_format.rb +129 -0
  108. data/lib/samagotchi/system_one/runs.rb +6 -1
  109. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  110. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  111. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  112. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  113. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  114. data/lib/samagotchi/terminal_ui.rb +2 -3
  115. data/lib/samagotchi/tools/context_read.rb +7 -2
  116. data/lib/samagotchi/tools/delegate.rb +19 -6
  117. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  118. data/lib/samagotchi/tools/send_note.rb +10 -4
  119. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  120. data/lib/samagotchi/turn_state.rb +108 -4
  121. data/lib/samagotchi/update_command.rb +9 -4
  122. data/lib/samagotchi/verify_fresh.rb +27 -9
  123. data/lib/samagotchi/version.rb +1 -1
  124. data/lib/samagotchi/web/app.rb +6 -5
  125. data/lib/samagotchi/web/public/app.js +2 -7
  126. data/lib/samagotchi/web/public/chains.js +4 -4
  127. data/lib/samagotchi/web/public/data.js +18 -2
  128. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  129. data/lib/samagotchi/web/public/session_fields.js +3 -2
  130. data/lib/samagotchi/web/public/session_state.js +2 -0
  131. data/lib/samagotchi/web/public/update_notice.js +2 -2
  132. data/lib/samagotchi/web/session_access_routes.rb +15 -11
  133. data/lib/samagotchi/web/session_control.rb +9 -3
  134. data/lib/samagotchi/web/session_create.rb +8 -4
  135. data/lib/samagotchi/web/session_hub.rb +50 -5
  136. data/lib/samagotchi/web/session_summary.rb +5 -2
  137. data/lib/samagotchi/worker.rb +61 -11
  138. data/lib/samagotchi/worker_boot.rb +22 -11
  139. data/lib/samagotchi/worker_inbound.rb +62 -10
  140. data/lib/samagotchi/worker_link.rb +4 -1
  141. data/lib/samagotchi/worker_presence.rb +106 -0
  142. data/lib/samagotchi/worker_sidecar.rb +3 -4
  143. metadata +9 -1
@@ -134,7 +134,8 @@ the session commands*. Keep that table in step with `SessionAccessRoutes`.
134
134
  `parent_continue_refused`, `no_vision`, exit 1) or `BadRequest` (the other `invalid_*`, `missing_fields`, `bad_images`,
135
135
  exit 2, the usage-error path). That is for the routes `raise_refusal` reads (create, uploads, …). `POST …/turn` is
136
136
  mapped in `Remote#deliver_turn`: any 4xx but 404 and `owned_by_tui` becomes a refused `DeliverResult` with the
137
- server's `ack`, which `SendCommand` prints as `<id> refused: <detail>` (`refused_word`, `REMOTE_REFUSALS`), exit 1.
137
+ server's `ack`, which `SendCommand` prints as `<id> <refused|failed>: <detail>` (`DeliverResult#refusal_text`,
138
+ whose refused codes are `DeliverResult::REMOTE_REFUSALS`), exit 1.
138
139
  A per-method mapping covers the rest (`Remote#deliver_turn`, `#note`, `#context_push`, `#context_remove`,
139
140
  `#bridge_reply` for answer/dismiss: chi web's 503 `not_live` or 404 `not_found` → nil, i.e. no worker; 504 → the
140
141
  Bridge's 408; 501 → its 404). `#context_push` maps 409 to `:not_push` and 410 `context_continued` (a continued
data/docs/memory.md CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  Samagotchi stores memories in two scopes:
4
4
 
5
- - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<name>_<hash>/`, one folder per
6
- project (named and hashed by its root): a git repository, shared by all its worktrees and
7
- subdirectories, or a folder marked by `chi project init` and its subfolders; outside both, one
8
- per working directory
5
+ - Project scope: `$XDG_CONFIG_HOME/samagotchi/memories/projects/<key>/`, one folder per project: a
6
+ git repository, shared by all its worktrees and subdirectories (`<name>_<hash>`, named and hashed
7
+ by its root), or a folder marked by `chi project init` and its subfolders (its id,
8
+ `<name>-<8 hex>`: see [Projects without git](#projects-without-git)); outside both, one per
9
+ working directory (`<name>_<hash>`)
9
10
  - System scope: `$XDG_CONFIG_HOME/samagotchi/memories`
10
11
 
11
12
  `$XDG_CONFIG_HOME` falls back to `~/.config` when unset.
@@ -13,8 +14,9 @@ Samagotchi stores memories in two scopes:
13
14
  ## Projects without git
14
15
 
15
16
  A folder in no git repository becomes a project with `chi project init [DIR]` (see
16
- [cli.md](cli.md#commands)): it writes `DIR/.chi/project.yml` (one comment line; the file, not a bare
17
- `.chi/` dir, is the marker), and DIR and every subfolder share one project memories folder, group their
17
+ [cli.md](cli.md#commands)): it writes `DIR/.chi/project.yml` (a comment line and the project's
18
+ `id:`; the file, not a bare `.chi/` dir, is the marker), and DIR and every subfolder share one
19
+ project memories folder, group their
18
20
  sessions (`chi sessions list`, the web's project picker) and take `chi context --project`. Nothing
19
21
  chi-specific has to be committed anywhere: a repository never needs the file. It can also be created
20
22
  from chi web's project picker (a folder inside a git repository opens that repository's project, and
@@ -26,10 +28,36 @@ the folder is left as is: it is created when missing, nothing is marked).
26
28
  at `~` puts every folder under home in that repository, so markers there never apply.
27
29
  - **Not at home.** A marker in `$HOME`, in a folder above it, or in `/` is ignored (`chi project init`
28
30
  refuses them), so a marker in `~` never makes every folder under home one project.
29
- - **The key.** The marked root is the folder's real path (as git's root is), so a folder chi already ran
30
- in at its top keeps its memories after `init`, and a later `git init` in the same folder keeps the same
31
- key. Memories saved from a *subfolder* before `init` stay in that subfolder's own folder: move them by
32
- hand if you want them in the project's.
31
+ - **The key is the id.** A marked project's memories folder and its project context sources
32
+ (`chi context --project`) are named by the marker's `id:` (`<folder name>-<8 random hex>`), not
33
+ by its path, so moving or renaming the folder keeps them: a session started there after the move
34
+ finds them, and one saved before it (its file stores `project_id`) keys its context sources by the
35
+ id too. A folder chi already ran in at its top keeps its memories after `init`: they move to the
36
+ id. A later `git init` in the folder makes it a repository, keyed by its path again (git wins): its
37
+ memories stay in the id folder until you move them, and chi names the id folder at session start
38
+ (and under a `memory_read` miss) while the repository's own folder holds no memories. Memories saved from a *subfolder* before
39
+ `init` stay in that subfolder's own folder: move them by hand if you want them in the project's.
40
+ A hand-edited id must be letters, digits, `_`, `.` or `-` (up to 64, a letter or digit first);
41
+ anything else (or a broken file) is ignored and the project keyed by its path (a debug log line
42
+ says why, `chi project init` reports it).
43
+ - **Older markers adopt an id** when you rerun `chi project init` in the folder: the folders keyed
44
+ by its path (memories, project context sources, the skills bundle's kept versions) move to the id,
45
+ each leaving a link by its old name (an older chi, keying by path, finds the same folder), then the
46
+ id is written. It refuses while a session of the project runs (stop it first, so nothing writes to
47
+ the old folder mid-move), and names memories an earlier key left (the same folder name with
48
+ another path's hash: moved before it had an id, or a `chi project init` id folder,
49
+ `<basename>-<8 hex>`, left behind when `git init` turns that marked folder into a repository),
50
+ merging nothing. A link stays behind: a new
51
+ folder (or git repository) made later at the old path reaches the moved project's memories
52
+ through it; delete the link to stop that. chi web's project picker marks a new folder with an id
53
+ but never moves folders: a folder it marks that already has path-keyed memories gets no id until
54
+ `chi project init`.
55
+ - **A copy shares memories.** A copied marked folder (`cp -R`) keeps the id, so the two share one
56
+ memories folder; at session start in the copy chi says so (a `memory>` load warning: "… share chi
57
+ project id … (a copied folder?)"; the id folder's `.root` names the folder that started a session
58
+ with it last, the original keeps it). `chi project init --new-id` in the copy gives it a fresh id,
59
+ with a copy of the memories and project context sources it had: each folder goes its own way.
60
+ After a move, the new path just takes `.root` over, with no warning.
33
61
  - **Restart what runs there.** A session running in the folder when it is marked keeps the old memories
34
62
  folder and AGENTS.md chain in its prompt until `chi sessions restart ID` (init names them); a running
35
63
  `chi web` groups the folder's older sessions under the project after its restart.
@@ -52,13 +80,15 @@ Tool behavior:
52
80
 
53
81
  ## Moving memories to another machine
54
82
 
55
- A project's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
83
+ A repository's folder is keyed by its root's absolute path (`<basename>_<8 hex of MD5(root)>`), so project
56
84
  memories copied to a machine (or a path) where the repository lives elsewhere sit in a folder chi no
57
85
  longer reads. Rename the folder to the new key, or move memories with a bundle zip (see
58
- [What a bundle owns](#what-a-bundle-owns)).
86
+ [What a bundle owns](#what-a-bundle-owns)). A marked project with an id needs neither: copy its id
87
+ folder along with the marked folder.
59
88
 
60
89
  Inside a project (a git repository or a marked folder), when this project's folder is missing or holds no `*.md` while the projects
61
- dir has a folder of the same basename with another hash, chi says so, naming both folders and the
90
+ dir has a folder of the same basename with another hash (`<basename>_<hash>`, or a marked project's id folder
91
+ `<basename>-<8 hex>`; links, an adopted project's old names, are skipped), chi says so, naming both folders and the
62
92
  `mv` that fixes it (a name match can't prove it's the same project, so nothing is renamed for you):
63
93
 
64
94
  - at session start, a `memory>` load warning (a `:guardrail_warning` with label `memory`; the attached
data/docs/plugins.md CHANGED
@@ -264,7 +264,7 @@ run by `call_tool` as the tool itself ([the chi group](#chitool_sourcecall-acts_
264
264
  it raises, the default is shown. Both are saved with the call's result, so
265
265
  a web page reloaded later shows the same row: the web server doesn't run
266
266
  plugins.
267
- - `targets`: `->(args) { { paths: [...], command: "…", cwd: "…", acts_as: "…", args: {…}, label: "…" } }`,
267
+ - `targets`: `->(args) { { paths: [...], writes: true, command: "…", cwd: "…", acts_as: "…", args: {…}, label: "…" } }`,
268
268
  each key optional, says what a call acts on, for [guardrails](#guardrails).
269
269
  - `refs`: `->(args) { [{ kind:, target:, label:, actions: [] }, …] }`, what the call's row traces to in the web
270
270
  (display only, the model never sees them). See [Refs on a tool's row](#refs-on-a-tools-row).
@@ -335,8 +335,15 @@ is what `targets:` says:
335
335
  - `paths:`: files the call reads or writes, absolute or relative to `cwd:`
336
336
  (else the session's directory). `path:` globs, `outside_repo` (measured
337
337
  from the session's repo, whatever the call's `cwd:`) and the protected
338
- paths (chi's config, …) match them.
339
- - `command:`: a shell command the call runs; `command:` rules match it.
338
+ paths (chi's config, …) match them. A call that writes them adds
339
+ `writes: true` (what `write` and `edit` carry): the protected paths and a
340
+ delegate child's boundary then judge its paths as they do a write's. Say
341
+ it when the call writes, never when it only reads (a read's paths are
342
+ `path:` rule material, not a write); a tool without it is judged as
343
+ today.
344
+ - `command:`: a shell command the call runs; `command:` rules match it, and
345
+ a delegate child's boundary checks where it runs and whether it changes
346
+ what every checkout shares (as for `execute`).
340
347
  - `cwd:`: where it runs, for the repo root and relative paths.
341
348
  - `acts_as:`: the tool a call stands for, when the tool runs other tools
342
349
  (chi's `call_tool`, which calls a tool a [source](#chitool_sourcecall-acts_as-label-wait-prefix) published). Rules keyed by that tool's name match the
@@ -1689,7 +1696,9 @@ gem.
1689
1696
 
1690
1697
  - `chi bundle install <dir>`: copies the plugin to
1691
1698
  `<memories>/.bundles/<name>/plugin/`. It records the plugin's sha256 and
1692
- `requires_chi`, and warns about a wrong declared sha256. A bundle whose
1699
+ `requires_chi`, and refuses a plugin or script whose bytes disagree with its
1700
+ declared sha256 (`--force` installs it anyway, with the `Checksum mismatch`
1701
+ warning; an automatic `chi update` of a shipped bundle only warns). A bundle whose
1693
1702
  `requires_chi` this chi doesn't meet is **refused**: `Install failed: NAME
1694
1703
  can't be installed: it requires chi >= X (this is chi Y)`, exit 1, nothing
1695
1704
  written. `--force` installs it anyway, with the warnings that its plugin,
data/docs/releasing.md CHANGED
@@ -76,7 +76,7 @@ as the GitHub release body.
76
76
  | `rake lint` / `rake lint:fix` | RuboCop over the repo (must report no offenses; CI runs it) / its safe autocorrect (`rubocop -a`). |
77
77
  | `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
78
78
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
79
- | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line, and each entry an `# Unreleased` comment in `Plugin::Api::SINCE` or `Manifest::SINCE` names moved into the table at X.Y.Z (the comment dropped). No commit. |
79
+ | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, a compatibility corpus at `spec/compat/X.Y.Z/` written through the current state writers, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line, and each entry an `# Unreleased` comment in `Plugin::Api::SINCE` or `Manifest::SINCE` names moved into the table at X.Y.Z (the comment dropped). No commit; commit the corpus with the release. |
80
80
  | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE` or `Manifest::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
81
81
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
82
82
 
@@ -97,9 +97,12 @@ The agent does each step and stops where the user has to say yes.
97
97
  markers are gone) and the profiles it raised with them (a patch bump of
98
98
  each one's version, none for a profile already bumped since the last tag),
99
99
  which it names in the CHANGELOG's "Update with `chi update`" line, and the
100
- SINCE entries it moved out of their `# Unreleased` comments.
101
- 5. **Check.** Commit first (the check wants a clean tree), then run it:
102
- `git commit -am "Release X.Y.Z"` and `bundle exec rake release:check`.
100
+ SINCE entries it moved out of their `# Unreleased` comments. It also records
101
+ the release's session, bridge, cards, schedules and bundle state under
102
+ `spec/compat/X.Y.Z/`; commit those samples with the release so the next
103
+ release's reader spec checks them as N-1 data.
104
+ 5. **Check.** Add all release files, including the new compatibility corpus, then run it:
105
+ `git add -A && git commit -m "Release X.Y.Z"` and `bundle exec rake release:check`.
103
106
  Fix anything it finds in new commits and run it again.
104
107
  6. **Push main.** `git push origin main`; wait for CI to go green.
105
108
  7. **Tag and push the tag.**
data/docs/scheduling.md CHANGED
@@ -7,6 +7,12 @@ worker's memory, so it outlives every session, every restart and every crash:
7
7
  chi web's hub can never lose an update to each other). A **tick** — cron, systemd,
8
8
  launchd, `chi schedule tick`, or chi web's hub when `schedule.auto_tick` is on —
9
9
  walks the entries whose `next_due` has passed and runs the ones that are not busy.
10
+ The top-level `last_tick_at` records the last acquired, non-dry-run tick.
11
+
12
+ The file is versioned: each entry member is required or declares the format
13
+ version and default that introduced it. Rewrites preserve unknown keys and
14
+ entries missing required members; those entries are warned about and ignored by
15
+ `list` and `tick`, never erased. A write never lowers the file's version.
10
16
 
11
17
  Each entry carries `id`, an optional `title`, `anchor` (the session it was made
12
18
  against, never rewritten: the chain's root and the id you remember), `head` (the
@@ -30,9 +36,17 @@ depends on whether the worker has to be running.
30
36
  | Longest interval | 1440 minutes | unlimited (`every`, `weekday`, `date`) |
31
37
  | What it produces | a turn in the same session | a new chain link, or a queued turn in the same session |
32
38
 
33
- A schedule turn carries `client_id: "system:schedule:<id>"`, so `ClientId.origin`
34
- says `schedule` and the terminal and the web label it as such rather than as
35
- something you typed. Like `register_reminder`, the schedule tools are owner-voiced:
39
+ A schedule turn carries `client_id: "system:schedule:<id>"` in both modes (a chain
40
+ link's first turn through `Session#first_turn_client_id`, which `Schedules#fire_chain`
41
+ passes to `SessionContinue.run`), so `ClientId.origin` says `schedule` and the terminal
42
+ and the web label it as such rather than as something you typed. For the same reason a
43
+ schedule's prompt is never a session command: a `/word` prompt reaches the model as
44
+ text, and one that starts with `!` is refused by `chi schedule add` and `schedule_task`
45
+ ([guardrails.md, Who may run a session command](guardrails.md#who-may-run-a-session-command)).
46
+ An entry stored with one before that fails when it fires (toward its `max_failures`),
47
+ before anything is started or delivered. A chi web
48
+ (or cron tick) still running an older chi starts chain links without the origin, so
49
+ restart it after updating. Like `register_reminder`, the schedule tools are owner-voiced:
36
50
  a participant's review or pair turn is denied them ([guardrails.md](guardrails.md)).
37
51
 
38
52
  ## The two modes
@@ -91,7 +105,10 @@ A cron tick cannot read a worker's memory, so the tick has two paths.
91
105
 
92
106
  **No live worker** (the usual case for a weekly schedule): the files say what can
93
107
  be known — the saved status is not `running` (and not inside the starting grace),
94
- `input/` is empty, no step-limit question is open. Nothing else can be busy.
108
+ `input/` is empty, no step-limit question is open, and, for chain mode, no worker
109
+ is being started for it (the `<id>/starting` marker every spawn writes: another
110
+ ticker's firing that just continued the chain; its archive refuses it too, a
111
+ skip). Nothing else can be busy.
95
112
 
96
113
  **A live worker**: ask it, over the new Bridge route
97
114
  `GET /session/:id/activity` (`Worker#activity_snapshot`, the same signals
@@ -149,6 +166,16 @@ both counters**, so twelve skips spread over months do not kill a healthy weekly
149
166
  schedule. `list` prints the skip budget as a time window — 12 skips at a 5 minute
150
167
  tick is about an hour of patience, which is what the count really measures.
151
168
 
169
+ An accepted delivery is **pending**, not fired: `schedules.json` records its
170
+ session and turn id, and the next tick follows that turn's own outcome, even if a
171
+ later turn has replaced `last_turn`. A completed turn is a success; a failed or
172
+ canceled turn counts toward `max_failures`, with its reason in `chi schedule list`
173
+ and `show`. A question does not time out with the 40-second firing step: the turn
174
+ stays pending until its hard limit, derived from the schedule interval (at least
175
+ 10 minutes and at most 6 hours). If no outcome is known by then, it counts as a
176
+ failure with reason `no outcome`. Failed, canceled and expired turns are not
177
+ re-fired; `next_due` remains at the next occurrence.
178
+
152
179
  A chain holds at most `schedule.max_per_session` enabled schedules (default 10) and
153
180
  the machine `schedule.max_total` (default 50); `schedule_task` and `chi schedule add`
154
181
  refuse past them, naming the key. A schedule the chain already has — the same prompt
@@ -162,7 +189,13 @@ is refused with exit 2, naming the matches.
162
189
  The tick's lock covers the file only, never the action: the clock advances before
163
190
  the work starts — for `every` and `weekday` to the next occurrence, for a `date`
164
191
  one-off to none — so a tick killed mid-continue cannot fire the same entry twice.
165
- `firing_since` is what `chi schedule show` prints; the advanced clock is the guard.
192
+ If a later tick finds a firing older than 40 seconds without an accepted turn, it
193
+ counts an `interrupted` failure. This 40-second limit covers only the firing
194
+ step, not an accepted turn or its pending question. The action may already have
195
+ taken effect before the process died, so that occurrence is not replayed; a
196
+ `date` one-off stays disabled with the failure visible, and recurring schedules
197
+ keep their advanced clock. An accepted turn is tracked separately until its own
198
+ outcome is known or its hard limit is reached.
166
199
  And a `chi schedule add` from a terminal never waits on an archive or a recap wait. Chain mode targets `last:<head>`, so `SessionContinue.run`'s
167
200
  own retry loop follows a continue a human made between our resolve and its lock
168
201
  instead of racing it.
@@ -191,6 +224,11 @@ pruned" instead of linking to a deleted session.
191
224
  If a link to prune still has a worker (`DeleteRefused(:worker_running)`), pruning
192
225
  stops there for this firing and is retried at the next one.
193
226
 
227
+ Retention keeps the stored `anchor` and `head`, as well as the resolved target,
228
+ of every enabled schedule. Deleting a protected session is refused—even with
229
+ `chi sessions delete --force`—until the schedule is cancelled with
230
+ `chi schedule cancel ID`; a disabled schedule does not protect its sessions.
231
+
194
232
  ## Running the tick
195
233
 
196
234
  `chi schedule tick` is quiet and safe to run often; it is this machine only
@@ -207,7 +245,9 @@ Run it from systemd or launchd instead if you prefer a timer: the command is the
207
245
  same, `chi schedule tick`, and the lock makes overlapping runs harmless — the
208
246
  second one exits 0 with "no schedules due (another tick holds the lock)".
209
247
  `--dry-run` prints what would fire and changes nothing; `--format json` is the seam
210
- for scripts and for a future web UI.
248
+ for scripts and for a future web UI. `chi schedule run-now` reserves the entry
249
+ under the same lock before starting its action, so it cannot race a tick into a
250
+ duplicate firing.
211
251
 
212
252
  A tick fires **one entry per chain**: of the due entries of one chain, the one due
213
253
  earliest (`next_due`, then id) fires and the rest are skipped — "the chain just
@@ -221,8 +261,13 @@ they queue a turn, they do not archive.
221
261
 
222
262
  `schedule.auto_tick: true` makes chi web's hub tick on
223
263
  `schedule.tick_interval_minutes` (default 5) as well, sharing the same lock, so
224
- cron and the hub can coexist without firing twice. It is off by default: cron or
225
- systemd is the documented driver.
264
+ cron and the hub can coexist without firing twice. The hub runs its tick outside
265
+ the session scan loop and allows only one hub tick at a time, so a firing waiting
266
+ on a worker does not stall session updates. It is off by default: cron or systemd
267
+ is the documented driver. When it is off and no tick has run within twice
268
+ the configured interval, `chi schedule add`, `chi schedule list` and `schedule_task`
269
+ warn to turn `schedule.auto_tick` on or run `chi schedule tick` from cron; `--dry-run`
270
+ and lock-held attempts do not refresh `last_tick_at`.
226
271
 
227
272
  See [cli.md](cli.md) for the subcommands and flags, and
228
273
  [configuration.md](configuration.md) for the `schedule.*` settings.
data/docs/sessions.md CHANGED
@@ -21,9 +21,9 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
21
21
  | `SAMAGOTCHI_SESSION_KEEP_STATUS` | (none) | CSV of statuses never auto-pruned |
22
22
  | `SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS` | `24` | Throttle lazy sweep |
23
23
 
24
- A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
24
+ A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes its `*.json` and sidecar dir under the deleting owner lock, along with its plugin state, attached context, participant grants, System 1 runs and context-candidate records. Explicit delete and retention use the same cleanup path. Set both `DAYS=0` and `MAX=0` to retain forever.
25
25
 
26
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `WorkerBoot.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`WorkerBoot.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
26
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `WorkerBoot.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`WorkerBoot.run_session_loop`, log `restart_dropped`). So does one that lands as it leaves any other way (an idle exit, `/exit`, a signal) with input queued or delegate reports waiting: no new worker un-stops it (log `respawn_dropped`), and what waits stays for the next start. A send after the stop still un-stops it, as a send always does: one that reaches a leaving worker clears the stop, so the leave's new worker runs it (log `send_unstopped_leaving`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
27
27
 
28
28
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
29
29
 
@@ -39,9 +39,9 @@ A new session field is one `Session::FIELDS` row, plus a `SessionSetup` member w
39
39
 
40
40
  **A worker killed mid-turn:** while a turn runs, its worker keeps it on disk (`TurnCheckpoint`): the session file has the turn so far (the prompt from the turn's start, then the finished tool steps and lines merged into it) and `interrupted_turn: {id, started_at, at, iteration, enqueued_id}` (`enqueued_id`: the input the turn runs, so any process can tell which turn is running; a continue turn carries its offer turn's), written as the turn starts, at the loop's first iteration boundary and then at most every 10 s (`updated_at` moves with it). The turn's end saves the real messages without the mark. A worker that died without ending its turn (SIGKILL, a crash of the process) leaves the mark, and loses what came after its last written boundary: at most the last 10 s of steps, plus the step in flight. Each checkpoint write also saves the turn's metrics so far to `analytics.json` (tokens, speeds, timings, its finished tool calls), as the record a killed turn leaves: canceled, `cancellation_reason: worker_stopped`; the turn's own end writes over it. The next worker to start for the session (a `chi send`, `--resume`, `--attach`, the web), or a `chi --resume ID -p TEXT --non-interactive` run, settles the turn before anything else (`TurnCheckpoint.settle`, log `interrupted_turn_settled`): a note `the previous turn was cancelled (worker-stopped) after Ns` goes at the tail, the status goes idle and the mark goes; `last_turn` stays the previous turn's (a new one saved before the turn a `chi send --wait` woke the worker for would end that wait at once); the dead turn's end is recorded under its `enqueued_id` instead, in `<id>/input/settled/<enqueued_id>` (a `last_turn` record: canceled, `cancel_reason: worker_stopped`), which only a wait for that very turn reads (`chi send --queue --wait`, a `chi send --wait -m` to an idle session or `--new`, `chi send --wait ID --turn`, a continue's wait): it ends `canceled` with `cancel_reason: worker_stopped`, detail "its worker exited mid-turn", instead of timing out. A wake or reminder turn's settle is recorded under its worker-minted `wake-…` id the same way, and a message merged into a turn as a steer follows that turn's id (`input/merged/`). A wait with no turn id that saw the turn running ends the same way when the settle comes (`chi send --wait ID` with no message on a turn with no id, such as a 0.156 worker's reminder turn; a steer merged by a 0.156 worker), unless an input still waits or was claimed since: an unmerged message runs as its own turn next, and that turn's end is the answer. A reply the turn wrote before its worker died is still its answer. The session shows the turn's prompt and steps with that note; the dead turn is not run again, and the model reads on its next turn that it was cut.
41
41
 
42
- **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an empty `<id>/stopped` file, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's ⚙ panel, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
42
+ **Stopping a worker:** `chi sessions stop ID` marks the session stopped (an `<id>/stopped` file holding when, `{"at"}`, not a session.json field, which a worker saving at that moment would write over; a resume removes it), sends its worker TERM, then CONT (a stopped worker, SIGSTOP or Ctrl-Z, runs its TERM handler only once continued, so it leaves at once instead of outliving the wait), and waits (up to 10 s) until the worker has let go of `owner.lock`. A running turn is canceled over the Bridge first, with reason `session_stopped` (the turn's line ends "(session stopped)", its note says `cancelled (session-stopped)`, and a command it was running says "stopped as the session was stopped"; a bare signal to the worker makes it "stopped by a signal to chi"; an older worker takes the reason as `manual`); the TERM stops whatever still runs as a Stop with reason `signal` and saves it (a spawned worker traps TERM, HUP and INT: `WorkerSignals`; a turn not ended within 3 s, 0.5 s after a stop or a second signal, is cut and the session saved as it was), and a stopped worker still saves its kept turn, since the marker, not the saved status, says stopped. Any TERM, HUP or INT does the same to a worker outside a stop: it leaves (log `worker stop reason=signal`) and the next send wakes a new one, so a `chi --resume ID` right after spawns a fresh worker. That is also how to restart a worker still running an older chi, which the attached terminal reports when a command or a question dismiss gets a 404, and the web when an answer, a dismiss, a command or a cancel does (`501 not_supported`; `BridgeClient.stale_worker_message`). A command this chi has that the older worker doesn't (`/llm-context`, the web's ⚙ panel, against a 0.39.0 worker) says the same, not `not a session command` (`SessionCommands.builtin_name`); a typo keeps its `400 unknown_command`. The web's `POST /api/sessions/:id/stop` waits the same way, for 2 s (the page's Stop shows the stop at once), or for the body's `wait` up to the same 10 s (`SessionManager::STOP_WAIT`): `chi --remote NAME sessions stop` sends it, so it waits as long as here. Before that stop, the page's `stop session` button asks what the worker still has that the user can stop: `GET /api/sessions/:id/holds` (owner-only) answers the worker's `holds` — the same list the exit ask's 409 carries — always an array, empty when there is no live worker, one from before the route, or one that did not answer. With something, a sheet (a bottom sheet on a phone, a popover at the button otherwise — `stop_sheet.js`) lists them, at most three of a kind named and `+N more`, and offers **Stop all** (`POST /api/sessions/:id/holds/stop` → `HoldStop#stop_all`, then the stop), **Stop session only** (the stop exactly as it was) or **Cancel**. Nothing to ask about is the plain confirm, one tap as before. Both buttons stay because a stop only TERMs the worker: its reminders die with it (a stop also deletes a restart's `reminders.json` no successor took yet, so a later resume doesn't bring them back), while a background task and a delegate are their own processes and keep running. A command sent during a turn that waits for its end (`/model X`, `!cmd`; see *Typing during a turn* in [cli.md](cli.md)) and hasn't run when the worker leaves (a stop, a crash) is answered `command_ran {status: "dropped"}`, so no UI keeps showing it waiting.
43
43
 
44
- **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a one-turn `chi --non-interactive` run has (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web page always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. The web's route is `DELETE /api/sessions/:id?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
44
+ **Deleting a session:** `chi sessions delete ID...`, `/exit --delete` in a terminal and the Web UI's delete all go through `SessionManager.delete_session`: it resolves a unique prefix, removes `<id>.json` and the whole `<id>/` dir, and returns what it removed. A session a one-turn `chi --non-interactive` run has (`owner.lock` kind `tui`) is always refused. One a worker runs is refused unless the caller asks to stop it: then it stops the worker as `chi sessions stop` does and deletes once `owner.lock` is free (`--force` on the CLI, 10 s; the web page always, 2 s; `/exit --delete` after the worker agreed to exit, 10 s). A worker that outlives the wait leaves the session in place. A session whose worker is being started (the `<id>/starting` marker) is refused or stopped the same way, and the files go while the delete holds `owner.lock`, so a worker can't take the session in the middle; a worker that still had it loaded doesn't write it back. The web's route is `DELETE /api/sessions/:id?force=true` (the page sends `force`; without it a live worker's session is 409 `worker_running`, as the CLI without `--force`; 200 `{status: "deleted", session_id, stopped}`; 409 `owned_by_tui` or `still_stopping`; 404).
45
45
 
46
46
  <a id="archiving-a-session"></a>**Archiving a session:** `chi sessions archive ID...`, `/archive` in a terminal and the Web UI's `archive` go through `SessionManager.archive_session`: it writes `<id>/archived` (`{"archived_at": …}`; not a session.json field, which every save rewrites) for the session and its delegated children. `Session.list` leaves archived sessions out unless `include_archived:`, and everything built on it follows: `chi sessions list` (`--archived` shows them, `[archived]`), the summaries (`--live`, json/tsv, the desktop panel, the agent's `list_sessions`) and the retention prune, which neither deletes nor counts them against `max_count`. `children_of` (max_children, `delegate_result`) still sees them, and `--resume`/`--attach ID`, `chi send`/`chi note ID` reach them as before. Refused: a turn running in the session or in one of its children (naming the child; 409 `busy` on the web), a prompt queued in `input/` (it would run on the next resume) or a live worker's step-limit question still open, in it or a child (409 `busy` too), a one-turn `chi --non-interactive` run owning it (`owned_by_tui`), a `chi scratch` session (`scratch`). A live idle worker is stopped first, and the marker is written even if it is still shutting down. Input a human typed brings a session back (`{"unarchived_at": …}`): a prompt from the web, an attached terminal or `chi send` (origin `web:*`, `tui:*`, `cli:send`, none), steering merged into a running turn, a `/continue` answer or a question answered (the step-limit question too); a delegate's follow-up, a plugin's turn, a reminder, the idle recap, a note and just opening it don't. A child's input brings only the child back. The retention sweep ages an unarchived session from when it was unarchived, so unarchiving an old session doesn't let the next sweep take it. `/archive` leaves the session first, as `/exit` does, then archives it: in an attached terminal the worker is asked to exit (one that stays up for another UI is stopped by the archive; a running turn, a queued prompt or an open step-limit question refuses it), and an empty session is discarded instead; `chi scratch` refuses it. Typed while a turn runs, `/archive` waits for the turn's end (an attached terminal for the commands queued behind it too) and then archives. `chi sessions unarchive ID...` and the web's `unarchive` bring back the whole family. Web routes: `POST /api/sessions/:id/archive` (200 `{status, session_id, archived, stopped, discarded}`, ids) and `POST /api/sessions/:id/unarchive` (200 `{status, session_id, unarchived}`). The session hub and `GET /api/sessions` keep archived sessions, `archived: true` in their summaries: the page leaves them out of the strip, its count and the list at render, and the `archived` toggle beside the all-sessions search shows them, dimmed with an `archived` badge. The info bar marks a session whose live worker runs an older chi than the newest installed (`worker_version` in its summary): `chi 0.18.1 · Restart`, which asks to confirm and calls `POST /api/sessions/:id/restart` (a refusal's reason shows in a toast); a worker from before restarts shows `chi sessions stop ID` instead. The info bar reads `continue → · archive · stop · delete` (`unarchive` on an archived one; no `continue →` on a link continued already, see *Session chains*; `continue →` is a plain button like `archive`, in the accent on an archived session, where it is the thing to do); archiving the open session stays on it, with an Undo toast.
47
47
 
@@ -110,12 +110,12 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
110
110
 
111
111
  - New sessions set `test_run:true` when `SAMAGOTCHI_ENV=test` or `RACK_ENV=test` or `CI` is set (explicit flag, `metadata_version` 2). Old sessions without the flag load as `test_run:false`.
112
112
  - Test runs are tagged and obey the same retention. `chi sessions clean` deletes every test session whatever its age (`--days N`: only those older than N days); a live worker or a `keep_status` status still keeps one. `chi sessions prune --test-only` applies the usual age and count rules to test sessions only.
113
- - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it and its file is 30 s old (`SessionRetention::SCRATCH_START_GRACE`, the worker start grace: `chi scratch` saves the session before the worker it spawns takes the owner lock). `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
113
+ - A `chi scratch` session (`"scratch": true`) is deleted when its terminal leaves (its worker deletes it too once its last client is gone); one left behind when both died is deleted by the next sweep, `prune` or `clean`, whatever its age or status, once nobody owns it and no worker is being started for it (the `<id>/starting` marker every spawn writes, before `chi scratch`'s file is saved, until its worker holds the owner lock). `list` shows it as `[scratch]` (every text form; `scratch: true` in `--format json`); `chi web` never shows one. See [CLI: Scratch sessions](cli.md#scratch-sessions).
114
114
  - For ad-hoc manual QA use `SAMAGOTCHI_ENV=test XDG_STATE_HOME=/tmp/chi-test-$USER chi ...` to isolate from real state; the flag also marks sessions that `chi web` or an attached `chi` spawn (their workers inherit the environment), so `clean` finds them if they land in the real state.
115
115
 
116
116
  ## Session file
117
117
 
118
- `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 41 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
118
+ `Session::FIELDS` is the table of `<uuid>.json`, in the order `Session#save` writes it (`Session.new`, `.from_h` and `#to_h` all go by it; 43 keys). Each key is one `SessionField` row (`session_field.rb`): its default for a file without it, how a value is cast when read and what the file gets, the codec being its type's own (`Toolkit.from_file`, `SessionReview.of`, `PromptNote.list`, `#to_file`…); the typed fields' writers cast through their row, and `#to_h` reads each field's instance variable, not its reader. A key a file lacks reads as the default below, a key present as `null` as `null` (then cast: `test_run` false, `toolkit_origin` chosen); `review`, `first_turn_id`, `first_turn_client_id`, `verdicts` and `verify_fresh` are written only when set (`OPTIONAL_FIELDS`). A key this chi doesn't know is kept and written back as it was (see the rule above). `thinking`, `reasoning_history` and `llm_context` follow one Unread rule (`SessionField.level`): a value this chi can't read (a newer chi's level, a hand edit) reads as none and is kept and written back as it was, until the session sets its own; `llm_context` keeps the parts it can't read under the ones it can. So does `toolkit_origin`: an unknown one (a String) reads as `chosen` and is written back as it was, until the session sets its own. Where the file and the session's dir are, and which ids name a session, is `SessionPaths` (`session_paths.rb`, a leaf that `Session` extends: `Session.session_dir` is `SessionPaths.session_dir`). The bytes a save writes for each field and on each path that makes or rewrites a file are pinned in `spec/session_file_goldens_spec.rb`, with what a fork, a continue, a delegate child and a respawn keep of their source (`carry_matrix.json`). "Who writes it": the Engine runs in the session's worker (or a `--non-interactive` run); the rest are named.
119
119
 
120
120
  | Key | Meaning | Written by |
121
121
  |-----|---------|------------|
@@ -136,7 +136,8 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
136
136
  | `pending_question` | The question the worker waits on (`PendingQuestion`), `null` with none. | `QuestionDesk` |
137
137
  | `used_memory_names` | The memories the session read. | the Engine |
138
138
  | `project_root` | The project the session started in, stored because a worktree may be deleted later; `null` outside a repository or a marked folder (looked up again on read). | creation |
139
- | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt. | creation |
139
+ | `project_id` | A marked project's id (its `.chi/project.yml`'s `id:`), saved beside `project_root`: the session's project context sources are keyed by it, so they follow a moved folder. Absent for any other project. | creation |
140
+ | `preloaded_memory_names`, `muted_memory_names` | `--memory` and `--mute`, written before the worker starts so a respawn builds the same prompt; copied by a fork and a continue. A fork drops the reserved `system/delegated` memory so its `parent_id` does not make it a delegate; a continuation of a delegate child drops it because the new link is not a delegate. | creation |
140
141
  | `prompt_notes` | The model notes the system prompt carried at its last build (`[{name, scope, chars, digest}]`, see [Memory](memory.md#model-notes)). | the Engine, at each prompt build |
141
142
  | `parent_id` | The session that delegated this one or forked it (`ctx.sessions.fork`); `null` otherwise. See [Delegating](#delegating). | `SessionManager.spawn_session`; `ChildMove` writes a `parent.json` beside it instead of this file |
142
143
  | `scratch` | A `chi scratch` session, deleted when its terminal leaves. | creation |
@@ -156,6 +157,7 @@ chi sessions clean --dry-run --days 7 # test sessions older than 7 da
156
157
  | `working_set_pins` | The working-set items the user pinned (`/working-set pin`); kept by a respawn, a continue and a fork. | the Engine's command |
157
158
  | `review` | A review session's subjects and source (`SessionReview`), see [Review sessions](#review-sessions). | `ReviewStart` |
158
159
  | `first_turn_id` | The id the creating prompt's turn gets, so a wait can name it (`chi send --new --wait`, `--wait --turn T ID`); `null` for a session created idle. | `SessionManager.spawn_session` |
160
+ | `first_turn_client_id` | Who sent the creating prompt when no person did (`delegate:<parent8>` for a delegate's task, `system:schedule:<id>` for a chain schedule's link): that first turn's origin, so its line is no command of the owner's (docs/guardrails.md, Who may run a session command) and `last_turn` names the sender. Written only when set; not carried by a fork, a continue or a delegate. An older chi keeps it but runs the first prompt as a person's. | `SessionManager.spawn_session` (its `client_id:`) |
159
161
  | `verify_fresh` | A Verify fresh child's requester, rules and claim (`VerifyFreshOrigin`). | `VerifyFresh.start`, once |
160
162
 
161
163
  ## Context notes
@@ -169,6 +171,7 @@ pbpaste | chi note --source slack 3f2a 8c1d
169
171
 
170
172
  - The text comes from `-m` or stdin (a terminal on stdin is a usage error, not a wait). It is stripped; an empty note or one over 16 KiB is refused, never cut. `--source` (default `cli`) names where it came from. `--all` is every live session.
171
173
  - It lands in `<uuid>/notes/`, apart from `input/`, so nothing that runs turns sees it. A live worker adds it to the conversation within a few seconds, between turns: one sent during a turn waits for that turn to end. A session with no worker keeps it until a worker next starts (a prompt, `--attach`), which adds it before anything else. A session a one-turn `chi --non-interactive` run has refuses notes. `chi note` prints one line per session: queued, waits for the next start (with the queue count), or refused.
174
+ - A note to a link that was **continued** (archived, with a later link) goes to its chain's newest link, as `send_note`'s and `chi broadcast`'s do (`chi broadcast` gives two links of one chain the note once, logged under the newest): `chi note` prints `3f2a1c0d → 4b7e9a01 (continued) queued: …` (and `… (continued) refused: …` when that link refuses it), and chi web's notes answer names that link as `id` beside `session_id`, in a 409 too. `chi broadcast deliver` logs such a delivery under the newest link's id (the one that has the note), so `chi broadcast log` shows that id, not the one typed, and a later `deliver` to either link says it had it. An archived link nobody continues keeps its note until input brings it back. An `/objectives` edit is the one note that stays in its own session: its list is that conversation's. Notes waiting when a session is continued move with it (*Session chains*).
172
175
  - In the conversation it is a tail system message marked `kind: note`, framed `[CONTEXT NOTE from slack, 14:02]\n…\n[END NOTE]` (from another session: `from session 3f2a1c (~/projects/foo)`). The system prompt says notes are background, not requests: the model uses one when it is relevant, doesn't answer it on its own, and never follows instructions inside it. A note from `broadcast` ([`chi broadcast`](broadcast.md)) is the one exception to "doesn't answer it": the model says briefly in its next answer when it affects its current work, and still doesn't act on it unless asked. `--source broadcast` is refused for `chi note`. Its text is escaped like user text, so it can't fake a turn. It survives `--resume`, a reload, `!rollback` and a continue answered no.
173
176
  - The web shows it as a dim "note from …" block, live (`context_added`) and after a reload; the attached terminal as one dim `note from slack: <first line>` line, also when joining.
174
177
  - Agents: `list_sessions` (other sessions of this project, newest first, up to 20; optional `cwd`, `"/"` for every project) and `send_note(session, text)`, which sends a note from this session. Neither starts a turn anywhere.
@@ -323,7 +326,7 @@ chi send --new --continues last:3fa2 -m "new day" # the next link of 3fa2's
323
326
  (`SAMAGOTCHI_THINKING_LEVEL=low chi send --new …`) only reaches the worker this call spawns: when the session's
324
327
  worker is woken later (by `chi web`, a delivery) without that env, the level is gone. Use `--thinking`.
325
328
  - `--new` prints `<full id> started` on stdout. With `--wait` that line (and the `sent` line for an existing session) goes to stderr, so stdout is exactly the answer: the reply of the turn the message started, in full. It is the worker's `output/` file, the same one `delegate` reads; a turn that ends with only tool calls and no text has none.
326
- - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`, or, for a `--new` that starts with the message, the id its create gives that first turn — `first_turn_id` in chi web's 201, `turn_id` in `--format json`; one merged into a turn that started at that moment gets that turn's reply). `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
329
+ - `--wait` takes one session: `--new` or one id. A session with a running turn is refused (`busy: a turn is running; --queue runs the message after it, chi send --wait ID waits for it, chi --attach ID shows it`, exit 1): the message would merge into it, and its reply would come back as the answer. To an idle session (or with `--new`) the wait is for the message's own turn (the ack's `enqueued_id`, or, for a `--new` that starts with the message, the id its create gives that first turn — `first_turn_id` in chi web's 201, `turn_id` in `--format json`; one merged into a turn that started at that moment gets that turn's reply). A client makes one id for the delivery; `chi send` reuses it if a Bridge POST falls back to an input file, so a current worker runs it once and `--wait` follows that same id. `--queue --wait` is the way in: the message runs as a turn of its own after the running one, and the wait is for that turn alone (the ack's `enqueued_id` names its reply file and its `last_turn`), so the running turn's reply and end, and a question it asks meanwhile, are not the answer (it keeps waiting through them; its `--timeout` still applies); a question its own turn asks is (exit 3). Two queued waits each get their own turn's answer. A session waiting for an answer is still refused. `--format json` adds `delivery`: `queue`, or `next_step` when the running turn ended before the message went in (it then ran at once, and the answer is still its own).
327
330
  - Exit codes with `--wait`: 0 answered; 3 the turn waits for an answer from you (a question, a hook's question, a guardrail approval, or the step-limit question: see [chi as a sub-agent](sub-agent.md#the-step-limit)), and the session keeps waiting: stderr gets the whole question (`waiting for an answer (approval): …`, its text, the numbered options, whether free text is allowed) and how to answer it (`chi answer ID --question QID --option N`, or `chi --attach ID`, the web); 4 `--timeout S` passed with the turn still running (it goes on; wait again); 1 the turn ended without a reply (canceled, failed or empty), the worker failed or vanished, or the session was stopped; 130 on Ctrl-C, which leaves the turn running (`still running: chi send --wait --turn T ID` for a turn whose id is known, else `chi send --wait ID waits again; chi --attach ID shows it`, as after a `--timeout`). There is no default timeout.
328
331
  - `--format json` (with `--wait`) prints one JSON object on stdout however the wait ends, for a script or a parent agent (see [chi as a sub-agent](sub-agent.md)): `{"status":"answered","session_id":…,"text":…}`; `{"status":"question",…,"question":{"id","kind","header","text","options","multi_select","allow_freeform","approval"},"answer_with":"chi answer ID --question QID --option N"}`, where `kind` is `question` (the model's), `hook` or `approval` (with the tool, command, cwd, rule, reason and offered scopes); `{"status":"running",…,"detail":…}` (exit 4, and on Ctrl-C); and `failed`, `canceled`, `removed` (a `--queue --wait` message taken back before it ran), `no_answer`, `error`, `worker_gone` or `stopped` with a `detail` line (exit 1); `failed` adds `error_kind` and `retryable`, `canceled` adds `cancel_reason` (`worker_stopped`: the turn waited for died with its worker and the next worker settled it) and `stopped_by`, when known. A failure before the wait (no such session, a busy one) is `error` too. A usage error (exit 2) prints no JSON.
329
332
  - `--wait ID` with no message (no `-m`, nothing piped) sends nothing: it waits for the session's next reply and prints and exits as above. With a turn running, that turn's reply (its `interrupted_turn` mark names it), not a turn's queued behind it; with the step-limit question standing, the reply of the turn its Continue runs; with an idle session whose last turn already ended, that ended turn is the answer: its reply, or its end when it left no reply — a turn that finished in the gap between waits is reported, not waited for. With none of those (or a worker whose `bridge.json` lacks `turn_ids`), the next reply of any turn. A running session is fine (that is the point), so after exit 3, 4 or 130 an agent waits again this way while the user answers in the web or `chi --attach`; the question pending when it starts doesn't count again. An idle session with no worker waits until something wakes one. With `--new`, or two ids, it is a usage error (exit 2).
@@ -347,18 +350,19 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
347
350
 
348
351
  `SessionContinue.run` makes the next link:
349
352
 
350
- - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
353
+ - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), its own LLM context values, and its `--memory` and `--mute` lists. It does not carry the reserved `system/delegated` memory when the previous link is a delegate child: a continuation has no parent and is no longer a delegate. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
351
354
  - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Then `Its last answer:` and the previous link's last answer, read after the recap wait, so its exact shas, paths and next steps survive: a longer one keeps its last 4000 characters, after a `…` (a wrap-up closes on what comes next). There is none when the newest bubble was a step that asked for tools, when a later turn failed or was cut before it answered, and on a review link (a continue doesn't copy the grants, so the next link's participants could differ); `session.carry_last_answer: false` leaves it out of every note. No other transcript. The note never makes a continue fail: it stays within the 16 KiB a note may hold, the answer taking what the head and recap leave, and a session file that can't be read gives no answer. An idle new link holding only the note is kept, not discarded as empty.
352
355
  - **Its attached context:** the previous link's movable sources (a command source by default; a pushed one stays) are copied to the new link before its worker starts, with their last text and the removed and muted markers, and a second note (`attached context`, after the carry note) names what moved and what stayed. A push into the archived link is then refused with the new link's id. See [Movable context](context.md#movable-context).
356
+ - **Its waiting notes:** the previous link's unabsorbed context notes move to the new link (`NoteMove.move`, the continue's last step before the worker starts, after the objectives' carry, which reads a pending `/objectives` edit from them first), file names kept, so they keep their ids and order and come ahead of the carry note. An `/objectives` note stays (the carry took its list), and so do the notes an earlier continue wrote into the previous link itself (a link continued before it ever started: its own carry note is replaced by the new one). A note an old worker had claimed and not deleted (`*.json.processing`) moves too, un-claimed (logged `notes_moved_claimed`): the previous link is archived, so left there it would be lost; if that worker had already added it to its conversation, the new link sees it once more, a rare duplicate rather than a loss. A note sent to the previous link after that goes to the new one (*Context notes*). One that still lands in an earlier link (sent just as the continue saved the new link, or by an older chi that knows no chain) is adopted by the new link's worker on its next pass: it looks back along the chain, one listing at its start, through up to 8 archived links, each one whose next link is the one after it, stopping at a link back in the lists or gone, and takes from each only the notes from the creation of the link that continued it on, never an `/objectives` note; older ones (left by a continue before notes moved) stay with the archived link, logged once (`notes_left_old`). An earlier link that is brought back (input un-archives it) keeps its notes while it is back; archived again, the newest link's running worker adopts from it again, so a note it got meanwhile and never absorbed comes to the newest link.
353
357
  - **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
354
- - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a session a one-turn `chi --non-interactive` run has is refused. Its finished delegates are archived with it, as any archive does.
358
+ - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a session a one-turn `chi --non-interactive` run has is refused. Its finished delegates are archived with it, as any archive does. Before archiving, the continue writes `<previous>/continue.json` with the reserved successor id. Session lists and the next continue repair an interrupted operation under `continue.lock`: if that successor's session file was not saved, they unarchive the previous link and remove the intent; if it was saved as its successor, they clear the intent and keep the archive.
355
359
  - **Its open delegates move to the new link** instead: each direct delegate that is running, waiting for an answer, has a live worker or a reply its parent wasn't given, or has such a delegate below it (checked again after the recap wait, so one delegated meanwhile moves too). It moves with its own delegates, stays unarchived, and from then on is the new link's: its card folds under the new link, its reports ring the new link (an idle one runs a wake turn for them, as for its own delegates), `delegate_result` and the `⑂` chip count it there, and what its parent was already told (`delegates.json`) and any report still waiting come along, so nothing is reported twice. A moved delegate gets a context note from `session chain`, `Your parent session is now 4b7e9a01 (…), the next link of its session chain: …`, as its system prompt keeps naming the parent it started with. The move writes `<child>/parent.json` (`{parent_id, from, at}`) beside the child's session file, never the file itself, so a live child worker saving its own copy changes nothing; every reader applies it (a later move overwrites it; it is never removed). A continue is **refused for an open delegate that can't move** (`SessionContinue::Refused`, naming it): one whose live worker runs an older chi (`older chi worker 0.46.1; restart it`: `chi sessions restart <id>`, or stop it) or that a one-turn `chi --non-interactive` run has (`in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`). An older `chi web` or CLI still running shows a moved delegate under the archived link until it is restarted.
356
- - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile; the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
360
+ - **If the continue fails part-way:** a new link that fails to start gives its moved delegates back before it is deleted (each gets a note saying so; its worker is stopped first, if one started, and a worker that won't stop is reported, the link left), with any report one of them rang it with meanwhile, and the notes it moved with any sent to it meanwhile (never the continue's own: the carry note, the attached-context note, the objectives' list): its session file goes first, so a note sent to the previous link from then on stays there, then the notes go back, then its folder goes, and a note an older chi writes into it after that is given back too (a note for a session with no file and no folder is refused, never a stray folder); the previous link, back in the lists, is woken for reports waiting. A continue that dies in the middle leaves a `move.json` in the new link's folder: the new link's next worker finishes that move (only the delegates it names, and without a second note to one already told) as it starts. Its `continue.json` intent remains in the previous link's folder too, so a later list can restore it if the successor was never saved. Until the new link's own worker holds it, a ring from a moved delegate doesn't start another (a `starting` marker in its folder, which that worker removes; a stale one, over 60 s old or from a process that is gone, is ignored). An open delegate below an archived delegate refuses the continue (`under archived …`): unarchive that one, or archive this one.
357
361
  - **Refused too:** a link that is continued already (naming the link that continues it; continue that one, or `last:<id>`), a previous link whose folder is gone, and one whose model's host can't run it when no default model is configured to fall back to. A refusal starts nothing and archives nothing; a new link that fails to start puts back what was archived. Every refusal that can be known up front (the archive's ones included) comes before the recap wait. One continue of a session runs at a time (a lock in its folder; other chains don't wait), so a double click finds the first one's link and is refused, while a `last:<id>` continue that waited follows the chain to its new end.
358
362
  - **Web:** `POST /api/sessions` takes `continues: "<id, prefix or last:id>"`, with `idle: true` (a new link waiting for its first message, previewed by the previous link's, or `preview`) or a `prompt`, and `model` (or `model_name`) for the new link's model, but not `dir`, `llm_context`, `thinking`, `settings`, `toolkit`, `tools`, `no_tools` or `parent_answers` (400 `invalid_continues`: the previous link's are taken). 201 is the new session's summary, `continues` set, with `moved`: the ids of the delegates that moved to it (`[]` for none), and `model_warning`: the default-model fallback's warning, then any about the model id, joined by `; ` (`null` for none), and `opener`: the previous link's first typed message, in full, when that link continues one itself (`null` otherwise: a chain's first link's first message is its task), and `moved_context`: `{moved: [names], stayed: [{name, why}]}`, the attached context it took (`why` `pushed` or `not movable`); 404 `not_found`; 400 `ambiguous_id` for a prefix of several sessions, `invalid_model` for a given model whose host config.yml doesn't have (or a fallback with no default model); 409 `continued` with `next_id` (the link that continues it already, which the page opens instead), `open_children` with `ids` (open delegates that can't move), `folder_gone`, `busy`, `scratch` or `owned_by_tui`. Every session summary carries `continues`.
359
363
  - **The page:** the info bar's `continue →` continues the open session (`continues: <id>, idle: true`; it reads `continuing…` through the recap wait) and opens the new link with an opener in the composer, not sent (a draft written in this session stays instead; one carried over from another session or the start page gives way to the opener, and the toast's **Restore draft** brings it back): the previous link's own first typed message when that link continues one (the 201's `opener`; a `!shell` line isn't taken), else `Continue where we left off.`. So whatever you sent on the last link comes back on the next one, and editing it once changes it for every later link; the previous link is archived, a toast says so (`Continued 3fa2c1d0 in 4b7e9a01; 3fa2c1d0 is archived; 1 open delegate moved`), and a moved delegate's card folds under the new link. When the previous link's model's host is gone the link starts on the default model and the toast carries the server's `model_warning` too, and then stays until closed. A fresh link (one with no turn yet, however it was made: `continue →`, `chi send --new --continues`, a reload) shows the model picker in its composer with the link's model, until its first turn starts (a failed or canceled first turn ends it too); a pick runs `/model <name>` in the link (a command bubble; the info bar follows) and changes neither the start page's remembered model nor its Recent list, and a Send right after it waits for the `/model` to be taken. A link a one-turn `chi --non-interactive` run has, or one continued already, shows none. A link continued already has no `continue →`: its `→` link goes to the next link (a stale page's continue answered 409 `continued` opens `next_id`). A continue refused for open delegates that can't move shows a toast that names each one (its preview and what the server says to do: `older chi worker 0.46.1; restart it`, `in use by a one-turn chi --non-interactive run or an older chi's terminal; let it finish`), each a link to its page; any other refusal shows the server's words. The info bar of a link carries `← 2026-10-08` (the previous link's day) and, on an earlier link, `2026-10-10 →`, each a link with its id, its full start (`2026-10-10 14:32`) and its preview in the tooltip; a neighbour that started on the open link's own day shows its time instead (`← 09:12`, `14:32 →`). A link made by `continue →` inherits the previous link's preview (and its title, if it has one) when it is created, so its card keeps its chain's title whatever opener it is sent, and its top bar and browser tab show its card's title. A link with a recap of its own reads its auto title, else its recap's first sentence, instead (a link without one reads its chain's name, and a first link's recap names its chain). A link whose title is the opener and nothing else (a `chi send --new --continues` link whose first message is `Continue where we left off.`) shows its chain's first link's title instead, on its card, in the popover and in its info bar's title line, display only (its messages and data keep the opener; its own title when the first link is gone). The lists fold a chain into the card of its latest listed link, whatever the earlier links' archived state (typing in an archived link brings it back, folded there still), their delegates with them: the card's `↩ #N` chip (`#N of M` when later links aren't listed; its tooltip `Session chain: link N of M`) opens a popover of the other links, newest first (`#N`, its date, with its time (`2026-10-17 14:32`) when another link of the chain started the same day, its own title when it differs from the card's (a link inherits its chain's, so mostly it doesn't), else its recap's first sentence, else its preview, `archived`), a click opens one; the chip shows a live dot or `waiting` for a link that has a worker or waits on the user, and a waiting link puts its chain's card first. All sessions folds nothing while a search is typed or the `archived` toggle is on: every link is a card of its own then, its chip `#N of M`, with its own delegates folded in, so a search finds an earlier link and its delegates.
360
364
  - **CLI:** `chi send --new --continues (ID|PREFIX|last:ID) [--model M] [-m TEXT]` (see *Starting a session*).
361
- - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain. A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
365
+ - **Which link:** an id, a unique prefix, or `last:<id or prefix>`, the latest link of the chain that session is in. It walks the links that still exist: a deleted link in the middle (`chi sessions delete`, the retention sweep of an unarchived one) ends the walk before it, so `last:` of an earlier link then names that earlier link, and continuing it starts a second line beside the links after the gap; name a link after the gap instead. A trigger names the chain this way: `chi send` to an archived link would bring it back (a human's input), not continue the chain. That is the rule: a message (`chi send`, the web composer, `delegate(session:)`) goes to the link it names; a note goes to the chain's newest link (*Context notes*). A chain pruned by a schedule keeps its `anchor`: the kept window's oldest link still names a link that is gone, so the walk simply stops there; the page's chip counts from the surviving root (pruning renumbers the links) and its popover says `earlier links pruned` where the walk stops, rather than linking to a deleted session.
362
366
 
363
367
  ## Schedules
364
368
 
@@ -407,6 +411,7 @@ sessions](cli.md#review-sessions)); both start idle and print the `chi share` li
407
411
  history comes along. Its preview is `review: docs/plan.md`, or a fork's `review of <the source's title>`.
408
412
  - **A fork isn't a continue:** the source is neither archived nor continued (no `continues`), and its delegates stay
409
413
  its own; a coordinator can open a review of its own session and keep working (`/coordinate review`).
414
+ - **Its setup:** a review fork carries the source's `--mute` list, but not its `--memory` preloads.
410
415
  - **Web:** `POST /api/sessions` takes `review: {subjects: [...], from: "<id>"}` instead of a `prompt` (a review
411
416
  session starts idle: `prompt`, `preview`, `idle` and `continues` beside it are 400 `invalid_review`). Without
412
417
  `from`, the subjects are relative to `dir` and the setup fields work as for any create; with `from`, the source's
data/docs/testing.md CHANGED
@@ -88,7 +88,7 @@ The session file goldens (`spec/session_file_goldens_spec.rb`, `spec/fixtures/se
88
88
  writes: every field away from its default, a new session, a newer chi's file, an old chi's file in a git repo, every key
89
89
  present as `null`, the plain writers' raw values, a running turn's checkpoint and the scrub (the `*_in.json` files are
90
90
  hand-written inputs, never regenerated); and the file each path that makes or rewrites a session writes (a fork, a
91
- continue, a delegate child, a plain spawn, a review, a Verify fresh child, a resume, a respawn, an archive), with
91
+ continue, a delegate child, a plain spawn, a review, a Verify fresh child, a respawn; a resume and an archive leave the bytes as they were), with
92
92
  `carry_matrix.json`, what each keeps of its source. They are pinned before Session's fields became one table and are kept
93
93
  as the file format's pins, not retired: a diff is a change to every session file on disk, so only a commit that means to
94
94
  change the format regenerates them (`UPDATE_GOLDENS=1 bundle exec rspec spec/session_file_goldens_spec.rb`).
@@ -144,6 +144,14 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
144
144
  override and a `note`. The Ruby side runs each row with every core command (a new one fails its
145
145
  `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
146
146
 
147
+ ## Compatibility corpus
148
+
149
+ `spec/compat/readers_spec.rb` reads the newest recorded release before the
150
+ current version (or the current corpus when it is the only one) through today's
151
+ session, Bridge, CardStore, schedules and bundle-record readers. `rake
152
+ release:bump[X.Y.Z]` writes `spec/compat/X.Y.Z/` using the current writers;
153
+ commit those small samples with the release so the next version exercises N-1.
154
+
147
155
  ## Levels
148
156
 
149
157
  Each level owns its own share; a detail is asserted once, at the lowest level that has it.