samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -35,8 +35,15 @@ module Samagotchi
35
35
 
36
36
  def initialize
37
37
  @entries = {}
38
+ @toolkit = nil
38
39
  end
39
40
 
41
+ # The session's resolved toolkit (Toolkit::Resolved), which the lists
42
+ # below and #offered filter by; nil declares every tool (full). The
43
+ # Engine sets it at a turn's start, before the prompt is built, and
44
+ # only there: a request's declared tools never change mid-turn.
45
+ attr_accessor :toolkit
46
+
40
47
  # @return [Entry]
41
48
  def register(name, schema:, handler:, label: nil, preview: nil, targets: nil, refs: nil, source: "core", layer: nil)
42
49
  raise ArgumentError, "tool #{name} is already registered" if @entries.key?(name)
@@ -59,19 +66,40 @@ module Samagotchi
59
66
  # @return [Entry, nil]
60
67
  def offered(name, layers: [])
61
68
  entry = self[name]
62
- entry if entry&.offered?(layers)
69
+ entry if entry&.offered?(layers) && declared?(entry)
70
+ end
71
+
72
+ # The entry +name+ names when the session's toolkit leaves it out (a
73
+ # real tool the model can't call here), nil otherwise.
74
+ # @return [Entry, nil]
75
+ def left_out(name, layers: [])
76
+ entry = self[name]
77
+ entry if entry&.offered?(layers) && !declared?(entry)
78
+ end
79
+
80
+ # The tools +toolkit+ (the session's by default) leaves out of a turn
81
+ # under +layers+.
82
+ # @return [Array<String>] in declaration order
83
+ def left_out_names(layers: [], toolkit: @toolkit)
84
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && !declared?(entry, toolkit) }).map(&:name)
63
85
  end
64
86
 
87
+ # Every tool +source+ registered, declared or not (a plugin's tool set
88
+ # changing: Plugin::ToolSpec.apply).
89
+ # @return [Array<Entry>]
90
+ def by_source(source) = @entries.values.select { |entry| entry.source == source }
91
+
65
92
  # The tools a turn offers is per turn: +layers+, the turn's LLM
66
93
  # context layers (LLMContextStrategy), adds the tools gated by them.
67
94
  # Without layers (none, the default) the list is what it always was.
68
95
  # @return [Array<String>] in declaration order
69
- def names(layers: []) = entries(layers: layers).map(&:name)
96
+ def names(layers: [], toolkit: @toolkit) = entries(layers: layers, toolkit: toolkit).map(&:name)
70
97
 
71
- # @return [Array<Entry>] in declaration order
72
- def entries(layers: [])
73
- core, added = @entries.values.select { |entry| entry.offered?(layers) }.partition(&:core?)
74
- core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
98
+ # @return [Array<Entry>] in declaration order: the ones the turn's
99
+ # layers offer and the session's toolkit declares
100
+ # (+toolkit+: another one's, for what a change would declare)
101
+ def entries(layers: [], toolkit: @toolkit)
102
+ ordered(@entries.values.select { |entry| entry.offered?(layers) && declared?(entry, toolkit) })
75
103
  end
76
104
 
77
105
  # @return [Array<Hash>] the schemas, in declaration order
@@ -81,6 +109,15 @@ module Samagotchi
81
109
  @entries.freeze
82
110
  super
83
111
  end
112
+
113
+ private
114
+
115
+ def declared?(entry, toolkit = @toolkit) = toolkit.nil? || toolkit.declares?(entry)
116
+
117
+ def ordered(entries)
118
+ core, added = entries.partition(&:core?)
119
+ core + added.sort_by { |entry| [entry.source.to_s, entry.name] }
120
+ end
84
121
  end
85
122
  end
86
123
  end
@@ -15,12 +15,12 @@ module Samagotchi
15
15
  return error if error
16
16
 
