samagotchi 0.157.0 → 0.159.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -47,6 +47,8 @@ module Samagotchi
47
47
  "web_fetch" => { content: "url", fallback: :prefix_or_raw },
48
48
  "register_reminder" => { content: "name", fallback: :prefix_or_raw },
49
49
  "cancel_reminder" => { content: "name", fallback: :prefix_or_raw },
50
+ "schedule_task" => { content: "prompt", fallback: :prefix_or_raw },
51
+ "cancel_schedule" => { content: "name", fallback: :prefix_or_raw },
50
52
  "list_sessions" => { fallback: :prefix, fallback_key: "cwd" },
51
53
  "send_note" => { content: "text" },
52
54
  "context_read" => { content: "name", fallback: :prefix_or_raw },
@@ -18,6 +18,9 @@ require_relative "web_fetch"
18
18
  require_relative "register_reminder"
19
19
  require_relative "cancel_reminder"
20
20
  require_relative "list_reminders"
21
+ require_relative "schedule_task"
22
+ require_relative "list_schedules"
23
+ require_relative "cancel_schedule"
21
24
  require_relative "list_sessions"
22
25
  require_relative "send_note"
23
26
  require_relative "context_read"
@@ -54,6 +57,9 @@ module Samagotchi
54
57
  RegisterReminder,
55
58
  CancelReminder,
56
59
  ListReminders,
60
+ ScheduleTask,
61
+ ListSchedules,
62
+ CancelSchedule,
57
63
  ListSessions,
58
64
  SendNote,
59
65
  ContextRead,
@@ -103,6 +109,17 @@ module Samagotchi
103
109
  end,
104
110
  CancelReminder::NAME => ->(call, kctx) { CancelReminder.call(call[:content], reminder_store: kctx.reminder_store) },
105
111
  ListReminders::NAME => ->(call, kctx) { ListReminders.call(call[:content], reminder_store: kctx.reminder_store) },
112
+ ScheduleTask::NAME => lambda do |call, kctx|
113
+ ScheduleTask.call(call[:content], title: call[:title], prompt: call[:prompt], every: call[:every],
114
+ weekday: call[:weekday], time: call[:time], date: call[:date], mode: call[:mode],
115
+ keep_links: call[:keep_links], peers: kctx.peers)
116
+ end,
117
+ ListSchedules::NAME => lambda do |call, kctx|
118
+ ListSchedules.call(call[:content], include_disabled: call[:include_disabled], all: call[:all], peers: kctx.peers)
119
+ end,
120
+ CancelSchedule::NAME => lambda do |call, kctx|
121
+ CancelSchedule.call(call[:content], all: call[:all], peers: kctx.peers)
122
+ end,
106
123
  ListSessions::NAME => ->(call, kctx) { ListSessions.call(call[:content], peers: kctx.peers, cwd: call[:cwd]) },
107
124
  SendNote::NAME => ->(call, kctx) { SendNote.call(call[:content], session: call[:session], peers: kctx.peers) },
108
125
  ContextRead::NAME => lambda do |call, kctx|
@@ -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
@@ -281,9 +281,10 @@ module Samagotchi
281
281
  private_class_method :unknown_toolkit
282
282
 
283
283
  # The new child's folder: the parent's, or +cwd+ (relative to the
284
- # parent's) when it is a folder of the parent's repository, in any of
285
- # its worktrees (one project_root, from the common git dir). Outside
286
- # git only a subfolder of the parent's folder. Nothing is created
284
+ # parent's) when it is a folder of the parent's project: in any of its
285
+ # repository's worktrees (one project_root, from the common git dir), or
286
+ # in its marked folder. Outside both only a subfolder of the parent's
287
+ # folder. Nothing is created
287
288
  # here: the model makes a worktree with execute, where guardrails see it.
288
289
  # @return [String] an absolute folder, or an Error: line
289
290
  def self.child_cwd(cwd, parent)
@@ -294,8 +295,8 @@ module Samagotchi
294
295
  return "Error: cwd #{folder} is not a folder (create the worktree first)" unless File.directory?(folder)
295
296
 
296
297
  folder = File.realpath(folder)
297
- unless same_repository?(folder, base)
298
- return "Error: cwd must be a folder of this session's repository (a worktree or subfolder of it), not #{folder}"
298
+ unless same_project?(folder, base)
299
+ return "Error: cwd must be a folder of this session's project (a worktree or subfolder of it), not #{folder}"
299
300
  end
