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
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SessionAccess
5
+ # Which session commands and flags run against another machine's
6
+ # sessions (chi --remote NAME …, CHI_REMOTE; SessionAccess::Dispatch)
7
+ # and which run on this machine only (refused under a remote, exit 2:
8
+ # "chi <cmd> runs on this machine only: run it on <remote>"). The v1 scope is the remote-sessions plan's
9
+ # D3: send, answer, note, a session's context (add, push, ls, show, rm)
10
+ # and sessions list/stop/restart/archive/unarchive/delete; the rest
11
+ # stays local. bin/chi's subcommands that act on the machine, not on
12
+ # sessions, are MACHINE. The run flags (--attach, -p, --resume) are not
13
+ # subcommands and not in this table (remote attach is its own phase).
14
+ #
15
+ # spec/session_access_contract_spec.rb fails when a subcommand, one of
16
+ # its subcommands or one of a supported one's flags is in neither.
17
+ module Contract
18
+ SUPPORTED = :supported
19
+ LOCAL_ONLY = :local_only
20
+
21
+ # bin/chi subcommands that act on this machine: never sessions. chi
22
+ # remote (this machine's list of remotes) runs here under a remote too
23
+ # (Dispatch); the others are refused there.
24
+ MACHINE = %w[bootstrap update models desktop memory bundle remote].freeze
25
+
26
+ # "command", "command sub" or either with a flag (its long name) →
27
+ # SUPPORTED or LOCAL_ONLY. A LOCAL_ONLY command or subcommand lists
28
+ # no flags: all of it is local.
29
+ TABLE = {
30
+ "send" => SUPPORTED,
31
+ "send --message" => SUPPORTED,
32
+ "send --new" => SUPPORTED,
33
+ # Required with --new under a remote (D4: no laptop path guessed).
34
+ "send --dir" => SUPPORTED,
35
+ "send --continues" => SUPPORTED,
36
+ "send --model" => SUPPORTED,
37
+ "send --llm-context" => SUPPORTED,
38
+ "send --llm-context-apply" => SUPPORTED,
39
+ "send --llm-context-budget" => SUPPORTED,
40
+ "send --thinking" => SUPPORTED,
41
+ "send --toolkit" => SUPPORTED,
42
+ "send --tool" => SUPPORTED,
43
+ "send --no-tool" => SUPPORTED,
44
+ "send --set" => SUPPORTED,
45
+ "send --parent-answers" => SUPPORTED,
46
+ "send --image" => SUPPORTED,
47
+ "send --wait" => SUPPORTED,
48
+ "send --cut" => SUPPORTED,
49
+ "send --queue" => SUPPORTED,
50
+ "send --timeout" => SUPPORTED,
51
+ "send --format" => SUPPORTED,
52
+ "send --turn" => SUPPORTED,
53
+ # Refused on both (there is no --all).
54
+ "send --all" => SUPPORTED,
55
+
56
+ "answer" => SUPPORTED,
57
+ "answer --question" => SUPPORTED,
58
+ "answer --option" => SUPPORTED,
59
+ "answer --text" => SUPPORTED,
60
+ "answer --dismiss" => SUPPORTED,
61
+ "answer --timeout" => SUPPORTED,
62
+ "answer --format" => SUPPORTED,
63
+
64
+ "note" => SUPPORTED,
65
+ "note --message" => SUPPORTED,
66
+ "note --source" => SUPPORTED,
67
+ "note --all" => SUPPORTED,
68
+
69
+ "context" => SUPPORTED,
70
+ "context add" => SUPPORTED,
71
+ "context add --cmd" => SUPPORTED,
72
+ "context add --push" => SUPPORTED,
73
+ "context add --every" => SUPPORTED,
74
+ "context add --why" => SUPPORTED,
75
+ "context add --hint" => SUPPORTED,
76
+ "context add --project" => LOCAL_ONLY,
77
+ "context push" => SUPPORTED,
78
+ "context push --message" => SUPPORTED,
79
+ "context push --project" => LOCAL_ONLY,
80
+ "context ls" => SUPPORTED,
81
+ "context ls --format" => SUPPORTED,
82
+ "context ls --project" => LOCAL_ONLY,
83
+ "context show" => SUPPORTED,
84
+ "context show --json" => SUPPORTED,
85
+ "context show --project" => LOCAL_ONLY,
86
+ "context rm" => SUPPORTED,
87
+ "context rm --project" => LOCAL_ONLY,
88
+ "context refresh" => LOCAL_ONLY,
89
+ "context mute" => LOCAL_ONLY,
90
+ "context unmute" => LOCAL_ONLY,
91
+
92
+ "sessions" => SUPPORTED,
93
+ "sessions list" => SUPPORTED,
94
+ "sessions list --sort" => SUPPORTED,
95
+ "sessions list --order" => SUPPORTED,
96
+ "sessions list --limit" => SUPPORTED,
97
+ "sessions list --cwd" => SUPPORTED,
98
+ "sessions list --format" => SUPPORTED,
99
+ # Remotely every project's by default (D4); --scope=project needs
100
+ # --cwd there.
101
+ "sessions list --scope" => SUPPORTED,
102
+ "sessions list --live" => SUPPORTED,
103
+ "sessions list --archived" => SUPPORTED,
104
+ "sessions stop" => SUPPORTED,
105
+ "sessions restart" => SUPPORTED,
106
+ "sessions archive" => SUPPORTED,
107
+ "sessions unarchive" => SUPPORTED,
108
+ "sessions delete" => SUPPORTED,
109
+ "sessions delete --force" => SUPPORTED,
110
+ "sessions prune" => LOCAL_ONLY,
111
+ "sessions clean" => LOCAL_ONLY,
112
+ "sessions stats" => LOCAL_ONLY,
113
+
114
+ "broadcast" => LOCAL_ONLY
115
+ }.freeze
116
+
117
+ module_function
118
+
119
+ # @param words [Array<String>] a command, maybe its subcommand, maybe
120
+ # a flag's long name: status("context", "add", "--cmd")
121
+ # @return [Symbol, nil] SUPPORTED, LOCAL_ONLY, or nil when not listed
122
+ def status(*words) = TABLE[words.join(" ")]
123
+
124
+ # Whether +words+ run against a remote: the command, its subcommand
125
+ # and the flag each listed SUPPORTED.
126
+ def supported?(*words) = (1..words.size).all? { |n| status(*words.first(n)) == SUPPORTED }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session_access"
4
+ require_relative "contract"
5
+ require_relative "../remotes"
6
+ require_relative "../cli/exit"
7
+
8
+ module Samagotchi
9
+ module SessionAccess
10
+ # bin/chi's front for chi --remote NAME … and CHI_REMOTE=NAME: takes the
11
+ # flag off the front of the arguments (before the subcommand lookup),
12
+ # names the remote (the flag beats the env; no default remote), refuses
13
+ # what runs on this machine only (Contract) and runs a session command
14
+ # with a Remote for its SessionAccess. chi remote itself, --version and
15
+ # --help always run here.
16
+ class Dispatch
17
+ FLAG = "--remote"
18
+ ENV_KEY = "CHI_REMOTE"
19
+ # Run here whatever the remote: chi remote manages the remotes.
20
+ ALWAYS_HERE = %w[remote].freeze
21
+ # bin/chi's own words besides its SUBCOMMANDS (a refusal names them).
22
+ OWN_WORDS = %w[web scratch self].freeze
23
+ HELP = %w[--version -h --help].freeze
24
+ # The run flags (not subcommands) a refusal names: remote attach is
25
+ # its own phase (P4).
26
+ RUN_FLAGS = { "--attach" => "--attach", "--resume" => "--resume", "-p" => "-p", "--prompt" => "-p",
27
+ "--shared" => "--shared", "--no-shared" => "--no-shared" }.freeze
28
+
29
+ # A usage error in the flag itself (--remote with no name, a bad one).
30
+ class Usage < StandardError; end
31
+
32
+ # The flag (any number of them at the front; the last wins) taken off
33
+ # +argv+, and the remote it and +env+ name.
34
+ # @return [Dispatch]
35
+ # @raise [Usage]
36
+ def self.from(argv, env: ENV)
37
+ rest = argv.dup
38
+ flag = nil
39
+ while (arg = rest.first) && (arg == FLAG || arg.start_with?("#{FLAG}="))
40
+ rest.shift
41
+ flag = arg == FLAG ? rest.shift : arg.delete_prefix("#{FLAG}=")
42
+ raise Usage, "#{FLAG} needs a remote's name (chi remote ls lists them)" if flag.nil? || flag.strip.empty? || flag.start_with?("-")
43
+ end
44
+ from_env = env.fetch(ENV_KEY, "").to_s.strip
45
+ new(rest, remote: flag || (from_env.empty? ? nil : from_env))
46
+ end
47
+
48
+ attr_reader :argv, :remote
49
+
50
+ def initialize(argv, remote:)
51
+ @argv = argv
52
+ @remote = remote
53
+ end
54
+
55
+ def remote? = !@remote.nil?
56
+
57
+ # Whether the arguments run here as they would without a remote: chi
58
+ # remote, --version, --help.
59
+ def here?
60
+ first = @argv.first
61
+ ALWAYS_HERE.include?(first) || (@argv.size == 1 && HELP.include?(first))
62
+ end
63
+
64
+ # The one refusal of a command or flag that runs on this machine only,
65
+ # or nil when the Contract lets it run against the remote.
66
+ # @return [String, nil]
67
+ def refusal
68
+ words = local_words or return nil
69
+
70
+ "chi #{words} runs on this machine only: run it on #{@remote}"
71
+ end
72
+
73
+ # The session command against the remote: refused (exit 2), or run
74
+ # with a Remote. A failure to reach it is the command's line, exit 1.
75
+ # @param subcommands [Hash] bin/chi's SUBCOMMANDS
76
+ # @return [Integer] the exit status
77
+ def run(subcommands, stderr: $stderr, transport: nil)
78
+ command = @argv.first
79
+ unless command.nil? || command.start_with?("-") || subcommands.key?(command) || OWN_WORDS.include?(command)
80
+ stderr.puts("Error: unknown command #{command} (see chi --help)")
81
+ return CLI::Exit::FAILED
82
+ end
83
+ if (message = refusal)
84
+ stderr.puts("Error: #{message}")
85
+ return CLI::Exit::USAGE
86
+ end
87
+
88
+ entry = Remotes.find(Remotes.check_name!(@remote))
89
+ path, klass = subcommands.fetch(@argv.first)
90
+ require path
91
+ access = Remote.new(entry, transport: transport)
92
+ Samagotchi.const_get(klass).new(@argv[1..] || [], access: access).run
93
+ rescue Remotes::Invalid => e
94
+ stderr.puts("Error: #{e.message}")
95
+ CLI::Exit::USAGE
96
+ rescue RemoteFailed => e
97
+ stderr.puts("chi #{@argv.first}: #{e.message}")
98
+ CLI::Exit::FAILED
99
+ end
100
+
101
+ private
102
+
103
+ # The words that run here only: the command (and its subcommand, a
104
+ # flag) the Contract lists local_only, a machine command, or the run
105
+ # flags (a session in this terminal, attach). nil: supported.
106
+ def local_words
107
+ command = @argv.first
108
+ return run_words if command.nil? || command.start_with?("-")
109
+ return command if Contract::MACHINE.include?(command) || Contract.status(command).nil?
110
+ return command if Contract.status(command) == Contract::LOCAL_ONLY
111
+
112
+ sub = @argv[1] if Contract::TABLE.key?("#{command} #{@argv[1]}")
113
+ return "#{command} #{sub}" if sub && Contract.status(command, sub) == Contract::LOCAL_ONLY
114
+
115
+ prefix = [command, sub].compact
116
+ flags.each do |flag|
117
+ return [*prefix, flag].join(" ") if Contract.status(*prefix, flag) == Contract::LOCAL_ONLY
118
+ end
119
+ nil
120
+ end
121
+
122
+ # The long flags given, up to a "--".
123
+ def flags
124
+ @argv.drop(1).take_while { |arg| arg != "--" }.select { |arg| arg.start_with?("--") }.map { |arg| arg.split("=", 2).first }
125
+ end
126
+
127
+ def run_words
128
+ flag = @argv.take_while { |arg| arg != "--" }.map { |arg| arg.split("=", 2).first }.find { |arg| RUN_FLAGS.key?(arg) }
129
+ flag ? RUN_FLAGS[flag] : "(a session in this terminal)"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "openssl"
7
+ require_relative "../session_access"
8
+
9
+ module Samagotchi
10
+ module SessionAccess
11
+ # A remote's chi web over HTTP (Net::HTTP), its access token as
12
+ # Authorization: Bearer: one request per call, each on its own
13
+ # connection (a command makes a handful). A connection that fails
14
+ # raises RemoteFailed naming the remote and why.
15
+ class HTTPTransport
16
+ # One answer: the status, the body, and the body read as JSON (nil
17
+ # when it isn't).
18
+ Reply = Data.define(:status, :body, :json) do
19
+ def self.of(status, body)
20
+ json = begin
21
+ JSON.parse(body.to_s)
22
+ rescue JSON::ParserError
23
+ nil
24
+ end
25
+ new(status: status.to_i, body: body.to_s, json: json)
26
+ end
27
+
28
+ def error = json.is_a?(Hash) ? json["error"] : nil
29
+ end
30
+
31
+ OPEN_TIMEOUT = 5
32
+ # Above chi web's longest answer (a wait route's 30 s, a create's
33
+ # wait for its worker).
34
+ READ_TIMEOUT = 75
35
+
36
+ # @param entry [Remotes::Entry]
37
+ def initialize(entry, open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT)
38
+ @entry = entry
39
+ @uri = URI(entry.url)
40
+ @open_timeout = open_timeout
41
+ @read_timeout = read_timeout
42
+ end
43
+
44
+ def inspect = "#<#{self.class.name} #{@entry.label}>"
45
+
46
+ # @param method [Symbol] :get, :post, :delete
47
+ # @param path [String] "/api/…"
48
+ # @param query [Hash, nil] the query's parameters (nil values left out)
49
+ # @param body [Hash, nil] sent as JSON
50
+ # @param raw [String, nil] sent as is (an image), with +content_type+
51
+ # @param read_timeout [Numeric, nil] this request's, instead of the
52
+ # transport's (a remote attach's turn, its join reads)
53
+ # @return [Reply]
54
+ # @raise [RemoteFailed] it could not be asked
55
+ def call(method, path, query: nil, body: nil, raw: nil, content_type: nil, read_timeout: nil)
56
+ read_timeout ||= @read_timeout
57
+ request = build(method, path, query)
58
+ request["Authorization"] = "Bearer #{@entry.token}" if @entry.token
59
+ request["Accept"] = "application/json"
60
+ if raw
61
+ request["Content-Type"] = content_type || "application/octet-stream"
62
+ request.body = raw
63
+ elsif body
64
+ request["Content-Type"] = "application/json"
65
+ request.body = JSON.generate(body)
66
+ elsif method != :get
67
+ request["Content-Length"] = "0"
68
+ end
69
+ response = http(read_timeout).request(request)
70
+ Reply.of(response.code, response.body)
71
+ rescue Errno::ECONNREFUSED
72
+ failed("connection refused")
73
+ rescue Net::OpenTimeout, Errno::ETIMEDOUT
74
+ failed("no answer within #{@open_timeout} s")
75
+ rescue Net::ReadTimeout
76
+ failed("it took the request but sent no answer within #{read_timeout} s")
77
+ rescue SocketError => e
78
+ failed(e.message.include?("getaddrinfo") ? "can't find host #{@uri.host}" : e.message)
79
+ rescue Errno::EHOSTUNREACH, Errno::ENETUNREACH
80
+ failed("host unreachable")
81
+ rescue OpenSSL::SSL::SSLError => e
82
+ failed("TLS failed: #{e.message}")
83
+ rescue IOError, SystemCallError => e
84
+ failed("the connection broke (#{e.class.name.split("::").last})")
85
+ end
86
+
87
+ private
88
+
89
+ def http(read_timeout)
90
+ Net::HTTP.new(@uri.host, @uri.port).tap do |http|
91
+ http.use_ssl = @uri.scheme == "https"
92
+ http.open_timeout = @open_timeout
93
+ http.read_timeout = read_timeout
94
+ http.write_timeout = read_timeout
95
+ http.max_retries = 0
96
+ end
97
+ end
98
+
99
+ def build(method, path, query)
100
+ target = path
101
+ params = query&.compact
102
+ target = "#{path}?#{URI.encode_www_form(params)}" if params && !params.empty?
103
+ { get: Net::HTTP::Get, post: Net::HTTP::Post, delete: Net::HTTP::Delete }.fetch(method).new(target)
104
+ end
105
+
106
+ def failed(reason) = raise(RemoteFailed, "#{@entry.label}: #{reason}")
107
+ end
108
+ end
109
+ end