samagotchi 0.5.1 → 0.7.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -1,8 +1,11 @@
1
1
  // The Spotlight-like panel: a message line on top, the images (a screenshot,
2
2
  // Finder files, drops) as thumbnails, the text (selection or clipboard)
3
- // below as quoted context, then a "New session" row and the sessions. ⏎
3
+ // below as quoted context, then a "New session" row and the targets: live
4
+ // sessions, agents in kitty windows (Kitty.swift), recent sessions. ⏎
4
5
  // sends a message (`chi send`, or `chi send --new` on the new row, with
5
- // `--image` per image), ⌘⏎ a note (`chi note`, text only), ⇧⏎ a newline.
6
+ // `--image` per image; pasted with Enter into a kitty window), ⌘⏎ a note
7
+ // (`chi note`, text only), ⌥⏎ a paste without Enter (kitty windows only),
8
+ // ⇧⏎ a newline.
6
9
  import AppKit
7
10
  import SwiftUI
8
11
 
@@ -23,9 +26,20 @@ final class PanelModel: ObservableObject {
23
26
  /// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
24
27
  @Published var hoveredImage: UUID?
25
28
  @Published var sessions: [LiveSession] = []
29
+ /// Agent windows in kitty; kittyOn when launch.json has a kitty section.
30
+ @Published var kittyWindows: [KittyWindow] = []
31
+ @Published var kittyOn = false
32
+ /// Set once the kitty listing answered: "no agents in kitty" or an error.
33
+ @Published var kittyNote: String?
34
+ /// Chi session ids and kitty window keys (never alike: UUIDs vs paths).
26
35
  @Published var selected: Set<String> = []
36
+ /// The user changed the selection: a late kitty listing keeps it.
37
+ var touched = false
27
38
  /// The "New session" row; never together with sessions (`--new` takes no ids).
28
39
  @Published var newSelected = false
40
+ /// The model a new session starts on (`chi self --model`), for the new
41
+ /// row's hint; nil while unknown.
42
+ @Published var newModel: String?
29
43
  @Published var phase: Phase = .loading
30
44
  @Published var message = ""
31
45
 
@@ -38,10 +52,14 @@ final class PanelModel: ObservableObject {
38
52
  var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
39
53
  var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
40
54
  var sendEnabled: Bool {
41
- canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
55
+ canSend && (newSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
42
56
  }
43
57
  var live: [LiveSession] { sessions.filter { !$0.recent } }
44
58
 
59
+ /// One list for rows, ⌘ numbers and selection: live sessions, then kitty
60
+ /// windows, then recent sessions.
61
+ var targets: [Target] { live.map(Target.chi) + kittyWindows.map(Target.kitty) + recent.map(Target.chi) }
62
+
45
63
  /// Up to maxImages; the rest is dropped (and its temp files deleted).
46
64
  func add(_ new: [PanelImage]) {
47
65
  let room = max(0, maxImages - images.count)
@@ -61,21 +79,23 @@ final class PanelModel: ObservableObject {
61
79
  }
62
80
  var recent: [LiveSession] { sessions.filter(\.recent) }
63
81
 
64
- /// The last choice, where still live; else the only live session; else,
65
- /// with none live, the new row. A recent (stopped) one is never
66
- /// preselected: a message would wake it.
82
+ /// The last choice, where still live or listed; else the only live
83
+ /// session; else, with none live and no kitty window chosen, the new row.
84
+ /// A recent (stopped) one is never preselected: a message would wake it.
67
85
  func preselect() {
68
86
  let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
69
- selected = last.intersection(Set(live.map(\.id)))
87
+ selected = last.intersection(Set(live.map(\.id) + kittyWindows.map(\.key)))
70
88
  if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
71
- newSelected = live.isEmpty
89
+ newSelected = live.isEmpty && selected.isEmpty
72
90
  }
73
91
 
74
92
  func toggle(_ id: String) {
93
+ touched = true
75
94
  if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
76
95
  }
77
96
 
78
97
  func toggleNew() {
98
+ touched = true
79
99
  newSelected.toggle()
80
100
  if newSelected { selected = [] }
81
101
  }
@@ -101,9 +121,32 @@ final class PanelModel: ObservableObject {
101
121
  var selectedIds: [String] {
102
122
  sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
103
123
  }
124
+
125
+ var selectedKitty: [KittyWindow] { kittyWindows.filter { selected.contains($0.key) } }
126
+
127
+ /// Who the message goes to, for the placeholder: chi, or the one agent
128
+ /// when only kitty windows running it are chosen.
129
+ var addressee: String {
130
+ let agents = Set(selectedKitty.map(\.agent))
131
+ return agents.count == 1 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
132
+ }
104
133
  }
105
134
 
106
- enum SendKind { case message, note }
135
+ /// A row of the panel: a chi session or an agent in a kitty window.
136
+ enum Target: Identifiable {
137
+ case chi(LiveSession)
138
+ case kitty(KittyWindow)
139
+
140
+ var id: String {
141
+ switch self {
142
+ case .chi(let session): return session.id
143
+ case .kitty(let window): return window.key
144
+ }
145
+ }
146
+ }
147
+
148
+ /// ⏎ message, ⌘⏎ note, ⌥⏎ paste (kitty windows only: no Enter).
149
+ enum SendKind { case message, note, paste }
107
150
 
108
151
  struct PanelView: View {
109
152
  @ObservedObject var model: PanelModel
@@ -113,7 +156,7 @@ struct PanelView: View {
113
156
  var body: some View {
114
157
  VStack(spacing: 0) {
115
158
  // The message is required with images, as in the web.
116
- TextField(model.images.isEmpty ? "Ask chi…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
159
+ TextField(model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
117
160
  .textFieldStyle(.plain)
118
161
  .font(.system(size: 17))
119
162
  .lineLimit(1...4)
@@ -177,22 +220,70 @@ struct PanelView: View {
177
220
  }.padding(10)
178
221
  default:
179
222
  newRow
180
- ForEach(Array(model.sessions.prefix(9).enumerated()), id: \.element.id) { index, session in
181
- if session.recent && index == model.live.count {
182
- HStack(spacing: 6) {
183
- Text("recent").font(.system(size: 11)).foregroundColor(.secondary)
184
- Rectangle().fill(Color.secondary.opacity(0.25)).frame(height: 1)
185
- }
186
- .padding(.horizontal, 8).padding(.top, index == 0 ? 2 : 6)
223
+ let live = model.live.count, kitty = model.kittyWindows.count
224
+ ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
225
+ if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
226
+ if index == live + kitty && !model.recent.isEmpty {
227
+ if kitty == 0 { kittyNoteLine }
228
+ groupHeader("recent", first: index == 0)
229
+ }
230
+ switch target {
231
+ case .chi(let session): row(session, index: index)
232
+ case .kitty(let window): kittyRow(window, index: index)
187
233
  }
188
- row(session, index: index)
189
234
  }
235
+ if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
190
236
  }
191
237
  }
192
238
  .padding(6)
193
239
  .frame(maxWidth: .infinity, alignment: .leading)
194
240
  }
195
241
 
242
+ func groupHeader(_ name: String, first: Bool) -> some View {
243
+ HStack(spacing: 6) {
244
+ Text(name).font(.system(size: 11)).foregroundColor(.secondary)
245
+ Rectangle().fill(Color.secondary.opacity(0.25)).frame(height: 1)
246
+ }
247
+ .padding(.horizontal, 8).padding(.top, first ? 2 : 6)
248
+ }
249
+
250
+ /// "no agents in kitty" (or why kitty can't be asked) where the kitty
251
+ /// rows would be; nothing without a kitty section.
252
+ @ViewBuilder var kittyNoteLine: some View {
253
+ if model.kittyOn, let note = model.kittyNote {
254
+ HStack(spacing: 6) {
255
+ Image(systemName: "terminal").font(.system(size: 11))
256
+ Text(note).font(.system(size: 11)).lineLimit(1)
257
+ }
258
+ .foregroundColor(.secondary.opacity(0.7))
259
+ .padding(.horizontal, 8).padding(.vertical, 4)
260
+ }
261
+ }
262
+
263
+ func kittyRow(_ window: KittyWindow, index: Int) -> some View {
264
+ let on = model.selected.contains(window.key)
265
+ return Button(action: { model.toggle(window.key) }) {
266
+ HStack(spacing: 10) {
267
+ Image(systemName: on ? "checkmark.circle.fill" : "circle")
268
+ .font(.system(size: 16))
269
+ .foregroundColor(on ? .accentColor : .secondary)
270
+ VStack(alignment: .leading, spacing: 1) {
271
+ Text(window.title.isEmpty ? window.label : "\(window.agent) · \(window.title)")
272
+ .font(.system(size: 13, weight: .medium)).lineLimit(1)
273
+ Text(window.shortCwd).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
274
+ }
275
+ Spacer()
276
+ Image(systemName: "terminal").font(.system(size: 12)).foregroundColor(.secondary)
277
+ .help("\(window.agent) in a kitty window: ⏎ pastes and presses Enter, ⌥⏎ only pastes")
278
+ Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
279
+ }
280
+ .padding(.horizontal, 8).padding(.vertical, 6)
281
+ .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
282
+ .contentShape(Rectangle())
283
+ }
284
+ .buttonStyle(.plain)
285
+ }
286
+
196
287
  var newRow: some View {
197
288
  let on = model.newSelected
198
289
  return Button(action: { model.toggleNew() }) {
@@ -205,6 +296,12 @@ struct PanelView: View {
205
296
  Text(LiveSession.shorten(model.newSessionDir)).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
206
297
  }
207
298
  Spacer()
299
+ if let name = model.newModel {
300
+ Text(name).font(.system(size: 11)).foregroundColor(.secondary)
301
+ .lineLimit(1).truncationMode(.middle)
302
+ .frame(maxWidth: 240, alignment: .trailing)
303
+ .help("the model a new session starts on: default.model in config.yml")
304
+ }
208
305
  Text("⌘0").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
209
306
  }
210
307
  .padding(.horizontal, 8).padding(.vertical, 6)
@@ -268,11 +365,18 @@ struct PanelView: View {
268
365
  .textSelection(.enabled)
269
366
  .frame(maxWidth: .infinity, alignment: .leading)
270
367
 
271
- // ⏎ and ⌘⏎ are handled by the panel (KeyPanel.sendEvent), whatever
272
- // has the focus; the buttons are for the mouse.
273
- Button("Note ⌘⏎") { send(.note) }
274
- .disabled(!model.sendEnabled)
275
- .help("add it as background the model sees on its next turn; starts no turn")
368
+ // ⏎, ⌘⏎ and ⌥⏎ are handled by the panel (KeyPanel.sendEvent),
369
+ // whatever has the focus; the buttons are for the mouse. Only kitty
370
+ // windows chosen: Paste instead of Note.
371
+ if !model.selectedKitty.isEmpty && model.selectedIds.isEmpty && !model.newSelected {
372
+ Button("Paste ⌥⏎") { send(.paste) }
373
+ .disabled(!model.sendEnabled)
374
+ .help("paste it into the agent's input without Enter: add more, then press Enter there")
375
+ } else {
376
+ Button("Note ⌘⏎") { send(.note) }
377
+ .disabled(!model.sendEnabled || !model.selectedKitty.isEmpty)
378
+ .help("add it as background the model sees on its next turn; starts no turn")
379
+ }
276
380
  Button(action: { send(.message) }) {
277
381
  if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
278
382
  }
@@ -355,6 +459,8 @@ final class KeyPanel: NSPanel {
355
459
  final class PanelController: NSObject, NSWindowDelegate {
356
460
  let model = PanelModel()
357
461
  let runner = ChiRunner()
462
+ /// This open's kitty runner (launch.json read at each open).
463
+ private var kitty: KittyRunner?
358
464
  private var panel: KeyPanel!
359
465
  private var returnTo: NSRunningApplication?
360
466
  /// Images a running `chi send` still reads: not deleted on close.
@@ -395,11 +501,12 @@ final class PanelController: NSObject, NSWindowDelegate {
395
501
  panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
396
502
  }
397
503
 
398
- /// ⏎ message, ⌘⏎ note, ⇧⏎ a newline in the focused field.
504
+ /// ⏎ message, ⌘⏎ note, ⌥⏎ paste, ⇧⏎ a newline in the focused field.
399
505
  private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
400
506
  switch flags {
401
507
  case []: send(.message)
402
508
  case .command: send(.note)
509
+ case .option: send(.paste)
403
510
  case .shift:
404
511
  (panel.firstResponder as? NSTextView)?.insertNewlineIgnoringFieldEditor(nil)
405
512
  default: return false
@@ -407,12 +514,13 @@ final class PanelController: NSObject, NSWindowDelegate {
407
514
  return true
408
515
  }
409
516
 
410
- /// ⌘1…⌘9 toggle a session, ⌘0 the new row.
517
+ /// ⌘1…⌘9 toggle a target, ⌘0 the new row.
411
518
  private func handleKey(_ event: NSEvent) -> Bool {
519
+ let targets = model.targets
412
520
  guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
413
521
  let chars = event.charactersIgnoringModifiers, let n = Int(chars),
414
- n <= model.sessions.count else { return false }
415
- if n == 0 { model.toggleNew() } else { model.toggle(model.sessions[n - 1].id) }
522
+ n <= min(targets.count, 9) else { return false }
523
+ if n == 0 { model.toggleNew() } else { model.toggle(targets[n - 1].id) }
416
524
  return true
417
525
  }
418
526
 
@@ -428,10 +536,14 @@ final class PanelController: NSObject, NSWindowDelegate {
428
536
  model.source = source
429
537
  model.message = ""
430
538
  model.newSelected = false
539
+ model.selected = []
540
+ model.touched = false
431
541
  model.phase = .loading
432
542
  placeOnActiveScreen()
433
543
  panel.makeKeyAndOrderFront(nil)
434
544
  loadSessions()
545
+ loadKitty()
546
+ loadModel()
435
547
  }
436
548
 
437
549
  private func placeOnActiveScreen() {
@@ -459,32 +571,130 @@ final class PanelController: NSObject, NSWindowDelegate {
459
571
  }
460
572
  }
461
573
 
574
+ /// Beside the chi listing, never blocking or failing it: a listing that
575
+ /// answers after the sessions preselects again unless the user chose.
576
+ private func loadKitty() {
577
+ let runner = KittyRunner(launchPath: self.runner.launchPath)
578
+ kitty = runner
579
+ model.kittyOn = runner.settings != nil
580
+ model.kittyWindows = []
581
+ model.kittyNote = nil
582
+ guard model.kittyOn else { return }
583
+ runner.windows { [weak self] result in
584
+ guard let self, self.kitty === runner else { return }
585
+ switch result {
586
+ case .success(let windows):
587
+ self.model.kittyWindows = windows
588
+ self.model.kittyNote = windows.isEmpty ? "no agents in kitty" : nil
589
+ case .failure(let error):
590
+ self.model.kittyNote = error.description
591
+ }
592
+ if self.model.phase != .loading && !self.model.touched { self.model.preselect() }
593
+ }
594
+ }
595
+
596
+ /// Kept from the last look while it runs; config rarely changes.
597
+ private func loadModel() {
598
+ runner.defaultModel { [weak self] model in self?.model.newModel = model }
599
+ }
600
+
601
+ /// Fans out: one `chi send` / `chi note` for the chi sessions (or the new
602
+ /// row) and one paste per kitty window, with one combined line when all
603
+ /// finished. Images the helper wrote are copied for kitty first (chi's
604
+ /// send deletes them) and deleted only after every job.
462
605
  func send(_ kind: SendKind) {
463
606
  let ids = model.selectedIds
607
+ let windows = model.selectedKitty
464
608
  let new = model.newSelected
609
+ if kind == .note, !windows.isEmpty {
610
+ NSSound.beep()
611
+ model.message = "Notes are for chi sessions"
612
+ return
613
+ }
614
+ if kind == .paste, windows.isEmpty || !ids.isEmpty || new {
615
+ NSSound.beep()
616
+ model.message = "Paste only is for kitty windows"
617
+ return
618
+ }
465
619
  if kind == .note, !model.images.isEmpty {
466
620
  NSSound.beep()
467
621
  model.message = "Notes are text only: ⏎ sends the image as a message"
468
622
  return
469
623
  }
470
624
  // A note into a session that doesn't exist yet makes no sense.
471
- guard model.sendEnabled, new ? kind == .message : !ids.isEmpty else { NSSound.beep(); return }
625
+ guard model.sendEnabled, new ? kind == .message : !(ids.isEmpty && windows.isEmpty) else { NSSound.beep(); return }
472
626
  model.phase = .sending
473
627
  model.message = ""
474
628
  if !new {
475
- UserDefaults.standard.set(ids.filter { id in model.live.contains { $0.id == id } }, forKey: PanelModel.lastChoiceKey)
629
+ let liveIds = ids.filter { id in model.live.contains { $0.id == id } }
630
+ UserDefaults.standard.set(liveIds + windows.map(\.key), forKey: PanelModel.lastChoiceKey)
631
+ }
632
+ let images = kind == .note ? [] : model.images
633
+ inFlight.formUnion(images.map(\.id))
634
+ let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
635
+ imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
636
+
637
+ let group = DispatchGroup()
638
+ var chiOutcome: SendOutcome?
639
+ var kittyOutcomes: [(KittyWindow, KittyError?)] = []
640
+ if new || !ids.isEmpty {
641
+ group.enter()
642
+ sendToChi(kind, ids: ids, new: new, images: images) { outcome in chiOutcome = outcome; group.leave() }
643
+ }
644
+ for window in windows {
645
+ group.enter()
646
+ (kitty ?? KittyRunner(launchPath: runner.launchPath)).send(key: window.key, text: kittyText, pasteOnly: kind == .paste) { result in
647
+ if case .failure(let error) = result { kittyOutcomes.append((window, error)) } else { kittyOutcomes.append((window, nil)) }
648
+ group.leave()
649
+ }
650
+ }
651
+ group.notify(queue: .main) { [weak self] in
652
+ guard let self else { return }
653
+ self.inFlight.subtract(images.map(\.id))
654
+ let pasted = windows.filter { w in kittyOutcomes.contains { $0.0 == w && $0.1 == nil } }
655
+ let failed = kittyOutcomes.compactMap { outcome in outcome.1.map { "\(outcome.0.label): \($0.description)" } }
656
+ let ok = (chiOutcome?.ok ?? true) && failed.isEmpty
657
+ // Sent: the temp files go (and the thumbnails). Else they stay for
658
+ // another try, unless the panel was closed or reopened meanwhile.
659
+ if ok { self.model.images.removeAll { images.contains($0) } }
660
+ ImageIntake.delete(images.filter { !self.model.images.contains($0) })
661
+ var parts: [String] = []
662
+ if let chiOutcome { parts.append(chiOutcome.message) }
663
+ if !pasted.isEmpty {
664
+ parts.append("\(kind == .paste ? "pasted into" : "sent to") \(pasted.map(\.label).joined(separator: ", "))")
665
+ }
666
+ parts += failed
667
+ self.model.message = parts.filter { !$0.isEmpty }.joined(separator: " · ")
668
+ if ok {
669
+ self.model.phase = .sent
670
+ DispatchQueue.main.asyncAfter(deadline: .now() + (chiOutcome?.linger ?? 1.0)) { [weak self] in
671
+ if self?.model.phase == .sent { self?.panel.close() }
672
+ }
673
+ } else {
674
+ self.model.phase = .failed
675
+ }
476
676
  }
677
+ }
678
+
679
+ struct SendOutcome {
680
+ let ok: Bool
681
+ let message: String
682
+ var linger = 1.0
683
+ }
684
+
685
+ private func sendToChi(_ kind: SendKind, ids: [String], new: Bool, images: [PanelImage],
686
+ completion: @escaping (SendOutcome) -> Void) {
477
687
  let prompt = model.trimmedPrompt
478
688
  let command: String
479
689
  var args: [String]
480
690
  let stdin: Data?
481
691
  switch kind {
482
- case .message:
692
+ case .message, .paste:
483
693
  // stdin is the quoted context; without -m, chi takes it as the message.
484
694
  command = "chi send"
485
695
  args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
486
696
  if !prompt.isEmpty { args += ["-m", prompt] }
487
- args += model.images.flatMap { ["--image", $0.url.path] }
697
+ args += images.flatMap { ["--image", $0.url.path] }
488
698
  stdin = model.hasContext ? Data(model.text.utf8) : nil
489
699
  case .note:
490
700
  command = "chi note"
@@ -495,42 +705,25 @@ final class PanelController: NSObject, NSWindowDelegate {
495
705
  stdin = Data(parts.joined(separator: "\n\n").utf8)
496
706
  }
497
707
  if !new { args += ids }
498
- let images = kind == .message ? model.images : []
499
- inFlight.formUnion(images.map(\.id))
500
708
  // Reading, converting and a new worker's start take longer than text.
501
709
  let timeout = images.isEmpty ? runner.timeout : 30
502
- runner.run(args, stdin: stdin, timeout: timeout) { [weak self] result in
503
- guard let self else { return }
504
- self.inFlight.subtract(images.map(\.id))
505
- // Sent: the temp files go (and the thumbnails). Else they stay for
506
- // another try, unless the panel was closed or reopened meanwhile.
507
- var sent = false
508
- if case .success(let r) = result { sent = r.status == 0 && !r.timedOut }
509
- if sent { self.model.images.removeAll { images.contains($0) } }
510
- ImageIntake.delete(images.filter { !self.model.images.contains($0) })
710
+ runner.run(args, stdin: stdin, timeout: timeout) { result in
511
711
  switch result {
512
712
  case .failure(let error):
513
- self.model.phase = .failed
514
- self.model.message = error.description
713
+ completion(SendOutcome(ok: false, message: error.description))
515
714
  case .success(let r):
516
715
  let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
517
716
  let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
518
717
  if r.timedOut {
519
- self.model.phase = .failed
520
718
  let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
521
- self.model.message = "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"
719
+ completion(SendOutcome(ok: false, message: "\(command) took over \(Int(timeout)) s and was stopped; \(what). \(out)"))
522
720
  } else if r.status == 0 {
523
- self.model.phase = .sent
524
- // --new prints "<full id> started".
525
- self.model.message = out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out
526
- // Long enough to read a "waits for the session's next start" line.
721
+ // --new prints "<full id> started". Long enough to read a
722
+ // "waits for the session's next start" line.
527
723
  let linger = out.contains("waits for") || out.contains("started") ? 3.0 : 1.0
528
- DispatchQueue.main.asyncAfter(deadline: .now() + linger) { [weak self] in
529
- if self?.model.phase == .sent { self?.panel.close() }
530
- }
724
+ completion(SendOutcome(ok: true, message: out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out, linger: linger))
531
725
  } else {
532
- self.model.phase = .failed
533
- self.model.message = [out, err].filter { !$0.isEmpty }.joined(separator: "\n")
726
+ completion(SendOutcome(ok: false, message: [out, err].filter { !$0.isEmpty }.joined(separator: "\n")))
534
727
  }
535
728
  }
536
729
  }
@@ -0,0 +1,74 @@
1
+ // Runs one outside program without a shell: stdin written from a
2
+ // background queue, stdout/stderr drained as they come (no pipe deadlock on
3
+ // big input or output), a time limit. ChiRunner and KittyRunner use it.
4
+ import Foundation
5
+
6
+ struct ChiResult {
7
+ let status: Int32
8
+ let stdout: String
9
+ let stderr: String
10
+ let timedOut: Bool
11
+ }
12
+
13
+ enum ProcessRunner {
14
+ /// Calls back on +queue+ (the main queue by default; a headless seam
15
+ /// waits on another one).
16
+ /// @param env the whole environment the program gets
17
+ static func run(executable: String, args: [String], env: [String: String], stdin: Data? = nil,
18
+ timeout: TimeInterval, queue: DispatchQueue = .main,
19
+ completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
20
+ let process = Process()
21
+ process.executableURL = URL(fileURLWithPath: executable)
22
+ process.arguments = args
23
+ process.environment = env
24
+ process.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory())
25
+ let out = Pipe(), err = Pipe(), input = Pipe()
26
+ process.standardOutput = out
27
+ process.standardError = err
28
+ process.standardInput = stdin == nil ? FileHandle.nullDevice : input
29
+
30
+ let lock = NSLock()
31
+ var outData = Data(), errData = Data()
32
+ out.fileHandleForReading.readabilityHandler = { handle in
33
+ let chunk = handle.availableData
34
+ lock.lock(); outData.append(chunk); lock.unlock()
35
+ }
36
+ err.fileHandleForReading.readabilityHandler = { handle in
37
+ let chunk = handle.availableData
38
+ lock.lock(); errData.append(chunk); lock.unlock()
39
+ }
40
+
41
+ var timedOut = false
42
+ process.terminationHandler = { proc in
43
+ out.fileHandleForReading.readabilityHandler = nil
44
+ err.fileHandleForReading.readabilityHandler = nil
45
+ let restOut = out.fileHandleForReading.readDataToEndOfFile()
46
+ let restErr = err.fileHandleForReading.readDataToEndOfFile()
47
+ lock.lock()
48
+ outData.append(restOut); errData.append(restErr)
49
+ let result = ChiResult(status: proc.terminationStatus,
50
+ stdout: String(decoding: outData, as: UTF8.self),
51
+ stderr: String(decoding: errData, as: UTF8.self),
52
+ timedOut: timedOut)
53
+ lock.unlock()
54
+ queue.async { completion(.success(result)) }
55
+ }
56
+
57
+ do { try process.run() } catch {
58
+ completion(.failure(.failed("Could not start \(executable): \(error.localizedDescription)")))
59
+ return
60
+ }
61
+ if let stdin {
62
+ DispatchQueue.global().async {
63
+ try? input.fileHandleForWriting.write(contentsOf: stdin)
64
+ try? input.fileHandleForWriting.close()
65
+ }
66
+ }
67
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
68
+ if process.isRunning {
69
+ lock.lock(); timedOut = true; lock.unlock()
70
+ process.terminate()
71
+ }
72
+ }
73
+ }
74
+ }
@@ -9,6 +9,7 @@ require "rbconfig"
9
9
  require_relative "../version"
10
10
  require_relative "../self_report"
11
11
  require_relative "../installed_gem"
12
+ require_relative "../config"
12
13
 
13
14
  module Samagotchi
14
15
  module Desktop
@@ -49,11 +50,21 @@ module Samagotchi
49
50
  end
50
51
  end
51
52
 
53
+ # The kitty: settings the helper lists agent windows with (baked into
54
+ # the launch file), or nil when kitty.listen_on is unset.
55
+ def self.kitty_settings(get = Config.method(:get))
56
+ listen_on = get.call("kitty.listen_on").to_s.strip
57
+ return nil if listen_on.empty?
58
+
59
+ agents = get.call("kitty.agents").to_s.split("|").map(&:strip).reject(&:empty?)
60
+ { "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
61
+ end
62
+
52
63
  attr_reader :app_dir, :support_dir
53
64
 
54
65
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
55
66
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
56
- arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil)
67
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil)
57
68
  home = env["HOME"] || Dir.home
58
69
  @app_dir = app_dir || File.join(home, "Applications")
59
70
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -66,6 +77,7 @@ module Samagotchi
66
77
  @arch = arch
67
78
  @sources_dir = sources_dir
68
79
  @register = register
80
+ @kitty = kitty
69
81
  end
70
82
 
71
83
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -78,11 +90,14 @@ module Samagotchi
78
90
  # What the helper runs: absolute ruby + chi (no shims, which need the
79
91
  # shell's PATH), and the allowlisted env. chi is an installed gem's
80
92
  # RubyGems wrapper (it activates the gem and outlives upgrades and
81
- # `gem cleanup`), else this checkout's bin/chi.
93
+ # `gem cleanup`), else this checkout's bin/chi. The kitty section only
94
+ # when kitty.listen_on is set.
82
95
  def launch_config
83
96
  env = { "LANG" => LANG }
84
97
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
85
- { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
98
+ config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
99
+ config["kitty"] = @kitty if @kitty
100
+ config
86
101
  end
87
102
 
88
103
  # A digest of what a build compiles: the Swift sources and the
@@ -104,15 +119,15 @@ module Samagotchi
104
119
  launch["sources_sha"] != sources_sha || !launch_ok?(launch)
105
120
  end
106
121
 
107
- # The launch file names another chi version or path than this one:
108
- # refresh_launch_file fixes that without a rebuild.
122
+ # The launch file names another chi version or path than this one, or
123
+ # other kitty settings: refresh_launch_file fixes that without a rebuild.
109
124
  def launch_outdated?
110
125
  launch = read_launch
111
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"]
126
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
112
127
  end
113
128
 
114
- # Rewrite the launch file for this chi, keeping the env it was
115
- # installed with. The app reads it at each send: no restart.
129
+ # Rewrite the launch file for this chi and the kitty settings, keeping
130
+ # the env it was installed with. The app reads it at each send: no restart.
116
131
  def refresh_launch_file
117
132
  config = launch_config
118
133
  env = read_launch["env"]
@@ -26,7 +26,7 @@ module Samagotchi
26
26
  @argv = argv.dup
27
27
  @stdout = stdout
28
28
  @stderr = stderr
29
- @platform = platform || ->(register:) { Desktop::MacOS.new(register: register) }
29
+ @platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
30
30
  @supported = supported
31
31
  end
32
32