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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- 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]) -> [
|
|
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
|
-
|
|
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 =
|
|
107
|
-
found.append(
|
|
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
|
-
///
|
|
117
|
-
static func
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
return
|
|
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
|
|
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
|
|
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],
|
|
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(.
|
|
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
|
|
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
|
|
129
|
+
guard !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
|
|
222
130
|
let group = DispatchGroup()
|
|
223
131
|
let lock = NSLock()
|
|
224
|
-
|
|
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:
|
|
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,
|
|
242
|
-
guard let (socket, id) =
|
|
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(
|
|
157
|
+
completion(.failure(.from(result, "kitty ls"))); return
|
|
249
158
|
}
|
|
250
|
-
guard r.status == 0 else { completion(.failure(.
|
|
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(
|
|
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(
|
|
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
|
-
}
|