samagotchi 0.37.0 → 0.38.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.
@@ -99,7 +99,7 @@ bundles:
99
99
  after: 50 # tool calls in one turn with no answer before the first check
100
100
  every: 50 # then again every N more
101
101
  mode: ask # ask (a card) | nudge (nudge the model by itself) | notify; message:, ignore_tools: [...]
102
- mcp: # tools become mcp_<server>_<tool>; /mcp lists them
102
+ mcp: # the model searches the tools (find_mcp_tools) and calls one (mcp_call, <server>/<tool>); /mcp lists them
103
103
  timeout: 60 # per call, seconds; startup_timeout: 10
104
104
  servers:
105
105
  files: # server name
@@ -107,11 +107,12 @@ bundles:
107
107
  env: {NODE_OPTIONS: "--no-warnings"} # optional, added to chi's env
108
108
  cwd: ~/scratch # optional; default the session's cwd
109
109
  tools: [read_*, list_directory] # optional filter (globs)
110
+ description: files in ~/scratch # optional: the server's line in find_mcp_tools (default: its instructions' first sentence)
110
111
  attach_image_paths: true # default: an answer that is only an image's path (temp dir/cwd) is attached as a picture
111
112
  start: lazy # default: tools from the saved tools/list, the server starts on the first call; eager: with every session
