samagotchi 0.158.0 → 0.160.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -7
  5. data/docs/cli.md +57 -2
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +24 -3
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/attached-context.md +4 -2
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +9 -7
  13. data/docs/internals/engine.md +7 -1
  14. data/docs/memory.md +46 -14
  15. data/docs/scheduling.md +241 -0
  16. data/docs/sessions.md +28 -2
  17. data/lib/samagotchi/bridge.rb +37 -4
  18. data/lib/samagotchi/broadcast/scope_card.rb +2 -1
  19. data/lib/samagotchi/bundle_command.rb +39 -1
  20. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  22. data/lib/samagotchi/client_id.rb +17 -3
  23. data/lib/samagotchi/command_intake.rb +56 -0
  24. data/lib/samagotchi/config.rb +12 -0
  25. data/lib/samagotchi/context_absorber.rb +6 -3
  26. data/lib/samagotchi/context_candidate_choice.rb +8 -5
  27. data/lib/samagotchi/context_candidates.rb +11 -8
  28. data/lib/samagotchi/context_command.rb +9 -3
  29. data/lib/samagotchi/context_move.rb +74 -0
  30. data/lib/samagotchi/context_poller.rb +5 -2
  31. data/lib/samagotchi/context_sources.rb +6 -4
  32. data/lib/samagotchi/engine/events.rb +13 -2
  33. data/lib/samagotchi/engine/guardrails.rb +3 -4
  34. data/lib/samagotchi/engine.rb +1 -0
  35. data/lib/samagotchi/engine_peers.rb +1 -0
  36. data/lib/samagotchi/events.rb +71 -0
  37. data/lib/samagotchi/guardrails/approvals.rb +27 -3
  38. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  39. data/lib/samagotchi/hook_routes.rb +0 -1
  40. data/lib/samagotchi/memory_bundle/asset_installer.rb +106 -104
  41. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  42. data/lib/samagotchi/memory_bundle/bundle_hook.rb +7 -27
  43. data/lib/samagotchi/memory_bundle/file_actions.rb +50 -0
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installer.rb +14 -11
  46. data/lib/samagotchi/memory_bundle/manifest.rb +6 -0
  47. data/lib/samagotchi/memory_bundle/shipped_update.rb +22 -15
  48. data/lib/samagotchi/memory_bundle/status.rb +22 -3
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +12 -4
  50. data/lib/samagotchi/memory_orphans.rb +14 -4
  51. data/lib/samagotchi/memory_paths.rb +29 -6
  52. data/lib/samagotchi/memory_usage.rb +23 -12
  53. data/lib/samagotchi/plugin/attached_context.rb +3 -1
  54. data/lib/samagotchi/plugin/sessions.rb +4 -1
  55. data/lib/samagotchi/project_command.rb +58 -20
  56. data/lib/samagotchi/project_id.rb +102 -0
  57. data/lib/samagotchi/project_id_clash.rb +62 -0
  58. data/lib/samagotchi/project_init.rb +214 -10
  59. data/lib/samagotchi/project_scope.rb +11 -2
  60. data/lib/samagotchi/run_options.rb +1 -0
  61. data/lib/samagotchi/schedule_command.rb +252 -0
  62. data/lib/samagotchi/schedules.rb +1008 -0
  63. data/lib/samagotchi/session.rb +17 -2
  64. data/lib/samagotchi/session_access/contract.rb +1 -1
  65. data/lib/samagotchi/session_access/local.rb +28 -5
  66. data/lib/samagotchi/session_access/remote.rb +2 -1
  67. data/lib/samagotchi/session_access.rb +4 -1
  68. data/lib/samagotchi/session_commands.rb +7 -3
  69. data/lib/samagotchi/session_continue.rb +10 -6
  70. data/lib/samagotchi/session_manager/spawn.rb +34 -13
  71. data/lib/samagotchi/session_manager/worker_command.rb +11 -23
  72. data/lib/samagotchi/state_format.rb +118 -0
  73. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  74. data/lib/samagotchi/tool_declarations.rb +43 -0
  75. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  76. data/lib/samagotchi/tools/builtins.rb +17 -0
  77. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  78. data/lib/samagotchi/tools/context_read.rb +7 -2
  79. data/lib/samagotchi/tools/delegate.rb +10 -2
  80. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  81. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  82. data/lib/samagotchi/turn_stages/context_judge.rb +7 -7
  83. data/lib/samagotchi/update_command.rb +2 -2
  84. data/lib/samagotchi/version.rb +1 -1
  85. data/lib/samagotchi/web/app.rb +5 -4
  86. data/lib/samagotchi/web/public/chains.js +32 -8
  87. data/lib/samagotchi/web/public/index.html +4 -1
  88. data/lib/samagotchi/web/public/info_bar.js +4 -3
  89. data/lib/samagotchi/web/public/turn_events.js +4 -0
  90. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  91. data/lib/samagotchi/web/session_hub.rb +20 -0
  92. data/lib/samagotchi/worker.rb +42 -12
  93. data/lib/samagotchi/worker_boot.rb +3 -4
  94. data/lib/samagotchi/worker_inbound.rb +44 -2
  95. data/lib/samagotchi/worker_link.rb +5 -0
  96. data/lib/samagotchi/worker_wakes.rb +9 -1
  97. metadata +12 -1
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # Cancel a durable schedule of this session's chain by its id, a unique
6
+ # prefix of it, or its title (docs/scheduling.md). A schedule of another
7
+ # session's chain is refused unless the call says `all: true`: `chi
8
+ # schedule cancel` is the machine-wide way.
9
+ class CancelSchedule
10
+ NAME = "cancel_schedule"
11
+
12
+ def self.name = NAME
13
+
14
+ def self.call(content, all: false, peers: nil)
15
+ # Required here, not at the top of the file: the kit does not pay
16
+ # for the session lifecycle until a schedule is actually used.
17
+ require_relative "../schedules"
18
+ name = content.to_s.strip
19
+ return "Error: cancel_schedule needs the schedule's id or title" if name.empty?
20
+
21
+ want_all = all == true || all.to_s.strip.casecmp?("true")
22
+ schedules = Schedules.new(state_dir: peers&.state_dir)
23
+ entry = schedules.lookup(name)
24
+ return "No schedule named #{name}." if entry.nil?
25
+
26
+ if want_all || peers&.session_id.nil?
27
+ schedules.cancel(name)
28
+ return "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
29
+ end
30
+
31
+ unless schedules.chain_of(peers.session_id).any? { |id| id == entry.anchor || id == entry.head }
32
+ return "Error: #{entry.id} belongs to another session's chain. " \
33
+ "chi schedule cancel #{entry.id} cancels it, or say all: true if you meant it."
34
+ end
35
+
36
+ schedules.cancel(name)
37
+ "Cancelled #{entry.id} (#{entry.title || entry.recurrence_words})."
38
+ rescue StandardError => e
39
+ "Error: #{e.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -51,8 +51,13 @@ module Samagotchi
51
51
  @own = ContextSources.session_location(peers.session_id, state_dir: @state_dir)
