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
@@ -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,7 +26,15 @@ 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
29
40
  /// The model a new session starts on (`chi self --model`), for the new
@@ -41,10 +52,14 @@ final class PanelModel: ObservableObject {
41
52
  var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
42
53
  var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
43
54
  var sendEnabled: Bool {
44
- canSend && (newSelected || !selected.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
55
+ canSend && (newSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
45
56
  }
46
57
  var live: [LiveSession] { sessions.filter { !$0.recent } }
47
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
+
48
63
  /// Up to maxImages; the rest is dropped (and its temp files deleted).
49
64
  func add(_ new: [PanelImage]) {
50
65
  let room = max(0, maxImages - images.count)
@@ -64,21 +79,23 @@ final class PanelModel: ObservableObject {
64
79
  }
65
80
  var recent: [LiveSession] { sessions.filter(\.recent) }
66
81
 
67
- /// The last choice, where still live; else the only live session; else,
68
- /// with none live, the new row. A recent (stopped) one is never
69
- /// 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.
70
85
  func preselect() {
71
86
  let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
72
- selected = last.intersection(Set(live.map(\.id)))
87
+ selected = last.intersection(Set(live.map(\.id) + kittyWindows.map(\.key)))
73
88
  if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
74
- newSelected = live.isEmpty
89
+ newSelected = live.isEmpty && selected.isEmpty
75
90
  }
76
91
 
77
92
  func toggle(_ id: String) {
93
+ touched = true
78
94
  if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
79
95
  }
80
96
 
81
97
  func toggleNew() {
98
+ touched = true
82
99
  newSelected.toggle()
83
100
  if newSelected { selected = [] }
84
101
  }
@@ -104,9 +121,32 @@ final class PanelModel: ObservableObject {
104
121
  var selectedIds: [String] {
105
122
  sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
106
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
+ }
133
+ }
134
+
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
+ }
107
146
  }
108
147
 
109
- enum SendKind { case message, note }
148
+ /// ⏎ message, ⌘⏎ note, ⌥⏎ paste (kitty windows only: no Enter).
149
+ enum SendKind { case message, note, paste }
110
150
 
111
151
  struct PanelView: View {
112
152
  @ObservedObject var model: PanelModel
@@ -116,7 +156,7 @@ struct PanelView: View {
116
156
  var body: some View {
117
157
  VStack(spacing: 0) {
118
158
  // The message is required with images, as in the web.
119
- 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)
120
160
  .textFieldStyle(.plain)
121
161
  .font(.system(size: 17))
122
162
  .lineLimit(1...4)
@@ -180,22 +220,70 @@ struct PanelView: View {
180
220
  }.padding(10)
181
221
  default:
182
222
  newRow
183
- ForEach(Array(model.sessions.prefix(9).enumerated()), id: \.element.id) { index, session in
184
- if session.recent && index == model.live.count {
185
- HStack(spacing: 6) {
186
- Text("recent").font(.system(size: 11)).foregroundColor(.secondary)
187
- Rectangle().fill(Color.secondary.opacity(0.25)).frame(height: 1)
188
- }
189
- .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)
190
233
  }
191
- row(session, index: index)
192
234
  }
235
+ if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
193
236
  }
194
237
  }
195
238
  .padding(6)
196
239
  .frame(maxWidth: .infinity, alignment: .leading)
197
240
  }
198
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
+
199
287
  var newRow: some View {
200
288
  let on = model.newSelected
201
289
  return Button(action: { model.toggleNew() }) {
@@ -277,11 +365,18 @@ struct PanelView: View {
277
365
  .textSelection(.enabled)
278
366
  .frame(maxWidth: .infinity, alignment: .leading)
279
367
 
280
- // ⏎ and ⌘⏎ are handled by the panel (KeyPanel.sendEvent), whatever
281
- // has the focus; the buttons are for the mouse.
282
- Button("Note ⌘⏎") { send(.note) }
283
- .disabled(!model.sendEnabled)
284
- .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
+ }
285
380
  Button(action: { send(.message) }) {
286
381
  if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
287
382
  }
@@ -364,6 +459,8 @@ final class KeyPanel: NSPanel {
364
459
  final class PanelController: NSObject, NSWindowDelegate {
365
460
  let model = PanelModel()
366
461
  let runner = ChiRunner()
462
+ /// This open's kitty runner (launch.json read at each open).
463
+ private var kitty: KittyRunner?
367
464
  private var panel: KeyPanel!
368
465
  private var returnTo: NSRunningApplication?
369
466
  /// Images a running `chi send` still reads: not deleted on close.
@@ -404,11 +501,12 @@ final class PanelController: NSObject, NSWindowDelegate {
404
501
  panel.onReturn = { [weak self] flags in self?.handleReturn(flags) ?? false }
405
502
  }
406
503
 
407
- /// ⏎ message, ⌘⏎ note, ⇧⏎ a newline in the focused field.
504
+ /// ⏎ message, ⌘⏎ note, ⌥⏎ paste, ⇧⏎ a newline in the focused field.
408
505
  private func handleReturn(_ flags: NSEvent.ModifierFlags) -> Bool {
409
506
  switch flags {
410
507
  case []: send(.message)
411
508
  case .command: send(.note)
509
+ case .option: send(.paste)
412
510
  case .shift:
413
511
  (panel.firstResponder as? NSTextView)?.insertNewlineIgnoringFieldEditor(nil)
414
512
  default: return false
@@ -416,12 +514,13 @@ final class PanelController: NSObject, NSWindowDelegate {
416
514
  return true
417
515
  }
418
516
 
419
- /// ⌘1…⌘9 toggle a session, ⌘0 the new row.
517
+ /// ⌘1…⌘9 toggle a target, ⌘0 the new row.
420
518
  private func handleKey(_ event: NSEvent) -> Bool {
519
+ let targets = model.targets
421
520
  guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
422
521
  let chars = event.charactersIgnoringModifiers, let n = Int(chars),
423
- n <= model.sessions.count else { return false }
424
- 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) }
425
524
  return true
426
525
  }
427
526
 
@@ -437,10 +536,13 @@ final class PanelController: NSObject, NSWindowDelegate {
437
536
  model.source = source
438
537
  model.message = ""
439
538
  model.newSelected = false
539
+ model.selected = []
540
+ model.touched = false
440
541
  model.phase = .loading
441
542
  placeOnActiveScreen()
442
543
  panel.makeKeyAndOrderFront(nil)
443
544
  loadSessions()
545
+ loadKitty()
444
546
  loadModel()
445
547
  }
446
548
 
@@ -469,37 +571,130 @@ final class PanelController: NSObject, NSWindowDelegate {
469
571
  }
470
572
  }
471
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
+
472
596
  /// Kept from the last look while it runs; config rarely changes.
473
597
  private func loadModel() {
474
598
  runner.defaultModel { [weak self] model in self?.model.newModel = model }
475
599
  }
476
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.
477
605
  func send(_ kind: SendKind) {
478
606
  let ids = model.selectedIds
607
+ let windows = model.selectedKitty
479
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
+ }
480
619
  if kind == .note, !model.images.isEmpty {
481
620
  NSSound.beep()
482
621
  model.message = "Notes are text only: ⏎ sends the image as a message"
483
622
  return
484
623
  }
485
624
  // A note into a session that doesn't exist yet makes no sense.
486
- 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 }
487
626
  model.phase = .sending
488
627
  model.message = ""
489
628
  if !new {
490
- 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
+ }
491
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
+ }
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) {
492
687
  let prompt = model.trimmedPrompt
493
688
  let command: String
494
689
  var args: [String]
495
690
  let stdin: Data?
496
691
  switch kind {
497
- case .message:
692
+ case .message, .paste:
498
693
  // stdin is the quoted context; without -m, chi takes it as the message.
499
694
  command = "chi send"
500
695
  args = new ? ["send", "--new", "--dir", model.newSessionDir] : ["send"]
501
696
  if !prompt.isEmpty { args += ["-m", prompt] }
502
- args += model.images.flatMap { ["--image", $0.url.path] }
697
+ args += images.flatMap { ["--image", $0.url.path] }
503
698
  stdin = model.hasContext ? Data(model.text.utf8) : nil
504
699
  case .note:
505
700
  command = "chi note"
@@ -510,42 +705,25 @@ final class PanelController: NSObject, NSWindowDelegate {
510
705
  stdin = Data(parts.joined(separator: "\n\n").utf8)
511
706
  }
512
707
  if !new { args += ids }
513
- let images = kind == .message ? model.images : []
514
- inFlight.formUnion(images.map(\.id))
515
708
  // Reading, converting and a new worker's start take longer than text.
516
709
  let timeout = images.isEmpty ? runner.timeout : 30
517
- runner.run(args, stdin: stdin, timeout: timeout) { [weak self] result in
518
- guard let self else { return }
519
- self.inFlight.subtract(images.map(\.id))
520
- // Sent: the temp files go (and the thumbnails). Else they stay for
521
- // another try, unless the panel was closed or reopened meanwhile.
522
- var sent = false
523
- if case .success(let r) = result { sent = r.status == 0 && !r.timedOut }
524
- if sent { self.model.images.removeAll { images.contains($0) } }
525
- ImageIntake.delete(images.filter { !self.model.images.contains($0) })
710
+ runner.run(args, stdin: stdin, timeout: timeout) { result in
526
711
  switch result {
527
712
  case .failure(let error):
528
- self.model.phase = .failed
529
- self.model.message = error.description
713
+ completion(SendOutcome(ok: false, message: error.description))
530
714
  case .success(let r):
531
715
  let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
532
716
  let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
533
717
  if r.timedOut {
534
- self.model.phase = .failed
535
718
  let what = kind == .note ? "the note may be partly delivered" : "the message may not have been sent"
536
- 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)"))
537
720
  } else if r.status == 0 {
538
- self.model.phase = .sent
539
- // --new prints "<full id> started".
540
- self.model.message = out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out
541
- // 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.
542
723
  let linger = out.contains("waits for") || out.contains("started") ? 3.0 : 1.0
543
- DispatchQueue.main.asyncAfter(deadline: .now() + linger) { [weak self] in
544
- if self?.model.phase == .sent { self?.panel.close() }
545
- }
724
+ completion(SendOutcome(ok: true, message: out.isEmpty ? "Sent." : new ? "started \(out.prefix(8))…" : out, linger: linger))
546
725
  } else {
547
- self.model.phase = .failed
548
- 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")))
549
727
  }
550
728
  }
551
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
+ }
@@ -6,9 +6,11 @@ require "fileutils"
6
6
  require "json"
7
7
  require "open3"
8
8
  require "rbconfig"
9
+ require_relative "../atomic_file"
9
10
  require_relative "../version"
10
11
  require_relative "../self_report"
11
12
  require_relative "../installed_gem"
13
+ require_relative "../config"
12
14
 
13
15
  module Samagotchi
14
16
  module Desktop
@@ -49,11 +51,21 @@ module Samagotchi
49
51
  end
50
52
  end
51
53
 
54
+ # The kitty: settings the helper lists agent windows with (baked into
55
+ # the launch file), or nil when kitty.listen_on is unset.
56
+ def self.kitty_settings(get = Config.method(:get))
57
+ listen_on = get.call("kitty.listen_on").to_s.strip
58
+ return nil if listen_on.empty?
59
+
60
+ agents = get.call("kitty.agents").to_s.split("|").map(&:strip).reject(&:empty?)
61
+ { "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
62
+ end
63
+
52
64
  attr_reader :app_dir, :support_dir
53
65
 
54
66
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
55
67
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
56
- arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil)
68
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil)
57
69
  home = env["HOME"] || Dir.home
58
70
  @app_dir = app_dir || File.join(home, "Applications")
59
71
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -66,6 +78,7 @@ module Samagotchi
66
78
  @arch = arch
67
79
  @sources_dir = sources_dir
68
80
  @register = register
81
+ @kitty = kitty
69
82
  end
70
83
 
71
84
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -78,11 +91,14 @@ module Samagotchi
78
91
  # What the helper runs: absolute ruby + chi (no shims, which need the
79
92
  # shell's PATH), and the allowlisted env. chi is an installed gem's
80
93
  # RubyGems wrapper (it activates the gem and outlives upgrades and
81
- # `gem cleanup`), else this checkout's bin/chi.
94
+ # `gem cleanup`), else this checkout's bin/chi. The kitty section only
95
+ # when kitty.listen_on is set.
82
96
  def launch_config
83
97
  env = { "LANG" => LANG }
84
98
  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 }
99
+ config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
100
+ config["kitty"] = @kitty if @kitty
101
+ config
86
102
  end
87
103
 
88
104
  # A digest of what a build compiles: the Swift sources and the
@@ -104,15 +120,15 @@ module Samagotchi
104
120
  launch["sources_sha"] != sources_sha || !launch_ok?(launch)
105
121
  end
106
122
 
107
- # The launch file names another chi version or path than this one:
108
- # refresh_launch_file fixes that without a rebuild.
123
+ # The launch file names another chi version or path than this one, or
124
+ # other kitty settings: refresh_launch_file fixes that without a rebuild.
109
125
  def launch_outdated?
110
126
  launch = read_launch
111
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"]
127
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
112
128
  end
113
129
 
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.
130
+ # Rewrite the launch file for this chi and the kitty settings, keeping
131
+ # the env it was installed with. The app reads it at each send: no restart.
116
132
  def refresh_launch_file
117
133
  config = launch_config
118
134
  env = read_launch["env"]
@@ -283,9 +299,7 @@ module Samagotchi
283
299
 
284
300
  def write_launch_file(config = launch_config)
285
301
  FileUtils.mkdir_p(@support_dir)
286
- tmp = "#{launch_path}.tmp"
287
- File.write(tmp, JSON.pretty_generate(config) + "\n")
288
- File.rename(tmp, launch_path)
302
+ AtomicFile.write(launch_path, JSON.pretty_generate(config) + "\n")
289
303
  end
290
304
 
291
305
  def register_app
@@ -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