17
17
  [
18
- "task_id: #{record.fetch("id")}",
19
- "status: #{record.fetch("status")}",
20
- "pid: #{record.fetch("pid")}",
21
- "created_at: #{record.fetch("created_at")}",
22
- "output_path: #{record.fetch("output_path")}",
23
- "cwd: #{record.fetch("cwd")}"
18
+ "task_id: #{record.id}",
19
+ "status: #{record.status}",
20
+ "pid: #{record.pid}",
21
+ "created_at: #{record.created_at}",
22
+ "output_path: #{record.output_path}",
23
+ "cwd: #{record.cwd}"
24
24
  ].join("\n")
25
25
  rescue StandardError => e
26
26
  "Error: #{e.message}"
@@ -20,20 +20,20 @@ module Samagotchi
20
20
 
21
21
  def self.format_record(record)
22
22
  [
23
- "task_id: #{record.fetch("id")}",
24
- "status: #{record.fetch("status")}",
25
- "command: #{record.fetch("command")}",
26
- "cwd: #{record.fetch("cwd")}",
27
- "pid: #{record.fetch("pid")}",
28
- "created_at: #{record.fetch("created_at")}",
29
- "started_at: #{record.fetch("started_at")}",
30
- "finished_at: #{record["finished_at"]}",
31
- "exit_code: #{record["exit_code"]}",
32
- "stop_reason: #{record["stop_reason"]}",
33
- "output_path: #{record.fetch("output_path")}",
23
+ "task_id: #{record.id}",
24
+ "status: #{record.status}",
25
+ "command: #{record.command}",
26
+ "cwd: #{record.cwd}",
27
+ "pid: #{record.pid}",
28
+ "created_at: #{record.created_at}",
29
+ "started_at: #{record.started_at}",
30
+ "finished_at: #{record.finished_at}",
31
+ "exit_code: #{record.exit_code}",
32
+ "stop_reason: #{record.stop_reason}",
33
+ "output_path: #{record.output_path}",
34
34
  # A handed-over execute's stderr has a log of its own.
35
- ("stderr_path: #{record["stderr_path"]}" if record["stderr_path"]),
36
- (TaskRuntime::USER_STOP_NOTE if TaskRuntime.stopped_by_user?(record))
35
+ ("stderr_path: #{record.stderr_path}" if record.stderr_path),
36
+ (TaskRuntime::USER_STOP_NOTE if record.stopped_by_user?)
37
37
  ].compact.join("\n")
38
38
  end
39
39
  private_class_method :format_record
@@ -21,12 +21,12 @@ module Samagotchi
21
21
 
22
22
  def self.format_entry(record)
23
23
  [
24
- "task_id: #{record.fetch("id")}",
25
- "status: #{record.fetch("status")}",
26
- "pid: #{record.fetch("pid")}",
27
- "created_at: #{record.fetch("created_at")}",
28
- "command: #{preview(record.fetch("command"))}",
29
- "output_path: #{record.fetch("output_path")}"
24
+ "task_id: #{record.id}",
25
+ "status: #{record.status}",
26
+ "pid: #{record.pid}",
27
+ "created_at: #{record.created_at}",
28
+ "command: #{preview(record.command)}",
29
+ "output_path: #{record.output_path}"
30
30
  ].join("\n")
31
31
  end
