samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,1049 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+ require "securerandom"
8
+ require "rbconfig"
9
+
10
+ require_relative "session"
11
+ require_relative "turn_note"
12
+ require_relative "owner_lock"
13
+ require_relative "bridge_client"
14
+ require_relative "log"
15
+ require_relative "log_path"
16
+ require_relative "installed_gem"
17
+ require_relative "recap_store"
18
+ require_relative "image_store"
19
+ require_relative "terminal_ui"
20
+
21
+ module Samagotchi
22
+ # SessionManager coordinates background session processes.
23
+ #
24
+ # Each session runs in its own forked Ruby process, communicating via
25
+ # file-based IPC in the session directory.
26
+ #
27
+ # The session itself (messages + metadata) is always saved by Session at
28
+ # <sessions dir>/<session_id>.json, for foreground chats too. A sibling
29
+ # directory holds the owner lock (any owner, TUI included) and, for
30
+ # background workers, their IPC files:
31
+ # ~/.local/state/samagotchi/sessions/
32
+ # ├── <session_id>.json # the session (Session#save)
33
+ # └── <session_id>/
34
+ # ├── owner.lock # flock held by the session's one owner (OwnerLock)
35
+ # ├── input/ # clients (web/terminal UI) write messages here
36
+ # │ └── <timestamp>.json # one file per user message: {prompt, client_id,
37
+ # │ # enqueued_id} (plain <timestamp>.txt for old workers)
38
+ # ├── notes/ # context notes: background text the worker adds to
39
+ # │ └── <ts>-<rand>.json # the conversation between turns, never a turn
40
+ # │ # ({text, source, from_session?, from_cwd?, created_at})
41
+ # ├── output/ # agent writes responses here
42
+ # │ └── <timestamp>.txt # one file per agent response
43
+ # ├── pid # PID of the owner, written by the owner itself
44
+ # └── bridge.json # Bridge sidecar (how clients reach the worker)
45
+ # Needed only at call time (run_session_loop); worker.rb requires this file.
46
+ autoload :Worker, File.expand_path("worker", __dir__)
47
+
48
+ class SessionManager
49
+ INPUT_DIR = "input"
50
+ # Context notes live apart from input/, so nothing that reads input/
51
+ # (the mid-turn drain, the idle-exit hold, the Waker) ever sees one.
52
+ NOTES_DIR = "notes"
53
+ NOTE_MAX_BYTES = 16 * 1024
54
+ OUTPUT_DIR = "output"
55
+ PID_FILE = "pid"
56
+ # Input-file format this worker reads, advertised in the Bridge sidecar:
57
+ # 2 JSON with the sender's ids (and plain text)
58
+ # 3 images: refs too
59
+ # A worker that doesn't advertise one reads only .txt, and such workers
60
+ # never exit.
61
+ INPUT_FORMAT = 3
62
+ STRUCTURED_INPUT_FORMAT = 2
63
+ IMAGES_INPUT_FORMAT = 3
64
+
65
+ # A turn with images for a worker older than IMAGES_INPUT_FORMAT, which
66
+ # would drop them.
67
+ class ImagesUnsupported < StandardError
68
+ def initialize(msg = "this session's worker predates images: restart it (/exit, then resume)") = super
69
+ end
70
+ # Origin of the synthetic turn queued when reminders are due.
71
+ REMINDER_CLIENT_ID = "system:reminder"
72
+
73
+ # Raised when the interactive TUI owns the session: it runs its own Engine
74
+ # and reads no input files, so a worker must not be spawned or signalled.
75
+ class OwnedByTUI < StandardError
76
+ def initialize(session_id)
77
+ super("session #{session_id} is owned by an interactive TUI")
78
+ end
79
+ end
80
+
81
+ # A note (or a `chi send` message) that can't go in: empty, or over
82
+ # NOTE_MAX_BYTES.
83
+ class NoteRejected < ArgumentError; end
84
+
85
+ # A delete that would pull the session from under its live worker.
86
+ # reason: :worker_running (not asked to stop it) or :still_stopping
87
+ # (stopped, but the worker outlived the wait).
88
+ class DeleteRefused < StandardError
89
+ attr_reader :session_id, :reason
90
+
91
+ def initialize(session_id, reason)
92
+ @session_id = session_id
93
+ @reason = reason
94
+ super(reason == :still_stopping ? "session #{session_id}'s worker is still shutting down" : "session #{session_id}'s worker is running")
95
+ end
96
+ end
97
+
98
+ # Spawn a new background session that processes the given prompt (or,
99
+ # with none, waits idle for input).
100
+ #
101
+ # Returns the session object with its ID. Every worker always starts its
102
+ # per-session Bridge (the single live client transport), so external
103
+ # clients can reach it once the sidecar is published.
104
+ # @param memories [Array<String>] --memory: preloaded into the worker's prompt
105
+ # @param muted_memories [Array<String>] --mute: hidden from the session
106
+ # (both are session fields, so a respawn keeps them)
107
+ # @param parent_id [String, nil] the session that delegates this one (the
108
+ # `delegate` tool) or was forked from; a session field too
109
+ # @param messages [Array<Hash>] a conversation to start from (a plugin's
110
+ # ctx.sessions.fork); its image refs are copied from +images_from+
111
+ # @param images_from [String, nil] the session dir the seed's images are in
112
+ # @param title [String, nil] what the lists show before the first turn
113
+ # (the prompt's preview by default, else the seed's first user message)
114
+ # @return [Session] with #seed_images_dropped: refs whose file was gone
115
+ def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
116
+ memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
117
+ title: nil)
118
+ sd = state_dir || Session.default_state_dir
119
+ session = Session.new_session(
120
+ mode: mode,
121
+ model_name: model_name || Samagotchi::ModelProfile.required_model_name,
122
+ working_directory: working_directory || Dir.pwd,
123
+ preloaded_memory_names: memories,
124
+ muted_memory_names: muted_memories,
125
+ parent_id: parent_id,
126
+ messages: messages
127
+ )
128
+ # With no prompt there is no first turn to run (an attaching UI sends
129
+ # the prompts), so the session starts idle.
130
+ session.status = prompt.to_s.strip.empty? ? Session::STATUS_IDLE : Session::STATUS_RUNNING
131
+ session.last_prompt = prompt
132
+ # The worker takes last_prompt and clears it, and messages are saved at
133
+ # the turn's end: until then this is the only preview a list has.
134
+ session.first_preview = Session.preview_of(title.to_s.strip.empty? ? prompt : title)
135
+ session_dir = Session.session_dir(session.id, state_dir: sd)
136
+ unless session.messages.empty?
137
+ session.messages, dropped = ImageStore.copy_refs(session.messages, from: images_from, to: session_dir)
138
+ session.seed_images_dropped = dropped
139
+ end
140
+ setup_session_directory(session_dir, session, state_dir: sd)
141
+ spawn_worker_for_session(session, state_dir: sd)
142
+ session
143
+ end
144
+
145
+ # Build the opts hash passed to Process.spawn for a forked worker. The
146
+ # child inherits this process's ENV; opts[:env] adds to it (merged, not
147
+ # replaced) the values a worker can't read from its own config: the
148
+ # hosts, default model and log settings as this `chi` resolved them
149
+ # (CLI flags included).
150
+ private_class_method def self.spawn_options(session)
151
+ # Own process group: workers outlive `chi web`, and a Ctrl-C in its
152
+ # terminal must not reach them.
153
+ opts = { out: File::NULL, err: File::NULL, pgroup: true }
154
+ # The worker's tools (and `!cmd`) run in the session's directory, not
155
+ # in the cwd of whoever woke it (`chi web`, another terminal).
156
+ dir = session.working_directory.to_s
157
+ if !dir.empty? && File.directory?(dir)
158
+ opts[:chdir] = dir
159
+ else
160
+ Log.warn(:worker, "cwd_gone", sid: session.id, dir: dir, cwd: Dir.pwd)
161
+ end
162
+ child_env = {}
163
+ # Propagate hosts config for multi-host routing
164
+ begin
165
+ require_relative "config"
166
+ hosts_json = ConfigFile.hosts_json_for_env
167
+ child_env["SAMAGOTCHI_HOSTS_JSON"] = hosts_json if hosts_json && !hosts_json.strip.empty?
168
+ rescue StandardError
169
+ nil
170
+ end
171
+ # Also propagate current default model (may be host-qualified)
172
+ child_env["SAMAGOTCHI_DEFAULT_MODEL"] = ENV["SAMAGOTCHI_DEFAULT_MODEL"] if ENV["SAMAGOTCHI_DEFAULT_MODEL"]
173
+ # A worker gets no CLI args: pass on an idle exit set by any layer.
174
+ idle_exit = config_idle_exit_minutes
175
+ child_env["SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES"] = idle_exit.to_s unless idle_exit.nil?
176
+ # And the spawner's debug log, absolute: a relative log.file would
177
+ # otherwise land in the worker's (the session's) directory.
178
+ log_path = begin LogPath.resolve rescue nil end
179
+ if log_path
180
+ child_env["SAMAGOTCHI_LOG_FILE"] = log_path
181
+ else
182
+ child_env["SAMAGOTCHI_LOG_DISABLE"] = "true"
183
+ end
184
+ # And its level (a --log-level flag isn't in the worker's own config).
185
+ level = begin Config.get("log.level") rescue nil end
186
+ child_env["SAMAGOTCHI_LOG_LEVEL"] = level.to_s if level
187
+ opts[:env] = child_env unless child_env.empty?
188
+ opts
189
+ end
190
+
191
+ # List all sessions, reading status from persisted session.json files.
192
+ # +project_root+: only that project's sessions (nil: every session).
193
+ def self.list_sessions(state_dir: nil, sort: "updated_at", order: "desc", limit: nil, offset: 0, project_root: nil)
194
+ Session.list(state_dir: state_dir || Session.default_state_dir, sort: sort, order: order, limit: limit,
195
+ offset: offset, project_root: project_root)
196
+ end
197
+
198
+ # Prune sessions per retention policy. Delegates to Session.prune with live-worker guard.
199
+ SUMMARY_DESC_LIMIT = 60
200
+
201
+ # Short summaries of sessions, newest first: the picker behind
202
+ # `chi sessions list --live --format json|tsv` (chi note from a script)
203
+ # and the agent's list_sessions tool; both pass the current project as
204
+ # +project_root+ unless asked for every project.
205
+ # @param live [Boolean] only sessions a worker owns now (the owner lock,
206
+ # not the saved status, which a dead worker leaves at "running"); a
207
+ # REPL-owned session is left out: it can't take notes
208
+ # @param cwd [String, nil] only sessions in this folder or below it
209
+ # @param project_root [String, nil] only this project's sessions
210
+ # (Session#project_root)
211
+ # @param limit [Integer, nil] taken after the filters
212
+ # @param include_tests [Boolean] false leaves out test runs
213
+ # @param exclude [String, nil] a session id to leave out (the asker)
214
+ # @return [Array<Hash>] {id:, short_id:, desc:, preview:, cwd:, project:,
215
+ # updated_at:, status:, live:, busy:, owner:, recap:, parent_id:,
216
+ # parent_short_id:}; busy = live with
217
+ # a turn running, recap = the saved recap's first sentence, project =
218
+ # Session#project_root
219
+ def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
220
+ project_root: nil)
221
+ sd = state_dir || Session.default_state_dir
222
+ root = cwd && folder_path(cwd)
223
+ roots = {}
224
+ summaries = Session.list(state_dir: sd, sort: "updated_at", order: "desc", project_root: project_root).lazy
225
+ .reject { |s| (!include_tests && s.test_run) || s.id == exclude }
226
+ .select { |s| root.nil? || in_folder?(s.working_directory, root) }
227
+ .filter_map do |s|
228
+ owner = session_owner(s.id, state_dir: sd)&.fetch("kind", nil)
229
+ owned = owner == "worker"
230
+ next if live && !owned
231
+
232
+ { id: s.id, short_id: s.id[0, 8], desc: summary_desc(s), preview: summary_preview(s), cwd: s.working_directory,
233
+ project: s.project_root(cache: roots), updated_at: s.updated_at, status: s.status, live: owned, busy: owned && s.status == Session::STATUS_RUNNING,
234
+ owner: owner, recap: RecapStore.preview(Session.session_dir(s.id, state_dir: sd)),
235
+ parent_id: s.parent_id, parent_short_id: s.parent_id&.[](0, 8) }
236
+ end
237
+ (limit ? summaries.first(limit) : summaries.to_a)
238
+ end
239
+
240
+ # The sessions delegated by +parent_id+ (the `delegate` tool), newest
241
+ # first, as .session_summaries rows. A running one (busy) counts against
242
+ # session.max_children.
243
+ def self.children_of(parent_id, state_dir: nil)
244
+ return [] if parent_id.to_s.empty?
245
+
246
+ session_summaries(state_dir: state_dir, include_tests: true).select { |s| s[:parent_id] == parent_id.to_s }
247
+ end
248
+
249
+ SUMMARY_PREVIEW_LIMIT = 120
250
+
251
+ # "<cwd basename> · <last prompt, or the first preview>", one line.
252
+ private_class_method def self.summary_desc(session)
253
+ desc = [File.basename(session.working_directory.to_s), summary_text(session)].reject(&:empty?).join(" · ")
254
+ cut(desc, SUMMARY_DESC_LIMIT)
255
+ end
256
+
257
+ private_class_method def self.summary_preview(session)
258
+ cut(summary_text(session), SUMMARY_PREVIEW_LIMIT)
259
+ end
260
+
261
+ private_class_method def self.summary_text(session)
262
+ one_line(session.last_prompt.to_s.strip.empty? ? session.first_preview.to_s : session.last_prompt.to_s)
263
+ end
264
+
265
+ # A prompt as one line for a session list: whitespace collapsed, and a
266
+ # quoted or annotated message (ContextQuote, the web's annotations)
267
+ # without its "> " markers, so the words show.
268
+ def self.one_line(text)
269
+ text.to_s.gsub(/^[ \t]*(?:>[ \t]?)+/, "").gsub(/\s+/, " ").strip
270
+ end
271
+
272
+ private_class_method def self.cut(text, limit)
273
+ text.length > limit ? "#{text[0, limit - 1]}…" : text
274
+ end
275
+
276
+ private_class_method def self.folder_path(path)
277
+ File.realpath(path)
278
+ rescue SystemCallError
279
+ File.expand_path(path)
280
+ end
281
+
282
+ private_class_method def self.in_folder?(dir, root)
283
+ dir = dir.to_s.chomp("/")
284
+ root = root.chomp("/")
285
+ dir == root || dir.start_with?("#{root}/")
286
+ end
287
+
288
+ def self.prune_sessions(state_dir: nil, days: nil, max_count: nil, keep_status: nil, dry_run: false, test_only: false,
289
+ any_age: false)
290
+ sd = state_dir || Session.default_state_dir
291
+ days = resolve_retention_days(days)
292
+ max_count = resolve_retention_max_count(max_count)
293
+ keep_status = resolve_retention_keep_status(keep_status)
294
+ discard = discard_empty?
295
+ default_model = discard ? (begin ModelProfile.required_model_name(nil) rescue nil end) : nil
296
+ result = Session.prune(
297
+ state_dir: sd,
298
+ days: days,
299
+ max_count: max_count,
300
+ keep_status: keep_status,
301
+ dry_run: dry_run,
302
+ test_only: test_only,
303
+ any_age: any_age,
304
+ alive_check: ->(sid) { worker_alive_for_session?(sid, state_dir: sd) },
305
+ empty_check: discard ? ->(sid) { left_empty?(sid, state_dir: sd, default_model: default_model) } : nil
306
+ )
307
+ result[:deleted].concat(prune_orphan_dirs(sd, dry_run: dry_run)) if discard && !test_only
308
+ result
309
+ end
310
+
311
+ # How long a session may sit empty before the sweep takes it: its
312
+ # worker (or a REPL) deletes it as it leaves, so the sweep only catches
313
+ # those killed first (a reboot, kill -9).
314
+ EMPTY_GRACE_SECONDS = 3600
315
+
316
+ private_class_method def self.left_empty?(session_id, state_dir:, default_model:)
317
+ path = File.join(state_dir, "#{session_id}#{Session::FILE_EXT}")
318
+ Time.now - File.mtime(path) > EMPTY_GRACE_SECONDS &&
319
+ empty_session?(session_id, state_dir: state_dir, default_model: default_model)
320
+ rescue SystemCallError
321
+ false
322
+ end
323
+
324
+ # Directories with no session file and nothing but the skeleton, nobody
325
+ # owning them: a REPL killed before its first save, or a worker woken
326
+ # just as its session was discarded.
327
+ # @return [Array<String>] their ids
328
+ private_class_method def self.prune_orphan_dirs(state_dir, dry_run:)
329
+ return [] unless Dir.exist?(state_dir)
330
+
331
+ Dir.children(state_dir).filter_map do |name|
332
+ dir = File.join(state_dir, name)
333
+ next unless name.match?(/\A[\w-]+\z/) && File.directory?(dir)
334
+ next if File.exist?(File.join(state_dir, "#{name}#{Session::FILE_EXT}"))
335
+ next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && empty_session_dir?(dir)
336
+ next if session_owner(name, state_dir: state_dir)
337
+
338
+ FileUtils.rm_rf(dir) unless dry_run
339
+ name
340
+ rescue SystemCallError
341
+ nil
342
+ end
343
+ end
344
+
345
+ # Lazy sweep guard: runs prune at most once per RETENTION_SWEEP_INTERVAL_HOURS.
346
+ RETENTION_MARKER = ".last_retention"
347
+ RETENTION_SWEEP_INTERVAL_HOURS = 24
348
+
349
+ def self.retention_sweep_if_due(state_dir: nil)
350
+ sd = state_dir || Session.default_state_dir
351
+ return unless Dir.exist?(sd)
352
+
353
+ cfg_interval = begin Samagotchi::Config.get("session.sweep_interval_hours") rescue nil end
354
+ interval = if cfg_interval && cfg_interval.to_i.positive?
355
+ cfg_interval.to_i * 3600
356
+ else
357
+ (ENV.fetch("SAMAGOTCHI_SESSION_SWEEP_INTERVAL_HOURS", RETENTION_SWEEP_INTERVAL_HOURS.to_s).to_i * 3600)
358
+ end
359
+ marker = File.join(sd, RETENTION_MARKER)
360
+ if File.exist?(marker)
361
+ age = Time.now - File.mtime(marker)
362
+ return if age < interval
363
+ end
364
+ result = prune_sessions(state_dir: sd)
365
+ FileUtils.touch(marker)
366
+ if result[:deleted].any?
367
+ Log.info(:worker, "retention_pruned", echo: "[retention] pruned #{result[:deleted].size} sessions (kept #{result[:kept].size})", deleted: result[:deleted].size, kept: result[:kept].size)
368
+ end
369
+ result
370
+ rescue StandardError => e
371
+ Log.warn(:worker, "retention_failed", echo: "[retention] sweep failed: #{e.class}: #{e.message}", error: e.class.name)
372
+ nil
373
+ end
374
+
375
+ private_class_method def self.resolve_retention_days(val)
376
+ return val.to_i if !val.nil? && val.to_s.strip != ""
377
+ cfg = begin Samagotchi::Config.get("session.retention_days") rescue nil end
378
+ return cfg.to_i if cfg && !cfg.to_s.strip.empty?
379
+ env = ENV["SAMAGOTCHI_SESSION_RETENTION_DAYS"]
380
+ return env.to_i if env && !env.strip.empty?
381
+ Session::DEFAULT_RETENTION_DAYS
382
+ end
383
+
384
+ private_class_method def self.resolve_retention_max_count(val)
385
+ return val.to_i if !val.nil? && val.to_s.strip != ""
386
+ cfg = begin Samagotchi::Config.get("session.max_count") rescue nil end
387
+ return cfg.to_i if cfg && !cfg.to_s.strip.empty?
388
+ env = ENV["SAMAGOTCHI_SESSION_MAX_COUNT"]
389
+ return env.to_i if env && !env.strip.empty?
390
+ Session::DEFAULT_MAX_COUNT
391
+ end
392
+
393
+ private_class_method def self.resolve_retention_keep_status(val)
394
+ raw = if !val.nil? && val.to_s.strip != ""
395
+ val.to_s
396
+ else
397
+ cfg = begin Samagotchi::Config.get("session.keep_status") rescue nil end
398
+ cfg && !cfg.to_s.strip.empty? ? cfg.to_s : (ENV["SAMAGOTCHI_SESSION_KEEP_STATUS"] || ENV["SAMAGOTCHI_SESSION_RETENTION_KEEP_STATUS"])
399
+ end
400
+ return Session::DEFAULT_KEEP_STATUS if raw.nil? || raw.strip.empty?
401
+ raw.split(",").map(&:strip).reject(&:empty?)
402
+ end
403
+
404
+ # Ensure an existing session has a live worker process.
405
+ # Returns the loaded session after state reconciliation.
406
+ # @raise [OwnedByTUI] when the interactive TUI owns the session
407
+ def self.resume_session(session_id, state_dir: nil)
408
+ sd = state_dir || Session.default_state_dir
409
+ session = Session.load(session_id, state_dir: sd)
410
+ owner = session_owner(session.id, state_dir: sd)
411
+ raise OwnedByTUI, session.id if owner && owner["kind"] == "tui"
412
+ return session if owner
413
+
414
+ # status is turn state, not liveness: a new worker runs no turn yet,
415
+ # and must not find the session stopped (it would exit).
416
+ session.status = Session::STATUS_IDLE
417
+ session.save(state_dir: sd)
418
+ spawn_worker_for_session(session, state_dir: sd)
419
+ session
420
+ end
421
+
422
+ # Read any new output files from a session directory.
423
+ # Optionally filters to only files newer than `since_time`.
424
+ def self.read_responses(session_id, since_time: nil, state_dir: nil)
425
+ session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
426
+ output_path = File.join(session_dir, OUTPUT_DIR)
427
+ responses = []
428
+ return responses unless Dir.exist?(output_path)
429
+
430
+ Dir.glob(File.join(output_path, "*.txt")).sort.each do |f|
431
+ if since_time.nil? || File.mtime(f) > since_time
432
+ responses << File.read(f)
433
+ end
434
+ end
435
+ responses
436
+ end
437
+
438
+ # Stop a session by sending TERM to its process.
439
+ # With +wait+ (seconds), also wait for the owner to let go of the session,
440
+ # so a resume right after spawns a fresh worker instead of finding the
441
+ # dying one.
442
+ # A turn still running is canceled first (STOP_CANCEL_WAIT), as the
443
+ # web's Cancel does: its Engine saves the prompt, which the TERM alone
444
+ # would lose with the worker.
445
+ # @return [Boolean, nil] with +wait+: whether the owner was gone in time
446
+ # @raise [OwnedByTUI] when the interactive TUI owns the session
447
+ def self.stop_session(session_id, state_dir: nil, wait: nil)
448
+ sd = state_dir || Session.default_state_dir
449
+ owner = session_owner(session_id, state_dir: sd)
450
+ raise OwnedByTUI, session_id if owner && owner["kind"] == "tui"
451
+
452
+ cancel_running_turn(session_id, state_dir: sd)
453
+ # Mark first: a worker that has not taken the lock yet sees it and exits.
454
+ Session.mark_stopped(session_id, state_dir: sd)
455
+ pid = owner && owner["pid"].to_i
456
+ begin
457
+ Process.kill("TERM", pid) if pid && pid > 0
458
+ rescue Errno::ESRCH
459
+ nil # already exited
460
+ end
461
+ wait_for_owner_release(session_id, timeout: wait, state_dir: sd) if wait
462
+ end
463
+
464
+ STOP_CANCEL_WAIT = 3.0
465
+
466
+ # Cancel the session's running turn over its Bridge and wait (up to
467
+ # STOP_CANCEL_WAIT) for the worker to save it. Best effort: without a
468
+ # live Bridge, or when it doesn't answer, the stop goes on as before.
469
+ private_class_method def self.cancel_running_turn(session_id, state_dir:)
470
+ return unless Session.load(session_id, state_dir: state_dir).status == Session::STATUS_RUNNING
471
+
472
+ client = BridgeClient.discover(session_id, session_dir: Session.session_dir(session_id, state_dir: state_dir))
473
+ return unless client && client.cancel(reason: "user").status == 202
474
+
475
+ BridgeClient.poll(STOP_CANCEL_WAIT) do
476
+ Session.load(session_id, state_dir: state_dir).status != Session::STATUS_RUNNING
477
+ end
478
+ rescue StandardError => e
479
+ Log.info(:worker, "stop_cancel_failed", sid: session_id, error: e.class.name, msg: e.message)
480
+ nil
481
+ end
482
+
483
+ # What a session's directory holds before anything happened in it. Any
484
+ # other entry (or a file in one of the EMPTY_DIRS) is something the
485
+ # session keeps.
486
+ EMPTY_SKELETON_FILES = %w[pid owner.lock bridge.json analytics.json].freeze
487
+ EMPTY_DIRS = [INPUT_DIR, NOTES_DIR, "images"].freeze
488
+ EMPTY_SKELETON_DIRS = (EMPTY_DIRS + [OUTPUT_DIR]).freeze
489
+
490
+ # session.keep_empty off: sessions left with nothing in them are deleted
491
+ # (the worker as it exits, the TUI at /exit, the retention sweep).
492
+ def self.discard_empty?
493
+ Samagotchi::Config.get("session.keep_empty") != true
494
+ rescue StandardError
495
+ false
496
+ end
497
+
498
+ # A session nothing happened in: no conversation, no turn tried (a failed
499
+ # one leaves no messages but a last_prompt and analytics turns), nothing
500
+ # queued or attached, and the model and mode a new session gets. A
501
+ # session prepared for later (/model, --model, a note, an image, memory)
502
+ # is not empty. Anything unreadable or unknown counts as not empty.
503
+ # @param default_model [String, nil] what a new session starts on
504
+ def self.empty_session?(session_id, state_dir: nil, default_model: nil)
505
+ sd = state_dir || Session.default_state_dir
506
+ session = Session.load(session_id, state_dir: sd)
507
+ return false unless no_conversation?(session.messages) && session.pending_question.nil? && session.used_memory_names.empty?
508
+ return false unless session.last_prompt.to_s.strip.empty? && session.first_preview.to_s.strip.empty?
509
+ return false unless session.mode.to_s == "assist" && !default_model.nil? && session.model_name.to_s == default_model.to_s
510
+
511
+ empty_session_dir?(Session.session_dir(session_id, state_dir: sd))
512
+ rescue ArgumentError, SystemCallError, JSON::ParserError
513
+ false
514
+ end
515
+
516
+ # Only the system prompt (the REPL seeds one, a saved session keeps
517
+ # it); a context note is a system message too, but with its kind.
518
+ # A turn note (the tail system message after a failed, cancelled or empty
519
+ # turn) is not a conversation either: a session whose only turn failed
520
+ # before any answer is still empty.
521
+ def self.no_conversation?(messages)
522
+ Array(messages).all? do |msg|
523
+ (msg[:role] || msg["role"]).to_s == "system" &&
524
+ ((msg[:kind] || msg["kind"]).nil? || TurnNote.note?(msg))
525
+ end
526
+ end
527
+
528
+ # @return [Boolean] whether a session's directory holds only the skeleton
529
+ def self.empty_session_dir?(dir)
530
+ return true unless Dir.exist?(dir)
531
+
532
+ Dir.children(dir).all? do |name|
533
+ path = File.join(dir, name)
534
+ if EMPTY_SKELETON_DIRS.include?(name)
535
+ File.directory?(path) && (!EMPTY_DIRS.include?(name) || Dir.children(path).empty?)
536
+ elsif name == "analytics.json"
537
+ JSON.parse(File.read(path))["turns"].to_i.zero?
538
+ else
539
+ EMPTY_SKELETON_FILES.include?(name)
540
+ end
541
+ end
542
+ end
543
+
544
+ # Delete one session: its <id>.json and the whole <id>/ directory
545
+ # (history sidecars, input, output, notes, images). The CLI, the TUI's
546
+ # /exit --delete and the web all come here.
547
+ # @param id_or_prefix [String] a session id or a unique prefix of one
548
+ # @param stop [Boolean] stop a live worker first (waits +wait+ seconds)
549
+ # @return [Hash] {id:, removed: [paths], stopped: whether a worker was stopped}
550
+ # @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
551
+ # @raise [OwnedByTUI] a chi REPL owns it (never stopped from here)
552
+ # @raise [DeleteRefused] a worker owns it and +stop+ is false, or it
553
+ # outlived the wait
554
+ def self.delete_session(id_or_prefix, state_dir: nil, stop: false, wait: 10)
555
+ sd = state_dir || Session.default_state_dir
556
+ given = id_or_prefix.to_s
557
+ # Only a plain id: anything else could name a path outside the state dir.
558
+ raise ArgumentError, "no session #{given}" unless given.match?(/\A[\w-]+\z/)
559
+
560
+ id = Session.resolve_id(given, state_dir: sd)
561
+ path = File.join(sd, "#{id}#{Session::FILE_EXT}")
562
+ dir = Session.session_dir(id, state_dir: sd)
563
+ raise ArgumentError, "no session #{given}" unless File.exist?(path) || Dir.exist?(dir)
564
+
565
+ owner = session_owner(id, state_dir: sd)
566
+ if owner
567
+ raise OwnedByTUI, id if owner["kind"] == "tui"
568
+ raise DeleteRefused.new(id, :worker_running) unless stop
569
+ raise DeleteRefused.new(id, :still_stopping) unless stop_session(id, state_dir: sd, wait: wait)
570
+ end
571
+
572
+ removed = []
573
+ if File.exist?(path)
574
+ FileUtils.rm_f(path)
575
+ removed << path
576
+ end
577
+ if Dir.exist?(dir)
578
+ FileUtils.rm_rf(dir)
579
+ removed << dir
580
+ end
581
+ { id: id, removed: removed, stopped: !owner.nil? }
582
+ end
583
+
584
+ # @return [Boolean] whether the session had no owner within +timeout+ seconds
585
+ def self.wait_for_owner_release(session_id, timeout:, state_dir:)
586
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
587
+ while session_owner(session_id, state_dir: state_dir)
588
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
589
+
590
+ sleep(OwnerLock::RETRY_INTERVAL)
591
+ end
592
+ true
593
+ end
594
+
595
+ # Wait for a session to reach a terminal state (completed, error, stopped).
596
+ # Returns true if the session finished, false if the timeout elapsed.
597
+ def self.wait_for_session(session_id, timeout: 30, state_dir: nil)
598
+ sd = state_dir || Session.default_state_dir
599
+ elapsed = 0
600
+ while elapsed < timeout
601
+ session = Session.load(session_id, state_dir: sd)
602
+ return true if %w[completed error stopped].include?(session.status)
603
+
604
+ sleep(0.5)
605
+ elapsed += 0.5
606
+ end
607
+ false
608
+ end
609
+
610
+ # Run the session loop inside the forked process.
611
+ # This is the entry point called by Process.spawn.
612
+ #
613
+ # Every worker starts its per-session Bridge (the single live client
614
+ # transport) on a port bound to 127.0.0.1 before the loop and stops it on
615
+ # exit. If the bridge fails to start the worker degrades: turns still flow
616
+ # through the input-dir loop, but there is no live SSE or in-process
617
+ # cancel/answer.
618
+ #
619
+ # The worker first takes the session's OwnerLock; when another owner holds
620
+ # it (a racing resume spawned two workers, or the TUI has the session) it
621
+ # exits quietly.
622
+ #
623
+ # A worker nobody uses returns once session.idle_exit_minutes have passed
624
+ # (see WorkerIdleExit), and one a client asked to exit (Bridge POST /exit)
625
+ # returns as soon as nothing keeps it. The next send wakes a new one.
626
+ # @param idle_exit_minutes [Numeric, nil] nil: session.idle_exit_minutes
627
+ # @param poll_interval [Numeric, nil] seconds between the loop's fallback
628
+ # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
629
+ # @return [Symbol] :idle_exit or :exit_requested
630
+ def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
631
+ idle_exit_minutes: nil, poll_interval: nil)
632
+ sd = state_dir || Session.default_state_dir
633
+ # The spawner passed the file and level through ENV; a worker's stderr
634
+ # is /dev/null, so warnings only reach the file.
635
+ Log.configure(stderr: false)
636
+ Log.session_id = session_id
637
+ session_dir = Session.session_dir(session_id, state_dir: sd)
638
+ # Kept in a class ivar so the lock's File lives as long as the worker.
639
+ @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
640
+ exit(0) unless @owner_lock
641
+ Log.info(:worker, "start", cwd: Dir.pwd)
642
+ worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
643
+ idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
644
+ result = begin
645
+ File.write(File.join(session_dir, PID_FILE), Process.pid.to_s)
646
+ worker.run
647
+ rescue StandardError, ScriptError => e
648
+ # The worker's stderr is /dev/null: the log is the only trace.
649
+ Log.exception(:worker, "crashed", e)
650
+ raise
651
+ ensure
652
+ @owner_lock.release
653
+ end
654
+ Log.info(:worker, "stop", reason: result)
655
+ # Only after the release: a writer that still saw this worker as the
656
+ # owner may have queued input since the last check. Either it finds no
657
+ # owner after its write and wakes one, or this finds its input.
658
+ if %i[idle_exit exit_requested].include?(result) && !find_new_input_files(session_dir).empty?
659
+ resume_session(session_id, state_dir: sd)
660
+ elsif worker.discard?
661
+ discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
662
+ end
663
+ result
664
+ end
665
+
666
+ # Delete a session its worker left empty. Checked again now the lock is
667
+ # free: a note or input may have come in since the worker looked, and a
668
+ # worker woken meanwhile owns it (delete_session refuses). A `chi send`
669
+ # landing between this check and the delete fails with "no session"
670
+ # (a window of an empty session's last moments, left as is).
671
+ private_class_method def self.discard_left_session(session_id, state_dir:, default_model:)
672
+ return unless empty_session?(session_id, state_dir: state_dir, default_model: default_model)
673
+
674
+ delete_session(session_id, state_dir: state_dir)
675
+ Log.info(:worker, "discarded_empty", sid: session_id)
676
+ rescue DeleteRefused, OwnedByTUI, ArgumentError, SystemCallError => e
677
+ Log.info(:worker, "kept_session", sid: session_id, error: e.class.name, msg: e.message)
678
+ end
679
+
680
+ def self.config_idle_exit_minutes
681
+ Samagotchi::Config.get("session.idle_exit_minutes")
682
+ rescue StandardError
683
+ nil
684
+ end
685
+
686
+ private_class_method def self.setup_session_directory(session_dir, session, state_dir:)
687
+ FileUtils.mkdir_p(session_dir)
688
+ FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
689
+ FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
690
+ session.save(state_dir: state_dir)
691
+ end
692
+
693
+ # The worker's command line: this chi's lib/ and ruby. Run from an
694
+ # installed gem, the worker activates that gem first, so its dependencies
695
+ # resolve as the gemspec pins them (reline ~> 0.6.3) rather than to the
696
+ # newest installed version. A source checkout (bin/chi, bundle exec)
697
+ # keeps the plain -I lib.
698
+ def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
699
+ boot = "require 'samagotchi/session_manager'; " \
700
+ "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
701
+ boot = "gem 'samagotchi', '= #{gem_spec.version}'; #{boot}" if gem_spec
702
+ [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot]
703
+ end
704
+
705
+ private_class_method def self.spawn_worker_for_session(session, state_dir:)
706
+ session_dir = Session.session_dir(session.id, state_dir: state_dir)
707
+ FileUtils.mkdir_p(session_dir)
708
+ FileUtils.mkdir_p(File.join(session_dir, INPUT_DIR))
709
+ FileUtils.mkdir_p(File.join(session_dir, OUTPUT_DIR))
710
+
711
+ opts = spawn_options(session)
712
+ env = opts.delete(:env)
713
+ command = worker_command(session.id, state_dir: state_dir)
714
+ # The worker writes the pid file itself once it owns the session.
715
+ pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
716
+ Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
717
+ pid
718
+ end
719
+
720
+ # Start the in-process Bridge transport for this worker. The bridge is
721
+ # the single live client transport, so every worker starts it. Bridge
722
+ # creation happens *before* the loop so the capture observer is in place
723
+ # for the whole session; the caller stops the returned instance on exit
724
+ # (see Worker#run's ensure).
725
+ #
726
+ # @return [Samagotchi::Bridge, nil] nil when the transport failed to start
727
+ # (the worker degrades: turns still flow through the input-dir loop, but
728
+ # there is no live SSE or in-process cancel/answer).
729
+ # @param on_input [#call, nil] called after the Bridge queues a turn
730
+ def self.start_bridge(engine:, state_dir:, session_id:, on_input: nil, on_command: nil, on_exit_request: nil,
731
+ exit_discards: nil)
732
+ require_relative "bridge"
733
+ Samagotchi::Bridge.new(
734
+ engine: engine, state_dir: state_dir, session_id: session_id, input_format: INPUT_FORMAT,
735
+ on_input: on_input, on_command: on_command, on_exit_request: on_exit_request, exit_discards: exit_discards
736
+ ).start
737
+ rescue StandardError => e
738
+ 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)
739
+ nil
740
+ end
741
+
742
+
743
+
744
+ # Write a user turn into a session's input directory via the same file IPC
745
+ # the worker polls. Reused by the bridge's POST surface so a turn is
746
+ # fire-and-forget and never calls run_turn across the thread/process
747
+ # boundary. The file is JSON carrying the sender's ids, unless the
748
+ # session's live worker predates structured input (plain text then).
749
+ # @param client_id [String, nil] the sending UI
750
+ # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
751
+ # @param images [Array<Hash>] image refs ({file:, name:}) in the
752
+ # session's images/ (raises ImagesUnsupported for an older worker)
753
+ # @return [String, false] the input file's path, or false.
754
+ def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
755
+ images: [])
756
+ sd = state_dir || Session.default_state_dir
757
+ session_dir = Session.session_dir(session_id, state_dir: sd)
758
+ images = Array(images)
759
+ raise ImagesUnsupported if !images.empty? && !images_input?(session_dir)
760
+
761
+ write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
762
+ end
763
+
764
+ # How long a turn waits for the Bridge of a worker a resume just spawned.
765
+ TURN_BRIDGE_WAIT = 5.0
766
+
767
+ # Hand a user message to the session the way a UI does, for the web
768
+ # composer and `chi send` alike: wakes the worker when none runs, posts
769
+ # through its Bridge so every live UI sees :turn_enqueued, and falls back
770
+ # to the input file when the Bridge is gone (a worker closing it on idle
771
+ # exit). Race-safe against a TUI taking the session, or the worker
772
+ # exiting, between the resume and the write.
773
+ # @param images [Array<Hash>] refs ({file:, name:}) already in images/
774
+ # @param manager [#resume_session, #write_turn_input] this class, or a
775
+ # stand-in (the web's specs); #session_owner is optional
776
+ # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
777
+ # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
778
+ # @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
779
+ # {status:, enqueued_id:, session_id:} for a file), {status: :refused,
780
+ # code:, ack:} when the Bridge refused the images, {status: :timeout,
781
+ # ack:} when it took the request but never answered (no file then), or
782
+ # {status: :failed} when the input file couldn't be written
783
+ # @raise [OwnedByTUI] a chi REPL owns the session
784
+ # @raise [ImagesUnsupported] images for a worker that predates them
785
+ # @raise [ArgumentError] no such session
786
+ def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
787
+ session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
788
+ bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
789
+ manager.resume_session(session_id, state_dir: state_dir) if manager.respond_to?(:resume_session)
790
+ # A worker that predates images would drop them (its Bridge ignores
791
+ # them): refuse, so the sender keeps the chips and the text.
792
+ raise ImagesUnsupported if images.any? && !images_input?(session_dir)
793
+
794
+ if (client = bridge.call)
795
+ begin
796
+ options = { prompt: prompt, client_id: client_id }
797
+ options[:images] = images unless images.empty?
798
+ reply = client.post_turn(**options)
799
+ ack = reply.json
800
+ return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
801
+ if ack.is_a?(Hash) && %w[bad_images images_unsupported].include?(ack["error"])
802
+ return { status: :refused, code: reply.status, ack: ack }
803
+ end
804
+ # Read after its deadline and dropped: a file would run it after all.
805
+ return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
806
+ rescue Errno::ETIMEDOUT
807
+ # The Bridge drops a turn it reads after the request's deadline
808
+ # (BridgeClient#post_turn), so a worker that wakes later won't run
809
+ # it; a file would.
810
+ return turn_timeout
811
+ rescue SystemCallError, IOError
812
+ nil # the worker closed its Bridge on the way out: queue the file
813
+ end
814
+ end
815
+ enqueued_id = SecureRandom.uuid
816
+ input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
817
+ input[:images] = images unless images.empty?
818
+ path = manager.write_turn_input(session_id, **input)
819
+ return { status: :failed } unless path
820
+
821
+ owner = delivery_owner(manager, session_id, state_dir)
822
+ # A TUI that took the session between the resume and the write never
823
+ # reads input files; a later worker would replay this one.
824
+ if owner&.fetch("kind", nil) == "tui"
825
+ FileUtils.rm_f(path) if path.is_a?(String)
826
+ raise OwnedByTUI, session_id
827
+ end
828
+ # A worker that idle-exited since the resume never reads it either:
829
+ # wake a new one. (The exiting worker also looks for input it left.)
830
+ if owner.nil? && manager.respond_to?(:session_owner) && manager.respond_to?(:resume_session)
831
+ manager.resume_session(session_id, state_dir: state_dir)
832
+ end
833
+ { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
834
+ end
835
+
836
+ private_class_method def self.turn_timeout
837
+ { status: :timeout, ack: { "error" => "worker_timeout",
838
+ "detail" => "the session's worker did not answer, so the message was not sent" } }
839
+ end
840
+
841
+ private_class_method def self.delivery_owner(manager, session_id, state_dir)
842
+ return nil unless manager.respond_to?(:session_owner)
843
+
844
+ manager.session_owner(session_id, state_dir: state_dir)
845
+ rescue StandardError
846
+ nil
847
+ end
848
+
849
+ private_class_method def self.write_input_file(session_dir, prompt, client_id, enqueued_id, no_interrupt, images)
850
+ input_dir = File.join(session_dir, INPUT_DIR)
851
+ FileUtils.mkdir_p(input_dir)
852
+
853
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
854
+ if structured_input?(session_dir)
855
+ path = File.join(input_dir, "#{timestamp}.json")
856
+ record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
857
+ "no_interrupt" => (no_interrupt ? true : nil),
858
+ "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
859
+ write_atomic(path, JSON.generate(record))
860
+ else
861
+ path = File.join(input_dir, "#{timestamp}.txt")
862
+ write_atomic(path, prompt.to_s)
863
+ end
864
+ path
865
+ rescue StandardError
866
+ false
867
+ end
868
+
869
+ # Queue a context note for a session: background text its worker adds
870
+ # to the conversation between turns. It never starts a turn.
871
+ # @param source [String] where it came from ("cli", "slack", "session")
872
+ # @param from_session [String, nil] the sending session, for a peer's note
873
+ # @return [String] the note file's path
874
+ # @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
875
+ def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil)
876
+ body = checked_text(text)
877
+ sd = state_dir || Session.default_state_dir
878
+ notes_dir = File.join(Session.session_dir(session_id, state_dir: sd), NOTES_DIR)
879
+ FileUtils.mkdir_p(notes_dir)
880
+
881
+ # The random part keeps two writers in one nanosecond apart; the
882
+ # timestamp keeps the names in arrival order.
883
+ name = "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}-#{SecureRandom.hex(3)}.json"
884
+ path = File.join(notes_dir, name)
885
+ record = { "text" => body, "source" => source.to_s, "from_session" => from_session,
886
+ "from_cwd" => from_cwd, "created_at" => Time.now.iso8601 }.compact
887
+ write_atomic(path, JSON.generate(record))
888
+ path
889
+ end
890
+
891
+ # The same empty and 16 KiB checks for a note and a sent message.
892
+ # @param noun [String] what the errors call the text
893
+ # @return [String] the stripped text
894
+ # @raise [NoteRejected]
895
+ def self.checked_text(text, noun: "note")
896
+ body = text.to_s.strip
897
+ raise NoteRejected, "the #{noun} is empty" if body.empty?
898
+ if body.bytesize > NOTE_MAX_BYTES
899
+ raise NoteRejected, "the #{noun} is #{body.bytesize} bytes; the limit is 16 KiB (#{NOTE_MAX_BYTES} bytes)"
900
+ end
901
+
902
+ body
903
+ end
904
+
905
+ # Queued notes, oldest first, plus any a crashed worker claimed and left
906
+ # (the absorber skips a note id the conversation already holds).
907
+ def self.find_new_note_files(session_dir)
908
+ notes_dir = File.join(session_dir, NOTES_DIR)
909
+ return [] unless Dir.exist?(notes_dir)
910
+
911
+ Dir.glob(File.join(notes_dir, "*.{json,json.processing}")).sort_by { |p| File.basename(p) }
912
+ end
913
+
914
+ # @return [String, nil] the claimed path, or nil when another claimed it
915
+ def self.claim_note_file(note_file)
916
+ return note_file if note_file.end_with?(".processing")
917
+
918
+ claim_input_file(note_file)
919
+ end
920
+
921
+ # @return [Hash, nil] {note_id:, text:, source:, from_session:, from_cwd:,
922
+ # created_at:}, or nil for a file that holds no usable note
923
+ def self.read_note(note_file)
924
+ data = JSON.parse(File.read(note_file))
925
+ text = data["text"].to_s.strip
926
+ return nil if text.empty?
927
+
928
+ { note_id: File.basename(note_file).sub(/\.json(\.processing)?\z/, ""), text: text,
929
+ source: data["source"].to_s.empty? ? "cli" : data["source"].to_s,
930
+ from_session: data["from_session"], from_cwd: data["from_cwd"], created_at: data["created_at"] }
931
+ rescue JSON::ParserError, SystemCallError, NoMethodError, TypeError
932
+ nil
933
+ end
934
+
935
+ def self.write_output(session_dir, response)
936
+ output_dir = File.join(session_dir, OUTPUT_DIR)
937
+ FileUtils.mkdir_p(output_dir)
938
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S%9N")
939
+ write_atomic(File.join(output_dir, "#{timestamp}.txt"), response.to_s)
940
+ end
941
+
942
+ def self.find_new_input_files(session_dir)
943
+ input_dir = File.join(session_dir, INPUT_DIR)
944
+ return [] unless Dir.exist?(input_dir)
945
+
946
+ Dir.glob(File.join(input_dir, "*.{txt,json}"))
947
+ end
948
+
949
+ # A worker's sidecar advertises the input format it reads; no sidecar
950
+ # means no worker yet, and the next one (this code) reads JSON.
951
+ private_class_method def self.structured_input?(session_dir)
952
+ worker_input_format(session_dir) >= STRUCTURED_INPUT_FORMAT
953
+ end
954
+
955
+ # Whether the session's worker (or the next one) reads images: refs.
956
+ def self.images_input?(session_dir)
957
+ worker_input_format(session_dir) >= IMAGES_INPUT_FORMAT
958
+ end
959
+
960
+ # The input format the session's worker advertises; no sidecar means no
961
+ # worker yet, and the next one (this code) reads INPUT_FORMAT.
962
+ private_class_method def self.worker_input_format(session_dir)
963
+ sidecar = File.join(session_dir, "bridge.json")
964
+ return INPUT_FORMAT unless File.file?(sidecar)
965
+
966
+ JSON.parse(File.read(sidecar))["input_format"].to_i
967
+ rescue JSON::ParserError, SystemCallError
968
+ INPUT_FORMAT
969
+ end
970
+
971
+ # @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
972
+ # file's prompt, origin ({client_id:, enqueued_id:}, nil for plain
973
+ # text), whether its turn runs with the raised iteration limit
974
+ # (--no-interrupt), and its image refs ({file:, name:})
975
+ def self.read_input(claimed_file)
976
+ raw = File.read(claimed_file).to_s
977
+ return [raw, nil, false, []] unless claimed_file.end_with?(".json.processing")
978
+
979
+ data = JSON.parse(raw)
980
+ origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
981
+ images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
982
+ [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
983
+ rescue JSON::ParserError
984
+ [nil, nil, false, []]
985
+ end
986
+
987
+ # Whether an unclaimed input file carries images (a mid-turn drain
988
+ # leaves it for its own turn).
989
+ def self.input_has_images?(input_file)
990
+ return false unless input_file.end_with?(".json")
991
+
992
+ data = JSON.parse(File.read(input_file))
993
+ data.is_a?(Hash) && Array(data["images"]).any?
994
+ rescue JSON::ParserError, SystemCallError
995
+ false
996
+ end
997
+
998
+ def self.claim_input_file(input_file)
999
+ processing_path = "#{input_file}.processing"
1000
+ File.rename(input_file, processing_path)
1001
+ processing_path
1002
+ rescue Errno::ENOENT, Errno::EACCES
1003
+ nil
1004
+ end
1005
+
1006
+ def self.stopped_on_disk?(session_id, state_dir:)
1007
+ Session.load(session_id, state_dir: state_dir).status == Session::STATUS_STOPPED
1008
+ rescue ArgumentError
1009
+ false
1010
+ end
1011
+
1012
+ private_class_method def self.write_atomic(path, content)
1013
+ temp_path = "#{path}.tmp"
1014
+ File.write(temp_path, content)
1015
+ File.rename(temp_path, path)
1016
+ end
1017
+
1018
+ # The session's live owner: the OwnerLock holder, or a live pid-only
1019
+ # worker started before the lock existed (such workers never exit).
1020
+ # @return [Hash, nil] {"pid", "kind" ("worker"/"tui"), ...} or nil
1021
+ def self.session_owner(session_id, state_dir: nil)
1022
+ session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1023
+ return OwnerLock.owner(session_dir) if OwnerLock.lock_file?(session_dir)
1024
+
1025
+ pid = legacy_worker_pid(session_dir)
1026
+ pid && { "pid" => pid, "kind" => "worker" }
1027
+ end
1028
+
1029
+ private_class_method def self.worker_alive_for_session?(session_id, state_dir:)
1030
+ !session_owner(session_id, state_dir: state_dir).nil?
1031
+ end
1032
+
1033
+ private_class_method def self.legacy_worker_pid(session_dir)
1034
+ pid_file = File.join(session_dir, PID_FILE)
1035
+ return nil unless File.exist?(pid_file)
1036
+
1037
+ pid = File.read(pid_file).strip.to_i
1038
+ return nil if pid <= 0
1039
+
1040
+ Process.kill(0, pid)
1041
+ pid
1042
+ rescue Errno::EPERM
1043
+ pid
1044
+ rescue Errno::ESRCH
1045
+ nil
1046
+ end
1047
+ end
1048
+ end
1049
+