52
52
  # The Engine's PeerView knows the session's project; a plain Peers
53
53
  # (specs, a bare kernel) has only its folder.
54
- root = peers.respond_to?(:project_root) ? peers.project_root : ProjectScope.root_for(peers.cwd)
55
- @attached = ContextSources.attached(peers.session_id, project_root: root, state_dir: @state_dir)
54
+ root, id = if peers.respond_to?(:project_root)
55
+ [peers.project_root, peers.project_id]
56
+ else
57
+ found = ProjectScope.project_for(peers.cwd)
58
+ [found&.root, found&.id]
59
+ end
60
+ @attached = ContextSources.attached(peers.session_id, project_root: root, project_id: id, state_dir: @state_dir)
56
61
  @max_chars = [max_chars.to_i, MIN_CHARS].max
57
62
  @now = now
58
63
  # The peer bundles' declared sources (PeerSources), through the
@@ -13,6 +13,10 @@ require_relative "../child_reports"
13
13
  require_relative "../worker_link"
14
14
 
15
15
  module Samagotchi
16
+ # Loaded on the first task (a leaf; the bundle test kit's load budget,
17
+ # spec/samagotchi/testing/sandbox_spec.rb).
18
+ autoload :CommandIntake, File.expand_path("../command_intake", __dir__)
19
+
16
20
  # Loaded on first use: this autoload keeps SessionManager and its ~80
17
21
  # files unloaded until the delegate/session path needs them.
18
22
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
@@ -53,6 +57,9 @@ module Samagotchi
53
57
 
54
58
  task = content.to_s.strip
55
59
  return "Error: give the task, the child's first message" if task.empty?
60
+ # The child's intake takes no shell line from its parent: say so
61
+ # now, so the model rewrites the task (a /word one is the child's text).
62
+ return "Error: #{CommandIntake::REFUSED}" if CommandIntake.shell_line?(task)
56
63
 
57
64
  sd = peers.state_dir || Session.default_state_dir
58
65
  parent = Session.load(peers.session_id, state_dir: sd)
@@ -172,7 +179,8 @@ module Samagotchi
172
179
  parent_answers: parent.parent_answers)