32
32
  private_class_method :format_entry
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Tools
5
+ # A background task's record, as TaskRuntime keeps it in
6
+ # tmp/tasks/<id>/task.json. Read with .from_file, written with #to_file;
7
+ # an update is a #with. The values are as on disk: times are iso8601
8
+ # strings, +pid+ whatever the file holds (a hand-edited one may not be
9
+ # a number; TaskRuntime never signals that).
10
+ #
11
+ # The file keeps its key order (the members' order) and leaves out the
12
+ # optional keys while unset: +stderr_path+ and +handed_over_by+ (only a
13
+ # handed-over execute has them) and +stop_requested_by+ (only once a
14
+ # stop started). bundles/slow-commands reads these files on its own.
15
+ TaskRecord = Data.define(
16
+ :metadata_version, :id, :command, :cwd, :workspace_root, :status, :pid, :pid_started,
17
+ :created_at, :started_at, :finished_at, :exit_code, :stop_reason,
18
+ :output_path, :stderr_path, :exit_code_path, :handed_over_by, :stop_requested_by
19
+ ) do
20
+ self::METADATA_VERSION = 1
21
+ self::OPTIONAL = %i[stderr_path handed_over_by stop_requested_by].freeze
22
+
23
+ # A record for a task that just started (task_create), or that an
24
+ # execute hands over (TaskRuntime.adopt fills in +pid_started+ then).
25
+ # @param started_at [String] iso8601; also its +created_at+
26
+ def self.running(id:, command:, cwd:, pid:, started_at:, output_path:, exit_code_path:, pid_started: nil,
27
+ stderr_path: nil, handed_over_by: nil)
28
+ new(metadata_version: self::METADATA_VERSION, id: id, command: command, cwd: cwd, workspace_root: Dir.pwd,
29
+ status: "running", pid: pid, pid_started: pid_started, created_at: started_at, started_at: started_at,
30
+ finished_at: nil, exit_code: nil, stop_reason: nil, output_path: output_path, stderr_path: stderr_path,
31
+ exit_code_path: exit_code_path, handed_over_by: handed_over_by, stop_requested_by: nil)
32
+ end
33
+
34
+ # @param data [Hash] the parsed task.json; a key it lacks reads nil
35
+ # @return [TaskRecord, nil] nil when +data+ isn't an object
36
+ def self.from_file(data)
37
+ return nil unless data.is_a?(Hash)
38
+
39
+ new(**members.to_h { |key| [key, data[key.to_s]] })
40
+ end
41
+
42
+ # @return [Hash] the task.json object, string keys in member order
43
+ def to_file
44
+ to_h.reject { |key, value| value.nil? && self.class::OPTIONAL.include?(key) }.transform_keys(&:to_s)
45
+ end
46
+
47
+ def running? = status == "running"
48
+
49
+ def stopped_by_user? = stop_reason == "stopped_by_user"
50
+ end
51
+ end
52
+ end
@@ -8,11 +8,13 @@ require "time"
8
8
  require_relative "../atomic_file"
9
9
  require_relative "../process_group"
10
10
  require_relative "shell"
11
+ require_relative "task_record"
11
12
 
12
13
  module Samagotchi
13
14
  module Tools
14
- # Execute requires nothing from here at load, and P1b's Execute will
15
- # require this file: autoload keeps the two from requiring each other.
15
+ # Execute requires this file (it hands a command over with adopt), and
16
+ # this one needs Execute only at run time: autoload keeps the two from
17
+ # requiring each other.
16
18
  autoload :Execute, File.expand_path("execute", __dir__)
17
19
 
18
20
  module TaskRuntime
@@ -22,7 +24,7 @@ module Samagotchi
22
24
  EXIT_CODE_FILENAME = "exit_code"
23
25
  STOP_GRACE_SEC = 3.0
24
26
  STOP_POLL_INTERVAL_SEC = 0.1
25
- METADATA_VERSION = 1
27
+ METADATA_VERSION = TaskRecord::METADATA_VERSION
26
28
  OUTPUT_TAIL_READ_BYTES = 16 * 1024
27
29
  SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
28
30
  # Set by chi (Builtins.parent_env); the model's env can't change it.
@@ -74,24 +76,9 @@ module Samagotchi
74
76
  # Read now, compared before each signal: a pid reused later is not this task.
75
77
  pid_started = ProcessGroup.start_time(pid)
76
78
 
77
- now = timestamp
78
- record = {
79
- "metadata_version" => METADATA_VERSION,
80
- "id" => task_id,
81
- "command" => normalized_command,
82
- "cwd" => resolved_cwd,
83
- "workspace_root" => Dir.pwd,
84
- "status" => "running",
85
- "pid" => pid,
86
- "pid_started" => pid_started,
87
- "created_at" => now,
88
- "started_at" => now,
89
- "finished_at" => nil,
90
- "exit_code" => nil,
91
- "stop_reason" => nil,
92
- "output_path" => output_path,
93
- "exit_code_path" => exit_code_path
94
- }
79
+ record = TaskRecord.running(id: task_id, command: normalized_command, cwd: resolved_cwd, pid: pid,
80
+ pid_started: pid_started, started_at: timestamp,
81
+ output_path: output_path, exit_code_path: exit_code_path)
95
82
 
