samagotchi 0.102.0 → 0.103.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -5,8 +5,10 @@ require "socket"
5
5
 
6
6
  require_relative "worker_sidecar"
7
7
  require_relative "delivery"
8
+ require_relative "chi_invocation"
8
9
  require_relative "bridge_client/sse_parser"
9
10
  require_relative "bridge_client/event_stream"
11
+ require_relative "bridge_client/stream_request"
10
12
 
11
13
  module Samagotchi
12
14
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
@@ -19,7 +21,7 @@ module Samagotchi
19
21
  class BridgeClient
20
22
  HOST = "127.0.0.1"
21
23
  # A reply's status line; its code is group 1.
22
- STATUS_LINE = %r{\AHTTP/1\.[01] (\d{3})}
24
+ STATUS_LINE = StreamRequest::STATUS_LINE
23
25
  STREAM_CONNECT_ATTEMPTS = 3
24
26
  # Seconds #stream waits for the Bridge's response headers, per attempt:
25
27
  # a worker that took the connection and froze must not hold the web's
@@ -54,9 +56,16 @@ module Samagotchi
54
56
  # What a UI says when the Bridge answers a route with 404: the worker runs
55
57
  # code from before the route. Restarting it picks up the installed chi.
56
58
  # @param cant [String] what the old worker can't do, e.g. "run commands"
57
- def self.stale_worker_message(session_id, cant:)
58
- "this session's worker runs an older chi and can't #{cant}; " \
59
- "restart it: chi sessions stop #{session_id} && chi --resume #{session_id} (its turns still work)"
59
+ # @param via [ChiInvocation] how the commands are spelled (chi --remote
60
+ # NAME: no --resume there, a message starts the fresh worker)
61
+ def self.stale_worker_message(session_id, cant:, via: ChiInvocation::HERE)
62
+ stop = via.command("sessions stop #{session_id}")
63
+ restart = if via.remote?
64
+ "#{stop}, then a message (#{via.command("send #{session_id} -m …")}) starts a fresh one"
65
+ else
66
+ "#{stop} && chi --resume #{session_id}"
67
+ end
68
+ "this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
60
69
  end
61
70
 
62
71
  # Port of the live Bridge advertised in +session_dir+'s sidecar, or nil.
@@ -103,6 +112,10 @@ module Samagotchi
103
112
 
104
113
  attr_reader :session_id, :port, :host
105
114
 
115
+ # Which worker this client talks to, for EventStream: a rediscovered
116
+ # client with another key is a new worker. A Bridge's port.
117
+ def worker_key = @port
118
+
106
119
  # @param read_timeout [Numeric] see READ_TIMEOUT
107
120
  def initialize(session_id:, port:, host: HOST, read_timeout: READ_TIMEOUT)
108
121
  @session_id = session_id
@@ -332,45 +345,16 @@ module Samagotchi
332
345
 
333
346
  # Open GET /session/:id/stream and read past the response headers.
334
347
  # @param timeout [Float, nil] give up when the headers aren't all in by then
335
- # @return [Array(TCPSocket, Integer), nil] the socket positioned at the
336
- # body and the HTTP status, or nil when the Bridge can't be reached
348
+ # @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
349
+ # at the body, the HTTP status and the headers (StreamRequest.open), or
350
+ # nil when the Bridge can't be reached
337
351
  def connect_stream(query: "", last_event_id: nil, timeout: nil)
338
- sock = TCPSocket.new(@host, @port)
339
- lei = last_event_id.to_s.strip
340
- last_event_line = lei.empty? ? "" : "Last-Event-ID: #{lei}\r\n"
341
- sock.write("GET /session/#{@session_id}/stream#{query} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\nAccept: text/event-stream\r\n#{last_event_line}Connection: keep-alive\r\n\r\n")
342
- deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
343
- status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
344
- while (line = header_line(sock, deadline))
345
- break if line.strip.empty?
346
- end
347
- [sock, status]
348
- rescue SystemCallError, SocketError, IOError
349
- sock&.close rescue nil
350
- nil
352
+ StreamRequest.open(host: @host, port: @port, path: "/session/#{@session_id}/stream#{query}",
353
+ last_event_id: last_event_id, timeout: timeout)
351
354
  end