300
301
  if MemoryPaths.in_repo?(base) && !work_tree?(folder)
301
302
  return "Error: cwd #{folder} is not in a work tree of this session's repository (a git dir, or a bare " \
@@ -327,8 +328,11 @@ module Samagotchi
327
328
  def self.within?(path, dir) = path == dir || path.start_with?("#{dir}/")
328
329
  private_class_method :within?
329
330
 
330
- def self.same_repository?(folder, base)
331
- if MemoryPaths.in_repo?(base)
331
+ # One project (a git repository or a marked folder): the same project
332
+ # root, so a nested repository or marked project is another one. A base
333
+ # in no project: a subfolder of it.
334
+ def self.same_project?(folder, base)
335
+ if MemoryPaths.in_project?(base)
332
336
  MemoryPaths.project_root(folder) == MemoryPaths.project_root(base)
333
337
  else
334
338
  within?(folder, File.realpath(base))
@@ -336,7 +340,7 @@ module Samagotchi
336
340
  rescue SystemCallError
337
341
  false
338
342
  end
339
- private_class_method :same_repository?
343
+ private_class_method :same_project?
340
344
 
341
345
  # @return [String] the child's id, or an Error: line
342
346
  def self.follow_up(task, session:, parent:, state_dir:)
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.157.0"
4
+ VERSION = "0.159.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -17,6 +17,7 @@ require_relative "../delivery"
17
17
  require_relative "../answer_tail"
18
18
  require_relative "../answer_refs"
19
19
  require_relative "../question_asked"
20
+ require_relative "../project_init"
20
21
  require_relative "../bridge_client"
21
22
  require_relative "../worker_link"
22
23
  require_relative "../bridge/bounded_queue"
@@ -268,6 +269,7 @@ module Samagotchi
268
269
  ["GET", %r{\A/(?:index\.html)?\z}, :serve_index, :participant],
269
270
  ["GET", %r{\A/api/sessions\z}, :handle_list, :owner],
270
271
  ["GET", %r{\A/api/projects\z}, :handle_projects, :owner],
272
+ ["POST", %r{\A/api/projects\z}, :handle_project_create, :owner],
271
273
  ["POST", %r{\A/api/sessions\z}, :handle_create, :owner],
272
274
  ["GET", %r{\A/api/sessions/resolve\z}, :handle_resolve, :participant],
273
275
  ["GET", %r{\A/api/sessions/summaries\z}, :handle_summaries, :owner],
@@ -1006,6 +1008,39 @@ module Samagotchi
1006
1008
  json_response(200, ProjectList.build(@hub.snapshot, start_dir: start_dir, current_root: current_root))
1007
1009
  end
1008
1010
 
1011
+ def handle_project_create(req)
1012
+ body = parse_json(request_body(req))
1013
+ return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
1014
+
1015
+ requested = body["dir"]
1016
+ requested = requested.strip if requested.is_a?(String)
1017
+ unless requested.is_a?(String) && !requested.empty? && !requested.include?("\0")
1018
+ return error_response(400, "invalid_dir", "dir must be an absolute folder")
1019
+ end
1020
+
1021
+ home = Dir.home
1022
+ dir = if requested == "~"
1023
+ home
1024
+ elsif requested.start_with?("~/")
1025
+ File.join(home, requested.delete_prefix("~/"))
1026
+ else
1027
+ requested
1028
+ end
1029
+ return error_response(400, "invalid_dir", "dir must be an absolute folder") unless dir.start_with?("/")
1030
+
1031
+ # One normal form for the decision, the mkdir and the realpath (a `..` after a symlink would differ).
1032
+ dir = File.expand_path(dir)
1033
+ outcome = ProjectInit.call(dir, home: home)
1034
+ return error_response(422, outcome.reason.to_s, outcome.message) if outcome.kind == :refused
1035
+
1036
+ if outcome.kind == :git
1037
+ failed = ProjectInit.ensure_folder(dir)
1038
+ return error_response(422, failed.reason.to_s, failed.message) if failed
1039
+ end
1040
+ dir = File.realpath(dir)
1041
+ json_response(200, outcome: outcome.kind.to_s, root: outcome.root, dir: dir)
1042
+ end
1043
+
1009
1044
  # A scope folder from the page: [dir, nil] (dir nil when none was
