samagotchi 0.65.0 → 0.66.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/cli.md +11 -1
  6. data/docs/configuration.md +51 -7
  7. data/docs/context.md +4 -1
  8. data/docs/internals/engine.md +4 -1
  9. data/docs/internals/prompt-caching.md +10 -2
  10. data/docs/memory.md +1 -1
  11. data/docs/plugins.md +163 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +10 -0
  14. data/docs/sub-agent.md +3 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
  19. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
  20. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  21. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
  22. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  23. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  25. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  28. data/lib/samagotchi/cache_confirm.rb +9 -6
  29. data/lib/samagotchi/commands/registry.rb +25 -8
  30. data/lib/samagotchi/config.rb +8 -1
  31. data/lib/samagotchi/engine.rb +112 -4
  32. data/lib/samagotchi/kernel_loop.rb +9 -1
  33. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  34. data/lib/samagotchi/plugin/api.rb +80 -7
  35. data/lib/samagotchi/plugin/context.rb +22 -1
  36. data/lib/samagotchi/plugin/loader.rb +3 -2
  37. data/lib/samagotchi/send_command.rb +23 -7
  38. data/lib/samagotchi/session.rb +18 -1
  39. data/lib/samagotchi/session_commands.rb +16 -1
  40. data/lib/samagotchi/session_manager.rb +4 -1
  41. data/lib/samagotchi/session_setup.rb +14 -7
  42. data/lib/samagotchi/system_prompt.rb +16 -1
  43. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  44. data/lib/samagotchi/terminal_ui.rb +18 -2
  45. data/lib/samagotchi/tool_runner.rb +13 -1
  46. data/lib/samagotchi/toolkit.rb +246 -0
  47. data/lib/samagotchi/toolkit_command.rb +117 -0
  48. data/lib/samagotchi/tools/registry.rb +43 -6
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -0
  51. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  52. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  53. data/lib/samagotchi/web/public/app.js +21 -7
  54. data/lib/samagotchi/web/public/commands.js +4 -2
  55. data/lib/samagotchi/web/public/index.html +9 -0
  56. data/lib/samagotchi/web/public/info_bar.js +5 -3
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  59. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  60. data/lib/samagotchi/worker.rb +2 -0
  61. metadata +10 -1
data/docs/sub-agent.md CHANGED
@@ -17,7 +17,9 @@ The other direction, chi sending a part of its work to a Claude Code session
17
17
  you already work with (to validate, as context, or with a question), is the
