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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- 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
|
|
5
|
-
// (
|
|
6
|
-
// send --new` on the new row, with `--image` per image;
|
|
7
|
-
// into
|
|
8
|
-
// on the Everyone row, Broadcast.swift), ⌥⏎ a paste
|
|
9
|
-
//
|
|
10
|
-
// chooser for a new session in place of
|
|
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
|
-
///
|
|
35
|
-
|
|
36
|
-
@Published var
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
///
|
|
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
|
|
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 || !
|
|
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
|
|
89
|
-
/// recent sessions.
|
|
90
|
-
var targets: [Target] { live.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
|
|
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) +
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 .
|
|
284
|
+
case .agent(let target): return target.key
|
|
262
285
|
}
|
|
263
286
|
}
|
|
264
287
|
}
|
|
265
288
|
|
|
266
|
-
/// ⏎ message, ⌘⏎ note, ⌥⏎ paste (
|
|
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,
|
|
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 &&
|
|
355
|
-
if index == live +
|
|
356
|
-
|
|
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 .
|
|
384
|
+
case .agent(let agent): agentRow(agent, index: index)
|
|
362
385
|
}
|
|
363
386
|
}
|
|
364
|
-
if model.recent.isEmpty
|
|
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
|
-
///
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
.
|
|
389
|
-
.
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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(
|
|
433
|
+
Text(target.title.isEmpty ? target.label : "\(target.agent) · \(target.title)")
|
|
402
434
|
.font(.system(size: 13, weight: .medium)).lineLimit(1)
|
|
403
|
-
Text(
|
|
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("\(
|
|
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
|
|
614
|
-
//
|
|
615
|
-
if !model.
|
|
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.
|
|
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
|
|
737
|
-
private var
|
|
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
|
-
|
|
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
|
|
895
|
-
let
|
|
896
|
-
|
|
897
|
-
model.
|
|
898
|
-
model.
|
|
899
|
-
model.
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
943
|
-
/// finished. Images the helper wrote are copied for
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
994
|
-
if case .failure(let error) = result {
|
|
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
|
|
1002
|
-
let failed =
|
|
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
|
|
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
|
+
}
|