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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- 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
|
|
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
|
|
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 || !
|
|
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
|
|
68
|
-
/// with none live, the new row.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
281
|
-
// has the focus; the buttons are for the mouse.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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 <=
|
|
424
|
-
if n == 0 { model.toggleNew() } else { model.toggle(
|
|
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
|
-
|
|
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 +=
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|