samagotchi 0.66.0 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -1,13 +1,14 @@
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
3
  // below as quoted context, then a "New session" row, an "Everyone it
4
- // concerns" row and the targets: live sessions, agents in kitty windows
5
- // (Kitty.swift), recent sessions. ⏎ sends a message (`chi send`, or `chi
6
- // send --new` on the new row, with `--image` per image; pasted with Enter
7
- // into a kitty window), ⌘⏎ a note (`chi note`, text only; `chi broadcast`
8
- // on the Everyone row, Broadcast.swift), ⌥⏎ a paste without Enter (kitty
9
- // windows only), ⇧⏎ a newline. ⌘M (or a click on the new row's model) opens the model
10
- // chooser for a new session in place of the targets (Models.swift).
4
+ // concerns" row and the targets: live sessions, agents in terminals
5
+ // (AgentTerminal.swift: kitty windows, tmux panes), recent sessions. ⏎ sends a message
6
+ // (`chi send`, or `chi send --new` on the new row, with `--image` per image;
7
+ // pasted with Enter into an agent's terminal), ⌘⏎ a note (`chi note`, text
8
+ // only; `chi broadcast` on the Everyone row, Broadcast.swift), ⌥⏎ a paste
9
+ // without Enter (agents in terminals only), ⇧⏎ a newline. ⌘M (or a click on
10
+ // the new row's model) opens the model chooser for a new session in place of
11
+ // the targets (Models.swift).
11
12
  import AppKit
12
13
  import SwiftUI
13
14
 