96
83
  write_record(record)
97
84
  [record, nil]
@@ -102,41 +89,29 @@ module Samagotchi
102
89
  end
103
90
 
104
91
  # A record for a command chi already runs: an execute still running at
105
- # its limit, handed over as a task (Execute::HANDED_OVER). Its logs and
92
+ # its limit, handed over as a task (Execute::HANDED_OVER), given as a
93
+ # TaskRecord.running whose +started_at+ is the spawn's. Its logs and
106
94
  # exit_code file stay where execute put them, so the paths must be
107
95
  # absolute; +stderr_path+ is a log of its own (task_wait tails it).
108
96
  # The pid's start time is read here, at the hand-over, not at the
109
97
  # spawn (a quick execute is spared the ps): read while execute's
110
98
  # detach waiter is still running, so the pid is still its child.
111
- # @param started_at [Time] when execute spawned the command
112
- # @return [Array(Hash, nil), Array(nil, String)] the record, or an error
113
- def adopt(id:, command:, cwd:, pid:, started_at:, output_path:, stderr_path:, exit_code_path:)
99
+ # @param task [TaskRecord]
100
+ # @return [Array(TaskRecord, nil), Array(nil, String)] the record, or an error
101
+ def adopt(task)
102
+ id = task.id
114
103
  return [nil, "Error: invalid task id: #{id}"] unless TASK_ID_SHAPE.match?(id.to_s)
115
104
  return [nil, "Error: task already exists: #{id}"] if File.exist?(metadata_path(id))
116
105
 
117
- paths = { "output_path" => output_path, "stderr_path" => stderr_path, "exit_code_path" => exit_code_path }
118
- relative = paths.find { |_, path| !File.absolute_path?(path.to_s) }
119
- return [nil, "Error: #{relative[0]} must be absolute: #{relative[1]}"] if relative
106
+ relative = %i[output_path stderr_path exit_code_path].find { |key| !File.absolute_path?(task.public_send(key).to_s) }
107
+ return [nil, "Error: #{relative} must be absolute: #{task.public_send(relative)}"] if relative
108
+
109
+ pid = task.pid
120
110
  return [nil, "Error: not a process chi can stop: #{pid.inspect}"] unless ProcessGroup.signalable?(pid)
121
111
 
122
- started = started_at.utc.iso8601
123
- record = {
124
- "metadata_version" => METADATA_VERSION,
125
- "id" => id,
126
- "command" => command.to_s.strip,
127
- "cwd" => File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd),
128
- "workspace_root" => Dir.pwd,
129
- "status" => "running",
130
- "pid" => pid,
131
- "pid_started" => ProcessGroup.start_time(pid),
132
- "created_at" => started,
133
- "started_at" => started,
134
- "finished_at" => nil,
135
- "exit_code" => nil,
136
- "stop_reason" => nil,
137
- **paths,
138
- "handed_over_by" => Execute::NAME
139
- }
112
+ cwd = task.cwd
113
+ record = task.with(command: task.command.to_s.strip, cwd: File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd),
114
+ pid_started: ProcessGroup.start_time(pid), handed_over_by: Execute::NAME)
140
115
  write_record(record)
141
116
  [record, nil]
142
117
  rescue StandardError => e
@@ -154,7 +129,7 @@ module Samagotchi
154
129
  next nil unless record
155
130
 
156
131
  refresh_record(record)
157
- end.compact.sort_by { |record| record["created_at"].to_s }.reverse
132
+ end.compact.sort_by { |record| record.created_at.to_s }.reverse
158
133
  end
159
134
 
