samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -1,7 +1,8 @@
1
1
  // Agent CLIs (claude, codex, …) in kitty windows as panel targets, through
2
2
  // the kitty binary's remote control (`kitty @ --to unix:<socket> …`). The
3
3
  // settings come from launch.json's "kitty" section (chi desktop bakes it
4
- // from config.yml's kitty:); without it there are no kitty targets.
4
+ // from config.yml's kitty:); without it there are no kitty targets. One of
5
+ // the AgentTerminals (AgentTerminal.swift).
5
6
  import Foundation
6
7
 
7
8
  struct KittySettings: Decodable, Equatable {
@@ -15,45 +16,6 @@ struct KittySettings: Decodable, Equatable {
15
16
  }
16
17
  }
17
18
 
18
- /// One kitty window running an agent: `<socket>#<id>` keys it.
19
- struct KittyWindow: Identifiable, Equatable {
20
- let socket: String
21
- let windowId: Int
22
- let agent: String
23
- let title: String
24
- let cwd: String
25
-
26
- var id: String { key }
27
- var key: String { "\(socket)#\(windowId)" }
28
- var shortCwd: String { LiveSession.shorten(cwd) }
29
- /// "claude · samagotchi": the agent and its folder.
30
- var label: String {
31
- let folder = (cwd as NSString).lastPathComponent
32
- return folder.isEmpty ? agent : "\(agent) · \(folder)"
33
- }
34
-
35
- /// Parses a key back into its socket and window id.
36
- static func parse(key: String) -> (socket: String, windowId: Int)? {
37
- guard let hash = key.lastIndex(of: "#"), let id = Int(key[key.index(after: hash)...]) else { return nil }
38
- let socket = String(key[..<hash])
39
- return socket.isEmpty ? nil : (socket, id)
40
- }
41
- }
42
-
43
- enum KittyError: Error, CustomStringConvertible {
44
- case notUnix(String)
45
- case windowClosed
46
- case failed(String)
47
-
48
- var description: String {
49
- switch self {
50
- case .notUnix(let value): return "kitty.listen_on \(value): only unix: sockets"
51
- case .windowClosed: return "window closed"
52
- case .failed(let message): return message
53
- }
54
- }
55
- }
56
-
57
19
  /// The pure parts: which files are sockets of this listen_on, which windows
58
20
  /// are agents, what text a paste carries. The runner below does the I/O.