@@ -31,14 +32,18 @@ final class PanelModel: ObservableObject {
31
32
  /// @State and don't ship it ("plugin for module 'SwiftUIMacros' not found").
32
33
  @Published var hoveredImage: UUID?
33
34
  @Published var sessions: [LiveSession] = []
34
- /// Agent windows in kitty; kittyOn when launch.json has a kitty section.
35
- @Published var kittyWindows: [KittyWindow] = []
36
- @Published var kittyOn = false
37
- /// Set once the kitty listing answered: "no agents in kitty" or an error.
38
- @Published var kittyNote: String?
39
- /// Chi session ids and kitty window keys (never alike: UUIDs vs paths).
35
+ /// Agents in terminals, and the terminals launch.json switches on
36
+ /// (none: no agent rows and no note).
37
+ @Published var agentTargets: [AgentTarget] = []
38
+ @Published var agentTerminalNames: [String] = []
39
+ /// Set once the listing answered with no rows: "no agents in kitty"
40
+ /// (the terminals that answered).
41
+ @Published var agentNote: String?
42
+ /// Why a terminal can't be asked, with or without rows; " · " between two.
43
+ @Published var agentErrors: String?
44
+ /// Chi session ids and agent keys (never alike: UUIDs vs paths).
40
45
  @Published var selected: Set<String> = []
41
- /// The user changed the selection: a late kitty listing keeps it.
46
+ /// The user changed the selection: a late agent listing keeps it.
42
47
  var touched = false
43
48
  /// The "New session" row; never together with sessions (`--new` takes no ids).
44
49
  @Published var newSelected = false
@@ -79,15 +84,15 @@ final class PanelModel: ObservableObject {
79
84
  /// A note's text (stdin of `chi note`): the message line, then the context.
80
85
  var noteText: String { [trimmedPrompt, hasContext ? text : ""].filter { !$0.isEmpty }.joined(separator: "\n\n") }
81
86
  var sendEnabled: Bool {
82
- canSend && (newSelected || everyoneSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty)
87
+ canSend && (newSelected || everyoneSelected || !selectedIds.isEmpty || !selectedAgents.isEmpty)
83
88
  && (!trimmedPrompt.isEmpty || hasContext)
84
89
  }
85
90
  var live: [LiveSession] { sessions.filter { !$0.recent } }
86
91
 
87
92
  /// One list for rows, ⌘ numbers and selection: live sessions (oldest
88
- /// started first: their numbers stay put), then kitty windows, then
89
- /// recent sessions.
90
- var targets: [Target] { live.map(Target.chi) + kittyWindows.map(Target.kitty) + recent.map(Target.chi) }
93
+ /// started first: their numbers stay put), then agents in terminals,
94
+ /// then recent sessions.
95
+ var targets: [Target] { live.map(Target.chi) + agentTargets.map(Target.agent) + recent.map(Target.chi) }
91
96
 
92
97
  /// Up to maxImages; the rest is dropped (and its temp files deleted).
93
98
  func add(_ new: [PanelImage]) {
@@ -190,11 +195,11 @@ final class PanelModel: ObservableObject {
190
195
  }
191
196
 
192
197
  /// The last choice, where still live or listed; else the only live
193
- /// session; else, with none live and no kitty window chosen, the new row.
198
+ /// session; else, with none live and no agent chosen, the new row.
194
199
  /// A recent (stopped) one is never preselected: a message would wake it.
195
200
  func preselect() {
196
201
  let last = Set(UserDefaults.standard.stringArray(forKey: Self.lastChoiceKey) ?? [])
197
- selected = last.intersection(Set(live.map(\.id) + kittyWindows.map(\.key)))
202
+ selected = last.intersection(Set(live.map(\.id) + agentTargets.map(\.key)))
198
203
  if selected.isEmpty, live.count == 1 { selected = [live[0].id] }
199
204
  newSelected = live.isEmpty && selected.isEmpty
200
205
  }
@@ -239,31 +244,49 @@ final class PanelModel: ObservableObject {
239
244
  sessions.filter { selected.contains($0.id) && $0.valid }.map(\.id)
240
245
  }
241
246
 
242
- var selectedKitty: [KittyWindow] { kittyWindows.filter { selected.contains($0.key) } }
247
+ /// A listing's answer: the rows, "no agents in kitty" when there are
248
+ /// none, and why a terminal can't be asked.
249
+ func showAgents(_ targets: [AgentTarget], errors: [(name: String, error: AgentTerminalError)]) {
250
+ agentTargets = targets
251
+ let answered = agentTerminalNames.filter { name in !errors.contains { $0.name == name } }
252
+ agentNote = targets.isEmpty && !answered.isEmpty ? "no agents in \(answered.joined(separator: " or "))" : nil
253
+ agentErrors = errors.isEmpty ? nil : errors.map(\.error.description).joined(separator: " · ")
254
+ }
255
+
256
+ /// ⌥⏎'s refusal: names the terminals switched on.
257
+ var pasteOnlyRefusal: String {
258
+ switch Set(agentTerminalNames) {
259
+ case ["tmux"]: return "Paste only is for tmux panes"
260
+ case ["kitty", "tmux"]: return "Paste only is for agents in kitty or tmux"
261
+ default: return "Paste only is for kitty windows"
262
+ }
263
+ }
264
+
265
+ var selectedAgents: [AgentTarget] { agentTargets.filter { selected.contains($0.key) } }
243
266
 
244
267
  /// Who the message goes to, for the placeholder: chi, or the one agent
245
- /// when only kitty windows running it are chosen.
268
+ /// when only terminals running it are chosen.
246
269
  var addressee: String {
247
270
  if everyoneSelected { return "everyone it concerns" }
248
- let agents = Set(selectedKitty.map(\.agent))
271
+ let agents = Set(selectedAgents.map(\.agent))
249
272
  return agents.count == 1 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
250
273
  }
251
274
  }
252
275
 
253
- /// A row of the panel: a chi session or an agent in a kitty window.
276
+ /// A row of the panel: a chi session or an agent in a terminal.
254
277
  enum Target: Identifiable {
255
278
  case chi(LiveSession)
256
- case kitty(KittyWindow)
279
+ case agent(AgentTarget)
257
280
 
258
281
  var id: String {
259
282
  switch self {
260
283
  case .chi(let session): return session.id
261
- case .kitty(let window): return window.key
284
+ case .agent(let target): return target.key
262
285
  }
263
286
  }
264
287
  }
265
288
 
266
- /// ⏎ message, ⌘⏎ note, ⌥⏎ paste (kitty windows only: no Enter).
289
+ /// ⏎ message, ⌘⏎ note, ⌥⏎ paste (agents in terminals only: no Enter).
267
290
  enum SendKind { case message, note, paste }
268
291
 
269
292
  struct PanelView: View {
@@ -349,19 +372,19 @@ struct PanelView: View {
349
372
  default:
350
373
  newRow
351
374
  everyoneRow
352
- let live = model.live.count, kitty = model.kittyWindows.count
375
+ let live = model.live.count, agents = model.agentTargets.count
353
376
  ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
354
- if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
355
- if index == live + kitty && !model.recent.isEmpty {
356
- if kitty == 0 { kittyNoteLine }
377
+ if index == live && agents > 0 { groupHeader(agentHeader, first: index == 0) }
378
+ if index == live + agents && !model.recent.isEmpty {
379
+ agentNoteLines
357
380
  groupHeader("recent", first: index == 0)
358
381
  }
359
382
  switch target {
360
383
  case .chi(let session): row(session, index: index)
361
- case .kitty(let window): kittyRow(window, index: index)
384
+ case .agent(let agent): agentRow(agent, index: index)
362
385
  }
363
386
  }
364
- if model.recent.isEmpty && kitty == 0 { kittyNoteLine }
387
+ if model.recent.isEmpty { agentNoteLines }
365
388
  }
366
389
  }
367
390
  }
@@ -377,34 +400,47 @@ struct PanelView: View {
377
400
  .padding(.horizontal, 8).padding(.top, first ? 2 : 6)
378
401
  }
379
402
 
380
- /// "no agents in kitty" (or why kitty can't be asked) where the kitty
381
- /// rows would be; nothing without a kitty section.
382
- @ViewBuilder var kittyNoteLine: some View {
383
- if model.kittyOn, let note = model.kittyNote {
384
- HStack(spacing: 6) {
385
- Image(systemName: "terminal").font(.system(size: 11))
386
- Text(note).font(.system(size: 11)).lineLimit(1)
387
- }
388
- .foregroundColor(.secondary.opacity(0.7))
389
- .padding(.horizontal, 8).padding(.vertical, 4)
403
+ /// The agent rows' divider: the terminal's name ("kitty").
404
+ var agentHeader: String { model.agentTerminalNames.count == 1 ? model.agentTerminalNames[0] : "agents" }
405
+
406
+ /// After the agent rows: "no agents in kitty" where they would be, and
407
+ /// why a terminal can't be asked (beside another's rows too); nothing
408
+ /// without a terminal section.
409
+ @ViewBuilder var agentNoteLines: some View {
410
+ if !model.agentTerminalNames.isEmpty {
411
+ if model.agentTargets.isEmpty, let note = model.agentNote { agentNoteLine(note) }
412
+ if let errors = model.agentErrors { agentNoteLine(errors) }
390
413
  }
391
414
  }
392
415
 
393
- func kittyRow(_ window: KittyWindow, index: Int) -> some View {
394
- let on = model.selected.contains(window.key)
395
- return Button(action: { model.toggle(window.key) }) {
416
+ func agentNoteLine(_ text: String) -> some View {
417
+ HStack(spacing: 6) {
418
+ Image(systemName: "terminal").font(.system(size: 11))
419
+ Text(text).font(.system(size: 11)).lineLimit(1)
420
+ }
421
+ .foregroundColor(.secondary.opacity(0.7))
422
+ .padding(.horizontal, 8).padding(.vertical, 4)
423
+ }
424
+
425
+ func agentRow(_ target: AgentTarget, index: Int) -> some View {
426
+ let on = model.selected.contains(target.key)
427
+ return Button(action: { model.toggle(target.key) }) {
396
428
  HStack(spacing: 10) {
397
429
  Image(systemName: on ? "checkmark.circle.fill" : "circle")
398
430
  .font(.system(size: 16))
399
431
  .foregroundColor(on ? .accentColor : .secondary)
400
432
  VStack(alignment: .leading, spacing: 1) {
401
- Text(window.title.isEmpty ? window.label : "\(window.agent) · \(window.title)")
433
+ Text(target.title.isEmpty ? target.label : "\(target.agent) · \(target.title)")
402
434
  .font(.system(size: 13, weight: .medium)).lineLimit(1)
403
- Text(window.shortCwd).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
435
+ Text(target.shortCwd).font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
404
436
  }
405
437
  Spacer()
438
+ // Which terminal, when two are on.
439
+ if model.agentTerminalNames.count > 1 {
440
+ Text(target.terminal).font(.system(size: 10)).foregroundColor(.secondary)
441
+ }
406
442
  Image(systemName: "terminal").font(.system(size: 12)).foregroundColor(.secondary)
407
- .help("\(window.agent) in a kitty window: ⏎ pastes and presses Enter, ⌥⏎ only pastes")
443
+ .help("\(target.agent) in \(target.whereabouts): ⏎ pastes and presses Enter, ⌥⏎ only pastes")
408
444
  Text("⌘\(index + 1)").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
409
445
  }
410
446
  .padding(.horizontal, 8).padding(.vertical, 6)
@@ -610,9 +646,9 @@ struct PanelView: View {
610
646
  .frame(maxWidth: .infinity, alignment: .leading)
611
647
 
612
648
  // ⏎, ⌘⏎ and ⌥⏎ are handled by the panel (KeyPanel.sendEvent),
613
- // whatever has the focus; the buttons are for the mouse. Only kitty
614
- // windows chosen: Paste instead of Note.
615
- if !model.selectedKitty.isEmpty && model.selectedIds.isEmpty && !model.newSelected {
649
+ // whatever has the focus; the buttons are for the mouse. Only agents
650
+ // in terminals chosen: Paste instead of Note.
651
+ if !model.selectedAgents.isEmpty && model.selectedIds.isEmpty && !model.newSelected {
616
652
  Button("Paste ⌥⏎") { send(.paste) }
617
653
  .disabled(!model.sendEnabled)
618
654
  .help("paste it into the agent's input without Enter: add more, then press Enter there")
@@ -622,7 +658,7 @@ struct PanelView: View {
622
658
  .help("share it as a note with every session it concerns (chi broadcast); starts no turn")
623
659
  } else {
624
660
  Button("Note ⌘⏎") { send(.note) }
625
- .disabled(!model.sendEnabled || !model.selectedKitty.isEmpty || model.newSelected)
661
+ .disabled(!model.sendEnabled || !model.selectedAgents.isEmpty || model.newSelected)
626
662
  .help(model.newSelected ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
627
663
  : "add it as background the model sees on its next turn; starts no turn")
628
664
  }
@@ -733,8 +769,8 @@ final class KeyPanel: NSPanel {
733
769
  final class PanelController: NSObject, NSWindowDelegate {
734
770
  let model = PanelModel()
735
771
  let runner = ChiRunner()
736
- /// This open's kitty runner (launch.json read at each open).
737
- private var kitty: KittyRunner?
772
+ /// This open's agent terminals (launch.json read at each open).
773
+ private var agents: AgentTerminals?
738
774
  private var panel: KeyPanel!
739
775
  private var returnTo: NSRunningApplication?
740
776
  /// Images a running `chi send` still reads: not deleted on close.
@@ -858,7 +894,7 @@ final class PanelController: NSObject, NSWindowDelegate {
858
894
  placeOnActiveScreen()
859
895
  panel.makeKeyAndOrderFront(nil)
860
896
  loadSessions()
861
- loadKitty()
897
+ loadAgents()
862
898
  loadModel()
863
899
  loadModels()
864
900
  }
@@ -891,22 +927,17 @@ final class PanelController: NSObject, NSWindowDelegate {
891
927
 
892
928
  /// Beside the chi listing, never blocking or failing it: a listing that
893
929
  /// answers after the sessions preselects again unless the user chose.
894
- private func loadKitty() {
895
- let runner = KittyRunner(launchPath: self.runner.launchPath)
896
- kitty = runner
897
- model.kittyOn = runner.settings != nil
898
- model.kittyWindows = []
899
- model.kittyNote = nil
900
- guard model.kittyOn else { return }
901
- runner.windows { [weak self] result in
902
- guard let self, self.kitty === runner else { return }
903
- switch result {
904
- case .success(let windows):
905
- self.model.kittyWindows = windows
906
- self.model.kittyNote = windows.isEmpty ? "no agents in kitty" : nil
907
- case .failure(let error):
908
- self.model.kittyNote = error.description
909
- }
930
+ private func loadAgents() {
931
+ let terminals = AgentTerminals(launchPath: runner.launchPath)
932
+ agents = terminals
933
+ model.agentTerminalNames = terminals.names
934
+ model.agentTargets = []
935
+ model.agentNote = nil
936
+ model.agentErrors = nil
937
+ guard !terminals.isEmpty else { return }
938
+ terminals.list { [weak self] targets, errors in
939
+ guard let self, self.agents === terminals else { return }
940
+ self.model.showAgents(targets, errors: errors)
910
941
  if self.model.phase != .loading && !self.model.touched { self.model.preselect() }
911
942
  }
912
943
  }
@@ -939,13 +970,13 @@ final class PanelController: NSObject, NSWindowDelegate {
939
970
  }
940
971
 
941
972
  /// Fans out: one `chi send` / `chi note` for the chi sessions (or the new
942
- /// row) and one paste per kitty window, with one combined line when all
943
- /// finished. Images the helper wrote are copied for kitty first (chi's
973
+ /// row) and one paste per agent, with one combined line when all
974
+ /// finished. Images the helper wrote are copied for the agents first (chi's
944
975
  /// send deletes them) and deleted only after every job.
945
976
  func send(_ kind: SendKind) {
946
977
  if model.everyoneSelected { broadcast(kind); return }
947
978
  let ids = model.selectedIds
948
- let windows = model.selectedKitty
979
+ let windows = model.selectedAgents
949
980
  let new = model.newSelected
950
981
  if kind == .note, !windows.isEmpty {
951
982
  NSSound.beep()
@@ -954,7 +985,7 @@ final class PanelController: NSObject, NSWindowDelegate {
954
985
  }
955
986
  if kind == .paste, windows.isEmpty || !ids.isEmpty || new {
956
987
  NSSound.beep()
957
- model.message = "Paste only is for kitty windows"
988
+ model.message = model.pasteOnlyRefusal
958
989
  return
959
990
  }
960
991
  // A note into a session that doesn't exist yet makes no sense.
@@ -977,29 +1008,34 @@ final class PanelController: NSObject, NSWindowDelegate {
977
1008
  }
978
1009
  let images = kind == .note ? [] : model.images
979
1010
  inFlight.formUnion(images.map(\.id))
980
- let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
1011
+ let agentText = windows.isEmpty ? "" : AgentText.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
981
1012
  imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
982
1013
 
983
1014
  let open = opens
984
1015
  let group = DispatchGroup()
985
1016
  var chiOutcome: SendOutcome?
986
- var kittyOutcomes: [(KittyWindow, KittyError?)] = []
1017
+ var agentOutcomes: [(AgentTarget, AgentTerminalError?)] = []
1018
+ let terminals = agents ?? AgentTerminals(launchPath: runner.launchPath)
987
1019
  if new || !ids.isEmpty {
988
1020
  group.enter()
989
1021
  sendToChi(kind, ids: ids, new: new, images: images) { outcome in chiOutcome = outcome; group.leave() }
990
1022
  }
991
1023
  for window in windows {
1024
+ guard let terminal = terminals.terminal(for: window.key) else {
1025
+ agentOutcomes.append((window, .failed("not an agent: \(window.key)")))
1026
+ continue
1027
+ }
992
1028
  group.enter()
993
- (kitty ?? KittyRunner(launchPath: runner.launchPath)).send(key: window.key, text: kittyText, pasteOnly: kind == .paste) { result in
994
- if case .failure(let error) = result { kittyOutcomes.append((window, error)) } else { kittyOutcomes.append((window, nil)) }
1029
+ terminal.send(key: window.key, text: agentText, pasteOnly: kind == .paste) { result in
1030
+ if case .failure(let error) = result { agentOutcomes.append((window, error)) } else { agentOutcomes.append((window, nil)) }
995
1031
  group.leave()
996
1032
  }
997
1033
  }
998
1034
  group.notify(queue: .main) { [weak self] in
999
1035
  guard let self else { return }
1000
1036
  self.inFlight.subtract(images.map(\.id))
1001
- let pasted = windows.filter { w in kittyOutcomes.contains { $0.0 == w && $0.1 == nil } }
1002
- let failed = kittyOutcomes.compactMap { outcome in outcome.1.map { "\(outcome.0.label): \($0.description)" } }
1037
+ let pasted = windows.filter { w in agentOutcomes.contains { $0.0 == w && $0.1 == nil } }
1038
+ let failed = agentOutcomes.compactMap { outcome in outcome.1.map { "\(outcome.0.label): \($0.description)" } }
1003
1039
  let ok = (chiOutcome?.ok ?? true) && failed.isEmpty
1004
1040
  // Sent: the temp files go (and the thumbnails). Else they stay for
1005
1041
  // another try, unless the panel was closed or reopened meanwhile.
@@ -1,6 +1,6 @@
1
1
  // Runs one outside program without a shell: stdin written from a
2
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.
3
+ // big input or output), a time limit. ChiRunner and the agent terminals use it.
4
4
  import Foundation
5
5
 
6
6
  struct ChiResult {
@@ -0,0 +1,239 @@
1
+ // Agent CLIs (claude, codex, …) in tmux panes as panel targets, through the
2
+ // tmux binary (`tmux -u -S <socket> …`): every server whose socket is in the
3
+ // launch file's socket dir (tmux's own `-L` place). The settings come from
4
+ // launch.json's "tmux" section (chi desktop bakes it from config.yml's
5
+ // tmux:, binary and socket dir resolved then); without it there are no tmux
6
+ // targets. One of the AgentTerminals (AgentTerminal.swift).
7
+ import Foundation
8
+
9
+ struct TmuxSettings: Decodable, Equatable {
10
+ let binary: String
11
+ let socketDir: String
12
+ let agents: [String]
13
+
14
+ enum CodingKeys: String, CodingKey {
15
+ case socketDir = "socket_dir"
16
+ case binary, agents
17
+ }
18
+ }
19
+
20
+ /// The pure parts: list-panes and ps rows into targets, keys.
21
+ enum Tmux {
22
+ /// One pane of `list-panes -a -F listFormat`.
23
+ struct Pane: Equatable {
24
+ let id: String
25
+ let tty: String
26
+ let command: String
27
+ let path: String
28
+ let inMode: Bool
29
+ /// "main:1.0": session, window index, pane index.
30
+ let place: String
31
+ let host: String
32
+ let serverPid: String
33
+ let title: String
34
+ }
35
+
36
+ /// Tab-separated, the title last (a tab in a title can't shift the
37
+ /// fields). `#{pid}` is the server's: pane ids restart with a server.
38
+ static let listFormat = ["#{pane_id}", "#{pane_tty}", "#{pane_current_command}", "#{pane_current_path}",
39
+ "#{pane_dead}", "#{pane_in_mode}", "#{session_name}:#{window_index}.#{pane_index}",
40
+ "#{host}", "#{pid}", "#{pane_title}"].joined(separator: "\t")
41
+
42
+ /// The live panes of one list-panes; dead ones (remain-on-exit) dropped.
43
+ static func panes(listPanes: String) -> [Pane] {
44
+ listPanes.split(separator: "\n").compactMap { line in
45
+ let f = line.split(separator: "\t", maxSplits: 9, omittingEmptySubsequences: false).map(String.init)
46
+ guard f.count == 10, f[0].hasPrefix("%"), f[4] != "1" else { return nil }
47
+ return Pane(id: f[0], tty: f[1], command: f[2], path: f[3], inMode: f[5] == "1", place: f[6], host: f[7],
48
+ serverPid: f[8], title: f[9])
49
+ }
50
+ }
51
+
52
+ /// The foreground programs' names per tty ("ttys003", no /dev/), from
53
+ /// `ps -A -o pgid=,tpgid=,tty=,args=`: a process whose group is its
54
+ /// terminal's foreground group; the basenames of its first two words, as
55
+ /// kitty's cmdline[0] and [1] (the native claude sets its title to
56
+ /// "claude"; codex runs as `node …/codex`). Only +ttys+ are kept.
57
+ static func foreground(ps: String, ttys: Set<String>) -> [String: [String]] {
58
+ var found: [String: [String]] = [:]
59
+ for line in ps.split(separator: "\n") {
60
+ let f = line.split(separator: " ", maxSplits: 3, omittingEmptySubsequences: true).map(String.init)
61
+ guard f.count == 4, ttys.contains(f[2]), f[0] == f[1] else { continue }
62
+ let names = f[3].split(separator: " ").prefix(2).map { AgentText.programName(String($0)) }.filter { !$0.isEmpty }
63
+ found[f[2], default: []] += names
64
+ }
65
+ return found
66
+ }
67
+
68
+ /// "/dev/ttys003" → "ttys003", ps's spelling.
69
+ static func ttyName(_ tty: String) -> String { tty.hasPrefix("/dev/") ? String(tty.dropFirst(5)) : tty }
70
+
71
+ /// The panes whose foreground program is an agent: matched on ps's names
72
+ /// when ps has the tty, else on pane_current_command (not both: with `*`
73
+ /// the last name wins, and the native claude's current command is its
74
+ /// version, "2.1.296"). A title equal to the host name is tmux's default:
75
+ /// none.
76
+ static func targets(panes: [Pane], foreground: [String: [String]], agents: [String], socket: String) -> [AgentTarget] {
77
+ panes.compactMap { pane in
78
+ let fromPs = foreground[ttyName(pane.tty)] ?? []
79
+ let names = fromPs.isEmpty ? [pane.command].filter { !$0.isEmpty } : fromPs
80
+ guard let agent = AgentText.agent(names: names, agents: agents) else { return nil }
81
+ return AgentTarget(terminal: "tmux", key: "tmux:\(socket)#\(pane.serverPid)#\(pane.id)", agent: agent,
82
+ title: pane.title == pane.host ? "" : AgentText.cleanTitle(pane.title), cwd: pane.path,
83
+ place: pane.place, names: names)
84
+ }
85
+ }
86
+
87
+ /// A `tmux:<socket>#<server pid>#<pane id>` key back into its parts.
88
+ static func parse(key: String) -> (socket: String, serverPid: String, paneId: String)? {
89
+ guard key.hasPrefix("tmux:") else { return nil }
90
+ let rest = key.dropFirst("tmux:".count)
91
+ guard let paneHash = rest.lastIndex(of: "#") else { return nil }
92
+ let paneId = String(rest[rest.index(after: paneHash)...])
93
+ let head = rest[..<paneHash]
94
+ guard let pidHash = head.lastIndex(of: "#") else { return nil }
95
+ let pid = String(head[head.index(after: pidHash)...])
96
+ let socket = String(head[..<pidHash])
97
+ guard !socket.isEmpty, Int(pid) != nil, paneId.hasPrefix("%"), Int(paneId.dropFirst()) != nil else { return nil }
98
+ return (socket, pid, paneId)
99
+ }
100
+ }
101
+
102
+ final class TmuxRunner: AgentTerminal {
103
+ let name = "tmux"
104
+ let settings: TmuxSettings
105
+ let queue: DispatchQueue
106
+ let env: [String: String]
107
+ let listTimeout: TimeInterval = 2
108
+ let sendTimeout: TimeInterval = 5
109
+
110
+ /// @param queue where completions run (the main queue in the app)
111
+ init(settings: TmuxSettings, queue: DispatchQueue = .main,
112
+ env: [String: String] = ProcessInfo.processInfo.environment) {
113
+ self.settings = settings
114
+ self.queue = queue
115
+ self.env = env
116
+ }
117
+
118
+ /// The helper's env minus tmux's own variables: a helper started from a
119
+ /// pane must not lean on that pane's server.
120
+ private var tmuxEnv: [String: String] { env.filter { $0.key != "TMUX" && $0.key != "TMUX_PANE" } }
121
+
122
+ func owns(key: String) -> Bool { Tmux.parse(key: key) != nil }
123
+
124
+ private var binaryError: AgentTerminalError? {
125
+ let binary = settings.binary
126
+ if binary.isEmpty { return .config("tmux not found: set tmux.binary, then chi update") }
127
+ guard FileManager.default.isExecutableFile(atPath: binary) else {
128
+ return .config("tmux not found at \(binary): set tmux.binary, then chi update")
129
+ }
130
+ return nil
131
+ }
132
+
133
+ /// The servers' sockets: the socket files in socket_dir (none when it is
134
+ /// missing). A stale one (a killed server's) drops out at list-panes.
135
+ func sockets() -> [String] {
136
+ let dir = settings.socketDir
137
+ let names = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? []
138
+ return names.sorted().map { (dir as NSString).appendingPathComponent($0) }
139
+ .filter { (try? FileManager.default.attributesOfItem(atPath: $0)[.type] as? FileAttributeType) == .typeSocket }
140
+ }
141
+
142
+ /// Agent panes of every live server; one ps over all of them names the
143
+ /// foreground programs (if it fails, pane_current_command alone).
144
+ func list(completion: @escaping (Result<[AgentTarget], AgentTerminalError>) -> Void) {
145
+ if let error = binaryError { queue.async { completion(.failure(error)) }; return }
146
+ let sockets = self.sockets()
147
+ guard !sockets.isEmpty else { queue.async { completion(.success([])) }; return }
148
+ let group = DispatchGroup()
149
+ let lock = NSLock()
150
+ var bySocket: [String: [Tmux.Pane]] = [:]
151
+ for socket in sockets {
152
+ group.enter()
153
+ tmux(socket, ["list-panes", "-a", "-F", Tmux.listFormat], timeout: listTimeout, on: .global()) { result in
154
+ if case .success(let r) = result, r.status == 0, !r.timedOut {
155
+ let panes = Tmux.panes(listPanes: r.stdout)
156
+ lock.lock(); bySocket[socket] = panes; lock.unlock()
157
+ }
158
+ group.leave()
159
+ }
160
+ }
161
+ group.notify(queue: queue) {
162
+ let all = sockets.flatMap { socket in (bySocket[socket] ?? []).map { (socket, $0) } }
163
+ guard !all.isEmpty else { completion(.success([])); return }
164
+ let ttys = Set(all.map { Tmux.ttyName($0.1.tty) })
165
+ ProcessRunner.run(executable: "/bin/ps", args: ["-A", "-ww", "-o", "pgid=,tpgid=,tty=,args="], env: self.tmuxEnv,
166
+ timeout: self.listTimeout, queue: self.queue) { result in
167
+ var foreground: [String: [String]] = [:]
168
+ if case .success(let r) = result, r.status == 0, !r.timedOut {
169
+ foreground = Tmux.foreground(ps: r.stdout, ttys: ttys)
170
+ }
171
+ let agents = self.settings.agents
172
+ completion(.success(sockets.flatMap { socket in
173
+ Tmux.targets(panes: bySocket[socket] ?? [], foreground: foreground, agents: agents, socket: socket)
174
+ }))
175
+ }
176
+ }
177
+ }
178
+
179
+ /// Checks the pane (same server, alive), leaves copy mode, pastes +text+
180
+ /// through a named buffer (`paste-buffer -p`: bracketed only when the
181
+ /// program asked for it; `-r`: newlines as given), then presses Enter
182
+ /// unless +pasteOnly+. One tmux call: claude and codex submitted reliably
183
+ /// with the Enter right after the paste's end marker, a large paste too.
184
+ func send(key: String, text: String, pasteOnly: Bool, completion: @escaping (Result<Void, AgentTerminalError>) -> Void) {
185
+ guard let (socket, serverPid, pane) = Tmux.parse(key: key) else {
186
+ queue.async { completion(.failure(.failed("not a tmux pane: \(key)"))) }; return
187
+ }
188
+ if let error = binaryError { queue.async { completion(.failure(error)) }; return }
189
+ // A missing pane: exit 0 and no output (not an error), so the id is
190
+ // checked too.
191
+ let probe = ["display-message", "-p", "-t", pane, "#{pid} #{pane_id} #{pane_dead} #{pane_in_mode} #{pane_mode}"]
192
+ tmux(socket, probe, timeout: listTimeout) { result in
193
+ guard case .success(let r) = result, !r.timedOut else {
194
+ completion(.failure(.from(result, "tmux display-message"))); return
195
+ }
196
+ let f = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ").map(String.init)
197
+ guard r.status == 0, f.count >= 4, f[0] == serverPid, f[1] == pane, f[2] == "0" else {
198
+ completion(.failure(.closed("pane closed"))); return
199
+ }
200
+ let mode = f.count > 4 ? f[4] : ""
201
+ let paste = { self.paste(socket, pane: pane, text: text, pasteOnly: pasteOnly, completion: completion) }
202
+ guard f[3] == "1" else { paste(); return }
203
+ guard mode == "copy-mode" || mode == "view-mode" else {
204
+ completion(.failure(.failed("pane in \(mode.isEmpty ? "a mode" : mode)"))); return
205
+ }
206
+ // Copy mode would eat the Enter: left first (the scroll position goes).
207
+ self.tmux(socket, ["send-keys", "-t", pane, "-X", "cancel"], timeout: self.sendTimeout) { result in
208
+ guard case .success(let r) = result, r.status == 0, !r.timedOut else {
209
+ completion(.failure(.from(result, "tmux send-keys -X cancel"))); return
210
+ }
211
+ paste()
212
+ }
213
+ }
214
+ }
215
+
216
+ private func paste(_ socket: String, pane: String, text: String, pasteOnly: Bool,
217
+ completion: @escaping (Result<Void, AgentTerminalError>) -> Void) {
218
+ let buffer = "chi-helper-\(UUID().uuidString)"
219
+ var args = ["load-buffer", "-b", buffer, "-", ";", "paste-buffer", "-p", "-r", "-d", "-b", buffer, "-t", pane]
220
+ if !pasteOnly { args += [";", "send-keys", "-t", pane, "Enter"] }
221
+ tmux(socket, args, stdin: Data(text.utf8), timeout: sendTimeout) { result in
222
+ guard case .success(let r) = result, r.status == 0, !r.timedOut else {
223
+ // A failed chain stops there and may leave the buffer behind.
224
+ self.tmux(socket, ["delete-buffer", "-b", buffer], timeout: self.sendTimeout) { _ in
225
+ completion(.failure(.from(result, "tmux paste")))
226
+ }
227
+ return
228
+ }
229
+ completion(.success(()))
230
+ }
231
+ }
232
+
233
+ /// `-u`: UTF-8 output even with launchd's bare env (no LANG).
234
+ private func tmux(_ socket: String, _ args: [String], stdin: Data? = nil, timeout: TimeInterval,
235
+ on queue: DispatchQueue? = nil, completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
236
+ ProcessRunner.run(executable: settings.binary, args: ["-u", "-S", socket] + args, env: tmuxEnv,
237
+ stdin: stdin, timeout: timeout, queue: queue ?? self.queue, completion: completion)
238
+ }
239
+ }