173
180
  child = SessionManager.spawn_session(prompt: task, working_directory: folder,
174
181
  model_name: child_model(model, parent), memories: CHILD_MEMORIES,
175
- parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir)
182
+ parent_id: parent.id, delegate: true, setup: setup, state_dir: state_dir,
183
+ client_id: ClientId.delegate(parent.id))
176
184
  DelegateWait.mark_started(parent.id, child, state_dir: state_dir)
177
185
  # The line for a task that names a worktree while the child runs in
178
186
  # the parent's folder (no cwd: given): the child still starts.
@@ -358,7 +366,7 @@ module Samagotchi
358
366
 
359
367
  # Only a reply after this delivery counts as the follow-up's.
360
368
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
361
- delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
369
+ delivered = SessionManager.deliver_turn(id, prompt: task, client_id: ClientId.delegate(parent.id),
362
370
  state_dir: state_dir)
363
371
  case delivered.status
364
372
  when :accepted
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # The durable schedules of this session's chain (docs/scheduling.md): id,
6
+ # mode, recurrence, next due and target. Another session's schedules are
7
+ # out of reach unless the call says `all: true`; `chi schedule list` is the
8
+ # machine-wide view. Schedules is required inside the call, so the testing
9
+ # kit does not pay for the session lifecycle at load time.
10
+ class ListSchedules
11
+ NAME = "list_schedules"
12
+
13
+ def self.name = NAME
14
+
15
+ def self.call(_content, include_disabled: false, all: false, peers: nil)
16
+ # Required here, not at the top of the file: the kit does not pay
17
+ # for the session lifecycle until a schedule is actually used.
18
+ require_relative "../schedules"
19
+ wanted = include_disabled == true || include_disabled.to_s.strip.casecmp?("true")
20
+ want_all = all == true || all.to_s.strip.casecmp?("true")
21
+ schedules = Samagotchi::Schedules.new(state_dir: peers&.state_dir)
22
+
23
+ scoped = !want_all && peers&.session_id
24
+ entries = scoped ? schedules.for_chain(peers.session_id, include_disabled: wanted) : schedules.entries(include_disabled: wanted)
25
+
26
+ if entries.empty?
27
+ scoped ? "No schedules in this session's chain. `chi schedule list` shows every schedule on this machine." : "No schedules."
28
+ else
29
+ lines = entries.map do |entry|
30
+ flags = []
31
+ flags << "skips #{entry.skips} of #{entry.max_skips}" if entry.skips.positive?
32
+ flags << "disabled after #{entry.failures} failures" unless entry.enabled
33
+ "#{entry.id} #{entry.target_words} #{entry.recurrence_words} next #{entry.next_due} #{flags.join(", ")}".rstrip
34
+ end
35
+ lines << "These are this session's chain only; `chi schedule list` shows every schedule on this machine." if scoped
36
+ lines.join("\n")
37
+ end
38
+ rescue StandardError => e
39
+ "Error: #{e.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # Register a durable schedule for this session (docs/scheduling.md): a
6
+ # prompt that runs on a clock, out of the worker's memory, until it is
7
+ # cancelled. The default mode is `chain`: each firing is the next link of
8
+ # this session's chain, so a long-running routine keeps a fresh context.
9
+ # Unlike register_reminder, a schedule needs no live worker — a tick run by
10
+ # cron (or chi web's hub) wakes one — and it never holds an idle worker up.
11
+ class ScheduleTask
12
+ NAME = "schedule_task"
13
+
14
+ def self.name = NAME
15
+
16
+ def self.call(content, title: nil, prompt: nil, every: nil, weekday: nil, time: nil, date: nil,
17
+ mode: nil, keep_links: nil, peers: nil)
18
+ # Required here, not at the top of the file: the kit does not pay
19
+ # for the session lifecycle until a schedule is actually used.
20
+ require_relative "../schedules"
21
+ return "Error: this session's id is not known here" unless peers&.session_id
22
+
23
+ prompt = content.to_s if prompt.to_s.strip.empty?
24
+ schedules = Schedules.new(state_dir: peers.state_dir)
25
+ entry = schedules.add(
26
+ target: peers.session_id, prompt: prompt, title: title,
27
+ recurrence: Schedules.recurrence(every: every, weekday: weekday, time: time, date: date),
28
+ mode: mode.nil? || mode.to_s.strip.empty? ? Schedules::CHAIN : mode.to_s.strip,
29
+ keep_links: keep_links
30
+ )
31
+ "Scheduled #{entry.id}: #{entry.target_words} · #{entry.recurrence_words} · next #{entry.next_due}. " \
32
+ "It fires outside this worker (chi schedule tick, run by cron or chi web's hub); " \
33
+ "chi schedule list and cancel_schedule show and stop it."
34
+ rescue Schedules::Usage, Session::NotFound, Session::AmbiguousId => e
35
+ "Error: #{e.message}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -89,7 +89,8 @@ module Samagotchi
89
89
  def pass(session, texts, phase:, state_dir:, recorder:)