112
113
  ```
113
114
 
114
- A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool; see `docs/guardrails.md`. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
115
+ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cover every MCP tool: an `mcp_call` acts as `mcp_<server>_<tool>`, so `tool: "mcp_github_*"` covers one server's (a `find_mcp_tools` search is never asked about); see `docs/guardrails.md`. A server's optional `description:` is its line in `find_mcp_tools`' description. The mcp bundle saves each server's tool list in `$XDG_STATE_HOME/samagotchi/plugins/mcp/tools-<server>.json` (keyed by a digest of command/env/cwd): a changed server config is picked up by the next session start, which shows "Starting MCP server x (config changed, …)".
115
116
 
116
117
  **Preservation rule**: `ConfigFile.write_default_model!` and `ConfigFile.write_model_alias!` (`/model --default`, `/model --alias`) change one key on its own line of the text (`ConfigTextEdit`: the user's comments and layout stay; a new key goes at the end of its section, a missing section at the end of the file), check that the result parses to the old data plus that key (else they dump the whole file from it), and write through `AtomicFile` (a unique temp file renamed over config.yml, through a symlink, keeping its mode). Never overwrite the file with only scalar keys — that would clobber `hooks:` / `model_aliases:` / `hosts:` / `recap:` / `guardrails:` / `bundles:`.
117
118
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.37.0
3
+ version: 0.38.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
- self_map.md: sha256:37063eb74fdcb4cde748f17193ff23f832723eb38f81aff086f48f410ce7497b
9
- config_modification_protocol.md: sha256:ec0b0c7023dbf03b93879e51cd5cdc38b22d43a2e5984ee11e8542ace077c3be
8
+ self_map.md: sha256:245813bd5bd9a0a86eef095ba78b057dd3bfaa389de36d213971f88b51da7cd6
9
+ config_modification_protocol.md: sha256:648b19b1b9f7ca35fccdd69fee4c9886cb8cfb3748ae86affef7457c12164399
10
10
  memory_guide.md: sha256:f7049304a333be842b761037bab5c8e31e890b8f4ad12f092b14ee8deb053c51
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -45,7 +45,7 @@
45
45
  commands (`anytime:` ones run mid-turn), model tools (they can return images I see), hooks,
46
46
  cards, side answers (`ask_model`),
47
47
  child sessions and services (e.g. MCP server processes). Shipped: `btw` (`/btw` side question),
48
- `mcp` (MCP server tools; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
48
+ `mcp` (MCP server tools, searched with find_mcp_tools and called with mcp_call; a screenshot comes as a picture), `guardrails` (rules), `known-names` (typo guard), `source-links`
49
49
  (turns source refs like JIRA-123 in an answer into links in the web, and a one-line note), `loop-guard`
50
50
  (denies a repeated tool call with the same result, stops the turn after a few, and cuts thinking that
51
51
  repeats itself, asking me again), `check-in` (after N tool
@@ -4,7 +4,8 @@
4
4
  // running after the first launch. `ChiHelper --login on|off|status` (run
5
5
  // directly by chi desktop) manages the login item and exits;
6
6
  // `ChiHelper --kitty list|send` is the kitty targets' seam (Kitty.swift),
7
- // `ChiHelper --model-pick` the model chooser's (Models.swift).
7
+ // `ChiHelper --model-pick` the model chooser's (Models.swift),
8
+ // `ChiHelper --broadcast` the Everyone row's (Broadcast.swift).
8
9
  import AppKit
9
10
  import ServiceManagement
10
11
 
@@ -107,6 +108,9 @@ struct ChiHelperMain {
107
108
  if args.count >= 2, args[1] == "--model-pick" {
108
109
  exit(modelPickCommand(Array(args.dropFirst(2))))
109
110
  }
111
+ if args.count >= 2, args[1] == "--broadcast" {
112
+ exit(broadcastCommand(Array(args.dropFirst(2))))
113
+ }
110
114
  let app = NSApplication.shared
111
115
  let delegate = AppDelegate()
112
116
  app.delegate = delegate
@@ -0,0 +1,97 @@
1
+ // The note interface's "Everyone it concerns" row: ⌘⏎ runs `chi broadcast`
2
+ // with the note on stdin instead of `chi note` for picked sessions; chi
3
+ // finds the sessions it concerns (tags, then a triage model). The panel
4
+ // shows "broadcasting…", then the summary line, and stays open; `chi
5
+ // broadcast log` has the details. `ChiHelper --broadcast` is the build
6
+ // spec's seam.
7
+ import Foundation
8
+
9
+ enum Broadcast {
10
+ /// broadcast.triage_deadline's default (20 s) + 10 s, for a launch file
11
+ /// written before it carried broadcast_timeout.
12
+ static let defaultTimeout: TimeInterval = 30
13
+
14
+ /// The launch file's broadcast_timeout (Desktop::MacOS.broadcast_timeout:
15
+ /// the triage deadline, then the deliveries and chi's start), not
16
+ /// ChiRunner's 10 s.
17
+ static func timeout(_ launch: LaunchConfig?) -> TimeInterval {
18
+ guard let seconds = launch?.broadcastTimeout, seconds > 0 else { return defaultTimeout }
19
+ return seconds
20
+ }
21
+
22
+ struct Outcome {
23
+ let ok: Bool
24
+ let message: String
25
+ }
26
+
27
+ /// What the panel says once chi broadcast ended: the broadcast's id and
28
+ /// its summary line ("b-7f3a1c9e: delivered 4 · skipped 2"); on a failure,
29
+ /// the summary, the failed recipients' lines and stderr (a refusal is
30
+ /// stderr alone).
31
+ static func outcome(_ result: Result<ChiResult, ChiError>, timeout: TimeInterval) -> Outcome {
32
+ let r: ChiResult
33
+ switch result {
34
+ case .failure(let error): return Outcome(ok: false, message: error.description)
35
+ case .success(let done): r = done
36
+ }
37
+ if r.timedOut {
38
+ return Outcome(ok: false, message: "chi broadcast took over \(Int(timeout)) s and was stopped; "
39
+ + "the note may be partly delivered (chi broadcast log)")
40
+ }
41
+ let lines = r.stdout.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
42
+ // "broadcast b-7f3a1c9e "…"": the first line names it.
43
+ let id = lines.first.flatMap { line -> String? in
44
+ let words = line.split(separator: " ")
45
+ return words.count >= 2 && words[0] == "broadcast" && words[1].hasPrefix("b-") ? String(words[1]) : nil
46
+ }
47
+ let summary = lines.last { $0.hasPrefix("delivered ") }.map { line in id.map { "\($0): \(line)" } ?? line }
48
+ if r.status == 0 {
49
+ return Outcome(ok: true, message: [summary ?? "Broadcast.", "details: chi broadcast log"].joined(separator: "\n"))
50
+ }
51
+ let failed = lines.filter { $0.split(separator: " ").dropFirst().first == "failed" }
52
+ let err = r.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
53
+ let parts = ([summary].compactMap { $0 } + failed + [err]).filter { !$0.isEmpty }
54
+ return Outcome(ok: false, message: parts.isEmpty ? "chi broadcast failed (exit \(r.status))" : parts.joined(separator: "\n"))
55
+ }
56
+ }
57
+
58
+ extension ChiRunner {
59
+ /// `chi broadcast` with +note+ on stdin, under Broadcast.timeout.
60
+ /// Calls back on +queue+ with the outcome and the timeout it had.
61
+ func broadcast(_ note: String, queue: DispatchQueue = .main,
62
+ completion: @escaping (Broadcast.Outcome, TimeInterval) -> Void) {
63
+ let timeout = Broadcast.timeout(try? loadLaunch())
64
+ run(["broadcast"], stdin: Data(note.utf8), timeout: timeout, queue: queue) { result in
65
+ completion(Broadcast.outcome(result, timeout: timeout), timeout)
66
+ }
67
+ }
68
+ }
69
+
70
+ /// `ChiHelper --broadcast [--launch PATH]`: the note on stdin, the outcome
71
+ /// as JSON on stdout ({"ok", "message", "timeout"}); exit 1 when not ok.
72
+ func broadcastCommand(_ args: [String]) -> Int32 {
73
+ var args = args
74
+ var launchPath = ChiRunner.launchPath
75
+ while !args.isEmpty {
76
+ let arg = args.removeFirst()
77
+ switch arg {
78
+ case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
79
+ default:
80
+ FileHandle.standardError.write("usage: ChiHelper --broadcast [--launch PATH]\n".data(using: .utf8)!)
81
+ return 2
82
+ }
83
+ }
84
+ let note = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
85
+ let queue = DispatchQueue(label: "broadcast-command")
86
+ let done = DispatchSemaphore(value: 0)
87
+ var status: Int32 = 0
88
+ ChiRunner(launchPath: launchPath).broadcast(note, queue: queue) { outcome, timeout in
89
+ let out: [String: Any] = ["ok": outcome.ok, "message": outcome.message, "timeout": Int(timeout)]
90
+ let data = try! JSONSerialization.data(withJSONObject: out, options: [.sortedKeys])
91
+ print(String(decoding: data, as: UTF8.self))
92
+ status = outcome.ok ? 0 : 1
93
+ done.signal()
94
+ }
95
+ done.wait()
96
+ return status
97
+ }
@@ -9,6 +9,13 @@ struct LaunchConfig: Decodable {
9
9
  let env: [String: String]?
10
10
  /// Agents in kitty windows (Kitty.swift); nil = none.
11
11
  let kitty: KittySettings?
12
+ /// Seconds `chi broadcast` may take (Broadcast.swift); nil = the default.
13
+ let broadcastTimeout: Double?
14
+
15
+ enum CodingKeys: String, CodingKey {
16
+ case version, argv, env, kitty
17
+ case broadcastTimeout = "broadcast_timeout"
18
+ }
12
19
  }