59
21
  enum Kitty {
@@ -91,106 +53,51 @@ enum Kitty {
91
53
  /// The windows of one `kitty @ ls` whose foreground programs include an
92
54
  /// agent: the basename of a process's cmdline[0] or cmdline[1] (codex and
93
55
  /// gemini run as `node …/codex`); `*` takes every window.
94
- static func windows(ls data: Data, socket: String, agents: [String]) -> [KittyWindow] {
56
+ static func windows(ls data: Data, socket: String, agents: [String]) -> [AgentTarget] {
95
57
  guard let osWindows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
96
- let all = agents.contains("*")
97
- var found: [KittyWindow] = []
58
+ var found: [AgentTarget] = []
98
59
  for osWindow in osWindows {
99
60
  for tab in osWindow["tabs"] as? [[String: Any]] ?? [] {
100
61
  for window in tab["windows"] as? [[String: Any]] ?? [] {
101
62
  guard let id = window["id"] as? Int else { continue }
102
63
  let names = (window["foreground_processes"] as? [[String: Any]] ?? []).flatMap { process -> [String] in
103
64
  let cmdline = process["cmdline"] as? [String] ?? []
104
- return cmdline.prefix(2).map(programName)
65
+ return cmdline.prefix(2).map(AgentText.programName)
105
66
  }.filter { !$0.isEmpty }
106
- guard let agent = names.first(where: agents.contains) ?? (all ? names.last ?? "" : nil) else { continue }
107
- found.append(KittyWindow(socket: socket, windowId: id, agent: agent,
108
- title: cleanTitle(window["title"] as? String ?? ""),
109
- cwd: window["cwd"] as? String ?? ""))
67
+ guard let agent = AgentText.agent(names: names, agents: agents) else { continue }
68
+ found.append(AgentTarget(terminal: "kitty", key: "\(socket)#\(id)", agent: agent,
69
+ title: AgentText.cleanTitle(window["title"] as? String ?? ""),
70
+ cwd: window["cwd"] as? String ?? "", place: "", names: names))
110
71
  }
111
72
  }
112
73
  }
113
74
  return found
114
75
  }
115
76
 
116
- /// "/usr/bin/claude" → "claude", a login shell's "-fish" → "fish".
117
- static func programName(_ arg: String) -> String {
118
- var name = (arg as NSString).lastPathComponent
119
- if name.hasPrefix("-") { name.removeFirst() }
120
- return name
121
- }
122
-
123
- /// A title without the status glyph agents put in front ("✳ Claude Code").
124
- static func cleanTitle(_ title: String) -> String {
125
- guard let first = title.unicodeScalars.first, first.value > 127, !first.properties.isAlphabetic,
126
- title.dropFirst().first == " " else { return title }
127
- return String(title.dropFirst(2))
128
- }
129
-
130
- /// Context as a quote above the message, then one image path per line:
131
- /// ContextQuote.block's shape (`> line`, `>` for an empty one, a blank line
132
- /// after). A paste-only text ends in a newline, so the next paste starts
133
- /// on its own line. Bracketed-paste markers are taken out of the text:
134
- /// it is wrapped in them to arrive as one paste.
135
- static func text(context: String, message: String, imagePaths: [String], pasteOnly: Bool) -> String {
136
- var text = quote(context) ?? ""
137
- text += message
138
- let paths = imagePaths.map(shellQuote)
139
- if !paths.isEmpty {
140
- if !text.isEmpty && !text.hasSuffix("\n") { text += "\n" }
141
- text += paths.joined(separator: "\n")
142
- }
143
- while text.hasSuffix("\n") { text.removeLast() }
144
- if pasteOnly && !text.isEmpty { text += "\n" }
145
- return text.replacingOccurrences(of: "\u{1b}[200~", with: "").replacingOccurrences(of: "\u{1b}[201~", with: "")
146
- }
147
-
148
- static func quote(_ context: String) -> String? {
149
- var lines = context.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
150
- .components(separatedBy: "\n")
151
- .map { line -> String in
152
- var line = line
153
- while let last = line.unicodeScalars.last, CharacterSet.whitespacesAndNewlines.contains(last) {
154
- line.unicodeScalars.removeLast()
155
- }
156
- return line
157
- }
158
- while lines.first?.isEmpty == true { lines.removeFirst() }
159
- while lines.last?.isEmpty == true { lines.removeLast() }
160
- guard !lines.isEmpty else { return nil }
161
- return lines.map { $0.isEmpty ? ">" : "> \($0)" }.joined(separator: "\n") + "\n\n"
162
- }
163
-
164
- /// A path as is when it holds only safe characters, else single-quoted
165
- /// (claude reads a quoted path with spaces as an image).
166
- static func shellQuote(_ path: String) -> String {
167
- if path.range(of: #"^[A-Za-z0-9_./~+@%,:=-]+$"#, options: .regularExpression) != nil { return path }
168
- return "'" + path.replacingOccurrences(of: "'", with: #"'\''"#) + "'"
77
+ /// A `<socket>#<window id>` key back into its parts.
78
+ static func parse(key: String) -> (socket: String, windowId: Int)? {
79
+ guard let hash = key.lastIndex(of: "#"), let id = Int(key[key.index(after: hash)...]) else { return nil }
80
+ let socket = String(key[..<hash])
81
+ return socket.isEmpty ? nil : (socket, id)
169
82
  }
170
83
  }
171
84
 
172
- final class KittyRunner {
173
- let settings: KittySettings?
85
+ final class KittyRunner: AgentTerminal {
86
+ let name = "kitty"
87
+ let settings: KittySettings
174
88
  let queue: DispatchQueue
175
89
  let env: [String: String]
176
90
  let lsTimeout: TimeInterval = 2
177
91
  let sendTimeout: TimeInterval = 5
178
92
 
179
93
  /// @param queue where completions run (the main queue in the app)
180
- init(settings: KittySettings?, queue: DispatchQueue = .main,
94
+ init(settings: KittySettings, queue: DispatchQueue = .main,
181
95
  env: [String: String] = ProcessInfo.processInfo.environment) {
182
96
  self.settings = settings
183
97
  self.queue = queue
184
98
  self.env = env
185
99
  }
186
100
 
187
- /// From a launch file; no settings when it has no kitty section.
188
- convenience init(launchPath: String, queue: DispatchQueue = .main) {
189
- let launch = FileManager.default.contents(atPath: launchPath)
190
- .flatMap { try? JSONDecoder().decode(LaunchConfig.self, from: $0) }
191
- self.init(settings: launch?.kitty, queue: queue)
192
- }
193
-
194
101
  /// kitty @ runs, and listen_on expands, with the helper's env minus
195
102
  /// kitty's own variables (a helper started from a kitty window must not
196
103
  /// talk to that one, nor fill in the ${KITTY_PID} kitty keeps literal).
@@ -198,10 +105,9 @@ final class KittyRunner {
198
105
 
199
106
  /// The sockets listen_on names: the files in its folder whose names match
200
107
  /// (a directory listing, not glob(): the path may hold `${ }` literally).
201
- func sockets() -> Result<[String], KittyError> {
202
- guard let settings else { return .success([]) }
108
+ func sockets() -> Result<[String], AgentTerminalError> {
203
109
  guard let path = Kitty.socketPath(listenOn: settings.listenOn, env: kittyEnv) else {
204
- return .failure(.notUnix(settings.listenOn))
110
+ return .failure(.config("kitty.listen_on \(settings.listenOn): only unix: sockets"))
205
111
  }
206
112
  let dir = (path as NSString).deletingLastPathComponent
207
113
  let base = (path as NSString).lastPathComponent
@@ -212,21 +118,24 @@ final class KittyRunner {
212
118
  }
213
119
 
214
120
  /// Agent windows from every live socket; a dead one drops out silently.
215
- func windows(completion: @escaping (Result<[KittyWindow], KittyError>) -> Void) {
121
+ func owns(key: String) -> Bool { Kitty.parse(key: key) != nil }
122
+
123
+ func list(completion: @escaping (Result<[AgentTarget], AgentTerminalError>) -> Void) {
216
124
  let sockets: [String]
217
125
  switch self.sockets() {
218
126
  case .failure(let error): queue.async { completion(.failure(error)) }; return
219
127
  case .success(let found): sockets = found
220
128
  }
221
- guard let settings, !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
129
+ guard !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
222
130
  let group = DispatchGroup()
223
131
  let lock = NSLock()
224
- var bySocket: [String: [KittyWindow]] = [:]
132
+ let agents = settings.agents
133
+ var bySocket: [String: [AgentTarget]] = [:]
225
134
  for socket in sockets {
226
135
  group.enter()
227
136
  kitty(socket, ["ls"], timeout: lsTimeout, on: .global()) { result in
228
137
  if case .success(let r) = result, r.status == 0, !r.timedOut {
229
- let found = Kitty.windows(ls: Data(r.stdout.utf8), socket: socket, agents: settings.agents)
138
+ let found = Kitty.windows(ls: Data(r.stdout.utf8), socket: socket, agents: agents)
230
139
  lock.lock(); bySocket[socket] = found; lock.unlock()
231
140
  }
232
141
  group.leave()
@@ -238,26 +147,26 @@ final class KittyRunner {
238
147
  /// Pastes +text+ into the window as one bracketed paste, then presses
239
148
  /// Enter unless +pasteOnly+. send-text reports nothing, even for a gone
240
149
  /// window, so `ls --match` checks the window first.
241
- func send(key: String, text: String, pasteOnly: Bool, completion: @escaping (Result<Void, KittyError>) -> Void) {
242
- guard let (socket, id) = KittyWindow.parse(key: key) else {
150
+ func send(key: String, text: String, pasteOnly: Bool, completion: @escaping (Result<Void, AgentTerminalError>) -> Void) {
151
+ guard let (socket, id) = Kitty.parse(key: key) else {
243
152
  queue.async { completion(.failure(.failed("not a kitty window: \(key)"))) }; return
244
153
  }
245
154
  let match = ["--match", "id:\(id)"]
246
155
  kitty(socket, ["ls"] + match, timeout: lsTimeout) { result in
247
156
  guard case .success(let r) = result, !r.timedOut else {
248
- completion(.failure(self.failure(result, "kitty ls"))); return
157
+ completion(.failure(.from(result, "kitty ls"))); return
249
158
  }
250
- guard r.status == 0 else { completion(.failure(.windowClosed)); return }
159
+ guard r.status == 0 else { completion(.failure(.closed("window closed"))); return }
251
160
  let paste = Data(("\u{1b}[200~" + text + "\u{1b}[201~").utf8)
252
161
  self.kitty(socket, ["send-text"] + match + ["--bracketed-paste=disable", "--stdin"], stdin: paste,
253
162
  timeout: self.sendTimeout) { result in
254
163
  guard case .success(let r) = result, r.status == 0, !r.timedOut else {
255
- completion(.failure(self.failure(result, "kitty send-text"))); return
164
+ completion(.failure(.from(result, "kitty send-text"))); return
256
165
  }
257
166
  if pasteOnly { completion(.success(())); return }
258
167
  self.kitty(socket, ["send-key"] + match + ["enter"], timeout: self.sendTimeout) { result in
259
168
  guard case .success(let r) = result, r.status == 0, !r.timedOut else {
260
- completion(.failure(self.failure(result, "kitty send-key"))); return
169
+ completion(.failure(.from(result, "kitty send-key"))); return
261
170
  }
262
171
  completion(.success(()))
263
172
  }
@@ -265,82 +174,9 @@ final class KittyRunner {
265
174
  }
266
175
  }
267
176
 
268
- private func failure(_ result: Result<ChiResult, ChiError>, _ what: String) -> KittyError {
269
- switch result {
270
- case .failure(let error): return .failed(error.description)
271
- case .success(let r):
272
- if r.timedOut { return .failed("\(what) timed out") }
273
- let detail = (r.stderr.isEmpty ? r.stdout : r.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
274
- return .failed("\(what) failed (exit \(r.status)): \(detail.prefix(200))")
275
- }
276
- }
277
-
278
177
  private func kitty(_ socket: String, _ args: [String], stdin: Data? = nil, timeout: TimeInterval,
279
178
  on queue: DispatchQueue? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
280
- guard let settings else { return }
281
179
  ProcessRunner.run(executable: settings.binary, args: ["@", "--to", "unix:\(socket)"] + args, env: kittyEnv,
282
180
  stdin: stdin, timeout: timeout, queue: queue ?? self.queue, completion: completion)
283
181
  }
284
182
  }
285
-
286
- /// `ChiHelper --kitty list|send …`, run directly (specs, a terminal):
287
- /// --kitty list [--launch PATH] JSON {"windows": […], "error": …}
288
- /// --kitty send KEY [--paste-only] [--message M] [--image PATH]… [--temp-image PATH]… [--launch PATH]
289
- /// send reads the context from stdin and prints "sent", "pasted" or the
290
- /// error (exit 1). --temp-image is an image the helper wrote (copied first).
291
- func kittyCommand(_ args: [String]) -> Int32 {
292
- var args = args
293
- let sub = args.isEmpty ? "" : args.removeFirst()
294
- var launchPath = ChiRunner.launchPath, key: String?, message = "", pasteOnly = false
295
- var images: [PanelImage] = []
296
- while !args.isEmpty {
297
- let arg = args.removeFirst()
298
- switch arg {
299
- case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
300
- case "--message" where !args.isEmpty: message = args.removeFirst()
301
- case "--image" where !args.isEmpty: images.append(PanelImage(url: URL(fileURLWithPath: args.removeFirst()), temp: false))
302
- case "--temp-image" where !args.isEmpty: images.append(PanelImage(url: URL(fileURLWithPath: args.removeFirst()), temp: true))
303
- case "--paste-only": pasteOnly = true
304
- default:
305
- if key == nil && !arg.hasPrefix("--") { key = arg } else { return kittyUsage() }
306
- }
307
- }
308
- let queue = DispatchQueue(label: "kitty-command")
309
- let runner = KittyRunner(launchPath: launchPath, queue: queue)
310
- let done = DispatchSemaphore(value: 0)
311
- var status: Int32 = 0
312
- switch sub {
313
- case "list":
314
- runner.windows { result in
315
- var out: [String: Any] = ["windows": [], "error": NSNull()]
316
- switch result {
317
- case .success(let windows):
318
- out["windows"] = windows.map { ["key": $0.key, "agent": $0.agent, "title": $0.title, "cwd": $0.cwd, "label": $0.label] }
319
- case .failure(let error): out["error"] = error.description; status = 1
320
- }
321
- let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
322
- print(String(decoding: data, as: UTF8.self))
323
- done.signal()
324
- }
325
- case "send":
326
- guard let key else { return kittyUsage() }
327
- let context = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
328
- let paths = ImageIntake.keepForPaste(images)
329
- let text = Kitty.text(context: context, message: message, imagePaths: paths, pasteOnly: pasteOnly)
330
- runner.send(key: key, text: text, pasteOnly: pasteOnly) { result in
331
- switch result {
332
- case .success: print(pasteOnly ? "pasted" : "sent")
333
- case .failure(let error): print(error.description); status = 1
334
- }
335
- done.signal()
336
- }
337
- default: return kittyUsage()
338
- }
339
- done.wait()
340
- return status
341
- }
342
-
343
- private func kittyUsage() -> Int32 {
344
- FileHandle.standardError.write("usage: ChiHelper --kitty list|send KEY [--paste-only] [--message M] [--image PATH] [--launch PATH]\n".data(using: .utf8)!)
345
- return 2
346
- }