90
90
  return if session.id.nil? || session.scratch
91
91
 
92
- rows = ContextCandidates.with_status(session.id, project_root: session.project_root, state_dir: state_dir)
92
+ rows = ContextCandidates.with_status(session.id, project_root: session.project_root,
93
+ project_id: session.project_id, state_dir: state_dir)
93
94
  return if rows.empty?
94
95
 
95
96
  log = ContextCandidatesLog.log_for(state_dir)
@@ -134,6 +135,7 @@ module Samagotchi
134
135
  def choice(session, log, settings, state_dir)
135
136
  session_id = session.id
136
137
  project_root = session.project_root
138
+ project_id = session.project_id
137
139
  show = lambda do |**card|
138
140
  current = @current_session.call
139
141
  next nil unless current.nil? || current.id == session_id
@@ -146,6 +148,7 @@ module Samagotchi
146
148
  end
147
149
  lambda do |job, fields|
148
150
  ContextCandidateChoice.after_judged(job: job, fields: fields, settings: settings, project_root: project_root,
151
+ project_id: project_id,
149
152
  state_dir: state_dir, log: log, show_card: show)
150
153
  end
151
154
  end
@@ -156,12 +159,9 @@ module Samagotchi
156
159
  def parent_attached(session, state_dir)
157
160
  return nil unless session.delegate? && session.parent_id
158
161
 
159
- parent_root = if Session.exist?(session.parent_id, state_dir: state_dir)
160
- Session.load(session.parent_id, state_dir: state_dir).project_root
161
- else
162
- session.project_root
163
- end
164
- ContextSources.attached(session.parent_id, project_root: parent_root, state_dir: state_dir)
162
+ parent = Session.exist?(session.parent_id, state_dir: state_dir) ? Session.load(session.parent_id, state_dir: state_dir) : session
163
+ ContextSources.attached(session.parent_id, project_root: parent.project_root, project_id: parent.project_id,
164
+ state_dir: state_dir)
165
165
  .map { |attached| attached.source.title || attached.name }
166
166
  rescue StandardError => e
167
167
  Log.warn(:context, "context_parent_attached_failed", error: e.class.name, msg: e.message.to_s[0, 200])
@@ -356,7 +356,7 @@ module Samagotchi
356
356
  notes = file_notes(row, update.name, update.to || update.from, update.merged, update.behind, update.trash_copy, earlier: earlier)
357
357
  case update.status
358
358
  when :would_update, :updated
359
- notes = [adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
359
+ notes = [adds(update.added_files), adds(update.adds), *notes, replaced(update.replaced), *update.kept_own, update.note].compact
360
360
  done(row, :update, notes.empty? ? nil : notes.join("; "))
361
361
  when :would_merge, :merged, :up_to_date then same_version(row, [*notes, update.note, update.manifest].compact, changed: Array(update.merged).any?)
362
362
  when :skipped then skipped(row, [update.note, *notes].join("; "))
@@ -522,7 +522,7 @@ module Samagotchi
522
522
  row
523
523
  end
524
524
 
525
- # A profile's new members: "+ btw".
525
+ # New memory files or profile members: "+ file".
526
526
  def adds(members)
527
527
  members.nil? || members.empty? ? nil : "+ #{members.join(", ")}"
528
528
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.158.0"
4
+ VERSION = "0.160.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1030,7 +1030,7 @@ module Samagotchi
1030
1030
 
1031
1031
  # One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
1032
1032
  dir = File.expand_path(dir)
1033
- outcome = ProjectInit.call(dir, home: home)
1033
+ outcome = ProjectInit.call(dir, home: home, state_dir: default_state_dir)
1034
1034
  return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
1035
1035
 
1036
1036
  if outcome.kind == :git
@@ -1455,7 +1455,8 @@ module Samagotchi
1455
1455
  body = { session_id: id, sources: rows, can_add_url: ContextProviders.any? }
1456
1456
  # The candidates too, for chi context ls (no UI shows them yet).
1457
1457
  if full
1458
- body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root, state_dir: default_state_dir)
1458
+ body[:candidates] = ContextCandidates.with_status(id, project_root: session.project_root, project_id: session.project_id,
1459
+ state_dir: default_state_dir)
1459
1460
  .map { |candidate, status| candidate.to_h.merge("name" => candidate.name, "status" => status) }
1460
1461
  end