352
355
 
353
356
  private
354
357
 
355
- # One line of the response headers, or nil at EOF; raises
356
- # Errno::ETIMEDOUT once +deadline+ (monotonic, nil: none) has passed. A
357
- # line read in pieces, so a Bridge that stops mid-line can't hold it.
358
- def header_line(sock, deadline)
359
- return sock.gets unless deadline
360
-
361
- line = +""
362
- loop do
363
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
364
- raise Errno::ETIMEDOUT unless remaining.positive? && sock.wait_readable(remaining)
365
-
366
- char = sock.getc
367
- return line.empty? ? nil : line if char.nil?
368
-
369
- line << char
370
- return line if char == "\n"
371
- end
372
- end
373
-
374
358
  # When this client stops waiting for a reply, as epoch seconds (see
375
359
  # DEADLINE_SHARE). Wall clock: the worker runs on this machine and reads
376
360
  # the same one.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: claude-peer
3
- version: 0.4.1
3
+ version: 0.4.2
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-outbox context; /claude install-skill installs Claude's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:50f427d3862ae9d0d15693b4d887c528162b96c0320cc663df89d59d830d4660
14
14
  scripts:
15
- pair: sha256:35f3be2b466a5ba07591d64040bc5780fdd843b9b08737f8ea8a2fccb2102832
15
+ pair: sha256:2e66103c43fce5eab05f897682ba943f394743c1ad47bf439be3ce529f1b2058
16
16
  chi-peer-SKILL.md: sha256:e991a91139a127a60f9a216043eb5864945fb211952a1e3c8db2068c6cc254f1
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -8,7 +8,9 @@
8
8
  # is this session's inbox socket (chi's claude_send tool writes there), and
9
9
  # claude-outbox, where Claude's answers go. Again after a Claude restart, it
10
10
  # only refreshes the socket: the push carries the new hint. CHI names the chi
11
- # command (default: chi on the PATH).
11
+ # command (default: chi on the PATH). Through a remote (CHI_REMOTE, or
12
+ # --remote NAME in CHI) chi there can't reach this machine's socket: it
13
+ # attaches claude-outbox only, and says so.
12
14
  set -eu
13
15
 
