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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/configuration.md +20 -0
  7. data/docs/desktop.md +49 -0
  8. data/docs/guardrails.md +2 -2
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/context-telemetry.md +3 -1
  11. data/docs/memory.md +48 -1
  12. data/docs/plugins.md +7 -5
  13. data/docs/releasing.md +5 -1
  14. data/docs/sessions.md +2 -2
  15. data/lib/samagotchi/archive_store.rb +3 -3
  16. data/lib/samagotchi/atomic_file.rb +34 -0
  17. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  18. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  19. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  20. data/lib/samagotchi/bootstrap_command.rb +78 -8
  21. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  22. data/lib/samagotchi/bridge.rb +56 -116
  23. data/lib/samagotchi/bridge_client.rb +7 -11
  24. data/lib/samagotchi/bundle_command.rb +81 -21
  25. data/lib/samagotchi/bundle_needs.rb +2 -11
  26. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  27. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  28. data/lib/samagotchi/bundles/system/identity.md +1 -0
  29. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  30. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  31. data/lib/samagotchi/client.rb +3 -23
  32. data/lib/samagotchi/config.rb +11 -11
  33. data/lib/samagotchi/context_note.rb +1 -1
  34. data/lib/samagotchi/context_status.rb +255 -0
  35. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  36. data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
  37. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  38. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  39. data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
  40. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  41. data/lib/samagotchi/desktop/macos.rb +25 -11
  42. data/lib/samagotchi/desktop_command.rb +1 -1
  43. data/lib/samagotchi/engine.rb +6 -7
  44. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  45. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  46. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  47. data/lib/samagotchi/hooks/loader.rb +2 -1
  48. data/lib/samagotchi/hooks/registry.rb +21 -0
  49. data/lib/samagotchi/image_store.rb +2 -3
  50. data/lib/samagotchi/kernel_loop.rb +21 -283
  51. data/lib/samagotchi/live_versions.rb +9 -13
  52. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  53. data/lib/samagotchi/llm/native_backend.rb +8 -8
  54. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  55. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  56. data/lib/samagotchi/log_path.rb +2 -3
  57. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  58. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  59. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  60. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  61. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  62. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  63. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  64. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  65. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  66. data/lib/samagotchi/memory_bundle.rb +1 -0
  67. data/lib/samagotchi/note_command.rb +5 -4
  68. data/lib/samagotchi/owner_lock.rb +0 -6
  69. data/lib/samagotchi/paths.rb +32 -0
  70. data/lib/samagotchi/plugin/api.rb +3 -6
  71. data/lib/samagotchi/plugin/context.rb +2 -3
  72. data/lib/samagotchi/plugin/loader.rb +1 -1
  73. data/lib/samagotchi/question_desk.rb +3 -2
  74. data/lib/samagotchi/recap_store.rb +2 -2
  75. data/lib/samagotchi/reply_wait.rb +2 -1
  76. data/lib/samagotchi/self_report.rb +2 -3
  77. data/lib/samagotchi/send_command.rb +3 -2
  78. data/lib/samagotchi/session.rb +41 -143
  79. data/lib/samagotchi/session_inbox.rb +175 -0
  80. data/lib/samagotchi/session_manager.rb +62 -363
  81. data/lib/samagotchi/session_metrics.rb +2 -3
  82. data/lib/samagotchi/session_retention.rb +259 -0
  83. data/lib/samagotchi/sessions_command.rb +3 -2
  84. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  85. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  86. data/lib/samagotchi/terminal_ui.rb +10 -20
  87. data/lib/samagotchi/tools/memory.rb +3 -2
  88. data/lib/samagotchi/tools/send_note.rb +4 -3
  89. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  90. data/lib/samagotchi/turn_note.rb +13 -2
  91. data/lib/samagotchi/update_command.rb +10 -3
  92. data/lib/samagotchi/version.rb +1 -1
  93. data/lib/samagotchi/web/app.rb +44 -76
  94. data/lib/samagotchi/web/info_probe.rb +33 -0
  95. data/lib/samagotchi/web/public/app.js +1 -1
  96. data/lib/samagotchi/web/public/index.html +1 -0
  97. data/lib/samagotchi/web/public/stage_model.js +22 -3
  98. data/lib/samagotchi/web/public/stage_view.js +25 -10
  99. data/lib/samagotchi/web/server.rb +8 -11
  100. data/lib/samagotchi/web/session_hub.rb +8 -7
  101. data/lib/samagotchi/web/session_summary.rb +4 -3
  102. data/lib/samagotchi/web/token.rb +2 -5
  103. data/lib/samagotchi/worker.rb +34 -20
  104. data/lib/samagotchi/worker_sidecar.rb +42 -0
  105. 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>`, with stdin written from a background queue and
110
- /// stdout/stderr drained as they come (no pipe deadlock near the 16 KiB
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
- let process = Process()
123
- process.executableURL = URL(fileURLWithPath: config.argv[0])
124
- process.arguments = Array(config.argv.dropFirst()) + args
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
- static let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("chi-helper", isDirectory: true)
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
- /// At launch: what a helper that quit mid-send left.
82
- static func sweep() {
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
+ }