160
135
  # The tasks this conversation started that are still running: its
@@ -166,7 +141,7 @@ module Samagotchi
166
141
  def running_created_in(messages)
167
142
  created_ids_in(messages).filter_map do |id|
168
143
  record, _error = get_record(id)
169
- { id: id, command: record["command"].to_s } if record&.fetch("status") == "running"
144
+ { id: id, command: record.command.to_s } if record&.running?
170
145
  end
171
146
  end
172
147
 
@@ -232,14 +207,12 @@ module Samagotchi
232
207
  path = File.join(root || Dir.pwd, TASKS_DIR, id, METADATA_FILENAME)
233
208
  return nil unless File.file?(path)
234
209
 
235
- command = JSON.parse(File.read(path))["command"]
210
+ command = TaskRecord.from_file(JSON.parse(File.read(path)))&.command
236
211
  command.is_a?(String) && !command.strip.empty? ? command : nil
237
212
  rescue StandardError
238
213
  nil
239
214
  end
240
215
 
241
- def stopped_by_user?(record) = record["stop_reason"] == "stopped_by_user"
242
-
243
216
  def get_record(task_id)
244
217
  record = load_record(task_id)
245
218
  return [nil, not_found(task_id)] unless record
@@ -271,19 +244,18 @@ module Samagotchi
271
244
  return [nil, not_found(task_id)] unless record
272
245
 
273
246
  refreshed = refresh_record(record)
274
- return [refreshed, nil] unless refreshed["status"] == "running"
247
+ return [refreshed, nil] unless refreshed.running?
275
248
 
276
- pid = refreshed["pid"]
277
249
  # Checked again just before the signal: never a group chi didn't start.
278
- return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
250
+ return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed.pid_started)
279
251
 
280
- refreshed["stop_requested_by"] = by.to_s
281
- write_record(refreshed)
252
+ flagged = refreshed.with(stop_requested_by: by.to_s)
253
+ write_record(flagged)
282
254
 
283
255
  # A group that isn't ours (EPERM) is an error, not a stop.
