samagotchi 0.63.0 → 0.64.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/architecture.md +12 -2
  4. data/docs/cli.md +16 -8
  5. data/docs/configuration.md +15 -8
  6. data/docs/internals/engine.md +10 -3
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +11 -8
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +18 -4
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/composer_route.rb +90 -0
  21. data/lib/samagotchi/config.rb +6 -3
  22. data/lib/samagotchi/continue_offer.rb +17 -3
  23. data/lib/samagotchi/delivery.rb +13 -8
  24. data/lib/samagotchi/engine.rb +2 -0
  25. data/lib/samagotchi/host_config.rb +6 -6
  26. data/lib/samagotchi/host_model.rb +2 -2
  27. data/lib/samagotchi/interrupted_turn.rb +7 -3
  28. data/lib/samagotchi/last_turn.rb +5 -2
  29. data/lib/samagotchi/pending_input_queue.rb +10 -3
  30. data/lib/samagotchi/plugin/sessions.rb +27 -5
  31. data/lib/samagotchi/reply_wait.rb +171 -6
  32. data/lib/samagotchi/send_command.rb +63 -19
  33. data/lib/samagotchi/session_commands.rb +1 -1
  34. data/lib/samagotchi/session_inbox.rb +63 -7
  35. data/lib/samagotchi/session_manager.rb +49 -20
  36. data/lib/samagotchi/terminal_ui/attached_loop.rb +47 -26
  37. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  38. data/lib/samagotchi/terminal_ui.rb +72 -57
  39. data/lib/samagotchi/tool_activity.rb +5 -5
  40. data/lib/samagotchi/tools/delegate.rb +28 -1
  41. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  42. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  43. data/lib/samagotchi/tools/execute.rb +11 -4
  44. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/public/app.js +6 -0
  47. data/lib/samagotchi/web/public/commands.js +8 -8
  48. data/lib/samagotchi/web/public/composer.js +3 -3
  49. data/lib/samagotchi/web/public/timing.js +2 -1
  50. data/lib/samagotchi/web/public/turn_model.js +13 -3
  51. data/lib/samagotchi/worker.rb +7 -5
  52. metadata +2 -1
@@ -10,9 +10,14 @@ module Samagotchi
10
10
  # What a parent was already told about one delegate child: the newest
11
11
  # reply it was given (+reply_file+, ReplyWait's cursor) and the child as
12
12
  # it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
13
- # baseline). nil +messages+ means no baseline.
14
- DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages) do
15
- def self.empty = new(reply_file: nil, last_turn: nil, question_id: nil, messages: nil)
13
+ # baseline). nil +messages+ means no baseline. +turn+: the enqueued_id
14
+ # of the child's turn the last follow-up went to (Delegate.follow_up),
15
+ # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
+ # was reported, or when unknown (a new child, an older worker).
17
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
18
+ def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
19
+
20
+ def self.empty = new
16
21
 
17
22
  # @param baseline [Hash, nil] ReplyWait.baseline_of's
18
23
  def with_baseline(baseline)
@@ -93,7 +98,7 @@ module Samagotchi
93
98
  return DelegateCursor.empty unless data.is_a?(Hash)
94
99
 
95
100
  DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
96
- question_id: data["question_id"], messages: data["messages"])
101
+ question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
97
102
  end
98
103
 
99
104
  def path(parent_id, state_dir:)
@@ -72,7 +72,8 @@ module Samagotchi
72
72
  started = monotonic
73
73
  wait = ReplyWait.call(child_id, state_dir: sd, cursor: cursor.reply_file, timeout: [left, 0].max,
74
74
  poll_interval: poll_interval, cancelled: cancelled, baseline: baseline,
75
- owner_grace: owner_grace, interject: others && -> { others.poll })
75
+ owner_grace: owner_grace, interject: others && -> { others.poll },
76
+ turn: cursor.turn)
76
77
  left -= monotonic - started
77
78
  unless wait.status == :waiting_for_answer && DelegateRelay.relayable?(relay, wait.question)
78
79
  outcomes.concat(relayed_outcomes.delete(key) || [])
@@ -143,7 +144,9 @@ module Samagotchi
143
144
  # gave, and the child as that same look loaded it (a fresh load could