13
20
 
14
21
  enum ChiError: Error, CustomStringConvertible {
@@ -102,9 +109,11 @@ final class ChiRunner {
102
109
  }
103
110
 
104
111
  /// Runs `chi <args>` through ProcessRunner (no pipe deadlock near the
105
- /// 16 KiB note cap). Calls back on the main queue.
112
+ /// 16 KiB note cap). Calls back on +queue+ (the main queue by default; a
113
+ /// headless seam waits on another one); a launch file that can't be read
114
+ /// calls back at once.
106
115
  /// @param timeout this call's limit instead of the runner's
107
- func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil,
116
+ func run(_ args: [String], stdin: Data? = nil, timeout: TimeInterval? = nil, queue: DispatchQueue = .main,
108
117
  completion: @escaping (Result<ChiResult, ChiError>) -> Void) {
109
118
  let config: LaunchConfig
110
119
  do { config = try loadLaunch() } catch let error as ChiError {
@@ -115,7 +124,7 @@ final class ChiRunner {
115
124
 
116
125
  ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
117
126
  env: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
118
- stdin: stdin, timeout: timeout ?? self.timeout, completion: completion)
127
+ stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
119
128
  }
120
129
 
121
130
  /// The live sessions, oldest started first, then up to +recentCount+
@@ -1,11 +1,12 @@
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 targets: live
4
- // sessions, agents in kitty windows (Kitty.swift), recent sessions. ⏎
5
- // sends a message (`chi send`, or `chi send --new` on the new row, with
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. ⌘M (or a click on the new row's model) opens the model
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
9
10
  // chooser for a new session in place of the targets (Models.swift).
10
11
  import AppKit
11
12
  import SwiftUI
@@ -41,6 +42,9 @@ final class PanelModel: ObservableObject {
41
42
  var touched = false
42
43
  /// The "New session" row; never together with sessions (`--new` takes no ids).
43
44
  @Published var newSelected = false
45
+ /// The "Everyone it concerns" row: ⌘⏎ broadcasts (`chi broadcast`);
46
+ /// never together with the new row or picked targets.
47
+ @Published var everyoneSelected = false
44
48
  /// The model a new session starts on (`chi self --model`), for the new
45
49
  /// row's hint; nil while unknown.
46
50
  @Published var newModel: String?
@@ -72,8 +76,11 @@ final class PanelModel: ObservableObject {
72
76
  }
73
77
  var trimmedPrompt: String { prompt.trimmingCharacters(in: .whitespacesAndNewlines) }
74
78
  var hasContext: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
79
+ /// A note's text (stdin of `chi note`): the message line, then the context.
80
+ var noteText: String { [trimmedPrompt, hasContext ? text : ""].filter { !$0.isEmpty }.joined(separator: "\n\n") }
75
81
  var sendEnabled: Bool {
76
- canSend && (newSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty) && (!trimmedPrompt.isEmpty || hasContext)
82
+ canSend && (newSelected || everyoneSelected || !selectedIds.isEmpty || !selectedKitty.isEmpty)
83
+ && (!trimmedPrompt.isEmpty || hasContext)
77
84
  }
78
85
  var live: [LiveSession] { sessions.filter { !$0.recent } }
79
86
 
@@ -134,6 +141,7 @@ final class PanelModel: ObservableObject {
134
141
  func openPicker() {
135
142
  touched = true
136
143
  newSelected = true
144
+ everyoneSelected = false
137
145
  selected = []
138
146
  modelQuery = ""
139
147
  pickerIndex = chooserRows.firstIndex(where: isCurrent) ?? 0
@@ -155,6 +163,7 @@ final class PanelModel: ObservableObject {
155
163
  func choose(_ row: ModelRow) {
156
164
  pickTouched = true
157
165
  newSelected = true
166
+ everyoneSelected = false
158
167
  selected = []
159
168
  if row.kind == .standard {
160
169
  pickedModel = nil
@@ -192,13 +201,20 @@ final class PanelModel: ObservableObject {
192
201
 
193
202
  func toggle(_ id: String) {
194
203
  touched = true
195
- if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false }
204
+ if selected.contains(id) { selected.remove(id) } else { selected.insert(id); newSelected = false; everyoneSelected = false }
196
205
  }
197
206
 
198
207
  func toggleNew() {
199
208
  touched = true
200
209
  newSelected.toggle()
201
- if newSelected { selected = [] }
210
+ if newSelected { selected = []; everyoneSelected = false }
211
+ }
212
+
213
+ /// The preselected session is dropped: a broadcast picks its own.
214
+ func toggleEveryone() {
215
+ touched = true
216
+ everyoneSelected.toggle()
217
+ if everyoneSelected { selected = []; newSelected = false }
202
218
  }
203
219
 
204
220
  /// Where a new session starts: the helper runs in $HOME, which is in no
@@ -228,6 +244,7 @@ final class PanelModel: ObservableObject {
228
244
  /// Who the message goes to, for the placeholder: chi, or the one agent
229
245
  /// when only kitty windows running it are chosen.
230
246
  var addressee: String {
247
+ if everyoneSelected { return "everyone it concerns" }
231
248
  let agents = Set(selectedKitty.map(\.agent))
232
249
  return agents.count == 1 && selectedIds.isEmpty && !newSelected ? agents.first! : "chi"
233
250
  }
@@ -257,8 +274,7 @@ struct PanelView: View {
257
274
 
258
275
  var body: some View {
259
276
  VStack(spacing: 0) {
260
- // The message is required with images, as in the web.
261
- TextField(model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…", text: $model.prompt, axis: .vertical)
277
+ TextField(placeholder, text: $model.prompt, axis: .vertical)
262
278
  .textFieldStyle(.plain)
263
279
  .font(.system(size: 17))
264
280
  .lineLimit(1...4)
@@ -294,6 +310,12 @@ struct PanelView: View {
294
310
  .onAppear { promptFocused = true }
295
311
  }
296
312
 
313
+ /// The message is required with images, as in the web.
314
+ var placeholder: String {
315
+ if model.everyoneSelected { return "Tell everyone it concerns… (⌘⏎)" }
316
+ return model.images.isEmpty ? "Ask \(model.addressee)…" : "Say something about the image…"
317
+ }
318
+
297
319
  @ViewBuilder var imageStrip: some View {
298
320
  if !model.images.isEmpty {
299
321
  ScrollView(.horizontal, showsIndicators: false) {
@@ -326,6 +348,7 @@ struct PanelView: View {
326
348
  }.padding(10)
327
349
  default:
328
350
  newRow
351
+ everyoneRow
329
352
  let live = model.live.count, kitty = model.kittyWindows.count
330
353
  ForEach(Array(model.targets.prefix(9).enumerated()), id: \.element.id) { index, target in
331
354
  if index == live && kitty > 0 { groupHeader("kitty", first: index == 0) }
@@ -416,6 +439,32 @@ struct PanelView: View {
416
439
  .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
417
440
  }
418
441
 
442
+ /// `chi broadcast` instead of picked sessions (⌘B): ⌘⏎ shares the note
443
+ /// with the sessions chi finds it concerns.
444
+ var everyoneRow: some View {
445
+ let on = model.everyoneSelected
446
+ return Button(action: { model.toggleEveryone() }) {
447
+ HStack(spacing: 10) {
448
+ Image(systemName: "antenna.radiowaves.left.and.right")
449
+ .font(.system(size: 14))
450
+ .foregroundColor(on ? .accentColor : .secondary)
451
+ .frame(width: 18)
452
+ VStack(alignment: .leading, spacing: 1) {
453
+ Text("Everyone it concerns").font(.system(size: 13, weight: .medium)).lineLimit(1)
454
+ Text("a note for the sessions chi finds it concerns: ⌘⏎").font(.system(size: 11)).foregroundColor(.secondary).lineLimit(1)
455
+ }
456
+ Spacer()
457
+ Text("⌘B").font(.system(size: 11, design: .rounded)).foregroundColor(.secondary)
458
+ }
459
+ .padding(.horizontal, 8).padding(.vertical, 6)
460
+ .background(RoundedRectangle(cornerRadius: 7).fill(on ? Color.accentColor.opacity(0.14) : Color.clear))
461
+ .contentShape(Rectangle())
462
+ }
463
+ .buttonStyle(.plain)
464
+ .help("chi broadcast: sessions sharing a tag with the note get it, a triage model judges the rest; "
465
+ + "a first line naming a project keeps it there. Details: chi broadcast log")
466
+ }
467
+
419
468
  /// The new session's model: grey for the default, accent for a pick; a
420
469
  /// click (or ⌘M) opens the chooser.
421
470
  var modelLabel: some View {
@@ -528,15 +577,18 @@ struct PanelView: View {
528
577
 
529
578
  var footer: some View {
530
579
  HStack(spacing: 10) {
531
- HStack(spacing: 4) {
532
- Image(systemName: "arrow.down.doc").font(.system(size: 11)).foregroundColor(.secondary)
533
- TextField("source", text: $model.source)
534
- .textFieldStyle(.plain)
535
- .font(.system(size: 12))
536
- .frame(width: 90)
580
+ // A broadcast's source is "broadcast": no field, more room for its summary.
581
+ if !model.everyoneSelected {
582
+ HStack(spacing: 4) {
583
+ Image(systemName: "arrow.down.doc").font(.system(size: 11)).foregroundColor(.secondary)
584
+ TextField("source", text: $model.source)
585
+ .textFieldStyle(.plain)
586
+ .font(.system(size: 12))
587
+ .frame(width: 90)
588
+ }
589
+ .padding(.horizontal, 8).padding(.vertical, 4)
590
+ .background(Capsule().fill(Color.primary.opacity(0.07)))
537
591
  }
538
- .padding(.horizontal, 8).padding(.vertical, 4)
539
- .background(Capsule().fill(Color.primary.opacity(0.07)))
540
592
 
541
593
  HStack(spacing: 0) {
542
594
  if !model.origin.isEmpty {
@@ -564,17 +616,26 @@ struct PanelView: View {
564
616
  Button("Paste ⌥⏎") { send(.paste) }
565
617
  .disabled(!model.sendEnabled)
566
618
  .help("paste it into the agent's input without Enter: add more, then press Enter there")
619
+ } else if model.everyoneSelected {
620
+ Button("Broadcast ⌘⏎") { send(.note) }
621
+ .disabled(!model.sendEnabled)
622
+ .help("share it as a note with every session it concerns (chi broadcast); starts no turn")
567
623
  } else {
568
624
  Button("Note ⌘⏎") { send(.note) }
569
625
  .disabled(!model.sendEnabled || !model.selectedKitty.isEmpty || model.newSelected)
570
626
  .help(model.newSelected ? "a note needs a session: pick one, or ⏎ starts a new one with the message"
571
627
  : "add it as background the model sees on its next turn; starts no turn")
572
628
  }
573
- Button(action: { send(.message) }) {
574
- if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
629
+ // A broadcast is a note: no Send.
630
+ if model.everyoneSelected {
631
+ if model.phase == .sending { ProgressView().controlSize(.small) }
632
+ } else {
633
+ Button(action: { send(.message) }) {
634
+ if model.phase == .sending { ProgressView().controlSize(.small) } else { Text("Send ⏎") }
635
+ }
636
+ .disabled(!model.sendEnabled)
637
+ .help("send it as your message: a turn runs")
575
638
  }
576
- .disabled(!model.sendEnabled)
577
- .help("send it as your message: a turn runs")
578
639
  }
579
640
  .padding(.horizontal, 14).padding(.vertical, 10)
580
641
  }
@@ -744,7 +805,7 @@ final class PanelController: NSObject, NSWindowDelegate {
744
805
  return true
745
806
  }
746
807
 
747
- /// ⌘1…⌘9 toggle a target, ⌘0 the new row; ⌘M opens or closes the model
808
+ /// ⌘1…⌘9 toggle a target, ⌘0 the new row, ⌘B the Everyone row; ⌘M opens or closes the model
748
809
  /// chooser, where ⌘1…⌘9 pick a row.
749
810
  private func handleKey(_ event: NSEvent) -> Bool {
750
811
  let command = event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command
@@ -759,6 +820,11 @@ final class PanelController: NSObject, NSWindowDelegate {
759
820
  model.choose(rows[n - 1])
760
821
  return true
761
822
  }
823
+ // By key code, as ⌘M.
824
+ if command, event.keyCode == 11 {
825
+ model.toggleEveryone()
826
+ return true
827
+ }
762
828
  let targets = model.targets
763
829
  guard command, let chars = event.charactersIgnoringModifiers, let n = Int(chars),
764
830
  n <= min(targets.count, 9) else { return false }
@@ -770,6 +836,7 @@ final class PanelController: NSObject, NSWindowDelegate {
770
836
  /// @param returnTo the app to give focus back to on close
771
837
  func show(text: String, images: [PanelImage] = [], origin: String, source: String, returnTo app: NSRunningApplication?) {
772
838
  returnTo = app
839
+ opens += 1
773
840
  // A show on an open panel resets it without a close.
774
841
  dropImages()
775
842
  model.prompt = ""
@@ -780,6 +847,7 @@ final class PanelController: NSObject, NSWindowDelegate {
780
847
  model.source = source
781
848
  model.message = ""
782
849
  model.newSelected = false
850
+ model.everyoneSelected = false
783
851
  model.selected = []
784
852
  model.touched = false
785
853
  model.pickerOpen = false
@@ -875,6 +943,7 @@ final class PanelController: NSObject, NSWindowDelegate {
875
943
  /// finished. Images the helper wrote are copied for kitty first (chi's
876
944
  /// send deletes them) and deleted only after every job.
877
945
  func send(_ kind: SendKind) {
946
+ if model.everyoneSelected { broadcast(kind); return }
878
947
  let ids = model.selectedIds
879
948
  let windows = model.selectedKitty
880
949
  let new = model.newSelected
@@ -953,6 +1022,35 @@ final class PanelController: NSObject, NSWindowDelegate {
953
1022
  }
954
1023
  }
955
1024
 
1025
+ /// Counts the panel's opens: a broadcast that ends after a reopen leaves
1026
+ /// the new open alone.
1027
+ private var opens = 0
1028
+
1029
+ /// The Everyone row: `chi broadcast` with the note (the source field is
1030
+ /// not used: a broadcast's source is "broadcast"). It stays open with
1031
+ /// the summary line (Esc closes): no auto-close.
1032
+ private func broadcast(_ kind: SendKind) {
1033
+ if kind != .note {
1034
+ NSSound.beep()
1035
+ model.message = "A broadcast is a note: ⌘⏎"
1036
+ return
1037
+ }
1038
+ if !model.images.isEmpty {
1039
+ NSSound.beep()
1040
+ model.message = "A broadcast is text only: remove the images"
1041
+ return
1042
+ }
1043
+ guard model.sendEnabled else { NSSound.beep(); return }
1044
+ model.phase = .sending
1045
+ model.message = "broadcasting…"
1046
+ let open = opens
1047
+ runner.broadcast(model.noteText) { [weak self] outcome, _ in
1048
+ guard let self, self.opens == open else { return }
1049
+ self.model.phase = outcome.ok ? .sent : .failed
1050
+ self.model.message = outcome.message
1051
+ }
1052
+ }
1053
+
956
1054
  struct SendOutcome {
957
1055
  let ok: Bool
958
1056
  let message: String
@@ -980,8 +1078,7 @@ final class PanelController: NSObject, NSWindowDelegate {
980
1078
  args = ["note"]
981
1079
  let source = model.source.trimmingCharacters(in: .whitespacesAndNewlines)
982
1080
  if !source.isEmpty { args += ["--source", source] }
983
- let parts = [prompt, model.hasContext ? model.text : ""].filter { !$0.isEmpty }
984
- stdin = Data(parts.joined(separator: "\n\n").utf8)
1081
+ stdin = Data(model.noteText.utf8)
985
1082
  }
986
1083
  if !new { args += ids }
987
1084
  // Reading, converting and a new worker's start take longer than text.
@@ -41,6 +41,10 @@ module Samagotchi
41
41
  ENV_ALLOWLIST = %w[XDG_CONFIG_HOME XDG_STATE_HOME GEM_HOME GEM_PATH RUBYLIB].freeze
42
42
  # Without it Ruby reads stdin as US-ASCII and non-ASCII notes break.
43
43
  LANG = "en_US.UTF-8"
44
+ # chi broadcast's default triage deadline, and the helper's seconds on
45
+ # top of it (MacOS.broadcast_timeout).
46
+ BROADCAST_TRIAGE_DEADLINE = 20
47
+ BROADCAST_MARGIN = 10
44
48
 
45
49
  # Runs a command without a shell; [output, success].
46
50
  class Runner
@@ -62,11 +66,25 @@ module Samagotchi
62
66
  { "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
63
67
  end
64
68
 
69
+ # The helper as chi desktop and chi update make it: the settings it
70
+ # bakes into the launch file read from the config.
71
+ def self.from_config(register: true)
72
+ new(register: register, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
73
+ end
74
+
75
+ # Seconds the helper gives `chi broadcast` before it stops it: the
76
+ # triage deadline, then the deliveries and chi's own start. Baked into
77
+ # the launch file: a changed deadline reaches the helper with chi update.
78
+ def self.broadcast_timeout(get = Config.method(:get))
79
+ (get.call("broadcast.triage_deadline") || BROADCAST_TRIAGE_DEADLINE).to_f + BROADCAST_MARGIN
80
+ end
81
+
65
82
  attr_reader :app_dir, :support_dir
66
83
 
67
84
  def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
68
85
  source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
69
- arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil)
86
+ arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, kitty: nil,
87
+ broadcast_timeout: nil)
70
88
  home = env["HOME"] || Dir.home
71
89
  @app_dir = app_dir || File.join(home, "Applications")
72
90
  @support_dir = support_dir || File.join(home, "Library", "Application Support", APP_NAME)
@@ -80,6 +98,7 @@ module Samagotchi
80
98
  @sources_dir = sources_dir
81
99
  @register = register
82
100
  @kitty = kitty
101
+ @broadcast_timeout = broadcast_timeout
83
102
  end
84
103
 
85
104
  def app_path = File.join(@app_dir, "#{APP_NAME}.app")
@@ -93,12 +112,13 @@ module Samagotchi
93
112
  # shell's PATH), and the allowlisted env. chi is an installed gem's
94
113
  # RubyGems wrapper (it activates the gem and outlives upgrades and
95
114
  # `gem cleanup`), else this checkout's bin/chi. The kitty section only
96
- # when kitty.listen_on is set.
115
+ # when kitty.listen_on is set; the broadcast timeout when given.
97
116
  def launch_config
98
117
  env = { "LANG" => LANG }
99
118
  ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
100
119
  config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
101
120
  config["kitty"] = @kitty if @kitty
121
+ config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
102
122
  config
103
123
  end
104
124
 
@@ -122,13 +142,15 @@ module Samagotchi
122
142
  end
123
143
 
124
144
  # The launch file names another chi version or path than this one, or
125
- # other kitty settings: refresh_launch_file fixes that without a rebuild.
145
+ # other kitty settings or broadcast timeout: refresh_launch_file fixes
146
+ # that without a rebuild.
126
147
  def launch_outdated?
127
148
  launch = read_launch
128
- launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty
149
+ launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] || launch["kitty"] != @kitty ||
150
+ launch["broadcast_timeout"] != @broadcast_timeout
129
151
  end
130
152
 
131
- # Rewrite the launch file for this chi and the kitty settings, keeping
153
+ # Rewrite the launch file for this chi and its settings, keeping
132
154
  # the env it was installed with. The app reads it at each send: no restart.
133
155
  def refresh_launch_file
134
156
  config = launch_config
@@ -41,7 +41,7 @@ module Samagotchi
41
41
  @argv = argv.dup
42
42
  @stdout = stdout
43
43
  @stderr = stderr
44
- @platform = platform || ->(register:) { Desktop::MacOS.new(register: register, kitty: Desktop::MacOS.kitty_settings) }
44
+ @platform = platform || Desktop::MacOS.method(:from_config)
45
45
  @supported = supported
46
46
  end
47
47
 
@@ -1531,8 +1531,9 @@ module Samagotchi
1531
1531
  # @param on_event [Proc, nil] receives event hashes
1532
1532
  # @param max_iterations [Integer, nil] max kernel iterations (nil: IterationLimit.for)
1533
1533
  # @param cancel_controller [CancellationController, nil]
1534
- # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1535
- # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
1534
+ # @param max_tool_output_chars [Integer, nil] per-output char cap on what
1535
+ # the model gets back and the :tool_call_completed event's `output:`
1536
+ # (nil → max_tool_output_chars)
1536
1537
  # @return [LLM::ModelResult]
1537
1538
  # @param pending_input [#call, nil] optional drain proc returning
1538
1539
  # Array<String> of steering messages queued while the turn runs; drained
@@ -176,8 +176,9 @@ module Samagotchi
176
176
  # @param on_stream_event [Proc, nil] optional callback for generation events
177
177
  # @param cancel_controller [CancellationController, nil] optional cancellation source
178
178
  # @param model_name [String, nil] optional per-run model override
179
- # @param max_tool_output_chars [Integer, nil] per-output char cap for the
180
- # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
179
+ # @param max_tool_output_chars [Integer, nil] per-output char cap on what
180
+ # the model gets back and the :tool_call_completed event's `output:`
181
+ # (nil → max_tool_output_chars)
181
182
  # @param pending_input [#call, nil] optional drain proc returning
182
183
  # Array<String> of user steering messages queued while the turn runs.
183
184
  # Drained at iteration boundaries (llama.cpp's /completion cannot accept
@@ -223,7 +224,8 @@ module Samagotchi
223
224
  @model_key_fallback = fallback
224
225
  end
225
226
 
226
- # Resolve the per-output character cap for the emitted tool call events.
227
+ # Resolve the per-output character cap for the tool outputs the model
228
+ # gets back and the emitted tool call events.
227
229
  #
228
230
  # Precedence: an explicit override wins, then max_tool_output_chars
229
231
  # (Config). A non-positive value falls back to
@@ -539,7 +541,7 @@ module Samagotchi
539
541
  runs = ToolResponse.run_batch(tool_runner, calls, iteration: turn.iteration, emit: turn.emit,
540
542
  on_stream_event: turn.on_stream_event, cap: turn.cap)
541
543
  turn.tool_activity.concat(ToolResponse.activities(runs))
542
- # One entry for the batch: the full outputs joined (ToolResponse.joined).
544
+ # One entry for the batch: the capped outputs joined (ToolResponse.joined).
543
545
  turn.conversation << ToolResponse.joined(runs)
544
546
  turn.pending_tool_calls = true
545
547
  :next
@@ -178,9 +178,11 @@ module Samagotchi
178
178
  # down) and returns a short summary ("3 tools") or raises (the UIs
179
179
  # show a warn card). Every UI shows it running (the label) and done.
180
180
  # @param label [String] what it does, shown while it runs
181
- # @param provides_tools [Boolean] it registers tools (chi.replace_tools):
182
- # a turn sent meanwhile waits for it before its first model request,
183
- # up to +timeout+; a Ctrl-C ends the wait
181
+ # @param provides_tools [Boolean] it provides what the plugin's tools
182
+ # need (its own, or through chi.replace_tools; the mcp bundle's
183
+ # index of a server's tools): a turn sent meanwhile waits for it
184
+ # before its first model request, up to +timeout+; a Ctrl-C ends
185
+ # the wait
184
186
  # @param quiet [Boolean] shown only if it fails (a background refresh)
185
187
  # @param timeout [Numeric, nil] seconds a turn waits for it (default 60)
186
188
  # @param failed [String, nil] the warn card's short title if it raises