1461
1462
  json_response(200, body)
@@ -1573,8 +1574,8 @@ module Samagotchi
1573
1574
  end
1574
1575
 
1575
1576
  def attached_context(session, shadowed: false)
1576
- ContextSources.attached(session.id, project_root: session.project_root, state_dir: default_state_dir,
1577
- shadowed: shadowed)
1577
+ ContextSources.attached(session.id, project_root: session.project_root, project_id: session.project_id,
1578
+ state_dir: default_state_dir, shadowed: shadowed)
1578
1579
  end
1579
1580
 
1580
1581
  # @return [ContextSources::Attached, nil]
@@ -128,10 +128,21 @@ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.
128
128
  return links;
129
129
  }
130
130
 
131
+ // Whether the chain's oldest link the page holds still names a link it does
132
+ // not: a schedule's prune (docs/scheduling.md, keep_links) or a delete. The
133
+ // walk stops there, so every count comes from the surviving root, and the page
134
+ // says "earlier links pruned" rather than linking to a session that is gone.
135
+ export function linksPruned(links, byId) {
136
+ const root = links[0];
137
+ return !!root?.continues && !byId.has(root.continues);
138
+ }
139
+
131
140
  // The session page's neighbours of +id+: {prev, next}, each {id, session}
132
- // or null. +continues+: the open session's own `continues`, when the list
133
- // may not have caught up with it. A previous link the list doesn't hold
134
- // (deleted) is null: its link would open a not-found page.
141
+ // or null, and +pruned+: the session's own `continues` names a link the list
142
+ // does not hold (a schedule pruned it, or it was deleted). +continues+: the
143
+ // open session's own `continues`, when the list may not have caught up with it.
144
+ // A previous link the list doesn't hold is null: its link would open a
145
+ // not-found page.
135
146
  export function chainNeighbours(list, id, { continues = null } = {}) {
136
147
  const self = list.find((s) => s.id === id);
137
148
  const prevId = self?.continues || continues || null;
@@ -139,7 +150,7 @@ export function chainNeighbours(list, id, { continues = null } = {}) {
139
150
  const prev = prevSession ? { id: prevId, session: prevSession } : null;
140
151
  const nextSession = nextsOf(list).get(id) || null;
141
152
  const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
142
- return { prev, next };
153
+ return { prev, next, pruned: !!prevId && !prevSession };
143
154
  }
144
155
 
145
156
  const pad = (n) => String(n).padStart(2, "0");
@@ -188,14 +199,22 @@ export function foldChains(fams, all, { fold = true } = {}) {
188
199
  let changed = false;
189
200
  for (const f of fams) {
190
201
  const links = chainLinks(all, f.head.id, nexts, byId);
202
+ const pruned = linksPruned(links, byId);
191
203
  if (links.length < 2) {
192
- out.push(f);
204
+ // A chain pruned to one link is still a chain: its card carries the chip
205
+ // and the popover says what is behind it.
206
+ if (pruned) {
207
+ changed = true;
208
+ out.push({ ...f, chain: { links, at: 0, folded: fold, pruned } });
209
+ } else {
210
+ out.push(f);
211
+ }
193
212
  continue;
194
213
  }
195
214
  changed = true;
196
215
  const at = links.findIndex((s) => s.id === f.head.id);
197
216
  if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
198
- out.push({ ...f, chain: { links, at, folded: fold } });
217
+ out.push({ ...f, chain: { links, at, folded: fold, pruned } });
199
218
  }
200
219
  return changed ? out : fams;
201
220
  }
@@ -233,7 +252,8 @@ export function chainChipTemplate(f, { open = false } = {}) {
233
252
  const text = n === links.length ? `#${n}` : `#${n} of ${links.length}`;
234
253
  const earlier = at;
235
254
  const later = links.length - 1 - at;
236
- const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
255
+ const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : "",
256
+ f.chain.pruned ? "earlier links pruned" : ""].filter(Boolean);
237
257
  const title = `Session chain: link ${n} of ${links.length} (${parts.join(", ")}); open the list`;
238
258
  const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
239
259
  const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
@@ -275,7 +295,11 @@ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {})
275
295
  date ? html`<span class=${date.length > 10 ? "cr-date timed" : "cr-date"}>${date}</span>` : nothing}<span class="fr-preview">${rowTitle(s, headTitle, byId)}</span>${
276
296
  rowBadgesTemplate(waiting, stopped, s.status)}${s.archived ? archivedBadge : nothing}</button></div>`;
277
297
  });
278
- return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
298
+ const prunedRow = f?.chain?.pruned
299
+ ? html`<div class="chain-row chain-pruned" role="listitem"><span class="cr-n" aria-hidden="true">↩</span>
300
+ <span class="fr-preview">earlier links pruned</span></div>`
301
+ : nothing;
302
+ return html`<div class="family-rows chain-rows" role="list">${rows}${prunedRow}</div>`;
279
303
  }
280
304
 
281
305
  // What the page does with a refused continue (POST /api/sessions
@@ -1384,7 +1384,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1384
1384
  /* One run: the groups' chips are the meta's. */
1385
1385
  #infoBar .meta .bar-group{display:contents}
1386
1386
  #infoBar .meta .bar-sep{display:none}
1387
- #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1387
+ #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.chain-pruned,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1388
1388
  #infoBar .info-actions{display:none}
1389
1389
  #infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
1390
1390
  /* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
@@ -1577,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1577
1577
  .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
1578
1578
  /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1579
1579
  .chain-rows:has(.cr-date.timed) .cr-date{min-width:16ch}
1580
+ /* A chain pruned by a schedule (docs/scheduling.md): the popover's last line and the info bar say the earlier links are gone. */
1581
+ .chain-row.chain-pruned,.chain-row.chain-pruned .cr-n{color:var(--text-muted)}
1582
+ #infoBar .meta .chain-pruned{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap}
1580
1583
  /* The session page's links to the previous and the next link of its chain. */
1581
1584
  #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
1582
1585
  @media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
@@ -98,10 +98,11 @@ export function infoBarHtml(bar) {
98
98
  if (!bar) return nothing;
99
99
  const memories = bar.memories || [];
100
100
  const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
101
- const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
102
- // The parent reads "↑ <short id>"; its title says "delegated by …".
101
+ const related = !!(bar.chain?.prev || bar.chain?.next || bar.chain?.pruned || bar.parent || bar.kids || bar.participants);
102
+ // A chain pruned by a schedule (docs/scheduling.md): the link before this one
103
+ // is gone, so there is nothing to link to — the bar says so instead.
103
104
  const relations = related
104
- ? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
105
+ ? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.pruned && !bar.chain?.prev ? html`<span class="model chain-pruned" title="The links before this one were pruned by a schedule">↩ earlier links pruned</span>` : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
105
106
  : nothing;
106
107
  const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
107
108
  return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
@@ -133,6 +133,9 @@ const CONTEXT_CLIENT = "context:";
133
133
  // A turn chi ran because a background task the session started finished
134
134
  // unseen: task:<task id> (the Worker's finish wake).
135
135
  const TASK_CLIENT = "task:";
136
+ // A durable schedule's firing (chi schedule, the schedule tools):
137
+ // system:schedule:<schedule id>.
138
+ const SCHEDULE_CLIENT = "system:schedule:";
136
139
 
137
140
  // A task id as people read it: the random part a task_create id ends with
138
141
  // (it starts with the time), else its first 8 chars (TaskRecord.short_id).
@@ -149,6 +152,7 @@ export function clientLabel(clientId) {
149
152
  const id = String(clientId);
150
153
  if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
151
154
  if (id.startsWith(TASK_CLIENT) && id.length > TASK_CLIENT.length) return `task ${shortTaskId(id.slice(TASK_CLIENT.length))} finished`;
155
+ if (id.startsWith(SCHEDULE_CLIENT)) return "schedule";
152
156
  const colon = id.indexOf(":");
153
157
  return CLIENT_LABELS[id] || (colon >= 0 && CLIENT_LABELS[id.slice(0, colon + 1)]) || AUTOMATIC_LABEL;
154
158
  }
@@ -101,7 +101,7 @@ module Samagotchi
101
101
  brief: true,
102
102
  session: { id: session.id, status: session.status, title: session.title,
103
103
  display_title: session.display_title, project_root: session.project_root,
104
- working_directory: session.working_directory, model_name: session.model_name,
104
+ project_id: session.project_id, working_directory: session.working_directory, model_name: session.model_name,
105
105
  model_typed: session.model_typed, parent_answers: session.parent_answers,
106
106
  owner: owner&.kind },
107
107
  pending_question: session.pending_question&.to_file
@@ -492,8 +492,8 @@ module Samagotchi
492
492
  # @raise [ArgumentError] no such session
493
493
  def context_target(id)
494
494
  session = session_access.show(id)
495
- ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
496
- label: id[0, 8])
495
+ ContextCommand::Target.new(session_id: id, project_root: session.project_root, project_id: session.project_id,
496
+ cwd: session.working_directory, label: id[0, 8])
497
497
  end
498
498
  end
499
499
  end
@@ -6,6 +6,7 @@ require "time"
6
6
 
7
7
  require_relative "../session"
8
8
  require_relative "../session_manager"
9
+ require_relative "../schedules"
9
10
  require_relative "../bridge_client"
10
11
  require_relative "../worker_link"
11
12
  require_relative "../config"
@@ -105,6 +106,7 @@ module Samagotchi
105
106
  @dir_mtime = nil # the state dir's mtime at the last glob
106
107
  @root_cache = {} # folder → project root, for sessions saved before project_root
107
108
  @next_full_probe = nil
109
+ @next_schedule_tick = nil
108
110
  @wakeup = Queue.new # stop wakes the tick thread out of its sleep
109
111
  @thread = nil
110
112
  @stopped = false
@@ -148,6 +150,8 @@ module Samagotchi
148
150
  check_installed
149
151
  end
150
152
  end
153
+ # Outside the projection's lock: a firing can wait for a recap.
154
+ schedule_tick
151
155
  nil
152
156
  end
153
157
 
@@ -314,6 +318,22 @@ module Samagotchi
314
318
  nil
315
319
  end
316
320
 
321
+ # chi web can be the scheduler when the user opts in (schedule.auto_tick):
322
+ # the same tick cron runs, gated by schedule.tick_interval_minutes and
323
+ # sharing schedules.lock, so cron and the hub never both fire.
324
+ def schedule_tick
325
+ return unless Config.get("schedule.auto_tick")
326
+
327
+ now = @now.call
328
+ return if @next_schedule_tick && now < @next_schedule_tick
329
+
330
+ @next_schedule_tick = now + (Samagotchi::Schedules.tick_interval_minutes * 60)
331
+ Samagotchi::Schedules.new(state_dir: @state_dir).tick
332
+ rescue StandardError => e
333
+ Log.warn(:web, "schedule_tick_failed", error: e.class.name, msg: e.message)
334
+ nil
335
+ end
336
+
317
337
  # A change (the first look too, unless it finds nothing) is a `chi`
318
338
  # event: chi web's terminal line and the open tabs hear it.
319
339
  def check_installed
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "securerandom"
5
5
 
6
6
  require_relative "client_id"
7
+ require_relative "command_intake"
7
8
  require_relative "config"
8
9
  require_relative "engine"
9
10
  require_relative "guardrails/parent_policy"
@@ -338,7 +339,7 @@ module Samagotchi
338
339
  predecessor: @session.continues)
339
340
  # Its attached context's notes (ContextSources).
340
341
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
341
- project_root: @session.project_root,
342
+ project_root: @session.project_root, project_id: @session.project_id,
342
343
  peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
343
344
  # What comes in between turns, and how each kind is saved.
344
345
  @inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
@@ -374,7 +375,9 @@ module Samagotchi
374
375
  # chi web's Stop asks about these before TERMed-ing
375
376
  # the worker (its own holds routes).
376
377
  on_holds: -> { @idle_exit&.stoppable_holds },
377
- on_holds_stop: -> { @hold_stop&.stop_all })
378
+ on_holds_stop: -> { @hold_stop&.stop_all },
379
+ # `chi schedule tick` asks this before firing.
380
+ on_activity: method(:activity_snapshot))
378
381
  # What failed to load and what plugins showed as they loaded, now that
379
382
  # a UI can be there (a late one gets them in the snapshot).
380
383
  @engine.announce_load_events!
@@ -410,12 +413,12 @@ module Samagotchi
410
413
  # there is no live SSE or in-process cancel/answer).
411
414
  # @param on_input [#call, nil] called after the Bridge queues a turn
412
415
  def start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
413
- exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil)
416
+ exit_discards: nil, exit_stopped: nil, on_holds: nil, on_holds_stop: nil, on_activity: nil)
414
417
  require_relative "bridge"
415
418
  Samagotchi::Bridge.new(
416
419
  engine: engine, state_dir: state_dir, session_id: session_id, input_format: SessionInbox::INPUT_FORMAT,
417
420
  on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards,
418
- exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop
421
+ exit_stopped: exit_stopped, on_holds: on_holds, on_holds_stop: on_holds_stop, on_activity: on_activity
419
422
  ).start
420
423
  rescue StandardError => e
421
424
  Log.error(:bridge, "start_failed", echo: "Bridge: failed to start for session #{session_id}: #{e.class}: #{e.message}", sid: session_id, error: e.class.name)
@@ -426,6 +429,22 @@ module Samagotchi
426
429
  Degrade.call(:config, "idle_exit_minutes_unreadable") { Samagotchi::Config.get("session.idle_exit_minutes") }
427
430
  end
428
431
 
432
+ # What this worker is busy with right now, for GET /activity: the same
433
+ # signals WorkerIdleExit#hold reads. A schedule never holds a worker up,
434
+ # so +reminders+ and +tasks_holding+ are reported for the log but are not a
435
+ # reason for a tick to skip a firing.
436
+ # @return [Hash]
437
+ def activity_snapshot
438
+ {
439
+ turn_running: @engine.turn_running?,
440
+ input_pending: input_pending?,
441
+ awaiting_continue: @turn_flow.awaiting_continue?,
442
+ open_streams: @bridge&.open_streams.to_i,
443
+ reminders: @engine.reminder_store&.any? ? 1 : 0,
444
+ tasks_holding: Degrade.call(:worker, "activity_tasks_unreadable") { @task_wakes&.watch&.holding&.size.to_i }
445
+ }
446
+ end
447
+
429
448
  # The delegate children of this session that are running a turn or have
430
449
  # a live worker. ChildrenStatus.of scans the session files; this runs on
431
450
  # an exit request, not every pass.
@@ -450,8 +469,8 @@ module Samagotchi
450
469
  # Attached context's commands, in the background; a new text wakes
451
470
  # the loop to absorb it. Not activity: the idle exit stops it.
452
471
  @context_poller = ContextPoller.new(session_id: @session_id, state_dir: @state_dir,
453
- project_root: @session.project_root, cwd: @session.working_directory,
454
- on_change: -> { @waker.wake }).start
472
+ project_root: @session.project_root, project_id: @session.project_id,
473
+ cwd: @session.working_directory, on_change: -> { @waker.wake }).start
455
474
  @scratch_watch = start_scratch_watch if @session.scratch
456
475
  end
457
476
 
@@ -741,14 +760,15 @@ module Samagotchi
741
760
  end
742
761
 
743
762
  # The first prompt (spawn_session's): a delegate child's task, which is
744
- # its parent's although no client sent it. Its turn carries the id
745
- # spawn_session gave it, so a wait for it (--new --wait) names it.
763
+ # its parent's. Its turn carries the id spawn_session gave it, so a
764
+ # wait for it (--new --wait) names it, and its sender
765
+ # (WorkerInbound#initial_origin).
746
766
  # A Verify fresh child's is a Verify turn on its claim (its verdict is
747
767
  # recorded and forwarded to the parent).
748
768
  def run_initial_prompt(prompt)
749
769
  @initial_turn = true
750
770
  fresh = @session.verify_fresh
751
- run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id),
771
+ run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: @inbound.initial_origin,
752
772
  anchors: { verify: fresh&.anchor }))
753
773
  ensure
754
774
  @initial_turn = false
@@ -1033,9 +1053,9 @@ module Samagotchi
1033
1053
  kept: "Its note stays for the user's next message.", mark: task_wake)
1034
1054
  end
1035
1055
 
1036
- # The idle loop's sleep: the fallback tick, or less while reports wait
1037
- # out the start grace.
1038
- def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due }
1056
+ # The idle loop's sleep: the fallback tick, or less while reports or
1057
+ # attached-context wakes wait out the start grace.
1058
+ def idle_wait = @wakes.idle_wait(@poll_interval) { wake_state == :due || @inbound.context_wake_waiting? }
1039
1059
 
1040
1060
  # The wake budget is spent: the parent's UIs hear once that reports
1041
1061
  # wait for the next message.
@@ -1197,6 +1217,8 @@ module Samagotchi
1197
1217
  # the cards and notices it showed, held: they follow its command_ran,
1198
1218
  # as its output
1199
1219
  def run_command_line(command)
1220
+ return [shell_refused(command), []] if CommandIntake.refused?(command.line, command.client_id)
1221
+
1200
1222
  @engine.holding_announcements do
1201
1223
  command_result(command) do
1202
1224
  @commands.run(command.line, confirm: confirm?(command), as_parent: as_parent?(command), user_ui: user_ui?(command))
@@ -1204,6 +1226,14 @@ module Samagotchi
1204
1226
  end
1205
1227
  end
1206
1228
 
1229
+ # A shell line from a sender that may not run one (CommandIntake: a
1230
+ # delegate's task, a schedule's prompt, a plugin's message): not run,
1231
+ # its command_ran an error with the one refusal text.
1232
+ def shell_refused(command)
1233
+ Log.warn(:worker, "shell_line_refused", client_id: command.client_id)
1234
+ SessionCommands::Result.new(status: :error, output: CommandIntake::REFUSED, changed: [])
1235
+ end
1236
+
1207
1237
  # Whether +command+ asks before a costly /thinking or /reasoning-history
1208
1238
  # change (CacheConfirm): only a person at a web tab or a TUI sees the
1209
1239
  # question; chi send, a delegate, a plugin and a card's action apply it.