1010
1045
  # given), or [nil, a 400 response] when it isn't an existing absolute
1011
1046
  # folder (a bookmarked worktree deleted since, a typo).
@@ -6,8 +6,8 @@ require_relative "push/attention"
6
6
 
7
7
  module Samagotchi
8
8
  module Web
9
- # Projects represented by the session hub, plus the start project even
10
- # when it has no sessions.
9
+ # Projects represented by the session hub, plus the start and current
10
+ # projects even when they have no sessions.
11
11
  module ProjectList
12
12
  module_function
13
13
 
@@ -24,6 +24,7 @@ module Samagotchi
24
24
  (groups[root] ||= []) << summary
25
25
  end
26
26
  groups[default_root] ||= [] if valid_root?(default_root)
27
+ groups[current_root] ||= [] if valid_root?(current_root)
27
28
 
28
29
  rows = groups.map do |root, sessions|
29
30
  default = root == default_root
@@ -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). */
@@ -1488,6 +1488,11 @@ body.has-session #newBtn .brand-new{display:inline}
1488
1488
  .project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
1489
1489
  .project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
1490
1490
  .project-error{color:var(--warn-text)}
1491
+ .project-picker .project-new{width:100%;text-align:left;background:var(--bg-inset);color:var(--accent)}
1492
+ .project-create{display:grid;gap:8px;padding:8px 2px}
1493
+ .project-create label{color:var(--text-muted);font-size:12px}
1494
+ .project-create input{width:100%;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset);color:var(--text)}
1495
+ .project-create-actions{display:flex;justify-content:flex-end;gap:8px}
1491
1496
  body.has-session #scope .scope-where{display:none}
1492
1497
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
1493
1498
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
@@ -1572,6 +1577,9 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1572
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}
1573
1578
  /* A row with its time (chainStamp): every row's date as wide, so the previews line up. */
1574
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}
1575
1583
  /* The session page's links to the previous and the next link of its chain. */
1576
1584
  #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
1577
1585
  @media(hover:hover){#infoBar .meta a.chain-link:hover{background:var(--accent-bg)}}
@@ -1780,7 +1788,7 @@ a{color:var(--accent)}
1780
1788
  @media (max-width:600px),(pointer:coarse){
1781
1789
  #composer textarea,.question-freeform,.continue-reason,.participants-popover input,.participants-popover select,
1782
1790
  .rs-form textarea,.pp-link input,.llmctx-field select,.llmctx-field input,.ws-add-in,.rename-input,
1783
- #modelSearch{font-size:16px}
1791
+ #modelSearch,.project-create input{font-size:16px}
1784
1792
  }
1785
1793
  </style>
1786
1794
  </head>
@@ -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>`;
@@ -24,10 +24,19 @@ function projectRow(row) {
24
24
  </a>`;
25
25
  }
26
26
 
27
+ function projectParent(rootLabel) {
28
+ if (typeof rootLabel !== "string" || !rootLabel) return "~/";
29
+ const slash = rootLabel.lastIndexOf("/");
30
+ if (slash < 0) return "~/";
31
+ const parent = rootLabel.slice(0, slash);
32
+ return parent ? `${parent.replace(/\/$/, "")}/` : "/";
33
+ }
34
+
27
35
  // +rows+ is the API's ordered list. When there is no project scope, the
28
36
  // all-projects row is current; a bad ?dir= has no current project to mark.
29
37
  export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