18
18
  [claude-peer bundle](plugins.md#the-claude-peer-bundle): Claude pairs with the
19
19
  chi session, chi's `claude_send` tool or `/claude` writes to Claude's inbox,
20
- and Claude's answer comes back as attached context.
20
+ and Claude's answer comes back as attached context. The
21
+ [codex-peer bundle](plugins.md#the-codex-peer-bundle) does the same with a
22
+ Codex CLI session (`codex_send`, `/codex`, through `codex queue`).
21
23
 
22
24
  ## The commands
23
25
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
5
  description: "Pair this session with a Claude Code session you work with: the claude_send tool and /claude send it a part of the work (a plan step, a diff, a finding) to validate or as context, and Claude's answer comes back as the claude-review context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -10,7 +10,7 @@ files:
10
10
  description: "Send a part of the work to the paired Claude Code session (claude_send): only when the user asks; validate, context or question; Claude's answer comes as the claude-review context"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:bf5a95eb125ba4831466e177295bdaba1f625e255b027cea1e59992310d8300e
13
+ sha256: sha256:ee44f119d6f8c6cb25917a0f3fb2cfd1fbca424d537ee5fa7043594a9eb04098
14
14
  scripts:
15
15
  pair: sha256:033f1fc6a644950b57e71865253526a0a487bb4c9fd5a61bb89d031b65f40417
16
16
  chi-peer-SKILL.md: sha256:f35f726b1a86091ff69d289e8dd58794ac6f44879a63630fcc88d69867c56e20
@@ -42,6 +42,12 @@ require "time"
42
42
  # being the user's consent: the copy happens in-process, where no guardrail
43
43
  # sees it. The skill is scripts/chi-peer-SKILL.md beside this file, with the
44
44
  # installed scripts/pair path and the payload folder written in.
45
+ #
46
+ # In the web, selecting text offers "Confirm with Claude" (chi.annotation;
47
+ # skipped on a chi without it, 0.62–0.65): it fills the composer with
48
+ # `/claude validate` and the quote, for the user to add what to confirm.
49
+ # Such a text is titled by its first quoted line, not the quote's
50
+ # `From your thinking:` label.
45
51
  class Plugin
46
52
  INBOX = "claude-inbox"
47
53
  REVIEW = "claude-review"
@@ -70,6 +76,8 @@ class Plugin
70
76
  PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
71
77
  PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
72
78
  DIFF_LINES = 20
79
+ # The label line a web quote starts with (annotations.js sourceLabel).
80
+ QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
73
81
 
74
82
  TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your last answer) to the " \
75
83
  "Claude Code session paired with it (the claude-inbox context), for Claude to validate, to take " \
@@ -111,6 +119,8 @@ class Plugin
111
119
  "install-skill: install Claude's chi-peer skill", anytime: true do |args, ctx|
112
120
  claude(args.to_s.strip, ctx)
113
121
  end
122
+ # A chi without chi.annotation (0.62–0.65) loads the bundle without the pill.
123
+ chi.annotation "Confirm with Claude", command: "/claude validate" if chi.respond_to?(:annotation)
114
124
  chi.on(:before_turn) do |event|
115
125
  @lock.synchronize { @before_turn = Array(event[:messages]) }
116
126
  end
@@ -167,12 +177,19 @@ class Plugin
167
177
  end
168
178
 
169
179
  def send_text(ctx, kind, text)
170
- "#{deliver(ctx, kind: kind, title: text.strip.lines.first.to_s, text: text)}; Claude's answer comes as the " \
180
+ "#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; Claude's answer comes as the " \
171
181
  "claude-review context"
172
182
  rescue SendError => e
173
183
  "/claude: #{e.message}"
174
184
  end
175
185
 
186
+ # A typed text's title: its first line, past a web quote's label line
187
+ # (`From your thinking:`) and the `> ` of a quoted line.
188
+ def text_title(text)
189
+ lines = text.strip.lines.map(&:strip).reject { |line| line.match?(QUOTE_LABEL) }
190
+ lines.map { |line| line.sub(/\A>\s*/, "") }.find { |line| !line.empty? }.to_s
191
+ end
192
+
176
193
  def send_last(ctx)
177
194
  answer, before = @lock.synchronize { [@last_answer, @before_turn] }
178
195
  answer ||= completed_answer(before || ctx.messages)
@@ -0,0 +1,21 @@
1
+ ---
2
+ name: codex-peer
3
+ version: 0.1.0
4
+ scope: system
5
+ description: "Pair this session with an OpenAI Codex CLI session you work with: the codex_send tool and /codex send it a part of the work (a plan step, a diff, a finding) to validate or as context, with codex queue, and Codex's answer comes back as the codex-review context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
+ trust_level: reviewed
7
+ files:
8
+ skill_codex_peer.md:
9
+ sha256: sha256:b17b97830d7895ca6bf393fe040d71a2eacc2e0b68fa9a23ba35d44f39132493
10
+ description: "Send a part of the work to the paired Codex CLI session (codex_send): only when the user asks; validate, context or question; Codex's answer comes as the codex-review context"
11
+ plugin:
12
+ file: plugin.rb
13
+ sha256: sha256:365382abe8f9f72e82eb965b762969e64c3774e3279d1e9cdc63a3f7286eb76f
14
+ scripts:
15
+ pair: sha256:90cc286587260a4221c6ca9621cf5f34dd2ac42dc1e6393b69ba3e974806e695
16
+ chi-peer-SKILL.md: sha256:886b6c430e9678f7f8bd991bdb374b6146ca2cbb0f42e36865ab14f5a21fa6e0
17
+ # Codex's answer is a pushed source's first wake: true push, which wakes the
18
+ # session from chi 0.63.0 (before, the first answer arrives as a note
19
+ # without waking); ctx.context.list's summary: (the /codex card's last
20
+ # answer) is 0.63.0 too.
21
+ requires_chi: ">= 0.63.0"
@@ -0,0 +1,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "tempfile"
7
+ require "time"
8
+
9
+ # codex-peer (docs/plugins.md, The codex-peer bundle): a live link between
10
+ # this chi session and an OpenAI Codex CLI session the user works with, the
11
+ # claude-peer bundle with the transport swapped. Codex pairs (its chi-peer
12
+ # skill, scripts/pair): it attaches the `codex-inbox` context, whose hint is
13
+ # its thread id (CODEX_THREAD_ID), and `codex-review`, where its answers
14
+ # come back as pushes that wake chi.
15
+ #
16
+ # The codex_send tool sends a part of the work (a plan step, a diff, a
17
+ # finding) with `codex queue --thread <uuid> --message <text>`, which wakes
18
+ # an idle Codex TUI: the text runs there as a new user turn (checked on
19
+ # Codex 0.162.1). Plain text, no wire format. The message starts with a
20
+ # header, `chi <id> · <kind> · <title>`; a short part follows inline, a
21
+ # long one goes to a file under the bundle's data dir (sessions/<id>/,
22
+ # removed with the session) and the message names it (`payload: <path>`).
23
+ # `codex` is CODEX, else codex on the PATH; a queue that takes over
24
+ # QUEUE_SECONDS is killed (its process group), so a send never hangs.
25
+ #
26
+ # The tool is declared whether or not Codex is paired, so pairing never
27
+ # changes the tools (no prompt rebuild). Without a paired Codex it fails
28
+ # with how to pair.
29
+ #
30
+ # /codex (anytime: it reads files, writes one and runs `codex queue`, up to
31
+ # QUEUE_SECONDS) lets the user send without the model: `/codex` alone is a
32
+ # status card (paired or not, the thread, the last send, the last
33
+ # codex-review answer); `/codex validate|context <text>` sends the text as
34
+ # it is, titled by its first line; `/codex last` sends the model's last
35
+ # completed answer as context (mid-turn, a worker's ctx.messages holds the
36
+ # running turn's partial text, so the before_turn and after_turn hooks keep
37
+ # what was completed); `/codex install-skill` shows a card with Codex's
38
+ # chi-peer skill's target ($CODEX_HOME, else ~/.codex) and an Install
39
+ # action (`--yes`), the card being the user's consent: the copy happens
40
+ # in-process, where no guardrail sees it. The skill is
41
+ # scripts/chi-peer-SKILL.md beside this file, with the installed
42
+ # scripts/pair path, the payload folder and chi's context folder written
43
+ # in. The card shows the writable_roots line Codex's config.toml needs for
44
+ # pairing and replies (its default sandbox can't write chi's state); chi
45
+ # never edits Codex's config.
46
+ #
47
+ # In the web, selecting text offers "Confirm with Codex" (chi.annotation;
48
+ # skipped on a chi without it, 0.63–0.65): it fills the composer with
49
+ # `/codex validate` and the quote, for the user to add what to confirm.
50
+ # Such a text is titled by its first quoted line, not the quote's
51
+ # `From your thinking:` label.
52
+ class Plugin
53
+ INBOX = "codex-inbox"
54
+ REVIEW = "codex-review"
55
+ KINDS = %w[validate context question].freeze
56
+ # A part up to this long goes inline (and keeps codex queue's argv small).
57
+ INLINE_CHARS = 4_000
58
+ # A part longer than this is refused (a send is a part, not a session).
59
+ MAX_CHARS = 200_000
60
+ TITLE_CHARS = 80
61
+ SLUG_CHARS = 40
62
+ # codex queue must be done within this long, or it is killed.
63
+ QUEUE_SECONDS = 10
64
+ # The Codex CLI version codex queue was checked on.
65
+ CHECKED_ON = "Codex 0.162.1"
66
+ THREAD_ID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
67
+ LAST_SEND = "last-send.json"
68
+ STATUS_CARD = "codex"
69
+ SKILL_CARD = "codex-skill"
70
+ USAGE = "usage: /codex — whether a Codex session is paired; /codex validate <text> | context <text> — send the " \
71
+ "text to it; /codex last — send the last answer as context; /codex install-skill — install Codex's " \
72
+ "chi-peer skill, which pairs"
73
+ # The shipped bundle keeps scripts/ beside plugin.rb; an installed one
74
+ # has plugin/plugin.rb and scripts/ beside plugin/.
75
+ SCRIPTS_DIRS = [File.join(__dir__, "scripts"), File.expand_path("../scripts", __dir__)].freeze
76
+ SKILL_FILE = "chi-peer-SKILL.md"
77
+ PAIR_PLACEHOLDER = "__CHI_PEER_PAIR__"
78
+ PAYLOADS_PLACEHOLDER = "__CHI_PEER_PAYLOADS__"
79
+ CONTEXT_PLACEHOLDER = "__CHI_PEER_CONTEXT__"
80
+ DIFF_LINES = 20
81
+ # The label line a web quote starts with (annotations.js sourceLabel).
82
+ QUOTE_LABEL = /\AFrom (?:your thinking|your earlier step|the .+ call|my earlier message):\z/
83
+
84
+ TOOL_DESCRIPTION = "Send a part of this session's work (a plan step, a diff, a finding, your last answer) to the " \
85
+ "Codex CLI session paired with it (the codex-inbox context), for Codex to validate, to take " \
86
+ "as context, or to answer a question about. Only when the user asks for it. Needs a paired " \
87
+ "Codex session: without one it fails, and the user pairs from Codex. Codex's answer comes " \
88
+ "back later as the codex-review context (a validate's wakes you): don't wait or poll. Never " \
89
+ "send secrets or the whole conversation."
90
+ TOOL_PARAMS = {
91
+ kind: { type: "string", enum: KINDS, required: true,
92
+ description: "validate: check this part and answer with a verdict; context: for Codex to know, no " \
93
+ "answer expected; question: answer the question in text" },
94
+ title: { type: "string", required: true,
95
+ description: "A short one-line title for the part; Codex's answer names it" },
96
+ text: { type: "string", required: true,
97
+ description: "The part itself and what to check or ask, as Codex should read it; name the files, " \
98
+ "functions or diff range to look at. A long text goes to a file Codex reads" }
99
+ }.freeze
100
+
101
+ # A send that didn't happen; the message says why, for the model or the user.
102
+ class SendError < StandardError; end
103
+
104
+ def initialize(_settings = {})
105
+ @lock = Mutex.new
106
+ # The running turn's history before it (nil between turns), and the
107
+ # last completed answer this process saw.
108
+ @before_turn = nil
109
+ @last_answer = nil
110
+ end
111
+
112
+ def register(chi)
113
+ chi.tool "codex_send", TOOL_DESCRIPTION, params: TOOL_PARAMS, label: "sent to Codex",
114
+ preview: ->(args) { "#{args["kind"]}: #{args["title"]}" } do |args, ctx|
115
+ sent = deliver(ctx, kind: args["kind"].to_s, title: args["title"].to_s, text: args["text"].to_s)
116
+ "#{sent}. #{follow_up(args["kind"].to_s)}"
117
+ rescue SendError => e
118
+ "Error: #{e.message}"
119
+ end
120
+ chi.command "/codex", "the paired Codex session: status; validate|context <text>, last: send to it; " \
121
+ "install-skill: install Codex's chi-peer skill", anytime: true do |args, ctx|
122
+ codex(args.to_s.strip, ctx)
123
+ end
124
+ # A chi without chi.annotation (0.63–0.65) loads the bundle without the pill.
125
+ chi.annotation "Confirm with Codex", command: "/codex validate" if chi.respond_to?(:annotation)
126
+ chi.on(:before_turn) do |event|
127
+ @lock.synchronize { @before_turn = Array(event[:messages]) }
128
+ end
129
+ chi.on(:after_turn) do |event|
130
+ answer = completed_answer(event[:messages]) if event[:status].to_s == "completed"
131
+ @lock.synchronize do
132
+ @before_turn = nil
133
+ @last_answer = answer if answer
134
+ end
135
+ end
136
+ end
137
+
138
+ # Sends +text+ to the paired Codex: the header, then the text inline or
139
+ # a payload file's path. Records it as the last send.
140
+ # @return [String] what was sent ("sent to Codex: validate · title")
141
+ # @raise [SendError]
142
+ def deliver(ctx, kind:, title:, text:)
143
+ raise SendError, "kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
144
+ raise SendError, "nothing to send: the text is empty" if text.strip.empty?
145
+ if text.length > MAX_CHARS
146
+ raise SendError, "the text is #{text.length} chars, over #{MAX_CHARS}: send a part, not a session"
147
+ end
148
+
149
+ id = ctx.session_id or raise SendError, "this session has no id yet: send after its first turn"
150
+ thread = thread_id(ctx)
151
+ title = one_line(title.strip.empty? ? text.strip.lines.first : title)
152
+ body, where = if text.length <= INLINE_CHARS
153
+ [text, "inline"]
154
+ else
155
+ path = write_payload(ctx, id, title, text)
156
+ ["payload: #{path} (#{text.lines.size} lines, #{text.length} chars)", path]
157
+ end
158
+ post(thread, "chi #{id[0, 8]} · #{kind} · #{title}\n#{body}")
159
+ record(ctx, id, kind: kind, title: title, chars: text.length, payload: where == "inline" ? nil : where)
160
+ "sent to Codex: #{kind} · #{title}#{" (the text in #{where})" unless where == "inline"}"
161
+ end
162
+
163
+ private
164
+
165
+ def codex(args, ctx)
166
+ verb = args.split(/\s+/, 2).first
167
+ # The rest as typed: a pasted selection keeps its lines.
168
+ rest = args.sub(/\A\S+\s*/, "")
169
+ case verb
170
+ when nil then status(ctx)
171
+ when "validate", "context" then rest.strip.empty? ? USAGE : send_text(ctx, verb, rest)
172
+ when "last" then rest.empty? ? send_last(ctx) : USAGE
173
+ when "install-skill" then install_skill(ctx, yes: rest == "--yes")
174
+ else USAGE
175
+ end
176
+ end
177
+
178
+ def send_text(ctx, kind, text)
179
+ "#{deliver(ctx, kind: kind, title: text_title(text), text: text)}; Codex's answer comes as the " \
180
+ "codex-review context"
181
+ rescue SendError => e
182
+ "/codex: #{e.message}"
183
+ end
184
+
185
+ # A typed text's title: its first line, past a web quote's label line
186
+ # (`From your thinking:`) and the `> ` of a quoted line.
187
+ def text_title(text)
188
+ lines = text.strip.lines.map(&:strip).reject { |line| line.match?(QUOTE_LABEL) }
189
+ lines.map { |line| line.sub(/\A>\s*/, "") }.find { |line| !line.empty? }.to_s
190
+ end
191
+
192
+ def send_last(ctx)
193
+ answer, before = @lock.synchronize { [@last_answer, @before_turn] }
194
+ answer ||= completed_answer(before || ctx.messages)
195
+ return "/codex last: no completed answer to send yet (the last turn has none); /codex context <text> sends a text" unless answer
196
+
197
+ deliver(ctx, kind: "context", title: "chi's last answer: #{answer.strip.lines.first}", text: answer)
198
+ rescue SendError => e
199
+ "/codex: #{e.message}"
200
+ end
201
+
202
+ # The answer a completed turn left: the history's last message when it
203
+ # is the model's (a cancelled, failed or empty turn ends in a turn note),
204
+ # after any notes that joined between turns; its thinking left out.
205
+ def completed_answer(messages)
206
+ last = Array(messages).reverse.find { |message| !(role(message) == "system" && kind(message) != "turn_note") }
207
+ return nil unless last && role(last) == "model" && !field(last, :interrupted) && !field(last, :tool_calls)
208
+
209
+ text = field(last, :content)
210
+ text = text.filter_map { |part| field(part, :text) if part.is_a?(Hash) }.join("\n") if text.is_a?(Array)
211
+ text = text.to_s.gsub(%r{<think>.*?</think>}m, "").strip
212
+ text.empty? ? nil : text
213
+ end
214
+
215
+ # The session's attached sources; none when they can't be read (a new
216
+ # session not saved yet; a corrupt session file).
217
+ def sources(ctx)
218
+ ctx.context.list
219
+ rescue ArgumentError, SystemCallError
220
+ []
221
+ end
222
+
223
+ def role(message) = field(message, :role).to_s
224
+ def kind(message) = field(message, :kind).to_s
225
+ def field(hash, key) = hash.is_a?(Hash) ? hash[key] || hash[key.to_s] : nil
226
+
227
+ # The status card; nil, so the command adds no text. There is no cheap
228
+ # check that the thread is still there, and a closed Codex session still
229
+ # takes sends (the app-server keeps them; codex queue exits 0).
230
+ def status(ctx)
231
+ id = ctx.session_id
232
+ sources = id ? sources(ctx) : []
233
+ inbox, review = [INBOX, REVIEW].map { |name| sources.find { |source| source[:name] == name } }
234
+ thread = inbox && inbox[:hint].to_s.strip
235
+ paired = !(thread.nil? || thread.empty?)
236
+ lines = if paired
237
+ ["**paired** with a Codex session", "thread: `#{thread}` (a closed Codex session still takes sends: they wait " \
238
+ "for `codex resume` there, or pair again)"]
239
+ else
240
+ ["**not paired.** In Codex, install the chi-peer skill (Install skill below), then tell Codex " \
241
+ "\"pair with chi #{id.to_s[0, 8]}\"."]
242
+ end
243
+ lines << "last send: #{last_send_line(ctx, id)}" if id && last_send(ctx, id)
244
+ lines << "last answer (codex-review): #{review_line(review)}" if review
245
+ lines << "Sent with `codex queue`: checked on #{CHECKED_ON}."
246
+ ctx.card(id: STATUS_CARD, title: "codex-peer: #{paired ? "paired" : "not paired"}",
247
+ body: lines.join("\n\n"), actions: [{ label: "Refresh", command: "/codex" },
248
+ { label: "Install skill", command: "/codex install-skill" }])
249
+ nil
250
+ end
251
+
252
+ def last_send(ctx, id)
253
+ path = File.join(ctx.data_dir, "sessions", id, LAST_SEND)
254
+ File.file?(path) ? JSON.parse(File.read(path)) : nil
255
+ rescue JSON::ParserError, SystemCallError
256
+ nil
257
+ end
258
+
259
+ def last_send_line(ctx, id)
260
+ sent = last_send(ctx, id)
261
+ where = sent["payload"] ? "in `#{sent["payload"]}`" : "inline"
262
+ "#{ago(sent["at"])} · #{sent["kind"]} · #{sent["title"]} (#{sent["chars"]} chars, #{where})"
263
+ end
264
+
265
+ def review_line(review)
266
+ return "none yet" unless review[:fetched_at]
267
+
268
+ [review[:summary], ago(review[:fetched_at])].compact.join(" · ")
269
+ end
270
+
271
+ def ago(stamp)
272
+ seconds = (Time.now - Time.iso8601(stamp.to_s)).to_i
273
+ if seconds < 60 then "just now"
274
+ elsif seconds < 3600 then "#{seconds / 60} min ago"
275
+ elsif seconds < 86_400 then "#{seconds / 3600} h ago"
276
+ else Time.iso8601(stamp).localtime.strftime("%Y-%m-%d %H:%M")
277
+ end
278
+ rescue ArgumentError
279
+ stamp.to_s
280
+ end
281
+
282
+ # /codex install-skill [--yes]
283
+ def install_skill(ctx, yes:)
284
+ source = SCRIPTS_DIRS.map { |dir| File.join(dir, SKILL_FILE) }.find { |path| File.file?(path) }
285
+ return "/codex install-skill: this bundle has no scripts/#{SKILL_FILE}; reinstall it (chi bundle install codex-peer --force)" unless source
286
+
287
+ text = File.read(source).gsub(PAIR_PLACEHOLDER, File.join(File.dirname(source), "pair"))
288
+ .gsub(PAYLOADS_PLACEHOLDER, tilde(ctx.data_dir))
289
+ .gsub(CONTEXT_PLACEHOLDER, context_dir(ctx))
290
+ target = File.join(codex_dir, "skills", "chi-peer", "SKILL.md")
291
+ yes ? write_skill(ctx, target, text) : skill_card(ctx, target, text)
292
+ end
293
+
294
+ def skill_card(ctx, target, text)
295
+ current = File.file?(target) ? File.read(target) : nil
296
+ state = if current.nil? then "Not installed yet."
297
+ elsif current == text then "Installed and up to date."
298
+ else "Installed, and different from this bundle's (Replace overwrites it):\n\n```diff\n#{diff(target, text)}\n```"
299
+ end
300
+ body = ["Codex's side: it pairs (`pair with chi <id>`), answers validates and replies into chi.",
301
+ "target: `#{target}`", state,
302
+ "Codex's default sandbox can't write chi's state, and pairing and replies do: add to " \
303
+ "`#{File.join(codex_dir, "config.toml")}` (into its `[sandbox_workspace_write]` section if it has one), " \
304
+ "then restart Codex:\n\n```toml\n#{roots_line(ctx)}\n```\n\nchi doesn't change Codex's settings."]
305
+ actions = if current == text
306
+ []
307
+ else
308
+ [{ label: current ? "Replace" : "Install", command: "/codex install-skill --yes" }]
309
+ end
310
+ ctx.card(id: SKILL_CARD, title: "Codex's chi-peer skill", body: body.join("\n\n"), actions: actions)
311
+ nil
312
+ end
313
+
314
+ def write_skill(ctx, target, text)
315
+ FileUtils.mkdir_p(File.dirname(target))
316
+ File.write(target, text)
317
+ ctx.card(id: SKILL_CARD, title: "Codex's chi-peer skill",
318
+ body: "Installed `#{target}`. Restart Codex if it is running, then say \"pair with chi " \
319
+ "#{ctx.session_id.to_s[0, 8]}\".")
320
+ nil
321
+ rescue SystemCallError => e
322
+ "/codex install-skill: couldn't write #{target}: #{e.message}"
323
+ end
324
+
325
+ # Codex's config folder: CODEX_HOME, else ~/.codex.
326
+ def codex_dir
327
+ dir = ENV.fetch("CODEX_HOME", "").strip
328
+ dir.empty? ? File.join(Dir.home, ".codex") : File.expand_path(dir)
329
+ end
330
+
331
+ # chi's context folder (<state>/samagotchi/context, where a push
332
+ # writes), beside the bundle's data dir (<state>/samagotchi/plugins/<name>).
333
+ # Absolute: config.toml doesn't expand ~.
334
+ def context_dir(ctx) = File.expand_path("../../context", ctx.data_dir)
335
+
336
+ def roots_line(ctx) = "[sandbox_workspace_write]\nwritable_roots = [#{JSON.generate(context_dir(ctx))}]"
337
+
338
+ # The first lines of `diff -u installed new`.
339
+ def diff(target, text)
340
+ Tempfile.create(["chi-peer", ".md"]) do |file|
341
+ file.write(text)
342
+ file.flush
343
+ out, = Open3.capture2("diff", "-u", "--label", "installed", "--label", "this bundle's", target, file.path)
344
+ lines = out.lines
345
+ lines = lines.first(DIFF_LINES) + ["… #{lines.size - DIFF_LINES} more lines\n"] if lines.size > DIFF_LINES
346
+ lines.join.chomp
347
+ end
348
+ rescue SystemCallError
349
+ "(diff isn't there to show how)"
350
+ end
351
+
352
+ def tilde(path)
353
+ home = Dir.home
354
+ path.start_with?("#{home}/") ? "~#{path.delete_prefix(home)}" : path
355
+ end
356
+
357
+ def follow_up(kind)
358
+ case kind
359
+ when "context" then "Codex takes it in; it answers only if something in it is wrong."
360
+ else "Codex's answer comes as an update of the codex-review context; don't wait or poll."
361
+ end
362
+ end
363
+
364
+ # The paired Codex's thread id: the codex-inbox context's hint.
365
+ def thread_id(ctx)
366
+ inbox = sources(ctx).find { |source| source[:name] == INBOX }
367
+ hint = inbox && inbox[:hint].to_s.strip
368
+ if hint.nil? || hint.empty?
369
+ raise SendError, "no codex-inbox: pair from Codex (install its skill with /codex install-skill, then " \
370
+ "tell Codex \"pair with chi #{ctx.session_id.to_s[0, 8]}\")"
371
+ end
372
+ raise SendError, "codex-inbox's hint isn't a Codex thread id (#{hint}): pair again from Codex" unless hint.match?(THREAD_ID)
373
+
374
+ hint
375
+ end
376
+
377
+ def codex_command
378
+ command = ENV.fetch("CODEX", "").strip
379
+ command.empty? ? "codex" : command
380
+ end
381
+
382
+ # Runs `codex queue --thread <thread> --message <text>` (an argv, never a
383
+ # shell line), killing its process group after QUEUE_SECONDS.
384
+ def post(thread, text)
385
+ command = codex_command
386
+ _out, err, status = run(command, "queue", "--thread", thread, "--message", text)
387
+ return if status.success?
388
+
389
+ first = err.lines.map(&:strip).find { |line| !line.empty? }.to_s
390
+ case first
391
+ when /ephemeral/i
392
+ raise SendError, "that Codex session is ephemeral and can't take queued messages: pair from an interactive " \
393
+ "Codex session"
394
+ # Codex 0.162.1: "… failed to read thread: invalid thread-store request:
395
+ # no rollout found for thread id <uuid> (code -32603)". A closed session
396
+ # isn't this: its queue still succeeds.
397
+ when /no rollout found|not found|unknown thread|no such thread|does not exist|no thread/i
398
+ raise SendError, "that Codex session is gone (codex queue: #{first}): pair again from Codex"
399
+ else
400
+ raise SendError, "codex queue failed (exit #{status.exitstatus}): #{first.empty? ? "no error text" : first}"
401
+ end
402
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOEXEC
403
+ where = command == "codex" ? "no codex on the PATH" : "no Codex CLI at #{command}"
404
+ raise SendError, "#{where}: install Codex CLI, or set CODEX= to its path where chi runs (a worker started by " \
405
+ "chi web or launchd may have another PATH)"
406
+ end
407
+
408
+ # [stdout, stderr, status]; both pipes read on threads, so a full one
409
+ # can't block the command.
410
+ def run(*command)
411
+ Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait|
412
+ stdin.close
413
+ out, err = [stdout, stderr].map { |io| Thread.new { io.read }.tap { |t| t.report_on_exception = false } }
414
+ unless wait.join(QUEUE_SECONDS)
415
+ begin
416
+ Process.kill("KILL", -wait.pid)
417
+ rescue SystemCallError
418
+ nil
419
+ end
420
+ # The group is gone, so the pipes end: let the readers finish
421
+ # before popen3 closes them.
422
+ [wait, out, err].each { |t| t.join(1) }
423
+ raise SendError, "Codex didn't take the message in #{QUEUE_SECONDS} s (codex queue was stopped): try " \
424
+ "again, or pair again from Codex"
425
+ end
426
+ [out.value, err.value, wait.value]
427
+ end
428
+ end
429
+
430
+ # sessions/<id>/<HHMMSS>-<slug>.md under the bundle's data dir, readable
431
+ # by its owner only.
432
+ def write_payload(ctx, id, title, text)
433
+ dir = session_dir(ctx, id)
434
+ base = "#{Time.now.strftime("%H%M%S")}-#{slug(title)}"
435
+ path = File.join(dir, "#{base}.md")
436
+ n = 1
437
+ path = File.join(dir, "#{base}-#{n += 1}.md") while File.exist?(path)
438
+ File.open(path, "w", 0o600) { |file| file.write(text) }
439
+ path
440
+ rescue SystemCallError => e
441
+ raise SendError, "couldn't write the payload file: #{e.message}"
442
+ end
443
+
444
+ def session_dir(ctx, id)
445
+ File.join(ctx.data_dir, "sessions", id).tap { |dir| FileUtils.mkdir_p(dir) }
446
+ end
447
+
448
+ def record(ctx, id, **send)
449
+ entry = { at: Time.now.utc.iso8601, **send }
450
+ @lock.synchronize { File.write(File.join(session_dir(ctx, id), LAST_SEND), JSON.generate(entry)) }
451
+ rescue SystemCallError => e
452
+ ctx.log.info(:last_send_not_saved, error: e.message)
453
+ end
454
+
455
+ def slug(title)
456
+ slug = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")[0, SLUG_CHARS].delete_suffix("-")
457
+ slug.empty? ? "part" : slug
458
+ end
459
+
460
+ def one_line(text)
461
+ line = text.to_s.gsub(/\s+/, " ").strip
462
+ line.length > TITLE_CHARS ? "#{line[0, TITLE_CHARS - 1]}…" : line
463
+ end
464
+ end