14
16
  usage() {
@@ -33,6 +35,23 @@ if [ -z "$sock" ]; then
33
35
  fi
34
36
  chi=${CHI:-chi}
35
37
 
38
+ # The remote, as SessionAccess::Dispatch names it: a --remote NAME (or
39
+ # --remote=NAME) word in CHI, the last one, beats CHI_REMOTE (trimmed; blank:
40
+ # local). An empty or dash-led name is still a remote: chi refuses it.
41
+ remote=$(printf '%s\n' "${CHI_REMOTE:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
42
+ via= take=
43
+ [ -z "$remote" ] || via=env
44
+ for word in $chi; do
45
+ if [ -n "$take" ]; then
46
+ remote=$word take=
47
+ continue
48
+ fi
49
+ case "$word" in
50
+ --remote) remote= via=chi take=1 ;;
51
+ --remote=*) remote=${word#--remote=} via=chi ;;
52
+ esac
53
+ done
54
+
36
55
  # A JSON string: backslashes, quotes, tabs and newlines escaped.
37
56
  json() {
38
57
  printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/ /\\t/g' |
@@ -51,6 +70,16 @@ attach() {
51
70
  fi
52
71
  }
53
72
 
73
+ if [ -n "$via" ]; then
74
+ attach claude-outbox --why "Claude's answers to what you sent it (claude-peer); Claude runs on another machine: claude_send can't reach it" \
75
+ --hint "latest Claude reply"
76
+ ask=$chi
77
+ [ "$via" = chi ] || ask="CHI_REMOTE=$remote $chi"
78
+ printf '%s\n' "pair: chi is on $remote, so chi can't send to this Claude session (claude_send needs its socket on chi's machine): paired with chi $id, claude-outbox only. Answer chi with $ask context push claude-outbox $id; chi's replies reach you only when the user relays them (or asks you to run $ask send --wait $id)"
79
+ $chi context ls "$id"
80
+ exit 0
81
+ fi
82
+
54
83
  attach claude-inbox --why "the Claude Code session paired with you: send it parts with claude_send" --hint "$sock"
55
84
  text="Claude Code peer inbox
56
85
  socket: $sock
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codex-peer
3
- version: 0.3.2
3
+ version: 0.3.3
4
4
  scope: system
5
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-outbox context; /codex install-skill installs Codex's chi-peer skill, which pairs"
6
6
  trust_level: reviewed
@@ -12,7 +12,7 @@ plugin:
12
12
  file: plugin.rb
13
13
  sha256: sha256:b03465566272891237ac1906980ff6956579a4703d3a42c414ce50a81de551f6
14
14
  scripts:
15
- pair: sha256:251448a31959b5c916400eff7909e85c696855d5c16b6b10973ada40915c8051
15
+ pair: sha256:0830dcb856debea0e4a2a0eb0da8a73b9e9ac2444da957d2928ec734280186a1
16
16
  chi-peer-SKILL.md: sha256:7c392ac04c115ea1e762ad562b5c4c44c3eca71558ba5d9dfeaeabdd533b6a82
17
17
  # The plugin needs Samagotchi::Plugin::Peer and chi.peer_source (chi 0.67.0).
18
18
  # An agreeing answer's closing wake (the skills say an OK wakes chi once)
@@ -9,12 +9,15 @@
9
9
  # `codex queue --thread <id>` with it), and codex-outbox, where Codex's
10
10
  # answers go. Again later, it only refreshes the thread id: the push carries
11
11
  # the new hint. CHI names the chi command (default: chi on the PATH).
12
+ # Through a remote (CHI_REMOTE, or --remote NAME in CHI) chi there can't
13
+ # queue to this machine's Codex: it attaches codex-outbox only, and says so.
12
14
  #
13
15
  # chi writes the session's context under its state folder, which Codex's
14
16
  # default sandbox doesn't let it write: ~/.codex/config.toml needs
15
17
  # [sandbox_workspace_write]
16
18
  # writable_roots = ["<state>/samagotchi/context"]
17
- # and a write refused says so.
19
+ # and a write refused says so. Through a remote chi talks HTTP instead,
20
+ # which the sandbox may refuse (no network): a connection that fails says so.
18
21
  set -eu
19
22
 
20
23
  usage() {
@@ -39,14 +42,46 @@ if [ -z "$thread" ]; then
39
42
  fi
40
43
  chi=${CHI:-chi}
41
44
 
42
- # Prints chi's error, and the writable_roots line when the sandbox refused.
45
+ # The remote, as SessionAccess::Dispatch names it: a --remote NAME (or
46
+ # --remote=NAME) word in CHI, the last one, beats CHI_REMOTE (trimmed; blank:
47
+ # local). An empty or dash-led name is still a remote: chi refuses it.
48
+ remote=$(printf '%s\n' "${CHI_REMOTE:-}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
49
+ via= take=
50
+ [ -z "$remote" ] || via=env
51
+ for word in $chi; do
52
+ if [ -n "$take" ]; then
53
+ remote=$word take=
54
+ continue
55
+ fi
56
+ case "$word" in
57
+ --remote) remote= via=chi take=1 ;;
58
+ --remote=*) remote=${word#--remote=} via=chi ;;
59
+ esac
60
+ done
61
+
62
+ config='~/.codex/config.toml'
63
+ [ -z "${CODEX_HOME:-}" ] || config=$CODEX_HOME/config.toml
64
+
65
+ # Prints chi's error, and the config line when the sandbox may have refused:
66
+ # writable_roots here, network_access through a remote.
43
67
  fail() {
44
68
  printf '%s\n' "$1" >&2
69
+ if [ -n "$via" ]; then
70
+ case "$1" in
71
+ *"connection broke"* | *"can't find host"* | *"host unreachable"* | *"no answer within"*)
72
+ cat >&2 <<MSG
73
+ pair: Codex's sandbox may be keeping chi from reaching $remote (workspace-write has no network by default): if so, add to $config
74
+ [sandbox_workspace_write]
75
+ network_access = true
76
+ then restart Codex and pair again.
77
+ MSG
78
+ ;;
79
+ esac
80
+ exit 1
81
+ fi
45
82
  case "$1" in
46
83
  *"Operation not permitted"* | *"Read-only file system"* | *"Permission denied"*)
47
84
  state=${XDG_STATE_HOME:-$HOME/.local/state}
48
- config='~/.codex/config.toml'
49
- [ -z "${CODEX_HOME:-}" ] || config=$CODEX_HOME/config.toml
50
85
  cat >&2 <<MSG
51
86
  pair: Codex's sandbox can't write chi's state: add to $config
52
87
  [sandbox_workspace_write]
@@ -75,6 +110,16 @@ attach() {
75
110
  fi
76
111
  }
77
112
 
113
+ if [ -n "$via" ]; then
114
+ attach codex-outbox --why "Codex's answers to what you sent it (codex-peer); Codex runs on another machine: codex_send can't reach it" \
115
+ --hint "latest Codex reply"
116
+ ask=$chi
117
+ [ "$via" = chi ] || ask="CHI_REMOTE=$remote $chi"
118
+ printf '%s\n' "pair: chi is on $remote, so chi can't send to this Codex session (codex_send runs codex queue on chi's machine): paired with chi $id, codex-outbox only. Answer chi with $ask context push codex-outbox $id; chi's replies reach you only when the user relays them (or asks you to run $ask send --wait $id)"
119
+ $chi context ls "$id"
120
+ exit 0
121
+ fi
122
+
78
123
  attach codex-inbox --why "the Codex session paired with you: send it parts with codex_send" --hint "$thread"
79
124
  text="Codex peer inbox
80
125
  thread: $thread
@@ -154,10 +154,12 @@ rules:
154
154
  # code). add NAME --push and chi context push don't run anything. Quoted
155
155
  # words ("context", '--cmd=…') and backslash-newline continuations still
156
156
  # match; xargs chi context add asks whatever it is fed (its arguments
157
- # aren't in the command).
157
+ # aren't in the command). chi's global flag --remote NAME (the only one
158
+ # that goes before a subcommand) may stand between chi and context;
159
+ # CHI_REMOTE=NAME leaves the words as they are.
158
160
  - id: chi-context-cmd
159
161
  tool: shell
160
- command: '\bchi["'']?[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b(?:[^\n]|\\\n)*[\s"''\\]--cmd(?:[\s="''\\]|$)|\bxargs\b[^;&|\n]*\bchi["'']?[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b'
162
+ command: '\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b(?:[^\n]|\\\n)*[\s"''\\]--cmd(?:[\s="''\\]|$)|\bxargs\b[^;&|\n]*\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?context["'']?[\s\\]+["'']?add\b'
161
163
  verdict: ask
162
164
  scopes: [once]
163
165
  reason: attaches a command chi will run every few minutes, outside the guardrails
@@ -165,10 +167,11 @@ rules:
165
167
  # ── chi broadcast: for the user, not an agent ─────────────────────────
166
168
  # chi broadcast refuses to run inside a session (SAMAGOTCHI_PARENT_SESSION
167
169
  # is set there), but an agent can unset that; this asks every time, and
168
- # only the user may allow it. Quoted words and continuations still match.
170
+ # only the user may allow it. Quoted words, continuations and --remote
171
+ # NAME before the word still match.
169
172
  - id: chi-broadcast
170
173
  tool: shell
171
- command: '\bchi["'']?[\s\\]+["'']?broadcast\b'
174
+ command: '\bchi["'']?(?:[\s\\]+["'']?--remote(?:=|["'']?[\s\\]+["'']?)[^\s"''\\;&|]+["'']?)*[\s\\]+["'']?broadcast\b'
172
175
  verdict: ask
173
176
  scopes: [once]
174
177
  reason: shares a note with every session it may concern; chi broadcast is for the user, not an agent
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.9.2
3
+ version: 0.9.3
4
4
  scope: system
5
5
  description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory, writing a memory that reaches the system prompt (identity, model notes) or a model overlay; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.102.0
3
+ version: 0.103.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # How the follow-up commands chi prints (ParentReport's chi answer and
5
+ # chi send --wait, a stop's chi --resume) are spelled so that they work
6
+ # when pasted: on this machine plain chi, against a remote (chi --remote
7
+ # NAME, CHI_REMOTE=NAME alike) chi --remote NAME. What runs on this
8
+ # machine only (--attach, --resume: remote attach is its own phase) has
9
+ # no remote form: #attach and #resume are nil there, and #open_it and
10
+ # #shows name the remote's chi web instead.
11
+ # @!attribute remote [String, nil] the remote's name; nil: this machine
12
+ ChiInvocation = Data.define(:remote) do
13
+ def remote? = !remote.nil?
14
+
15
+ # The command word: "chi", or "chi --remote NAME".
16
+ def chi = remote? ? "chi --remote #{remote}" : "chi"
17
+
18
+ # +words+ after #chi: command("answer ID …").
19
+ def command(words) = "#{chi} #{words}"
20
+
21
+ # chi --attach ID; nil remotely.
22
+ def attach(id) = ("chi --attach #{id}" unless remote?)
23
+
24
+ # chi --resume ID; nil remotely.
25
+ def resume(id) = ("chi --resume #{id}" unless remote?)
26
+
27
+ # What shows the session ("… shows it"): chi --attach ID, or the
28
+ # remote's chi web.
29
+ def shows(id) = attach(id) || web
30
+
31
+ # Where the user opens it ("open it: …"): chi --attach ID or the web,
32
+ # or the remote's chi web.
33
+ def open_it(id) = remote? ? web : "#{attach(id)} or the web"
34
+
35
+ # The remote's chi web, by name.
36
+ def web = "chi web on #{remote}"
37
+ end
38
+
39
+ class ChiInvocation
40
+ # This machine's: plain chi.
41
+ HERE = new(remote: nil)
42
+ end
43
+ end
@@ -26,9 +26,10 @@ module Samagotchi
26
26
  @stderr.puts(text)
27
27
  end
28
28
 
29
+ # @param usage [String, nil] what follows the line (nil: "(see …
30
+ # --help)" on it); a one-line error whose fix the line says passes nil
29
31
  # @return [Integer] the usage exit status
30
- def usage_error(message)
31
- usage = usage_on_error
32
+ def usage_error(message, usage: usage_on_error)
32
33
  error_line("#{command_name}: #{message}#{" (see #{command_name} --help)" unless usage}")
33
34
  @stderr.puts(usage) if usage
34
35
  Exit::USAGE
@@ -68,6 +68,11 @@ module Samagotchi
68
68
  add(names, Flag.new(refusal: message))
69
69
  end
70
70
 
71
+ # Each declared flag's names, in the order declared ([["-m",
72
+ # "--message"], ["--new"]]); refused flags too.
73
+ # @return [Array<Array<String>>]
74
+ def names = @flags.keys.group_by { |name| @flags[name].object_id }.values
75
+
71
76
  # @param defaults [Hash] the options before any flag (not changed)
72
77
  # @return [Result]
73
78
  def parse(argv, defaults = {})
@@ -4,6 +4,7 @@ require_relative "../session"
4
4
  require_relative "../reply_wait"
5
5
  require_relative "../parent_report"
6
6
  require_relative "exit"
7
+ require_relative "../session_access"
7
8
 
8
9
  module Samagotchi
9
10
  module CLI
@@ -12,8 +13,8 @@ module Samagotchi
12
13
  # for it, and the wait for what comes next, reported the ParentReport
13
14
  # way (Ctrl-C included). The including class (a CLI::Command) defines
14
15
  # #parse (options, or an exit status) and #run_parsed(options), sets
15
- # @stdout, @stderr and @state_dir, and may override the constants
16
- # (specs stub them on the class).
16
+ # @stdout, @stderr and @access (a SessionAccess backend), and may
17
+ # override the constants (specs stub them on the class).
17
18
  module ParentWait
18
19
  POLL_INTERVAL = ReplyWait::POLL_INTERVAL
19
20
  # No live worker this long while waiting: it died before it could mark
@@ -30,7 +31,14 @@ module Samagotchi
30
31
  # With --format json stdout is one JSON object, whatever the end: a
31
32
  # failure before the wait is reported from stderr's last line.
32
33
  @stderr = ParentReport::LastLine.new(@stderr) if @json
33
- status = run_parsed(options)
34
+ status = begin
35
+ run_parsed(options)
36
+ rescue SessionAccess::RemoteFailed => e
37
+ # A remote that went away mid-run: the command's line, and with
38
+ # --format json its object.
39
+ error_line("#{command_name}: #{e.message}")
40
+ Exit::FAILED
41
+ end
34
42
  if @json && !@reported && status == 1
35
43
  detail = @stderr.last.to_s.delete_prefix("#{command_name}: ")
36
44
  @stdout.puts(ParentReport.error_line(detail, session_id: @session_id))
@@ -45,9 +53,7 @@ module Samagotchi
45
53
  # error line; nil after the error line.
46
54
  # @return [String, nil]
47
55
  def resolve(given)
48
- id = Session.resolve_id(given, state_dir: @state_dir)
49
- Session.load(id, state_dir: @state_dir)
50
- @session_id = id
56
+ @session_id = @access.resolve(given)
51
57
  rescue ArgumentError => e
52
58
  error_line("#{command_name}: #{e.is_a?(Session::AmbiguousId) ? e.message : "no session #{given}"}")
53
59
  nil
@@ -70,16 +76,19 @@ module Samagotchi
70
76
  # @return [Integer] the exit status
71
77
  def wait_for_reply(id, cursor:, baseline:, timeout:, owner_grace: self.class::WORKER_GONE_AFTER, turn: nil,
72
78
  queued: false, ended_before: false, report_turn_id: turn)
73
- result = ReplyWait.call(id, state_dir: @state_dir, cursor: cursor, timeout: timeout, baseline: baseline,
74
- owner_grace: owner_grace, poll_interval: self.class::POLL_INTERVAL, turn: turn,
75
- queued: queued, ended_before: ended_before)
79
+ result = @access.wait_reply(id, cursor: cursor, timeout: timeout, baseline: baseline, owner_grace: owner_grace,
80
+ poll_interval: self.class::POLL_INTERVAL, turn: turn, queued: queued,
81
+ ended_before: ended_before)
76
82
  result.turn_id = report_turn_id if report_turn_id && !report_turn_id.to_s.empty?
77
83
  result.text = result.text&.dup&.force_encoding(Encoding::UTF_8)&.scrub
78
84
  @reported = true
79
- # What a parent may pick there: config.yml tightened by the session's own.
80
- strategy = ParentReport.strategy_for(id, state_dir: @state_dir)
85
+ # What a parent may pick there: config.yml tightened by the session's
86
+ # own. Machine-local (this machine's config.yml): remotely the
87
+ # server's (P2's wait route).
88
+ strategy = @access.parent_strategy(id)
81
89
  ParentReport.report(result, session_id: id, stdout: @stdout, stderr: @stderr, command: command_name,
82
- json: @json, timeout: timeout, extra: report_extra, strategy: strategy)
90
+ json: @json, timeout: timeout, extra: report_extra, strategy: strategy,
91
+ via: @access.invocation)
83
92
  rescue Interrupt
84
93
  if @json
85
94
  @stdout.puts(ParentReport.json_line(ReplyWait::Result.new(status: :canceled, turn_id: report_turn_id), session_id: id,
@@ -87,7 +96,7 @@ module Samagotchi
87
96
  @stdout.flush
88
97
  @reported = true
89
98
  end
90
- error_line("#{command_name}: still running: chi --attach #{id}")
99
+ error_line("#{command_name}: still running: #{@access.invocation.shows(id)}")
91
100
  Exit::INTERRUPTED
92
101
  rescue ArgumentError
93
102
  error_line("#{command_name}: the session is gone (deleted while waiting)")