144
145
  # swallow a turn that ended in between), so a later wait looks for a
145
146
  # later turn and a question already reported isn't reported again.
146
- # Nothing moves on a timeout or a cancel.
147
+ # The follow-up's turn (DelegateCursor#turn) is done with once its end
148
+ # was reported; a question keeps it. Nothing moves on a timeout or a
149
+ # cancel.
147
150
  # @param wait [ReplyWait::Result]
148
151
  # @return [DelegateCursor, nil] the new cursor
149
152
  def advance(parent_id, child_id, wait, state_dir:)
@@ -151,6 +154,7 @@ module Samagotchi
151
154
 
152
155
  DelegateCursors.update(parent_id, child_id, state_dir: state_dir) do |cursor|
153
156
  cursor = cursor.with(reply_file: wait.file) if wait.status == :done
157
+ cursor = cursor.with(turn: nil) unless wait.status == :waiting_for_answer
154
158
  cursor.with_baseline(ReplyWait.baseline_of(wait.session))
155
159
  end
156
160
  end
@@ -187,20 +191,28 @@ module Samagotchi
187
191
 
188
192
  # Point the cursor at the newest reply now, so only a later one counts
189
193
  # (a follow-up sent to a child that already answered), and take the
190
- # child's baseline before the follow-up goes in.
194
+ # child's baseline before the follow-up goes in. No turn yet
195
+ # (#mark_turn, once it went in).
191
196
  # @param child [Session] the child as loaded before sending
192
197
  def mark_seen(parent_id, child, state_dir:)
193
198
  newest = ReplyWait.newest_reply(child.id, state_dir: state_dir)
194
199
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
195
- cursor.with(reply_file: newest).with_baseline(ReplyWait.baseline_of(child))
200
+ cursor.with(reply_file: newest, turn: nil).with_baseline(ReplyWait.baseline_of(child))
196
201
  end
197
202
  end
198
203
 
204
+ # The follow-up went in: the child's turn it joins, whose reply the
205
+ # wait takes (DelegateCursor#turn).
206
+ # @param turn [String, nil]
207
+ def mark_turn(parent_id, child_id, turn, state_dir:)
208
+ DelegateCursors.update(parent_id, child_id, state_dir: state_dir) { |cursor| cursor.with(turn: turn) }
209
+ end
210
+
199
211
  # A new child's baseline: as spawned, before its first turn ran.
200
212
  # @param child [Session] the session spawn_session returned
201
213
  def mark_started(parent_id, child, state_dir:)
202
214
  DelegateCursors.update(parent_id, child.id, state_dir: state_dir) do |cursor|
203
- cursor.with_baseline(ReplyWait.baseline_of(child, question_id: nil))
215
+ cursor.with(turn: nil).with_baseline(ReplyWait.baseline_of(child, question_id: nil))
204
216
  end
205
217
  end
206
218
 
@@ -32,6 +32,10 @@ module Samagotchi
32
32
  # stopped the turn: its cancel reason is :signal.
33
33
  NOT_RUN_ON_SIGNAL = "Error: not run, a signal to chi stopped the turn"
34
34
  STOPPED_BY_SIGNAL = "Error: command stopped by a signal to chi"
35
+ # The same two when a session stop (chi sessions stop, the web's stop,
36
+ # a parent stopping its delegate) canceled the turn: :session_stopped.
37
+ NOT_RUN_ON_SESSION_STOP = "Error: not run, the session was stopped"
38
+ STOPPED_BY_SESSION_STOP = "Error: command stopped as the session was stopped"
35
39
  # The first words of a command killed at the limit in kill mode (hooks
36
40
  # match on it).
37
41
  TIMEOUT_PREFIX = "Error: command timed out after"
@@ -79,7 +83,8 @@ module Samagotchi
79
83
  # @param cancelled [#call] true once the user stopped the turn: the
80
84
  # command is killed (or not started) and its output so far returned
81
85
  # @param stop_reason [#call] the stop's reason (:signal: a signal to
82
- # chi, which the result then names instead of the user)
86
+ # chi, :session_stopped: a session stop, which the result then names
87
+ # instead of the user)
83
88
  # @param env [Hash, nil] added to the command's environment