284
- ProcessGroup.stop(pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
256
+ ProcessGroup.stop(flagged.pid, grace: STOP_GRACE_SEC, poll: STOP_POLL_INTERVAL_SEC, quiet: [Errno::ESRCH])
285
257
 
286
- updated = mark_stopped(refresh_record(refreshed))
258
+ updated = mark_stopped(refresh_record(flagged))
287
259
  write_record(updated)
288
260
 
289
261
  [updated, nil]
@@ -292,10 +264,10 @@ module Samagotchi
292
264
  end
293
265
 
294
266
  def refresh_record(record)
295
- return record unless record["status"] == "running"
267
+ return record unless record.running?
296
268
 
297
- pid = record["pid"]
298
- return record if chis_group?(record, started: record["pid_started"])
269
+ pid = record.pid
270
+ return record if chis_group?(record, started: record.pid_started)
299
271
  # Gone: its exit code says how it ended.
300
272
  return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
301
273
 
@@ -307,7 +279,7 @@ module Samagotchi
307
279
  # ps per call on macOS (~5 ms), asked by each refresh (task_list,
308
280
  # task_get, each task_wait poll) so a reused pid reads as exited, and
309
281
  # by task_stop again just before the signal.
310
- def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
282
+ def chis_group?(record, started: nil) = ProcessGroup.leader?(record.pid, started: started)
311
283
 
312
284
  # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
313
285
  # ends as failed, unless its exit code or a stop on disk says more.
@@ -338,18 +310,18 @@ module Samagotchi
338
310
  path = metadata_path(task_id)
339
311
  return nil unless File.exist?(path)
340
312
 
341
- JSON.parse(File.read(path))
313
+ TaskRecord.from_file(JSON.parse(File.read(path)))
342
314
  rescue JSON::ParserError
343
315
  nil
344
316
  end
345
317
 
346
318
  def write_record(record)
347
319
  ensure_tasks_dir
348
- dir = task_dir_for(record.fetch("id"))
320
+ dir = task_dir_for(record.id)
349
321
  FileUtils.mkdir_p(dir)
350
322
 
351
323
  path = File.join(dir, METADATA_FILENAME)
352
- AtomicFile.write(path, JSON.pretty_generate(record) + "\n")
324
+ AtomicFile.write(path, JSON.pretty_generate(record.to_file) + "\n")
353
325
  end
354
326
 
355
327
  def metadata_path(task_id)
@@ -434,26 +406,20 @@ module Samagotchi
434
406
 
435
407
  def mark_finished_without_exit_code(record, missing_reason: "process_ended_without_exit_code")
436
408
  record = current_record(record)
437
- return record unless record["status"] == "running"
438
-
439
- exit_code = read_exit_code(record["exit_code_path"])
440
-
441
- if record["stop_requested_by"]
442
- record["exit_code"] = exit_code
443
- mark_stopped(record)
444
- elsif exit_code.nil?
445
- record["status"] = "failed"
446
- record["exit_code"] = nil
447
- record["stop_reason"] ||= missing_reason
448
- elsif exit_code.zero?
449
- record["status"] = "completed"
450
- record["exit_code"] = 0
451
- else
452
- record["status"] = "failed"
453
- record["exit_code"] = exit_code
454
- end
455
-
456
- record["finished_at"] ||= timestamp
409
+ return record unless record.running?
410
+
411
+ exit_code = read_exit_code(record.exit_code_path)
412
+ record =
413
+ if record.stop_requested_by
414
+ mark_stopped(record.with(exit_code: exit_code))
415
+ elsif exit_code.nil?
416
+ record.with(status: "failed", exit_code: nil, stop_reason: record.stop_reason || missing_reason)
417
+ elsif exit_code.zero?
418
+ record.with(status: "completed", exit_code: 0)
419
+ else
420
+ record.with(status: "failed", exit_code: exit_code)
421
+ end
422
+ record = record.with(finished_at: record.finished_at || timestamp)
457
423
  write_record(record)
458
424
  record
459
425
  end
@@ -461,18 +427,16 @@ module Samagotchi
461
427
  # The disk copy when it is newer than +record+: already finished, or
462
428
  # flagged by a stop that started after +record+ was read.
463
429
  def current_record(record)
464
- disk = load_record(record.fetch("id"))
430
+ disk = load_record(record.id)
465
431
  return record unless disk
466
- return disk if disk["status"] != "running" || disk["stop_requested_by"]
432
+ return disk if !disk.running? || disk.stop_requested_by
467
433
 
468
434
  record
469
435
  end
470
436
 
471
437
  def mark_stopped(record)
472
- record["status"] = "stopped"
473
- record["stop_reason"] = "stopped_by_#{record["stop_requested_by"]}"
474
- record["finished_at"] ||= timestamp
475
- record
438
+ record.with(status: "stopped", stop_reason: "stopped_by_#{record.stop_requested_by}",
439
+ finished_at: record.finished_at || timestamp)
476
440
  end
477
441
 
478
442
  def read_exit_code(path)
@@ -14,12 +14,12 @@ module Samagotchi
14
14
  return error if error
15
15
 
16
16
  [
17
- "task_id: #{record.fetch("id")}",
18
- "status: #{record.fetch("status")}",
19
- "exit_code: #{record["exit_code"]}",
20
- "finished_at: #{record["finished_at"]}",
21
- "stop_reason: #{record["stop_reason"]}",
22
- "output_path: #{record.fetch("output_path")}"
17
+ "task_id: #{record.id}",
18
+ "status: #{record.status}",
19
+ "exit_code: #{record.exit_code}",
20
+ "finished_at: #{record.finished_at}",
21
+ "stop_reason: #{record.stop_reason}",
22
+ "output_path: #{record.output_path}"
23
23
  ].join("\n")
24
24
  rescue StandardError => e
25
25
  "Error: #{e.message}"
@@ -39,8 +39,7 @@ module Samagotchi
39
39
  record, error = TaskRuntime.get_record(task_id)
40
40
  return error if error
41
41
 
42
- status = record.fetch("status")
43
- return finished_response(record, tail_lines) unless status == "running"
42
+ return finished_response(record, tail_lines) unless record.running?
44
43
 
45
44
  if completion_pattern
46
45
  output_tail = output_tail(record, tail_lines)
@@ -56,7 +55,7 @@ module Samagotchi
56
55
 
57
56
  # Timeout reached — do one final refresh to capture the latest status
58
57
  refreshed, = TaskRuntime.get_record(task_id)
59
- return finished_response(refreshed, tail_lines) unless refreshed.fetch("status") == "running"
58
+ return finished_response(refreshed, tail_lines) unless refreshed.running?
60
59
 
61
60
  format_response(refreshed, wait_result: "timeout", output_tail: output_tail(refreshed, tail_lines), tail_lines: tail_lines)
62
61
  rescue StandardError => e
@@ -68,7 +67,7 @@ module Samagotchi
68
67
  def self.cancelled_response(task_id, tail_lines, started)
69
68
  record, error = TaskRuntime.get_record(task_id)
70
69
  return error if error
71
- return finished_response(record, tail_lines) unless record.fetch("status") == "running"
70
+ return finished_response(record, tail_lines) unless record.running?
72
71
 
73
72
  note = [
74
73
  "note: the user stopped the turn while waiting; the task is STILL RUNNING.",
@@ -83,7 +82,7 @@ module Samagotchi
83
82
  # A task the user stopped also says so, with the tail of its output:
84
83
  # the model sees where the job was cut off.
85
84
  def self.finished_response(record, tail_lines)
86
- return format_response(record) unless TaskRuntime.stopped_by_user?(record)
85
+ return format_response(record) unless record.stopped_by_user?
87
86
 
88
87
  format_response(record, output_tail: output_tail(record, tail_lines), tail_lines: tail_lines,
89
88
  extra: [TaskRuntime::USER_STOP_NOTE])
@@ -91,14 +90,14 @@ module Samagotchi
91
90
  private_class_method :finished_response
92
91
 
93
92
  def self.output_tail(record, tail_lines)
94
- TaskRuntime.output_tail_lines(record.fetch("output_path"), tail_lines)
93
+ TaskRuntime.output_tail_lines(record.output_path, tail_lines)
95
94
  end
96
95
  private_class_method :output_tail
97
96
 
98
97
  # The last lines of a handed-over execute's stderr log (stderr_path),
99
98
  # "" for a task_create task (one log) or an empty stderr.
100
99
  def self.stderr_tail(record, tail_lines)
101
- path = record["stderr_path"]
100
+ path = record.stderr_path
102
101
  return "" if path.to_s.empty? || tail_lines.nil?
103
102
 
104
103
  TaskRuntime.output_tail_lines(path, [tail_lines, STDERR_TAIL_LINES_MAX].min)
@@ -107,13 +106,13 @@ module Samagotchi
107
106
 
108
107
  def self.format_response(record, wait_result: nil, output_tail: nil, tail_lines: nil, extra: [])
109
108
  lines = [
110
- "task_id: #{record.fetch("id")}",
111
- "status: #{record.fetch("status")}",
112
- "exit_code: #{record["exit_code"]}",
113
- "stop_reason: #{record["stop_reason"]}",
114
- "output_path: #{record.fetch("output_path")}"
109
+ "task_id: #{record.id}",
110
+ "status: #{record.status}",
111
+ "exit_code: #{record.exit_code}",
112
+ "stop_reason: #{record.stop_reason}",
113
+ "output_path: #{record.output_path}"
115
114
  ]
116
- lines << "stderr_path: #{record["stderr_path"]}" if record["stderr_path"]
115
+ lines << "stderr_path: #{record.stderr_path}" if record.stderr_path
117
116
  return lines.join("\n") unless output_tail
118
117
 
119
118
  lines << "wait_result: #{wait_result}" if wait_result