samagotchi 0.6.0 → 0.8.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 +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// (text, or images: files in Finder, a picture in Preview), the hotkey with
|
|
3
3
|
// the clipboard (a screenshot too). No Dock icon (LSUIElement); it stays
|
|
4
4
|
// running after the first launch. `ChiHelper --login on|off|status` (run
|
|
5
|
-
// directly by chi desktop) manages the login item and exits
|
|
5
|
+
// directly by chi desktop) manages the login item and exits;
|
|
6
|
+
// `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift).
|
|
6
7
|
import AppKit
|
|
7
8
|
import ServiceManagement
|
|
8
9
|
|
|
@@ -97,6 +98,9 @@ struct ChiHelperMain {
|
|
|
97
98
|
if args.count >= 2, args[1] == "--login" {
|
|
98
99
|
exit(loginCommand(args.count >= 3 ? args[2] : ""))
|
|
99
100
|
}
|
|
101
|
+
if args.count >= 2, args[1] == "--kitty" {
|
|
102
|
+
exit(kittyCommand(Array(args.dropFirst(2))))
|
|
103
|
+
}
|
|
100
104
|
let app = NSApplication.shared
|
|
101
105
|
let delegate = AppDelegate()
|
|
102
106
|
app.delegate = delegate
|
|
@@ -7,13 +7,8 @@ struct LaunchConfig: Decodable {
|
|
|
7
7
|
let version: String?
|
|
8
8
|
let argv: [String]
|
|
9
9
|
let env: [String: String]?
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
struct ChiResult {
|
|
13
|
-
let status: Int32
|
|
14
|
-
let stdout: String
|
|
15
|
-
let stderr: String
|
|
16
|
-
let timedOut: Bool
|
|
10
|
+
/// Agents in kitty windows (Kitty.swift); nil = none.
|
|
11
|
+
let kitty: KittySettings?
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
enum ChiError: Error, CustomStringConvertible {
|
|
@@ -106,9 +101,8 @@ final class ChiRunner {
|
|
|
106
101
|
return config
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
/// Runs `chi <args
|
|
110
|
-
///
|
|
111
|
-
/// note cap). Calls back on the main queue.
|
|
104
|
+
/// Runs `chi <args>` through ProcessRunner (no pipe deadlock near the
|
|
105
|
+
/// 16 KiB note cap). Calls back on the main queue.
|
|
112
106
|
/// @param timeout this call's limit instead of the runner's
|
|
113
107
|
func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
|
|
114
108
|
completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
@@ -119,59 +113,9 @@ final class ChiRunner {
|
|
|
119
113
|
completion(.failure(.failed("\(error)"))); return
|
|
120
114
|
}
|
|
121
115
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
process.environment = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
|
|
126
|
-
process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
|
|
127
|
-
let out = Pipe(), err = Pipe(), input = Pipe()
|
|
128
|
-
process.standardOutput = out
|
|
129
|
-
process.standardError = err
|
|
130
|
-
process.standardInput = stdin == nil ? FileHandle.nullDevice : input
|
|
131
|
-
|
|
132
|
-
let lock = NSLock()
|
|
133
|
-
var outData = Data(), errData = Data()
|
|
134
|
-
out.fileHandleForReading.readabilityHandler = { handle in
|
|
135
|
-
let chunk = handle.availableData
|
|
136
|
-
lock.lock(); outData.append(chunk); lock.unlock()
|
|
137
|
-
}
|
|
138
|
-
err.fileHandleForReading.readabilityHandler = { handle in
|
|
139
|
-
let chunk = handle.availableData
|
|
140
|
-
lock.lock(); errData.append(chunk); lock.unlock()
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
var timedOut = false
|
|
144
|
-
process.terminationHandler = { proc in
|
|
145
|
-
out.fileHandleForReading.readabilityHandler = nil
|
|
146
|
-
err.fileHandleForReading.readabilityHandler = nil
|
|
147
|
-
let restOut = out.fileHandleForReading.readDataToEndOfFile()
|
|
148
|
-
let restErr = err.fileHandleForReading.readDataToEndOfFile()
|
|
149
|
-
lock.lock()
|
|
150
|
-
outData.append(restOut); errData.append(restErr)
|
|
151
|
-
let result = ChiResult(status: proc.terminationStatus,
|
|
152
|
-
stdout: String(decoding: outData, as: UTF8.self),
|
|
153
|
-
stderr: String(decoding: errData, as: UTF8.self),
|
|
154
|
-
timedOut: timedOut)
|
|
155
|
-
lock.unlock()
|
|
156
|
-
DispatchQueue.main.async { completion(.success(result)) }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
do { try process.run() } catch {
|
|
160
|
-
completion(.failure(.failed("Could not start \(config.argv[0]): \(error.localizedDescription)")))
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
if let stdin {
|
|
164
|
-
DispatchQueue.global().async {
|
|
165
|
-
try? input.fileHandleForWriting.write(contentsOf: stdin)
|
|
166
|
-
try? input.fileHandleForWriting.close()
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
DispatchQueue.global().asyncAfter(deadline: .now() + (timeout ?? self.timeout)) {
|
|
170
|
-
if process.isRunning {
|
|
171
|
-
lock.lock(); timedOut = true; lock.unlock()
|
|
172
|
-
process.terminate()
|
|
173
|
-
}
|
|
174
|
-
}
|
|
116
|
+
ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
|
|
117
|
+
env: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
|
|
118
|
+
stdin: stdin, timeout: timeout ?? self.timeout, completion: completion)
|
|
175
119
|
}
|
|
176
120
|
|
|
177
121
|
/// The live sessions, then up to +recentCount+ stopped ones, newest
|
|
@@ -27,7 +27,11 @@ struct PanelImage: Identifiable, Equatable {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
enum ImageIntake {
|
|
30
|
-
|
|
30
|
+
/// $TMPDIR when set (launchd sets it; a spec points it elsewhere), else
|
|
31
|
+
/// the user's temp folder.
|
|
32
|
+
static let tempRoot = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TMPDIR"].flatMap { $0.isEmpty ? nil : $0 }
|
|
33
|
+
?? NSTemporaryDirectory(), isDirectory: true)
|
|
34
|
+
static let tempDir = tempRoot.appendingPathComponent("chi-helper", isDirectory: true)
|
|
31
35
|
|
|
32
36
|
/// A pasteboard's content for the panel, in this order: image files
|
|
33
37
|
/// (Finder ⌘C, a Service on files) with no text; else its text, as before
|
|
@@ -78,9 +82,40 @@ enum ImageIntake {
|
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
///
|
|
82
|
-
|
|
85
|
+
/// Images pasted into kitty windows, kept for the agent to read after
|
|
86
|
+
/// the send: never deleted by it, swept at launch after a week.
|
|
87
|
+
static let sentDir = tempRoot.appendingPathComponent("chi-helper-sent", isDirectory: true)
|
|
88
|
+
static let sentMaxAge: TimeInterval = 7 * 86_400
|
|
89
|
+
|
|
90
|
+
/// The paths a kitty paste names: an image the helper wrote is copied to
|
|
91
|
+
/// sentDir first (the send deletes the original), with no spaces in its
|
|
92
|
+
/// name; a file of the user's is named as it is. Call before any send
|
|
93
|
+
/// job starts. An image that can't be copied is left out.
|
|
94
|
+
static func keepForPaste(_ images: [PanelImage]) -> [String] {
|
|
95
|
+
images.compactMap { image in
|
|
96
|
+
guard image.temp else { return image.url.path }
|
|
97
|
+
let ext = image.url.pathExtension.isEmpty ? "png" : image.url.pathExtension
|
|
98
|
+
let copy = sentDir.appendingPathComponent("\(UUID().uuidString).\(ext)")
|
|
99
|
+
do {
|
|
100
|
+
try FileManager.default.createDirectory(at: sentDir, withIntermediateDirectories: true)
|
|
101
|
+
try FileManager.default.copyItem(at: image.url, to: copy)
|
|
102
|
+
return copy.path
|
|
103
|
+
} catch {
|
|
104
|
+
return nil
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// At launch: what a helper that quit mid-send left, and pasted images
|
|
110
|
+
/// older than a week.
|
|
111
|
+
static func sweep(now: Date = Date()) {
|
|
83
112
|
try? FileManager.default.removeItem(at: tempDir)
|
|
113
|
+
let fm = FileManager.default
|
|
114
|
+
for name in (try? fm.contentsOfDirectory(atPath: sentDir.path)) ?? [] {
|
|
115
|
+
let url = sentDir.appendingPathComponent(name)
|
|
116
|
+
let modified = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? now
|
|
117
|
+
if now.timeIntervalSince(modified) > sentMaxAge { try? fm.removeItem(at: url) }
|
|
118
|
+
}
|
|
84
119
|
}
|
|
85
120
|
}
|
|
86
121
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Agent CLIs (claude, codex, …) in kitty windows as panel targets, through
|
|
2
|
+
// the kitty binary's remote control (`kitty @ --to unix:<socket> …`). The
|
|
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.
|
|
5
|
+
import Foundation
|
|
6
|
+
|
|
7
|
+
struct KittySettings: Decodable, Equatable {
|
|
8
|
+
let listenOn: String
|
|
9
|
+
let binary: String
|
|
10
|
+
let agents: [String]
|
|
11
|
+
|
|
12
|
+
enum CodingKeys: String, CodingKey {
|
|
13
|
+
case listenOn = "listen_on"
|
|
14
|
+
case binary, agents
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
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
|
+
/// The pure parts: which files are sockets of this listen_on, which windows
|
|
58
|
+
/// are agents, what text a paste carries. The runner below does the I/O.
|
|
59
|
+
enum Kitty {
|
|
60
|
+
/// listen_on's path with `~`, `$VAR` and `${VAR}` expanded from +env+
|
|
61
|
+
/// (unknown variables stay as written: kitty.conf's `${KITTY_PID}` is not
|
|
62
|
+
/// expanded by kitty either). Nil for anything but `unix:`.
|
|
63
|
+
static func socketPath(listenOn: String, env: [String: String]) -> String? {
|
|
64
|
+
guard listenOn.hasPrefix("unix:") else { return nil }
|
|
65
|
+
var path = String(listenOn.dropFirst("unix:".count))
|
|
66
|
+
let home = env["HOME"] ?? NSHomeDirectory()
|
|
67
|
+
if path == "~" || path.hasPrefix("~/") { path = home + path.dropFirst() }
|
|
68
|
+
let pattern = try! NSRegularExpression(pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)"#)
|
|
69
|
+
var result = ""
|
|
70
|
+
var last = path.startIndex
|
|
71
|
+
for match in pattern.matches(in: path, range: NSRange(path.startIndex..., in: path)) {
|
|
72
|
+
let whole = Range(match.range, in: path)!
|
|
73
|
+
let nameRange = Range(match.range(at: 1), in: path) ?? Range(match.range(at: 2), in: path)!
|
|
74
|
+
result += path[last..<whole.lowerBound]
|
|
75
|
+
result += env[String(path[nameRange])] ?? String(path[whole])
|
|
76
|
+
last = whole.upperBound
|
|
77
|
+
}
|
|
78
|
+
return result + path[last...]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Whether +name+ is a socket name kitty makes from +base+: base itself,
|
|
82
|
+
/// base with `-<pid>` appended (kitty.conf's listen_on without
|
|
83
|
+
/// {kitty_pid}), or base with {kitty_pid} filled in.
|
|
84
|
+
static func socketName(_ name: String, matches base: String) -> Bool {
|
|
85
|
+
let parts = base.components(separatedBy: "{kitty_pid}")
|
|
86
|
+
let pattern = parts.map(NSRegularExpression.escapedPattern(for:)).joined(separator: #"\d+"#)
|
|
87
|
+
let full = parts.count > 1 ? "^\(pattern)$" : "^\(pattern)(-\\d+)?$"
|
|
88
|
+
return name.range(of: full, options: .regularExpression) != nil
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The windows of one `kitty @ ls` whose foreground programs include an
|
|
92
|
+
/// agent: the basename of a process's cmdline[0] or cmdline[1] (codex and
|
|
93
|
+
/// gemini run as `node …/codex`); `*` takes every window.
|
|
94
|
+
static func windows(ls data: Data, socket: String, agents: [String]) -> [KittyWindow] {
|
|
95
|
+
guard let osWindows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
|
96
|
+
let all = agents.contains("*")
|
|
97
|
+
var found: [KittyWindow] = []
|
|
98
|
+
for osWindow in osWindows {
|
|
99
|
+
for tab in osWindow["tabs"] as? [[String: Any]] ?? [] {
|
|
100
|
+
for window in tab["windows"] as? [[String: Any]] ?? [] {
|
|
101
|
+
guard let id = window["id"] as? Int else { continue }
|
|
102
|
+
let names = (window["foreground_processes"] as? [[String: Any]] ?? []).flatMap { process -> [String] in
|
|
103
|
+
let cmdline = process["cmdline"] as? [String] ?? []
|
|
104
|
+
return cmdline.prefix(2).map(programName)
|
|
105
|
+
}.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 ?? ""))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found
|
|
114
|
+
}
|
|
115
|
+
|
|
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: #"'\''"#) + "'"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
final class KittyRunner {
|
|
173
|
+
let settings: KittySettings?
|
|
174
|
+
let queue: DispatchQueue
|
|
175
|
+
let env: [String: String]
|
|
176
|
+
let lsTimeout: TimeInterval = 2
|
|
177
|
+
let sendTimeout: TimeInterval = 5
|
|
178
|
+
|
|
179
|
+
/// @param queue where completions run (the main queue in the app)
|
|
180
|
+
init(settings: KittySettings?, queue: DispatchQueue = .main,
|
|
181
|
+
env: [String: String] = ProcessInfo.processInfo.environment) {
|
|
182
|
+
self.settings = settings
|
|
183
|
+
self.queue = queue
|
|
184
|
+
self.env = env
|
|
185
|
+
}
|
|
186
|
+
|
|
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
|
+
/// kitty @ runs, and listen_on expands, with the helper's env minus
|
|
195
|
+
/// kitty's own variables (a helper started from a kitty window must not
|
|
196
|
+
/// talk to that one, nor fill in the ${KITTY_PID} kitty keeps literal).
|
|
197
|
+
private var kittyEnv: [String: String] { env.filter { !$0.key.hasPrefix("KITTY_") } }
|
|
198
|
+
|
|
199
|
+
/// The sockets listen_on names: the files in its folder whose names match
|
|
200
|
+
/// (a directory listing, not glob(): the path may hold `${ }` literally).
|
|
201
|
+
func sockets() -> Result<[String], KittyError> {
|
|
202
|
+
guard let settings else { return .success([]) }
|
|
203
|
+
guard let path = Kitty.socketPath(listenOn: settings.listenOn, env: kittyEnv) else {
|
|
204
|
+
return .failure(.notUnix(settings.listenOn))
|
|
205
|
+
}
|
|
206
|
+
let dir = (path as NSString).deletingLastPathComponent
|
|
207
|
+
let base = (path as NSString).lastPathComponent
|
|
208
|
+
let names = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []
|
|
209
|
+
return .success(names.filter { Kitty.socketName($0, matches: base) }.sorted()
|
|
210
|
+
.map { (dir as NSString).appendingPathComponent($0) }
|
|
211
|
+
.filter { (try? FileManager.default.attributesOfItem(atPath: $0)[.type] as? FileAttributeType) == .typeSocket })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Agent windows from every live socket; a dead one drops out silently.
|
|
215
|
+
func windows(completion: @escaping (Result<[KittyWindow], KittyError>) -> Void) {
|
|
216
|
+
let sockets: [String]
|
|
217
|
+
switch self.sockets() {
|
|
218
|
+
case .failure(let error): queue.async { completion(.failure(error)) }; return
|
|
219
|
+
case .success(let found): sockets = found
|
|
220
|
+
}
|
|
221
|
+
guard let settings, !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
|
|
222
|
+
let group = DispatchGroup()
|
|
223
|
+
let lock = NSLock()
|
|
224
|
+
var bySocket: [String: [KittyWindow]] = [:]
|
|
225
|
+
for socket in sockets {
|
|
226
|
+
group.enter()
|
|
227
|
+
kitty(socket, ["ls"], timeout: lsTimeout, on: .global()) { result in
|
|
228
|
+
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)
|
|
230
|
+
lock.lock(); bySocket[socket] = found; lock.unlock()
|
|
231
|
+
}
|
|
232
|
+
group.leave()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
group.notify(queue: queue) { completion(.success(sockets.flatMap { bySocket[$0] ?? [] })) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Pastes +text+ into the window as one bracketed paste, then presses
|
|
239
|
+
/// Enter unless +pasteOnly+. send-text reports nothing, even for a gone
|
|
240
|
+
/// 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 {
|
|
243
|
+
queue.async { completion(.failure(.failed("not a kitty window: \(key)"))) }; return
|
|
244
|
+
}
|
|
245
|
+
let match = ["--match", "id:\(id)"]
|
|
246
|
+
kitty(socket, ["ls"] + match, timeout: lsTimeout) { result in
|
|
247
|
+
guard case .success(let r) = result, !r.timedOut else {
|
|
248
|
+
completion(.failure(self.failure(result, "kitty ls"))); return
|
|
249
|
+
}
|
|
250
|
+
guard r.status == 0 else { completion(.failure(.windowClosed)); return }
|
|
251
|
+
let paste = Data(("\u{1b}[200~" + text + "\u{1b}[201~").utf8)
|
|
252
|
+
self.kitty(socket, ["send-text"] + match + ["--bracketed-paste=disable", "--stdin"], stdin: paste,
|
|
253
|
+
timeout: self.sendTimeout) { result in
|
|
254
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else {
|
|
255
|
+
completion(.failure(self.failure(result, "kitty send-text"))); return
|
|
256
|
+
}
|
|
257
|
+
if pasteOnly { completion(.success(())); return }
|
|
258
|
+
self.kitty(socket, ["send-key"] + match + ["enter"], timeout: self.sendTimeout) { result in
|
|
259
|
+
guard case .success(let r) = result, r.status == 0, !r.timedOut else {
|
|
260
|
+
completion(.failure(self.failure(result, "kitty send-key"))); return
|
|
261
|
+
}
|
|
262
|
+
completion(.success(()))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
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
|
+
private func kitty(_ socket: String, _ args: [String], stdin: Data? = nil, timeout: TimeInterval,
|
|
279
|
+
on queue: DispatchQueue? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
|
|
280
|
+
guard let settings else { return }
|
|
281
|
+
ProcessRunner.run(executable: settings.binary, args: ["@", "--to", "unix:\(socket)"] + args, env: kittyEnv,
|
|
282
|
+
stdin: stdin, timeout: timeout, queue: queue ?? self.queue, completion: completion)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
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
|
+
}
|