84
89
  # @param on_timeout [Symbol, String, nil] :task or :kill (ON_TIMEOUT);
85
90
  # nil reads execute.on_timeout
@@ -116,16 +121,18 @@ module Samagotchi
116
121
  # a long command, and return whatever it printed before it was killed.
117
122
  stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
118
123
  rescue CommandCancelled => e
119
- first = signal?(stop_reason) ? STOPPED_BY_SIGNAL : STOPPED_BY_USER
124
+ first = STOPPED_BY.fetch(stop_reason.call, STOPPED_BY_USER)
120
125
  stopped_result("#{first} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
121
126
  rescue StandardError => e
122
127
  "Error: #{e.message}"
123
128
  end
124
129
 
125
130
  # What a command the turn's stop kept from starting returns.
126
- def self.not_run(stop_reason) = signal?(stop_reason) ? NOT_RUN_ON_SIGNAL : NOT_RUN_ON_STOP
131
+ def self.not_run(stop_reason) = NOT_RUN.fetch(stop_reason.call, NOT_RUN_ON_STOP)
127
132
 
128
- def self.signal?(stop_reason) = stop_reason.call == :signal
133
+ # The texts above by the stop's reason; any other is the user's Stop.
134
+ NOT_RUN = { signal: NOT_RUN_ON_SIGNAL, session_stopped: NOT_RUN_ON_SESSION_STOP }.freeze
135
+ STOPPED_BY = { signal: STOPPED_BY_SIGNAL, session_stopped: STOPPED_BY_SESSION_STOP }.freeze
129
136
 
130
137
  # execute.on_timeout, or the caller's: :task (the default) or :kill.
131
138
  def self.on_timeout_mode(value)
@@ -67,6 +67,7 @@ module Samagotchi
67
67
  @boundaries = 0
68
68
  @written_at = nil
69
69
  @started_at = nil
70
+ @enqueued_id = nil
70
71
  end
71
72
 
72
73
  # A turn begins (before Engine#run_turn and the worker's running save):
@@ -74,14 +75,18 @@ module Samagotchi
74
75
  # images, no turn id), marked at iteration 0, so a worker killed before
75
76
  # the loop's first boundary leaves the prompt and a mark the next one
76
77
  # settles. A continue turn (no prompt) is the history, marked.
78
+ # Every mark of the turn carries +enqueued_id+ (InterruptedTurn's).
77
79
  # @param prompt [String, nil]
78
- def begin_turn(prompt: nil)
80
+ # @param enqueued_id [String, nil] the input the turn runs
81
+ def begin_turn(prompt: nil, enqueued_id: nil)
79
82
  @boundaries = 0
83
+ @enqueued_id = enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
80
84
  @written_at = nil
81
85
  @started_at = Time.now.iso8601(3)
82
86
  messages = Array(@session.messages).dup
83
87
  messages << { role: "user", content: prompt } unless prompt.to_s.empty?
84
- @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0) }
88
+ @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
89
+ enqueued_id: @enqueued_id) }
85
90
  end
86
91
 
87
92
  # The turn ended (or never began): the session saves its own messages.
@@ -104,7 +109,8 @@ module Samagotchi
104
109
  # One level deep: the loop may edit an entry in place later (a
105
110
  # forget stub) while a save on another thread serializes this one.
106
111
  messages = conversation.map { |message| message.is_a?(Hash) ? message.dup : message } + merged
107
- marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries)
112
+ marker = InterruptedTurn.new(id: turn_id, started_at: @started_at, at: Time.now.iso8601(3), iteration: @boundaries,
113
+ enqueued_id: @enqueued_id)
108
114
  @session.turn_checkpoint = { messages: messages, marker: marker }
109
115
  return false unless due? || !merged.empty?
110
116
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.63.0"
4
+ VERSION = "0.64.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1741,6 +1741,7 @@ async function handleSendTurn() {
1741
1741
  updateInfoBar();
1742
1742
  }
1743
1743
  actionBtn.disabled = true;