30
- allCurrent = false } = {}) {
38
+ allCurrent = false, createOpen = false, createPath = "~/", creating = false,
39
+ createError = "", onPathInput = () => {} } = {}) {
31
40
  const allHref = allScopeHref("", currentDir);
32
41
  return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
33
42
  data-act="close" aria-label="Close">×</button></div>
@@ -39,7 +48,15 @@ export function pickerTemplate({ rows = [], loading = false, error = "", current
39
48
  <span class="project-main"><span class="project-name">All projects</span
40
49
  ><span class="project-hint">Every session</span></span>
41
50
  ${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
42
- </a>
51
+ </a>${createOpen ? html`<form class="project-create" aria-busy=${creating}>
52
+ <label for="projectCreatePath">Folder path</label>
53
+ <input id="projectCreatePath" name="dir" type="text" autocomplete="off" .value=${createPath}
54
+ aria-invalid=${createError ? "true" : "false"} aria-describedby=${createError ? "projectCreateError" : nothing}
55
+ @input=${onPathInput}>
56
+ ${createError ? html`<div class="project-note project-error" id="projectCreateError" role="alert">${createError}</div>` : nothing}
57
+ <div class="project-create-actions"><button type="submit">${creating ? "Creating…" : "Create"}</button>
58
+ <button type="button" class="ghost" data-act="cancel-new">Cancel</button></div>
59
+ </form>` : html`<button type="button" class="project-row project-new" data-act="new-project">New project…</button>`}
43
60
  </nav>`;
44
61
  }
45
62
 
@@ -51,6 +68,12 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
51
68
  let error = "";
52
69
  // Only the newest read is drawn; a close drops the one out (latest.js).
53
70
  const latest = createLatest();
71
+ // Creating a project must not invalidate an in-flight list read.
72
+ const createLatestAnswer = createLatest();
73
+ let createOpen = false;
74
+ let createPath = "~/";
75
+ let creating = false;
76
+ let createError = "";
54
77
 
55
78
  const currentDir = () => scopeError() ? null : scope.dir || null;
56
79
  const paint = (focus = false) => {
@@ -58,6 +81,8 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
58
81
  render(pickerTemplate({
59
82
  rows, loading, error, currentDir: currentDir(),
60
83
  allCurrent: !scope.dir && !failedScope,
84
+ createOpen, createPath, creating, createError,
85
+ onPathInput: (event) => { createPath = event.currentTarget.value; },
61
86
  }), shell.pop);
62
87
  if (focus) {
63
88
  const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
@@ -89,11 +114,53 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
89
114
  if (!fresh()) return;
90
115
  loading = false;
91
116
  if (shell.isOpen()) {
92
- paint(focus);
117
+ // An open form keeps the caret: a late list must not pull focus to a row.
118
+ paint(focus && !createOpen);
93
119
  shell.replace();
94
120
  }
95
121
  }
96
122
 
123
+ async function submitProject() {
124
+ if (creating) return;
125
+ const fresh = createLatestAnswer.begin();
126
+ creating = true;
127
+ createError = "";
128
+ paint();
129
+ shell.replace();
130
+ try {
131
+ const answer = await api("/api/projects", { method: "POST", body: JSON.stringify({ dir: createPath }) });
132
+ if (!fresh()) return;
133
+ win.location.assign(projectScopeHref(answer.dir, ""));
134
+ } catch (e) {
135
+ if (!fresh()) return;
136
+ createError = e?.body?.detail || e?.message || String(e);
137
+ creating = false;
138
+ paint();
139
+ shell.replace();
140
+ }
141
+ }
142
+
143
+ function openCreateForm() {
144
+ createOpen = true;
145
+ createPath = projectParent(rows.find((row) => row.default)?.root_label);
146
+ createError = "";
147
+ creating = false;
148
+ paint();
149
+ shell.replace();
150
+ const input = shell.pop.querySelector("#projectCreatePath");
151
+ input?.focus();
152
+ input?.setSelectionRange(input.value.length, input.value.length);
153
+ }
154
+
155
+ function closeCreateForm() {
156
+ createLatestAnswer.drop();
157
+ createOpen = false;
158
+ creating = false;
159
+ createError = "";
160
+ paint();
161
+ shell.replace();
162
+ }
163
+
97
164
  const shell = createChipPopover({
98
165
  id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
99
166
  summary: () => ({}), sheet: true, label: "Projects", doc, win,
@@ -101,10 +168,25 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api,
101
168
  rows = [];
102
169
  loading = true;
103
170
  error = "";
171
+ createOpen = false;
172
+ createPath = "~/";
173
+ creating = false;
174
+ createError = "";
104
175
  paint();
105
176
  void load(true);
106
177
  },
107
- onClose: () => { latest.drop(); },
178
+ onSubmit: () => { void submitProject(); },
179
+ onAct: (name) => {
180
+ if (name === "new-project") openCreateForm();
181
+ else if (name === "cancel-new") closeCreateForm();
182
+ },
183
+ onClose: () => {
184
+ latest.drop();
185
+ createLatestAnswer.drop();
186
+ createOpen = false;
187
+ creating = false;
188
+ createError = "";
189
+ },
108
190
  });
109
191
 
110
192
  return { pop: shell.pop, isOpen: shell.isOpen, load };
@@ -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
  }