samagotchi 0.160.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +7 -3
  4. data/docs/cli.md +6 -4
  5. data/docs/context.md +10 -1
  6. data/docs/guardrails.md +12 -3
  7. data/docs/internals/bridge.md +21 -6
  8. data/docs/internals/bundles.md +24 -5
  9. data/docs/internals/objectives.md +8 -2
  10. data/docs/internals/remote-sessions.md +2 -1
  11. data/docs/memory.md +4 -2
  12. data/docs/plugins.md +13 -4
  13. data/docs/releasing.md +7 -4
  14. data/docs/scheduling.md +37 -5
  15. data/docs/sessions.md +14 -11
  16. data/docs/testing.md +9 -1
  17. data/lib/samagotchi/bridge/card_store.rb +31 -3
  18. data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
  19. data/lib/samagotchi/bridge/sse_writer.rb +8 -5
  20. data/lib/samagotchi/bridge.rb +92 -42
  21. data/lib/samagotchi/bridge_client.rb +9 -6
  22. data/lib/samagotchi/broadcast_command.rb +24 -8
  23. data/lib/samagotchi/bundle_command.rb +56 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/child_reports.rb +29 -23
  26. data/lib/samagotchi/client_id.rb +2 -0
  27. data/lib/samagotchi/compat.rb +32 -0
  28. data/lib/samagotchi/context_candidates_log.rb +10 -1
  29. data/lib/samagotchi/context_command.rb +1 -1
  30. data/lib/samagotchi/context_move.rb +6 -1
  31. data/lib/samagotchi/engine/events.rb +12 -0
  32. data/lib/samagotchi/engine/plugins.rb +1 -1
  33. data/lib/samagotchi/engine/steering.rb +2 -2
  34. data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
  35. data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
  36. data/lib/samagotchi/guardrails/targets.rb +25 -6
  37. data/lib/samagotchi/live_versions.rb +7 -2
  38. data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
  39. data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
  40. data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
  41. data/lib/samagotchi/memory_bundle/installer.rb +79 -12
  42. data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  44. data/lib/samagotchi/memory_bundle/status.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
  47. data/lib/samagotchi/memory_orphans.rb +40 -9
  48. data/lib/samagotchi/note_command.rb +9 -1
  49. data/lib/samagotchi/note_delivery.rb +34 -24
  50. data/lib/samagotchi/note_move.rb +205 -0
  51. data/lib/samagotchi/objectives_command.rb +3 -1
  52. data/lib/samagotchi/owner_lock.rb +7 -1
  53. data/lib/samagotchi/project_init.rb +3 -3
  54. data/lib/samagotchi/review_start.rb +5 -3
  55. data/lib/samagotchi/schedule_command.rb +12 -0
  56. data/lib/samagotchi/schedules.rb +286 -48
  57. data/lib/samagotchi/send_command.rb +2 -15
  58. data/lib/samagotchi/session.rb +42 -5
  59. data/lib/samagotchi/session_access/local.rb +45 -17
  60. data/lib/samagotchi/session_access/remote.rb +11 -4
  61. data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
  62. data/lib/samagotchi/session_access.rb +16 -3
  63. data/lib/samagotchi/session_continue.rb +155 -21
  64. data/lib/samagotchi/session_delete_command.rb +3 -1
  65. data/lib/samagotchi/session_inbox.rb +41 -1
  66. data/lib/samagotchi/session_inbox_delivery.rb +254 -0
  67. data/lib/samagotchi/session_manager/delivery.rb +66 -12
  68. data/lib/samagotchi/session_manager/emptiness.rb +7 -2
  69. data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
  70. data/lib/samagotchi/session_manager/listing.rb +4 -1
  71. data/lib/samagotchi/session_manager/removal.rb +130 -26
  72. data/lib/samagotchi/session_manager/spawn.rb +164 -104
  73. data/lib/samagotchi/session_manager.rb +1 -0
  74. data/lib/samagotchi/session_retention.rb +53 -23
  75. data/lib/samagotchi/session_setup.rb +27 -7
  76. data/lib/samagotchi/starting_marker.rb +93 -24
  77. data/lib/samagotchi/state_format.rb +18 -7
  78. data/lib/samagotchi/system_one/runs.rb +6 -1
  79. data/lib/samagotchi/system_one/runs_store.rb +18 -14
  80. data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
  81. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
  82. data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
  83. data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
  84. data/lib/samagotchi/terminal_ui.rb +2 -3
  85. data/lib/samagotchi/tools/delegate.rb +9 -4
  86. data/lib/samagotchi/tools/schedule_task.rb +5 -3
  87. data/lib/samagotchi/tools/send_note.rb +10 -4
  88. data/lib/samagotchi/turn_state.rb +108 -4
  89. data/lib/samagotchi/update_command.rb +7 -2
  90. data/lib/samagotchi/verify_fresh.rb +27 -9
  91. data/lib/samagotchi/version.rb +1 -1
  92. data/lib/samagotchi/web/app.rb +1 -1
  93. data/lib/samagotchi/web/public/app.js +2 -7
  94. data/lib/samagotchi/web/public/chains.js +4 -4
  95. data/lib/samagotchi/web/public/data.js +18 -2
  96. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  97. data/lib/samagotchi/web/public/session_fields.js +3 -2
  98. data/lib/samagotchi/web/public/session_state.js +2 -0
  99. data/lib/samagotchi/web/public/update_notice.js +2 -2
  100. data/lib/samagotchi/web/session_access_routes.rb +12 -8
  101. data/lib/samagotchi/web/session_control.rb +9 -3
  102. data/lib/samagotchi/web/session_create.rb +8 -4
  103. data/lib/samagotchi/web/session_hub.rb +50 -5
  104. data/lib/samagotchi/web/session_summary.rb +5 -2
  105. data/lib/samagotchi/worker.rb +45 -7
  106. data/lib/samagotchi/worker_boot.rb +22 -11
  107. data/lib/samagotchi/worker_inbound.rb +41 -8
  108. data/lib/samagotchi/worker_link.rb +4 -1
  109. data/lib/samagotchi/worker_presence.rb +106 -0
  110. data/lib/samagotchi/worker_sidecar.rb +3 -4
  111. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f4ca470372c140d0bcdc30583312ee1139b05b878a8e0f4688f9401a7af3700
4
- data.tar.gz: dfd3b0bf49a29758ea645bdf4ac6614c9a93610f013e7517d75d8eefe4b3e3ab
3
+ metadata.gz: d3a8c4d1d4d37965fda091bc5c48281cdf352d6f3e496d5e5073f72777fe5994
4
+ data.tar.gz: 74a26cfde1a9028c44763b99cdc60ce044028598ddd2955b244338076ef8fd86
5
5
  SHA512:
6
- metadata.gz: 18bd973f0dfffd58845df4341e41a2f3d32292a4d158e866cf76d907b64d9aac4539bacfc0a9cbc4a81a05b5a3bcc81cf8fec8413f52deb0a95c7141a6235082
7
- data.tar.gz: 2c2aaf26b58566064737487ae20dfc7037a752f20e426c80375ca0068e14f31e85ac2bc5927522c500dd4622c17f7696e0e93aadf3697cb79cf6b7d08e00669d
6
+ metadata.gz: 1de962941edd2efc63a07e566954a8e6cd212891c4858db791667341e0eca601b0e920a4661198760317f5133ee26e3411d9cad570a63a87e861527ab8e21a7e
7
+ data.tar.gz: 0fb530ff96ccbfe79f2dc04b4cba6d59fc1b0ee6a921cf593907d9159b60eec1bed10c3360f1777417f30333c9afd36090d2b3abf7f58b9e4e4cf1b27e558946
data/CHANGELOG.md CHANGED
@@ -8,6 +8,90 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.161.0] - 2026-11-10
12
+
13
+ ### Added
14
+
15
+ - `chi bundle upgrade NAME` and `chi bundle uninstall NAME`, when the bundle's plugin changed (or an upgrade brought
16
+ one in) and a session running it can be restarted, end with one line naming those sessions and
17
+ `chi sessions restart ID` for each (nothing on a dry run, with no restartable worker, or when the plugin is
18
+ unchanged — only the plugin stays in a worker's memory; its rules reload by stamp and its scripts run from disk).
19
+
20
+ ### Changed
21
+
22
+ - Worker restart hints now follow one compatibility rule: the current and previous minor release are supported.
23
+ - `chi bundle install` and `chi bundle upgrade` refuse a memory, plugin or script whose bytes disagree with the
24
+ sha256 its manifest declares, before writing anything (`Checksum mismatch for FILE: ...; chi bundle install
25
+ --force installs it anyway`, exit 1). `--force` installs it anyway, as it already does for an edited memory (its
26
+ copy going to the trash first) and for a bundle whose `requires_chi` this chi doesn't meet; a dev install, a
27
+ non-strict run, `chi update` and a bundle profile keep the warning-only behaviour.
28
+ - Core guardrail checks judge a plugin tool by what its `targets:` say it acts on, not by its name: paths it
29
+ declares it writes (`writes: true`) are checked like `write` and `edit`. See docs/plugins.md (Guardrails).
30
+ - `chi context push` says what a wake push did: `· chi woken` (an idle-exited session started), `· chi is stopped:
31
+ it sees this at its next start` (a stopped session is never started) or `· chi is starting`; chi web's push
32
+ answer carries it as `wake`. A delegate's report, or a push, now wakes a session whose file still said stopped
33
+ without a `chi sessions stop` behind it: only the stop marker means stopped.
34
+
35
+ ### Fixed
36
+
37
+ - A session being started is no longer pulled from under its worker: a session resumed while the retention sweep runs
38
+ is not deleted, two starts at once (chi web and a `chi send`, a delegate's report) start one worker, and a second
39
+ continue of a chain can't archive the link the first one just started. A deleted session no longer comes back
40
+ when a worker that still had it saves.
41
+ - A `chi sessions stop` that lands while a session's worker is leaving (an idle exit, `/exit`, a signal) is no longer
42
+ undone by a new worker started for input or delegate reports that were waiting: the session stays stopped and
43
+ they wait for its next start, as a stop during a restart already did.
44
+ - Session deletion and retention now also end participant grants and purge System 1 runs and context-candidate records.
45
+ - An interrupted continue now repairs its archived previous link when no successor was saved, so the session stays listed.
46
+ - A note sent to a session that was continued (`chi note`, `send_note`, `chi broadcast`, chi web) reaches the new link
47
+ (`chi note` prints `OLD → NEW (continued)`), and notes waiting when it was continued move with it; a note that
48
+ still lands in the old link as it is continued is taken by the new link's worker, and a continue that fails gives
49
+ the notes back. An `/objectives` edit stays in its own session.
50
+ - `chi bundle uninstall NAME` of a bundle whose install was interrupted before its record (only the
51
+ `pending.json` journal left) retags the index line of a kept shared file in the journal's own target folder, not the
52
+ current folder's: run from another project, it no longer edits that project's `index.md` for a file living elsewhere.
53
+ - A schedule counts a firing only after its turn completes: failed, canceled or unanswered turns now count toward
54
+ `max_failures` with the reason visible in `chi schedule list` and `show`.
55
+ - A schedule firing left without a turn after a process interruption is recorded as a failure instead of staying
56
+ stuck; its uncertain occurrence is not replayed.
57
+ - `chi schedule run-now` reserves an entry under the tick lock before firing, preventing an overlapping tick from
58
+ starting the same work.
59
+ - Retention protects an enabled schedule's stored anchor and head as well as its resolved target; cancel the
60
+ schedule with `chi schedule cancel ID` before deleting any of them.
61
+ - Schedule add, list and `schedule_task` warn when the ticker is stale and auto-tick is off; acquired ticks record
62
+ their timestamp.
63
+ - chi web's schedule tick runs outside the session scan loop, and overlapping hub scans do not start a second
64
+ tick while one is in flight.
65
+ - The web chain chip waits until its session is in the loaded list before saying earlier links were pruned.
66
+ - A `chi send`, delegate or schedule delivery whose Bridge answered an error after it had queued the prompt no
67
+ longer runs it twice: the client makes the enqueued id, the input-file fallback reuses it, and the worker runs
68
+ each id once (also after a restart, a turn that already ran or was cut off by the worker's death).
69
+ - The continue's carry note of unabsorbed pushed context keeps the sources after one trimmed to the per-source 4 KiB
70
+ cap: a source that fits the room left is carried whole even when that room is under the cap, the note ends only at a
71
+ source it has no room for, and "[additional pushed text omitted]" is added whenever text is left out.
72
+ - The memory-orphan hint says how the other folder is keyed: a `chi project init` folder is named by its project id
73
+ instead of "another path's hash", and a project keyed by an id is named by its folder, not by that id (so a marked
74
+ folder whose memories stayed in its id folder after `git init` is described correctly).
75
+ - A browser tab or terminal that connects to a worker with no cursor, after its event ring has wrapped, is re-synced
76
+ from the session instead of replaying from the middle of a turn.
77
+ - Stop (the web button, Ctrl-C in the terminal) names the turn it was pressed for, so one pressed as that turn ends no
78
+ longer cancels the queued turn that starts next.
79
+ - `chi bundle install` and `upgrade` check that the plugin and scripts the manifest names are in the bundle before
80
+ they write anything, so a bundle that lacks one is refused with your memories, plugin and record as they were.
81
+ - A bundle install or upgrade that is killed or fails after it copied memories (a `pending.json` journal in
82
+ `.bundles/NAME/`) no longer leaves them as your own files or local edits: a retry owns them, `chi bundle status`
83
+ shows no edit, the next upgrade fast-forwards them and uninstall removes them.
84
+ - A `delegate` follow-up the child's worker refuses says why, in the same sentence `chi send` prints for a delivery
85
+ that did not go in (`DeliverResult#refusal_text`): "Error: session abc12345 refused: this session's worker predates
86
+ queueing ... and send again" rather than the bare error code.
87
+ - A fork and a continue keep the session's `--memory` and `--mute` lists; a non-delegate child does not inherit the
88
+ reserved `system/delegated` memory.
89
+ - A review fork carries its source's `--mute` list, but not its `--memory` preloads.
90
+
91
+ Update with `chi update`, then restart chi web and running sessions (`chi sessions restart`): workers from
92
+ 0.160.0 keep working (N-1), but the new starting marker, session inbox and stop-during-leave rule apply only to
93
+ workers started by 0.161.0.
94
+
11
95
  ## [0.160.0] - 2026-11-09
12
96
 
13
97
  ### Added
@@ -5922,7 +6006,8 @@ and long-lived sessions.
5922
6006
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5923
6007
  hotkey panel that send selected text or the clipboard to your sessions.
5924
6008
 
5925
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.160.0...HEAD
6009
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.161.0...HEAD
6010
+ [0.161.0]: https://github.com/dm1try/samagotchi/compare/v0.160.0...v0.161.0
5926
6011
  [0.160.0]: https://github.com/dm1try/samagotchi/compare/v0.159.0...v0.160.0
5927
6012
  [0.159.0]: https://github.com/dm1try/samagotchi/compare/v0.158.0...v0.159.0
5928
6013
  [0.158.0]: https://github.com/dm1try/samagotchi/compare/v0.157.0...v0.158.0
data/docs/architecture.md CHANGED
@@ -193,10 +193,14 @@ log's last 4 MB). Rows: [configuration.md](configuration.md#checking-it-chi-home
193
193
 
194
194
  ## Session retention & ordering
195
195
 
196
- - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware), and the `<uuid>/stopped` and `<uuid>/archived` marker files. Durable schedules belong to the machine, not a session: `~/.local/state/samagotchi/schedules.json` and its `schedules.lock`, next to `sessions/` ([Scheduling](scheduling.md)).
196
+ - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware), and the `<uuid>/stopped`, `<uuid>/starting` and `<uuid>/archived` marker files. Durable schedules belong to the machine, not a session: `~/.local/state/samagotchi/schedules.json` and its `schedules.lock`, next to `sessions/` ([Scheduling](scheduling.md)).
197
197
  - **Single owner:** the process running a session's Engine (its worker, or a `--non-interactive` one-shot) holds a flock on `owner.lock` (`OwnerLock`); a second owner backs off, and the web answers 409 for a session a one-shot owns.
198
- - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock. A stop is the `<uuid>/stopped` marker (`Session.mark_stopped`; a resume removes it), which `Session.load` and `.summary_from_file` read as status `stopped`, so no other process rewrites a worker's session file.
199
- - **Retention:** 14 days / 500 cap (env `SAMAGOTCHI_SESSION_RETENTION_DAYS`/`MAX_COUNT`, optional `KEEP_STATUS`), live-owner guard, only when `*.json` present; lazy sweep at most once per `session.sweep_interval_hours` (default 24) from the session hub's full-probe tick (and on `GET /api/sessions`, which the page no longer calls), manual via `bin/chi sessions prune --dry-run`.
198
+ - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock. A stop is the `<uuid>/stopped` marker (`Session.mark_stopped`; a resume removes it), which `Session.load` and `.summary_from_file` read as status `stopped`, so no other process rewrites a worker's session file. A resume writes nothing else: the worker, holding the lock, turns a status from before it idle (`Worker#settle_stale_status`: `stopped` with no marker, `error`, a `running` no turn is behind; not a new session's `running` whose first prompt waits), and saves at start whatever the status, which stamps `updated_at` as the resume's save did (a session woken with no turn stays young for the sweep). N-1: a newer spawner starting an older worker (a checkout's chi spawning the installed one) loses that reset; a restart covers it.
199
+ - **Starting:** every spawn marks the session starting (`<uuid>/starting`, `{pid, at}`, `StartingMarker`) until its worker holds the lock: `spawn_worker_for_session` writes it with its own pid before `Process.spawn` and the starter's after (the starter lives until the worker holds the lock, at most `WORKER_START_GRACE`; a spawn that raises or whose starter is gone leaves none), `spawn_session` before the new file is saved, and `Worker#start` removes it. It holds for 60 s at most, and only while the pid it names runs. A resume claims it (`StartingMarker.claim`, a link(2): of resumes racing from any processes one wins), then reads the owner again. A stop marker cancels the hold-off (`start_pending?` = starting and not stop-marked) for a start marked before the stop only: of two sends after a stop, the claim lets one spawn. The stop marker carries its own `at` (`{"at"}`, on the starting marker's clock; `Session.stopped_at`), an older chi's empty one its mtime. The sweep, archive (`:starting`) and delete (refused as an owned session) honour it. N-1: an older spawner writes none for a resume (the owner checks stay); an older worker never leaves one it was given (every worker clears it at start); a leftover one is stale by age or pid (a claim's temp files a kill left, `starting.*`, count as skeleton); a 0.160 spawner or chi web still holds off on a fresh marker under a stop (it reads `starting?` before the stop), until the start ends or 60 s. `SessionManager` keeps only its starters' pids, for chi web's restart to adopt (`live_spawn_pids`), and decides nothing from them.
200
+ - **Deleting:** `delete_session`, the retention sweep and a failed continue's cleanup use `remove_files` for the session JSON, sidecar dir, plugin state, attached context, participant grants, System 1 runs and context-candidate records. The JSON and dir go through `remove_session_files`, holding the owner lock (kind `deleting`, which `stop_session` never signals; not waited for: a worker that took it since the check keeps the session) and reading the starting marker again under it (a resume that claimed the start just before keeps the session; one after it sees the lock held). N-1: an older chi's stop reading the owner then would TERM the deleter (chi web, the CLI) in that moment; an older reader shows the kind as unknown. `Session#save` of a session read from its file (or saved once) skips a file that is gone (log `save_skipped_deleted`) rather than recreate it; only a new session's first save creates one.
201
+ - **Worker presence:** whether a worker is up, coming or held off is read once per decision as a `WorkerPresence` (`worker_presence.rb`, a `Data.define`), the deciders' one reader of those facts (display and list rows still read `session_owner` / `WorkerLink` themselves): the owner lock's holder, the stop marker (`stop_marked?`: the only "stopped" a decider reads, never the session file's status), the `starting` marker (`StartingMarker`, `starting?`, `start_pending?`: every spawn writes it, see Starting), the archive marker, the session file's existence, and the `WorkerLink` outcome when asked (never the file itself: a scratch session's flag is read by `WakePolicy`, last). The deciders read it: `resume_session` (tui, owned, `resume_held_off?`), the session inbox's `WakePolicy` (a ring, a push's wake, a note, a fresh verdict, `wake_for_report`, `deliver_turn`'s two liveness checks, `WorkerBoot`'s leave: below), `Local#wake`/`#worker`, `AttachLauncher`, `Schedules#busy_reason`, archive, delete and the retention sweep. Each keeps its own subset of the facts and their order.
202
+ - **Session inbox:** a delivery into a session (`SessionInbox.deliver(kind, id) { |at| write }`, `session_inbox_delivery.rb`) runs one path: resolve the link (`SessionInbox::Head.resolve`: send, push and a fresh verdict are pinned to the id named; a note goes to the chain's newest link; a ring's file stays with the parent `parent.json` names and its wake goes one hop, to the link whose `ChildReports` reads that file; only an archived id is walked, with one `Session.list`), refuse before any write (a gone session; a one-turn run's or a scratch session for send and a note), write with the kind's own writer (formats unchanged), read `WorkerPresence` once, and apply the kind's `WakePolicy` row. `decide` reads in one order for every kind: gone, one-turn run (`:owned`), owned (`:live`), the stop marker (`:stopped`: the only "stopped", the file's status never decides; send un-stops), archived (send alone starts one), starting (`:starting`, never a second spawn), the kind never starts (`:none`: a note, a fresh verdict) or wasn't asked to (`:off`), scratch (the session file is read only here), else `:start` through `SessionManager.start_for_inbox` (`resume_session`, its claim on the starting marker; it answers `:woken` only when it spawned the worker, `:starting` when another start won the claim, `:live` when a worker took the lock since). N-1 gap: a session file that says `stopped` with no stop marker (stopped before the marker existed, or a resume whose spawn failed after it cleared the marker) is woken, and an older worker, which has no `Worker#settle_stale_status`, exits on its first pass; the wake still reads `:woken`, and each later ring or push spawns another such worker (bounded by the rings and pushes). Restarting chi on the new version (`chi web`, the workers) closes it. `SessionInbox.wake` is the wake half alone (`wake_for_report`, a failed continue's rings). On it: `ChildRing.ring` (its start on a thread), `Local#context_push`'s wake (its outcome is `ContextPush#wake`, the push line's ending), `NoteDelivery.deliver` (every note writer a person or another session drives: `chi note`, `send_note`, `chi broadcast`, chi web's notes route; `NoteDelivery::Result#id` is the link that has it, `#asked` the one named; `/objectives` passes `pin: true`: its list is its own session's) and `VerifyFresh.forward` (`:fresh_verdict`: the child's parent, pinned, no worker). Send keeps its Bridge/file flow in `SessionManager.deliver_turn` (pinned to the id it names: an archived link gets the message and comes back); its two liveness checks (before the post, after a file's write) read the `:send` row and leave the refusals to `resume_session`'s wording. One row is not a delivery: `:leave`, `WorkerBoot.run_session_loop`'s successor as a worker leaves (input left after a signal, an idle exit's or `/exit`'s input or rings, a restart), read on a fresh presence once the lock is free: a stop that landed as it left starts none for any way out (`resume_session` would clear the stop; logged `respawn_dropped`, `restart_dropped` for a restart, whose reminders go with it), the input and rings stay for the next start. The notes a spawn writes into the session it starts stay direct `SessionInbox.write_note` calls, pinned by construction: spawn_session's chain note, `ContextMove`'s notes, `Objectives.carry`, `ChildMove`'s note to a moved child. A continue moves the previous link's waiting notes to the new link last in its `before_spawn` (`NoteMove.move`), a failed one gives them back before the link goes (`NoteMove.give_back`), and the new link's worker adopts what the archived links before it get after their continue (`NoteMove.adoption`, from `WorkerInbound#absorb_notes` and `Worker#start`: back along the chain through up to 8 archived links, from each only notes from the creation of the link that continued it, never an objectives list). `write_note` refuses a session with no file and no folder (`Session::NotFound`), so a late writer can't bring back a deleted link's folder.
203
+ - **Retention:** 14 days / 500 cap (env `SAMAGOTCHI_SESSION_RETENTION_DAYS`/`MAX_COUNT`, optional `KEEP_STATUS`), live-owner and starting guard (looked at again right before a session's files go), only when `*.json` present; lazy sweep at most once per `session.sweep_interval_hours` (default 24) from the session hub's full-probe tick (and on `GET /api/sessions`, which the page no longer calls), manual via `bin/chi sessions prune --dry-run`.
200
204
  - **Ordering:** `Session.list(sort:,order:,limit:,offset:)` and `GET /api/sessions?sort=&order=&limit=&offset=` default `updated_at desc`; Web UI sort/filter/pagination.
201
205
  - **Test hygiene:** `test_run` flag when `SAMAGOTCHI_ENV=test`/`RACK_ENV=test`/`CI`, targetable via `prune --test-only` / `clean`.
202
206
 
data/docs/cli.md CHANGED
@@ -37,7 +37,7 @@
37
37
  - `chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, and check the bundles from elsewhere, in one table (see [Updating](#updating))
38
38
  - `chi home`, `chi home set [--ruby PATH]`, `chi home doctor` — the chi home: the one Ruby (and its gem path) every chi launch boots, whoever launches it. `chi home` shows it (its Ruby, the newest chi in it, gem home and path, `mise`, when and by which chi it was set, and the chi you ran when that is another one; exit 1 with the problem when it can't boot chi), `set` records this chi's Ruby and gem path (or `--ruby PATH`'s own; it must have samagotchi), `doctor` checks it and what should follow it, one row each with `ok`, `warn` or `fail` and what to run (exit 1 on a `fail`; see [Checking it](configuration.md#checking-it-chi-home-doctor)). At a terminal, a chi on another Ruby than the home's says so in one dim line a day. This machine only (see [The chi home](configuration.md#the-chi-home))
39
39
  - `chi project`, `chi project init [--new-id] [DIR]` — this folder's project, and making a folder in no git repository one. `chi project` prints how its project was found (`kind` `git`, `marker` or `none`), its `root`, its `top` (where the AGENTS.md chain starts), a marked project's `id` (what its memories and project context are keyed on; else the root's path is) and its project `memories` folder, with a `note` for a marker a repository makes ignored, or a `$HOME` that is a git repository. `init` writes `DIR/.chi/project.yml` with a new `id:` (DIR defaults to this folder and is created when missing), so DIR and every subfolder share one memories folder (kept when DIR moves or is renamed), group their sessions and take `chi context --project`; it writes nothing in a git repository (the repository already is the project, exit 0), says so (with the id) for a folder already marked, refuses `$HOME`, a folder above it and `/` (exit 1), and marks a folder inside another marked project as its own, nested one. On a folder marked without an id (an older chi's marker) it adopts one: the folders keyed by its path move to the id, each leaving a link by its old name, and it says what moved and names memories an earlier path left (refused, exit 1, while a session of the project runs). `--new-id` gives a marked folder a fresh id with a copy of its memories and project context (a copied folder going its own way; refused elsewhere, exit 1). It prints the root, the id and the memories folder (with the memories already there: a folder chi ran in at its top keeps them, moved to the id), the running sessions in DIR to restart (`chi sessions restart ID`: their prompt keeps the old project) and, when DIR has saved sessions, that a running chi web groups them after its restart. This machine only. See [Projects without git](memory.md#projects-without-git)
40
- - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Plugins](plugins.md) and [Bundle hooks](hooks.md#bundle-hooks)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet; without it such a bundle is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A source with class-file hooks (`hooks/*.rb` or a manifest `hooks:` entry) is refused, `--dry-run` too; an older install's hooks are removed by the next install or upgrade (`Removed (chi no longer loads class-file bundle hooks): …`, an edited one copied to the trash first; see [Class-file hooks are retired](hooks.md#class-file-hooks-are-retired)). `status NAME` lists the bundle's guardrail rules and scripts beside its plugin, each `[ok]`, `[missing]` or `[modified]` (edited: the next install replaces it, its copy kept in the trash; an edited rule doesn't load, so it counts in `issues=`), and `diff NAME` names an edited rule or script with its recorded and current sha256 (chi keeps no base text for them). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
40
+ - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Plugins](plugins.md) and [Bundle hooks](hooks.md#bundle-hooks)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet and a memory, plugin or script whose bytes disagree with its manifest `sha256`; without it such a bundle or file is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A source with class-file hooks (`hooks/*.rb` or a manifest `hooks:` entry) is refused, `--dry-run` too; an older install's hooks are removed by the next install or upgrade (`Removed (chi no longer loads class-file bundle hooks): …`, an edited one copied to the trash first; see [Class-file hooks are retired](hooks.md#class-file-hooks-are-retired)). `status NAME` lists the bundle's guardrail rules and scripts beside its plugin, each `[ok]`, `[missing]` or `[modified]` (edited: the next install replaces it, its copy kept in the trash; an edited rule doesn't load, so it counts in `issues=`), and `diff NAME` names an edited rule or script with its recorded and current sha256 (chi keeps no base text for them). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
41
41
 
42
42
  - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
43
43
  - `chi guardrails rule <id>` — print exactly `asks` when that rule is loaded, not disabled and votes `ask` in this `guardrails.mode` for every model; any other line says why not (no such rule, disabled, another mode, it denies, for some models only, rules that failed to load, `guardrails.enabled: false`). Exit 0 either way, 2 for a usage error. It reads config.yml and the installed bundles as a worker does (`GuardrailWiring#rules`) and takes the rule's id, never a command; the [coordinator skill](bundles.md#the-coordinator-bundle) reads it before it merges without asking
@@ -512,9 +512,9 @@ invalid option (exit 1).
512
512
  | `--parent-answers LEVEL` | A new session's own `guardrails.parent_answers` (`hard`, `standard` or `soft`): what a parent agent may answer on its questions. It may only tighten config.yml's: the stricter of the two applies, and a looser one is saved as given with a warning (`--parent-answers soft is looser than config.yml's guardrails.parent_answers: standard; standard applies`). Saved in the session; a respawned worker, a fork and a continue keep it, a `delegate` child takes its parent's. Ignored with `--attach`/`--resume` (one line, as `--toolkit`); refused with `chi web`. Only you loosen it: `/parent-answers` in the terminal or the web. See [Guardrails](guardrails.md#answers-from-a-parent-agent). |
513
513
  | `--set KEY=VALUE` | A setting for a new session (repeatable; a later one of the same key wins). A core key a session may set (`recap.enabled`, `recap.sentences`) is typed by the key and goes first, over the CLI, env and config.yml: `--set recap.enabled=false` turns the idle recap off for this session; a value it doesn't take (`recap.enabled=maybe`) or a key no session sets (`guardrails.mode`) is an error, exit 2 (see [Per-session settings](configuration.md#per-session-settings)). Any other name is a bundle's `BUNDLE.KEY`, laid over config.yml's `bundles: <bundle>:` in what the bundle's plugin reads (`ctx.settings`): `--set cost-cap.cap=3`. The value is typed simply: an integer (`3`, `-1`) or a decimal (`2.5`, `.5`, `1e1`) is a number, `true`, `yes` and `on` are true, `false`, `no` and `off` are false (any case, as in config.yml), anything else stays the text as given (`010`, `0x10`, `3:30`, `y`, `null`), and so does every value of a key the manifest types `string`. For cost-cap, `0`, a negative number or `off` is no cap. Only a key the installed bundle's manifest lists in `session_settings:` is taken, with a value of the type it gives the key: a bundle that isn't installed (or whose plugin doesn't load), a key it doesn't list (a record installed before the bundle listed it says to run `chi update` or `chi bundle upgrade NAME`), a value of another type (`--set cost-cap.cap=five`), or another shape (`--set foo`, `--set a.b.c=1`) is an error naming what it takes, exit 2, nothing started. Saved in the session; a respawned worker, a fork and a continue keep it, a delegate child doesn't. Ignored with `--attach`/`--resume` (one line, as `--toolkit`); refused with `chi web`. See [Per-session settings](plugins.md#per-session-settings). |
514
514
  | `--profile NAME` | Prompt profile (`qwen36` or `gemma4`) for every model in this run, over config and the server's template (same as `--model-profile`, env `SAMAGOTCHI_MODEL_PROFILE`). See "Prompt profile" in configuration.md. |
515
- | `--memory NAME` | Preload a memory entry into the system prompt (repeatable; a comma list too). Merged under the config.yml `memories:` baseline. Works attached: the list is stored on the session, so its worker builds the same prompt on every respawn. |
515
+ | `--memory NAME` | Preload a memory entry into the system prompt (repeatable; a comma list too). Merged under the config.yml `memories:` baseline. Stored on the session; a fork and a continue keep the list too. A fork leaves out reserved `system/delegated` so it does not become a delegate. |
516
516
  | `--append-system-prompt FILE` | Put `FILE`'s text into the system prompt, after AGENTS.md and before the memory indexes (repeatable; relative to the shell's directory). Added after the config.yml `append_system_prompt:` list. A missing or unreadable file is an error, exit 1. Saved in the session, so a respawned worker, a fork and a continue keep it; ignored with `--attach`/`--resume` (one line, as `--memory`). See [Appended prompt files](configuration.md#appended-prompt-files). |
517
- | `--mute NAME` | Hide a memory from this session (repeatable; a comma list too): its index line is not in the prompt, `memory_read` refuses it, the identity auto-load skips it, and it is dropped from the preloads (config baseline or `--memory`). A name matches in both scopes (`gh-helper`, `project/gh-helper` and `gh-helper.md` all hide `gh-helper`). Nothing on disk changes. See [Muting a memory](#muting-a-memory). |
517
+ | `--mute NAME` | Hide a memory from this session (repeatable; a comma list too): its index line is not in the prompt, `memory_read` refuses it, the identity auto-load skips it, and it is dropped from the preloads (config baseline or `--memory`). A name matches in both scopes (`gh-helper`, `project/gh-helper` and `gh-helper.md` all hide `gh-helper`). A fork and a continue keep the list. Nothing on disk changes. See [Muting a memory](#muting-a-memory). |
518
518
  | `--llm-context LAYERS` | This session's own LLM context strategy: `none`, or layers such as `stale` or `stale,forget` (comma, space or `\|` separated). Saved in the session, before the model's `llm_context_strategy`. With `--resume` it is set on the resumed session; on an attached or resumed worker it runs as `/llm-context` before the first prompt (after `--model`'s `/model`). A value that isn't one is an error before anything starts. See "LLM context: a session's own strategy" in configuration.md. |
519
519
  | `--llm-context-apply RULE` | This session's own apply rule: `payoff`, `next_request` or `turn_end`. |
520
520
  | `--llm-context-budget TOKENS` | This session's own context budget: `64000`, `64k` (4k to 10M), or `off` (wins over the model's budget). |
@@ -804,7 +804,9 @@ file and index line stay as they are; only this session doesn't see it.
804
804
  `muted_memory_names` in the session's JSON), written before the worker
805
805
  starts. A worker respawned by `--resume`, `--attach` or `chi send` builds
806
806
  the same prompt, and a `--resume ID --non-interactive` one-shot keeps the
807
- lists too (merged with the flags it is given).
807
+ lists too (merged with the flags it is given). A fork and a continue keep
808
+ the lists too; a fork drops the reserved `system/delegated` memory, and a
809
+ continuation of a delegate child drops it because the new link is not a delegate.
808
810
  - A mute wins: `--mute user_preferences` drops that config baseline entry for
809
811
  one session, and `--memory x --mute x` is a mute (one warning line).
810
812
  - Names are checked before the launch, warnings only: `Warning: --memory 'x'
data/docs/context.md CHANGED
@@ -252,7 +252,16 @@ you're away from the terminal. All of these must hold:
252
252
  - the session's worker is up and idle: no turn running, nothing queued, no
253
253
  question or continue offer waiting, not just started. A changed push from a
254
254
  session-scoped source can start an idle-exited worker; it never restarts an
255
- explicitly stopped session;
255
+ explicitly stopped session (the push waits for its next start). Only the
256
+ stop marker (`chi sessions stop`) counts as stopped. `chi context push`
257
+ ends its line with what that wake did: `· chi woken`, `· chi is stopped: it
258
+ sees this at its next start` or `· chi is starting` (nothing when the
259
+ worker is up, or the push asked no wake; chi web's push answer carries it
260
+ as `wake`). `· chi woken` means this push started the worker; one another
261
+ start had begun says `· chi is starting`. A worker from an older chi woken
262
+ for a session whose file still says stopped with no `chi sessions stop`
263
+ behind it exits at once: the line still says woken; restarting chi on the
264
+ new version covers it;
256
265
  - under `session.max_wakes` turns in a row with no message from you (the
257
266
  budget delegate reports and finished background tasks use too; a turn that
258
267
  failed, or was stopped before the model call, pauses wakes until you write);
data/docs/guardrails.md CHANGED
@@ -314,7 +314,9 @@ as before ([their review boundary](#a-participants-review-boundary)). The rule i
314
314
 
315
315
  ## Protected paths
316
316
 
317
- Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
317
+ Built in, for a call whose [targets](plugins.md#guardrails) say it writes
318
+ its paths: `write`, `edit`, `memory_write`, and a plugin tool's `targets:`
319
+ with `writes: true` (symlinks resolved; see
318
320
  [How paths resolve](#how-paths-resolve)):
319
321
 
320
322
  - deny: the approval store's dir, installed bundles (`memories/.bundles/`),
@@ -334,7 +336,9 @@ bundle: a child asks before it changes anything outside its folder, the work
334
336
  tree top of the folder it was started in (a worktree from `delegate cwd:`,
335
337
  else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
336
338
 
337
- - `write` or `edit` names a path outside it (tmp dirs and memory files don't count);
339
+ - a call whose [targets](plugins.md#guardrails) say it writes its paths —
340
+ `write`, `edit`, and a plugin tool's `targets:` with `writes: true` —
341
+ names a path outside it (tmp dirs and memory files don't count);
338
342
  - `execute` or `task_create` runs mutating git in another dir (`git -C`, `cd`,
339
343
  `GIT_DIR`: what `git: outside_repo` matches), or a command that isn't
340
344
  read-only starts outside it (its `cwd:`) or names a path in the repository's
@@ -344,7 +348,12 @@ else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
344
348
  even from its own worktree: a `git config` write (not `--get`, `--list`,
345
349
  `--worktree`), `update-ref`, `worktree add|remove|move|prune`, `stash
346
350
  drop|clear|pop`, creating or deleting a tag, and deleting, forcing or
347
- overwriting a branch other than its own.
351
+ overwriting a branch other than its own;
352
+ - any call whose `targets:` carry a `command:` (a plugin tool's) is checked
353
+ for git that changes what every checkout shares, and for a `cwd:` outside
354
+ the child's folder. Its command's text may not be shell, so the reads a
355
+ shell call gets (its `git -C`/`cd` dirs, its redirects, its read-only test)
356
+ are left out.
348
357
 
349
358
  Read-only commands (`git -C ../app log`, `cat ../app/x`) and paths elsewhere
350
359
  (`/usr/bin`, `~/.gem`) never ask, nor does a read-only command that writes only
@@ -195,8 +195,11 @@ ring holds the event. `serve!` orders a join so that nothing is missed or double
195
195
 
196
196
  A **`reset`** frame (the same content, numbered with its own seq) is written when a cursor cannot be replayed: it is
197
197
  from another epoch, ahead of the engine (a restarted worker), behind the ring's window (a cursor at `oldest - 1` has
198
- seen everything before the window and replays), or the queue overflowed since the last drain. A cold connect replays
199
- the whole window. Replay state is in memory per worker; there is no durable resume.
198
+ seen everything before the window and replays), or the queue overflowed since the last drain. A cold connect (no
199
+ cursor) is behind the window too once the ring has evicted an event (`RingBuffer#dropped?`: a replay would start mid-turn), so
200
+ it gets the `reset`; on a ring that never evicted (its first seq can be above 1: events before the Bridge started) it
201
+ replays the whole window. The clients re-sync on a `reset` and
202
+ reconnect with the snapshot's own id, which is inside the window (the web `resync`, the TUI's `follow`). Replay state is in memory per worker; there is no durable resume.
200
203
 
201
204
  Each frame is `id: <seq>-<epoch>` (`EventId`: the seq first so a reader that takes it as a number still gets the
202
205
  seq), `event: <type>`, `data: <json>`. A `: ping` goes out after `heartbeat_interval` (15 s) of silence. A watcher
@@ -293,9 +296,18 @@ The Bridge exposes:
293
296
  doesn't: the client uses `generation_started`'s). Unknown provider fields never cross; a field is added here
294
297
  when a client needs it, never as a catch-all bag. Details: [internals/context-telemetry.md](context-telemetry.md).
295
298
  - `POST /session/:id/turn` — fire-and-forget turn creation; returns `202` with an `enqueued_id`
296
- (delivery is at-least-once via the worker's file-IPC input path — it never calls `run_turn`
297
- across the HTTP boundary). Only the bridge's own session: another id, in the path or the body's
298
- `session_id`, is `404 unknown_session`. Inspect results through the read surface, not the turn response.
299
+ (body: `{session_id, prompt, enqueued_id?}`; the optional id is a client-made UUID, generated by
300
+ the Bridge when absent or invalid). `BridgeClient#post_turn` supplies one when its caller does not,
301
+ including the attached TUI. The turn still goes through the worker's file-IPC input path — the
302
+ Bridge never calls `run_turn` across the HTTP boundary. Repeating an id already accepted by this
303
+ worker with the same prompt returns the original `202` acknowledgement, including its delivery and
304
+ cut outcome, and does not enqueue another turn; a different prompt with that id is refused with
305
+ `409 enqueued_id_conflict`. An input file with an already accepted id is consumed without running.
306
+ The worker keeps a bounded recent history of accepted ids.
307
+ For N-1 compatibility, an older worker ignores the optional field and generates its own id; an older
308
+ client sends none, so this Bridge generates one. Only the bridge's own session: another id, in the
309
+ path or the body's `session_id`, is `404 unknown_session`. Inspect results through the read surface,
310
+ not the turn response.
299
311
  An optional `deadline` (epoch seconds; `BridgeClient` sends 5/6 of its read timeout ahead) makes
300
312
  a request read after it (a worker frozen by sleep or SIGSTOP) answer `408 deadline_passed` and
301
313
  not run: a client that timed out has said the message was not sent. `/answer`,
@@ -307,7 +319,10 @@ The Bridge exposes:
307
319
  timeout is `504 worker_timeout`; a Bridge 404 for a route an older worker lacks is
308
320
  `501 not_supported` with the restart hint; a Bridge `404 unknown_session`, no bridge or a
309
321
  refused connection is `503 not_live`.
310
- - `POST /session/:id/cancel` — cancel the running turn; `202`, or `409` when none runs.
322
+ - `POST /session/:id/cancel` — cancel the running turn; `202`, or `409 not_running` when none runs. The optional
323
+ `turn_id` names the turn the Stop means (the web page and the TUI send the one they show): `TurnState#cancel!` compares
324
+ it to the running turn's id under its lock, and another turn running is `409 turn_mismatch`, nothing cancelled, so a
325
+ Stop pressed as a turn ends can't cancel the queued next one. No `turn_id` (an older client) cancels whichever runs.
311
326
  - `POST /session/:id/recap` — `/recap` in an attached TUI: `200 {enabled, saved, request,
312
327
  min_user_turns}` (`{enabled: false, session_off}` without recaps; `session_off` when the session's own
313
328
  `/recap off` turned it off); a new recap is asked for at once and
@@ -31,7 +31,7 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
31
31
 
32
32
  | Piece | Role |
33
33
  |---|---|
34
- | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check/try` (`install --dev`: the Installer's `dev:`, links and `Provenance::DEV_SHA`); runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it). `upgrade` resolves its argument by `upgrade_source`: a git URL or an existing path, then a shipped name, then the bare name of an installed bundle — whose record's source it then uses (a git URL with its `#ref`, a dir or an archive that is still there), printed as `Upgrading NAME from SOURCE`; a dev record is refused with `Installer.dev_install_line`, a recorded source that is relative, empty or gone says so and asks for the path or URL of a copy (`Installer.source_gone_line`, the words `ManifestStanding` uses too) |
34
+ | `BundleCommand` (`lib/samagotchi/bundle_command.rb`) | `chi bundle install/upgrade/uninstall/status/diff/list/build/trash/new/test/check/try` (`install --dev`: the Installer's `dev:`, links and `Provenance::DEV_SHA`); runs the Installer, prints its summary, runs the conflict agent step; refuses a source already installed under another name (`installed_under_another_name?`: a git URL bundle recorded under its repo's name before the manifest named it). `upgrade` resolves its argument by `upgrade_source`: a git URL or an existing path, then a shipped name, then the bare name of an installed bundle — whose record's source it then uses (a git URL with its `#ref`, a dir or an archive that is still there), printed as `Upgrading NAME from SOURCE`; a dev record is refused with `Installer.dev_install_line`, a recorded source that is relative, empty or gone says so and asks for the path or URL of a copy (`Installer.source_gone_line`, the words `ManifestStanding` uses too). After an upgrade or uninstall that changed the bundle's plugin (or brought one in), one line names the sessions still running its old code (`LiveVersions.workers`, the ones whose worker takes a restart) and `chi sessions restart ID` for each: only the plugin stays in memory, since a bundle's rules reload by their stamp and its scripts run from the replaced files. |
35
35
  | `BundleCommand::Check` (bundle_command/check.rb) | `chi bundle check [PATH] [--fix] [--set K=V]`: a bundle being written, checked as an install takes it (docs/cli.md lists the codes); parses the manifest from `Shas.parseable` (a placeholder sha is a `sha` finding, not a YAML error), reads the user's installed bundles only for `clash`, and installs and loads through `Testing::Install` in a `Testing::Sandbox` with `force: true`, the log's echoes swallowed |
36
36
  | `BundleCommand::Try` (`bundle_command/try/`) | `chi bundle try PATH -p TEXT`: `Command` (its flags, help and checks) drives the rest and `Render` prints the trace; a filtered-config `Sandbox` (its folders a `SandboxLayout`, the layout `Testing::Sandbox` uses too) installs the tried bundle and shipped `guardrails` / `cost-cap` in a separate child, then `Run` starts one non-interactive chi process from its realpath work dir; `TraceReader` reads that session's analytics, cards (`Bridge::CardStore.saved`, as the web does) and session JSON by id (never `Session.list`), joining timing and tool args for text or experimental JSON output (a turn a `--timeout` interrupt cut keeps its calls like a Ctrl-C, the one cut short answered as interrupted, so its rows come from the saved calls too; `Run::Result#timed_out` becomes `timed_out`). `Sandbox#child_env` is a Hash, never an ENV mutation; it keeps ordinary user model settings and Keychain access while dropping the spawner's session/guardrail/worker markers (`ChildEnv`, the scrub a session's worker spawn uses too). Every dropped key is sent as nil, so the child really lacks it: a spawn env is merged onto the spawner's, so a key only deleted from the Hash is still inherited (`ChildEnv.spawn_env`). The child runs in its own process group: a `--timeout` sends it INT, then `ProcessGroup.stop` (the groups its processes started too); after any exit the group is signalled only while a straggler is still in it, never a reaped group. `Sandbox#stop_leftovers` (before the trace, and in `cleanup`) stops what left the child's tree, found by the sandbox's own records: a delegate's worker by its session's `owner.lock`, a task by its running `task.json` whose pid still leads its group. |
37
37
  | `BundleCommand::ApiCalls` (bundle_command/api_calls.rb) | what a plugin's source calls of the API, by Prism: `#findings` (a call or keyword the API's classes lack, `check`'s `api`), `#keywords` (the floor check's `call(keyword:)`). Its only list is `MEMBERS`, which object a member gives (`ctx.sessions` → `Plugin::Sessions`); a new such object joins it |
@@ -131,8 +131,12 @@ has the bundle's own manifest at the recorded version (`Provenance#adopt_manifes
131
131
 
132
132
  ## Installer#run, in order
133
133
 
134
- The order matters: warnings print in it, and a plugin/script the manifest names but the bundle lacks raises
135
- `InstallError` after the memories are already copied.
134
+ The order matters: warnings print in it. Before the first write, `AssetInstaller#check_named_files` raises `InstallError`
135
+ for a plugin or script the manifest names but the bundle lacks (or ships as a link): nothing is written then. With
136
+ `refuse_mismatch: true` (only `BundleCommand#installer_for` passes it), `Installer#refuse_memory_mismatches` and
137
+ `AssetInstaller#check_named_checksums` raise the same way for a memory, plugin or script whose bytes disagree with the
138
+ sha its manifest declares -- `--force` skips the check, a dev install follows its own tree, and a non-strict run has no
139
+ shas to trust; `chi update`, `SystemBundle` and a profile pass nothing and keep the post-write warning.
136
140
 
137
141
  1. `load_source`: normalize (or take the `PreparedSource` `BundleCommand` passed as `prepared:`), read `manifest.yml` (strict: a missing/invalid one raises; non-strict: a warning). Then `refuse_too_new`: a bundle whose `requires_chi` this chi doesn't meet raises `InstallError` unless `force` — nothing is written, dry run included (docs/plugins.md, "Loading, and when it fails").
138
142
  2. `resolve_target`: scope = CLI > manifest > `system`; then `check_source` against the existing record
@@ -155,7 +159,8 @@ The order matters: warnings print in it, and a plugin/script the manifest names
155
159
  rules. A file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`) is skipped with a
156
160
  warning (a rule) or raises `InstallError` (the plugin, a script).
157
161
  5. An upgrade's `prune_dropped_files`: each dropped file's `FileState` (`shipped: false`) and its `UPGRADE` / `FORCE` row: unchanged (`#edited?` false) → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
158
- 6. `verify_memory_checksums` (strict except a dev install, whose own tree may have stale shas; `chi bundle check` flags them: a memory whose action takes the bundle's text, `Action#takes_source?`: installed, forced, an upgrade's or a re-install's fast-forward, or merged into a local edit; it checks the source file the write or merge takes its bytes from, so a dry run checks what it would bring in; a `Remerge` merge checks its incoming file too), `warn_missing_needs`.
162
+ 6. `verify_memory_checksums` (strict except a dev install, whose own tree may have stale shas; the warning only, since a
163
+ run that refuses a mismatch (`refuse_mismatch: true`) never reaches it with one unless `--force`; `chi bundle check` flags them: a memory whose action takes the bundle's text, `Action#takes_source?`: installed, forced, an upgrade's or a re-install's fast-forward, or merged into a local edit; it checks the source file the write or merge takes its bytes from, so a dry run checks what it would bring in; a `Remerge` merge checks its incoming file too), `warn_missing_needs`.
159
164
  7. `write_provenance` (manifest, not a dry run): each owned file's base comes from its action's `next_base`
160
165
  (`Installer#next_base`): the file on disk, which has the bundle's bytes; the incoming file for a merge, since the merged
161
166
  file holds the user's edit and taking it would make current == base, so the next upgrade would fast-forward over the
@@ -171,7 +176,21 @@ The order matters: warnings print in it, and a plugin/script the manifest names
171
176
  A file system error in steps 3 to 5 (`Installer#replace_files`; a `.trash` it can't write, an edited file it can't read) is an
172
177
  `InstallError` in `Trash.failure_message`'s words, as it is an `UninstallError` in `Uninstaller#run`. Each save comes before
173
178
  its removal, so no edit is lost, and the record isn't written, so a retry is the same upgrade. Not atomic: files the
174
- failed run already placed stay.
179
+ failed run already placed stay, and the intent journal says they are the bundle's.
180
+
181
+ **The intent journal.** After `check_named_files` and before the first write, a real run with a manifest writes
182
+ `.bundles/<name>/pending.json` (`Provenance#write_pending`, atomically like the record): `{"version": "2.0.0",
183
+ "scope": "project", "target_dir": "/path/to/project-memories", "files": {"a.md": ["<sha256>"]}}`: the target
184
+ `version` is the latest run's target version, while `files` holds the union of incoming shas recorded by interrupted
185
+ runs for that scope/target directory. A retry of another version in the same target adds its incoming shas; a journal
186
+ for another scope directory is ignored. `Provenance#write` removes it with the record write; an uninstall's `rm_rf`
187
+ sweeps it. `FileState.of` reads it:
188
+ a file whose disk sha is one of its shas is that bundle's text at the pending version, owned and its own base (an entry
189
+ made up when the record has none), so a killed fresh install's copy isn't `:user_same` (kept as the user's for good) and
190
+ a killed upgrade's fast-forward isn't `:edited` (a conflict at the next version). Install, upgrade, uninstall and
191
+ `chi bundle status` all go through it. A file matching none of its shas follows the usual rules; a merged file isn't
192
+ journaled (its bytes are no bundle text). Not covered: a journal left by a run that failed before writing a file whose
193
+ text the user already had is read the same way.
175
194
 
176
195
  Which files a plan asks `Merger::Upgrade` about is the caller's, and the lists differ, each less the files the bundle
177
196
  doesn't own: the Installer's `MemoryFiles.select`; `ShippedUpdate.forecast`' every shipped `*.md`, so `chi update`'s plan
@@ -61,7 +61,10 @@ the payload, so an edit and a continue carry none: a new link starts with no las
61
61
  ## Editing
62
62
 
63
63
  `/objectives` (`ObjectivesCommand`, registered `anytime`) reads `Objectives.current`. An edit goes out through
64
- `NoteDelivery.deliver(objectives:)`, which means:
64
+ `NoteDelivery.deliver(objectives:, pin: true)`, which means:
65
+
66
+ - It stays in its own session, even one that was continued: the list is built from this conversation, so it never
67
+ follows the chain to the newest link (as other notes do) and overwrites that link's list.
65
68
 
66
69
  - One edit's read and write are one step (`Objectives.with_lock`, a file lock in the session's folder, across threads
67
70
  and processes), and the worker's absorb holds it from its claim to the files' delete, so no edit reads between them.
@@ -176,7 +179,10 @@ command), which absorbs the file. It no longer waits for the fallback tick.
176
179
  `SessionChain.note_text` (capped; the last answer gets what the recap leaves). It reads the previous link fresh,
177
180
  with its unabsorbed newest file. A failure is only logged (`continue_objectives_failed`), because it runs inside the
178
181
  continue's rollback. A review link's list stays behind, as its last answer does: the next link's participants could
179
- differ.
182
+ differ. The previous link's other waiting notes move after it (`NoteMove.move`, the step after: moved first, the
183
+ carry would miss a pending edit, and the stale list would be the batch's newest); its objectives notes never move,
184
+ and the new link's worker never adopts one from it (`NoteMove.adoption`), so a list from the archived link can't
185
+ overwrite the carried one.
180
186
  - **Rollback**: a note survives `TurnFlow#restore`, and an unabsorbed edit is on disk, untouched.
181
187
  - **Fork, delegate**: a plugin fork copies what it is passed. A delegate starts with an empty fold, so the tool answers
182
188
  that there are none.
@@ -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
@@ -44,8 +44,10 @@ the folder is left as is: it is created when missing, nothing is marked).
44
44
  by its path (memories, project context sources, the skills bundle's kept versions) move to the id,
45
45
  each leaving a link by its old name (an older chi, keying by path, finds the same folder), then the
46
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 path left (the same folder name with
48
- another hash: a folder moved before it had an id), merging nothing. A link stays behind: a new
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
49
51
  folder (or git repository) made later at the old path reaches the moved project's memories
50
52
  through it; delete the link to stop that. chi web's project picker marks a new folder with an id
51
53
  but never moves folders: a folder it marks that already has path-keyed memories gets no id until
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,7 @@ 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.
10
11
 
11
12
  The file is versioned: each entry member is required or declares the format
12
13
  version and default that introduced it. Rewrites preserve unknown keys and
@@ -104,7 +105,10 @@ A cron tick cannot read a worker's memory, so the tick has two paths.
104
105
 
105
106
  **No live worker** (the usual case for a weekly schedule): the files say what can
106
107
  be known — the saved status is not `running` (and not inside the starting grace),
107
- `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.
108
112
 
109
113
  **A live worker**: ask it, over the new Bridge route
110
114
  `GET /session/:id/activity` (`Worker#activity_snapshot`, the same signals
@@ -162,6 +166,16 @@ both counters**, so twelve skips spread over months do not kill a healthy weekly
162
166
  schedule. `list` prints the skip budget as a time window — 12 skips at a 5 minute
163
167
  tick is about an hour of patience, which is what the count really measures.
164
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
+
165
179
  A chain holds at most `schedule.max_per_session` enabled schedules (default 10) and
166
180
  the machine `schedule.max_total` (default 50); `schedule_task` and `chi schedule add`
167
181
  refuse past them, naming the key. A schedule the chain already has — the same prompt
@@ -175,7 +189,13 @@ is refused with exit 2, naming the matches.
175
189
  The tick's lock covers the file only, never the action: the clock advances before
176
190
  the work starts — for `every` and `weekday` to the next occurrence, for a `date`
177
191
  one-off to none — so a tick killed mid-continue cannot fire the same entry twice.
178
- `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.
179
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
180
200
  own retry loop follows a continue a human made between our resolve and its lock
181
201
  instead of racing it.
@@ -204,6 +224,11 @@ pruned" instead of linking to a deleted session.
204
224
  If a link to prune still has a worker (`DeleteRefused(:worker_running)`), pruning
205
225
  stops there for this firing and is retried at the next one.
206
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
+
207
232
  ## Running the tick
208
233
 
209
234
  `chi schedule tick` is quiet and safe to run often; it is this machine only
@@ -220,7 +245,9 @@ Run it from systemd or launchd instead if you prefer a timer: the command is the
220
245
  same, `chi schedule tick`, and the lock makes overlapping runs harmless — the
221
246
  second one exits 0 with "no schedules due (another tick holds the lock)".
222
247
  `--dry-run` prints what would fire and changes nothing; `--format json` is the seam
223
- 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.
224
251
 
225
252
  A tick fires **one entry per chain**: of the due entries of one chain, the one due
226
253
  earliest (`next_due`, then id) fires and the rest are skipped — "the chain just
@@ -234,8 +261,13 @@ they queue a turn, they do not archive.
234
261
 
235
262
  `schedule.auto_tick: true` makes chi web's hub tick on
236
263
  `schedule.tick_interval_minutes` (default 5) as well, sharing the same lock, so
237
- cron and the hub can coexist without firing twice. It is off by default: cron or
238
- 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`.
239
271
 
240
272
  See [cli.md](cli.md) for the subcommands and flags, and
241
273
  [configuration.md](configuration.md) for the `schedule.*` settings.