1744
+ const sentTo = selected;
1744
1745
  try {
1745
1746
  for (const chip of sending) {
1746
1747
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
@@ -1787,6 +1788,11 @@ async function handleSendTurn() {
1787
1788
  updateComposerMode();
1788
1789
  return;
1789
1790
  }
1791
+ // A stale tab sent into a session deleted meanwhile: the start page
1792
+ // says so, and the line stays in the composer.
1793
+ if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1794
+ // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1795
+ if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1790
1796
  alert(e.message);
1791
1797
  } finally {
1792
1798
  actionBtn.disabled = current.owner === "tui";
@@ -23,16 +23,17 @@ export function sessionCommandLine(text, commands) {
23
23
  }
24
24
 
25
25
  // /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
26
- // for Tab and /help, never run as commands; TerminalUI::Formatting's
27
- // STEER_CHOICE: the word, then any whitespace and the text). During a
26
+ // for Tab and /help, never run as commands; ComposerRoute::STEER_CHOICE
27
+ // (spec/fixtures/composer_routes.json): the word, then any whitespace and the text). During a
28
28
  // turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
29
- // as chi send --cut does; /queue isn't in the web yet (the page says so,
30
- // as the attached TUI does). With no running turn both are the text as a
31
- // normal turn. The bare word gets the usage line.
29
+ // as chi send --cut does, and /queue TEXT the text queued to run as a turn
30
+ // of its own after this one (delivery "queue"; a worker that can't queue
31
+ // refuses it, and the page shows the refusal's detail). With no running
32
+ // turn both are the text as a normal turn. The bare word gets the usage
33
+ // line.
32
34
  // @return {{send: string, delivery?: string} | {reply: string} | null}
33
35
  // null: not a delivery line
34
36
  export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
35
- export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
36
37
  const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
37
38
 
38
39
  export function steerChoiceRoute(text, { running = false } = {}) {
@@ -41,8 +42,7 @@ export function steerChoiceRoute(text, { running = false } = {}) {
41
42
  const message = (match[2] || "").trim();
42
43
  if (!message) return { reply: STEER_CHOICE_USAGE };
43
44
  if (!running) return { send: message };
44
- if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
45
- return { send: message, delivery: "cut" };
45
+ return { send: message, delivery: match[1] };
46
46
  }
47
47
 
48
48
  // A line that is one word: a slash and a name, no spaces, no second slash
@@ -7,8 +7,8 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
7
7
 
8
8
  // Where a composer line goes, in the order the page reads it:
9
9
  // 1. /cut TEXT, /queue TEXT (steerChoiceRoute): during a turn in a session
10
- // the text as a cut, or the page's own answer; else the text goes on
11
- // as the line.
10
+ // the text as a cut or queued for after the turn; else the text goes
11
+ // on as the line.
12
12
  // 2. On the start page (+session+ false): the page's own reply or a typo
13
13
  // hint (startPageReplyOrHint) needs no session; anything else makes one.
14
14
  // 3. In a session (routeSessionLine): a session command, the page's own
@@ -21,7 +21,7 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
21
21
  // +send+ again in it (routeSessionLine, with its
22
22
  // commands)
23
23
  // {command} POST /command
24
- // {send, delivery} POST /turn (+delivery+ "cut" or null)
24
+ // {send, delivery} POST /turn (+delivery+ "cut", "queue" or null)
25
25
  export function routeComposerLine(text, { session = true, running = false, commands = [], images = 0 } = {}) {
26
26
  const choice = steerChoiceRoute(text, { running: running && session });
27
27
  if (choice?.reply) return { reply: choice.reply, line: text };
@@ -33,7 +33,8 @@ export function liveDurationText(durationMs) {
33
33
 
34
34
  // A cancel's reason as the terminal UIs name it too (Formatting::
35
35
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
36
- const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped" };
36
+ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped",
37
+ session_stopped: "session stopped" };
37
38
 
38
39
  // The line a canceled turn ends with: "■ canceled (stopped)", live from
39
40
  // turn_canceled's reason and after a re-render from the turn record's
@@ -222,13 +222,23 @@ export function genLabelParts(gen) {
222
222
  if (line) return { head: cut(line), calls, repeats: true };
223
223
  if (first?.title) return { head: cut(`${toolName(first)} ${first.title}`), calls, repeats: true };
224
224
  if (gen.tools.length) {
225
- const names = [...new Set(gen.tools.map(toolName))];
226
- const head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
227
- return { head, calls, repeats: false };
225
+ // A plugin label that is a verb ("echoing", the default "calling
226
+ // tool") is the head itself when every call has one; among other
227
+ // tools it gives way to its tool's name ("working with read, echo").
228
+ const verbs = gen.tools.every(isVerbLabel);
229
+ const names = [...new Set(gen.tools.map((r) => (verbs || !isVerbLabel(r) ? toolName(r) : r.tool || "tool")))];
230
+ const list = `${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
231
+ return { head: verbs ? list : `working with ${list}`, calls, repeats: false };
228
232
  }
229
233
  return { head: "thinking", calls: "", repeats: false };
230
234
  }
231
235
 
236
+ // A plugin tool's label that reads as a verb (its first word ends in
237
+ // "ing": "echoing", "calling tool"), not as a name ("chrome: screenshot").
238
+ function isVerbLabel(row) {
239
+ return /^[a-z]+ing\b/i.test(String(row?.label || ""));
240
+ }
241
+
232
242
  // A step that said something (its text is not the turn's answer): a row of
233
243
  // its own in a finished block's outline.
234
244
  export function isNarrated(gen) {
@@ -900,7 +900,7 @@ module Samagotchi
900
900
  @reporting_turn = reports_to_parent?(turn_args[:origin])
901
901
  restores = @turn_flow.restores
902
902
  begin
903
- @checkpoint.begin_turn(prompt: prompt)
903
+ @checkpoint.begin_turn(prompt: prompt, enqueued_id: turn_args[:origin]&.dig(:enqueued_id))
904
904
  @session.status = Session::STATUS_RUNNING
905
905
  # A continue that moved this session to the chain's new link wrote
906
906
  # the new parent beside it (Session::PARENT_FILE): this turn's
@@ -929,7 +929,7 @@ module Samagotchi
929
929
  # no reply: its text is what came before its last tool calls, and a
930
930
  # wait (chi send --wait) gets the question instead.
931
931
  unless response.nil? || response.strip.empty? || @continue_offer.awaiting?
932
- SessionInbox.write_output(@session_dir, response)
932
+ SessionInbox.write_output(@session_dir, response, enqueued_id: turn_args[:origin]&.dig(:enqueued_id))
933
933
  end
934
934
  save_or_log(:turn) { save_session }
935
935
  # The reports the turn merged were read unless its end took the turn
@@ -1045,10 +1045,12 @@ module Samagotchi
1045
1045
  @pending_input_drain ||= lambda do
1046
1046
  # An iteration boundary: tell whoever sent a command now that it waits.
1047
1047
  refuse_queued_commands(mid_turn: true)
1048
- # A line with images runs as its own next turn (steering merges text
1049
- # only), and so does anything queued after it, to keep the order.
1048
+ # A queued line or one with images runs as its own turn after this
1049
+ # one (steering merges text only): skipped here, not a barrier, so a
1050
+ # line sent after it still goes in at this boundary. Only the files
1051
+ # claimed below reach @merged_this_turn and the checkpoint.
1050
1052
  files = SessionInbox.find_new_input_files(@session_dir).sort
1051
- .take_while { |input_file| !SessionInbox.waits_for_turn_end?(input_file) }
1053
+ .reject { |input_file| SessionInbox.waits_for_turn_end?(input_file) }
1052
1054
  merged = files.filter_map do |input_file|
1053
1055
  claimed_file = SessionInbox.claim_input_file(input_file)
1054
1056
  next unless claimed_file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.63.0
4
+ version: 0.64.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -235,6 +235,7 @@ files:
235
235
  - lib/samagotchi/client_id.rb
236
236
  - lib/samagotchi/command_steps.rb
237
237
  - lib/samagotchi/commands/registry.rb
238
+ - lib/samagotchi/composer_route.rb
238
239
  - lib/samagotchi/config.rb
239
240
  - lib/samagotchi/config_text_edit.rb
240
241
  - lib/samagotchi/context_absorber.rb