samagotchi 0.123.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +23 -3
  5. data/docs/cli.md +40 -13
  6. data/docs/desktop.md +51 -3
  7. data/docs/internals/participants.md +26 -1
  8. data/docs/internals/remote-sessions.md +2 -0
  9. data/docs/sessions.md +2 -2
  10. data/docs/testing.md +2 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  13. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  14. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  15. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  16. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  17. data/lib/samagotchi/process_group.rb +10 -1
  18. data/lib/samagotchi/reminder_carry.rb +92 -0
  19. data/lib/samagotchi/reminder_store.rb +49 -0
  20. data/lib/samagotchi/run_options.rb +43 -2
  21. data/lib/samagotchi/session_manager.rb +39 -4
  22. data/lib/samagotchi/sessions_command.rb +1 -1
  23. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  24. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  25. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  26. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  27. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  28. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  29. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  30. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  31. data/lib/samagotchi/update_command.rb +40 -13
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/app.rb +205 -5
  34. data/lib/samagotchi/web/participants_admin.rb +15 -4
  35. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  36. data/lib/samagotchi/web/public/annotate_bar.js +34 -6
  37. data/lib/samagotchi/web/public/app.js +106 -6
  38. data/lib/samagotchi/web/public/claim_link.js +347 -0
  39. data/lib/samagotchi/web/public/data.js +17 -0
  40. data/lib/samagotchi/web/public/index.html +21 -2
  41. data/lib/samagotchi/web/public/participant.html +1 -0
  42. data/lib/samagotchi/web/public/participant.js +46 -4
  43. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  44. data/lib/samagotchi/web/public/route.js +3 -2
  45. data/lib/samagotchi/web/public/toasts.js +13 -5
  46. data/lib/samagotchi/web/public/tokens.css +2 -1
  47. data/lib/samagotchi/web/public/update_notice.js +6 -6
  48. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  49. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  50. data/lib/samagotchi/web/server.rb +116 -7
  51. data/lib/samagotchi/web/session_hub.rb +28 -0
  52. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  53. data/lib/samagotchi/web/update_check.rb +14 -7
  54. data/lib/samagotchi/worker.rb +57 -3
  55. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  56. metadata +13 -1
@@ -36,7 +36,8 @@ enum ChiError: Error, CustomStringConvertible {
36
36
  }
37
37
 
38
38
  /// One session from `chi sessions list --format json`: a live one, or a
39
- /// recent stopped one (listed below the live ones; a message wakes it).
39
+ /// recent stopped one (listed below the live ones; a message wakes it), on
40
+ /// this Mac or on a remote (`chi --remote NAME sessions list`).
40
41
  struct LiveSession: Decodable, Identifiable, Equatable {
41
42
  let id: String
42
43
  let desc: String?
@@ -45,6 +46,9 @@ struct LiveSession: Decodable, Identifiable, Equatable {
45
46
  let owner: String?
46
47
  let updatedAt: String?
47
48
  var recent = false
49
+ /// The remote it was listed on (set by the helper, never decoded); nil:
50
+ /// this Mac.
51
+ var remote: String?
48
52
 
49
53
  enum CodingKeys: String, CodingKey {
50
54
  case id, desc, cwd, busy, owner
@@ -54,7 +58,13 @@ struct LiveSession: Decodable, Identifiable, Equatable {
54
58
  /// Only UUID-shaped ids go into chi's argv, so nothing can turn into a flag.
55
59
  var valid: Bool { UUID(uuidString: id) != nil }
56
60
 
57
- var shortCwd: String { cwd.map(Self.shorten) ?? "" }
61
+ /// The row's key in the selection, ⌘ numbers and the remembered choice:
62
+ /// "studio:<id>" on a remote, the plain id on this Mac (so a choice
63
+ /// remembered before remotes still counts).
64
+ var key: String { remote.map { "\($0):\(id)" } ?? id }
65
+
66
+ /// A remote's home may differ from this Mac's: its path stays whole.
67
+ var shortCwd: String { remote == nil ? cwd.map(Self.shorten) ?? "" : cwd ?? "" }
58
68
 
59
69
  /// A path with the home folder as "~".
60
70
  static func shorten(_ path: String) -> String {
@@ -83,7 +93,43 @@ struct LiveSession: Decodable, Identifiable, Equatable {
83
93
  }
84
94
  }
85
95
 
96
+ /// The remotes `chi remote ls` keeps (`chi remote add`): a name goes into
97
+ /// argv only when it came from that answer and passes chi's own rule.
98
+ enum Remotes {
99
+ /// One row of `chi remote ls --format json`; the token itself never
100
+ /// leaves chi, only whether there is one.
101
+ struct Entry: Decodable {
102
+ let name: String
103
+ let token: Bool?
104
+ }
105
+
106
+ /// chi's `Remotes::NAME`: no leading "-", so never a flag.
107
+ static func valid(_ name: String) -> Bool {
108
+ name.range(of: "\\A[A-Za-z0-9][A-Za-z0-9_-]{0,39}\\z", options: .regularExpression) != nil
109
+ }
110
+
111
+ /// The names with a token (one without can't reach that chi web), in
112
+ /// chi's order.
113
+ static func names(_ entries: [Entry]) -> [String] {
114
+ var seen = Set<String>()
115
+ return entries.filter { $0.token == true && valid($0.name) && seen.insert($0.name).inserted }.map(\.name)
116
+ }
117
+ }
118
+
119
+ /// A remote's part of the panel: its rows, or why there are none.
120
+ struct RemoteSection: Equatable {
121
+ let name: String
122
+ var sessions: [LiveSession] = []
123
+ /// "studio: no answer within 4 s", or the first line of chi's stderr.
124
+ var error: String?
125
+ var loading = true
126
+ }
127
+
86
128
  final class ChiRunner {
129
+ /// A remote's two lists together: chi's own open timeout is 5 s, so a
130
+ /// down host is stopped here before chi says anything.
131
+ static let remoteBudget: TimeInterval = 4
132
+
87
133
  static let supportDir = NSString(string: "~/Library/Application Support/Chi Helper").expandingTildeInPath
88
134
  static let launchPath = supportDir + "/launch.json"
89
135
 
@@ -125,30 +171,75 @@ final class ChiRunner {
125
171
  completion(.failure(.failed("\(error)"))); return
126
172
  }
127
173
 
174
+ var env = ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked }
175
+ // chi reads CHI_REMOTE for every call without --remote: one from the
176
+ // shell would send this Mac's rows to a remote. --remote is the only way.
177
+ env["CHI_REMOTE"] = nil
128
178
  ProcessRunner.run(executable: config.argv[0], args: Array(config.argv.dropFirst()) + args,
129
- env: ProcessInfo.processInfo.environment.merging(config.env ?? [:]) { _, baked in baked },
130
- stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
179
+ env: env, stdin: stdin, timeout: timeout ?? self.timeout, queue: queue, completion: completion)
131
180
  }
132
181
 
133
182
  /// The live sessions, oldest started first, then up to +recentCount+
134
183
  /// stopped ones, newest first. The live order is by creation so ⌘1…⌘9
135
184
  /// stay on their sessions as turns update them; a new one comes last.
136
185
  /// A session a chi REPL holds is in neither: it takes no notes or
137
- /// messages. A failed second call just leaves "recent" empty.
138
- func sessions(recentCount: Int = 3, completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
139
- list(["--live", "--sort", "created_at", "--order", "asc"]) { result in
186
+ /// messages. A failed second call just leaves "recent" empty. Calls
187
+ /// back on +queue+ (a headless seam waits on another one).
188
+ func sessions(recentCount: Int = 3, queue: DispatchQueue = .main,
189
+ completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
190
+ list(Self.liveFlags, queue: queue) { result in
140
191
  guard case .success(let live) = result else { completion(result); return }
141
- self.list(["--limit", "20"]) { recent in
142
- let liveIds = Set(live.map(\.id))
143
- let stopped = ((try? recent.get()) ?? [])
144
- .filter { $0.owner == nil && !liveIds.contains($0.id) }
145
- .prefix(recentCount)
146
- .map { session -> LiveSession in var session = session; session.recent = true; return session }
147
- completion(.success(live + stopped))
192
+ self.list(Self.recentFlags, queue: queue) { recent in
193
+ completion(.success(Self.rows(live: live, recent: recent, recentCount: recentCount)))
148
194
  }
149
195
  }
150
196
  }
151
197
 
198
+ private static let liveFlags = ["--live", "--sort", "created_at", "--order", "asc"]
199
+ private static let recentFlags = ["--limit", "20"]
200
+
201
+ /// The live rows, then up to +recentCount+ of the recent list that are
202
+ /// neither live nor a REPL's; a failed recent list adds none.
203
+ private static func rows(live: [LiveSession], recent: Result<[LiveSession], ChiError>, recentCount: Int) -> [LiveSession] {
204
+ let liveIds = Set(live.map(\.id))
205
+ let stopped = ((try? recent.get()) ?? [])
206
+ .filter { $0.owner == nil && !liveIds.contains($0.id) }
207
+ .prefix(recentCount)
208
+ .map { session -> LiveSession in var session = session; session.recent = true; return session }
209
+ return live + stopped
210
+ }
211
+
212
+ /// `chi remote ls --format json`: the remotes with a token, in chi's
213
+ /// order; none when it fails (an older chi has no `remote`).
214
+ func remotes(queue: DispatchQueue = .main, completion: @escaping ([String]) -> Void) {
215
+ run(["remote", "ls", "--format", "json"], queue: queue) { result in
216
+ guard case .success(let r) = result, r.status == 0, !r.timedOut,
217
+ let entries = try? JSONDecoder().decode([Remotes.Entry].self, from: Data(r.stdout.utf8)) else {
218
+ completion([]); return
219
+ }
220
+ completion(Remotes.names(entries))
221
+ }
222
+ }
223
+
224
+ /// A remote's rows as sessions(...) lists this Mac's, each tagged with
225
+ /// +name+. Its two lists run at once under one budget (each is a chi boot
226
+ /// and two requests); the failure is the panel's line for that remote.
227
+ func sessions(remote name: String, recentCount: Int = 3, queue: DispatchQueue = .main,
228
+ completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
229
+ var live: Result<[LiveSession], ChiError>?
230
+ var recent: Result<[LiveSession], ChiError>?
231
+ // Both answer on +queue+, one at a time.
232
+ let finish = {
233
+ guard let live, let recent else { return }
234
+ completion(live.map { rows in
235
+ Self.rows(live: rows, recent: recent, recentCount: recentCount)
236
+ .map { session -> LiveSession in var session = session; session.remote = name; return session }
237
+ })
238
+ }
239
+ list(Self.liveFlags, remote: name, queue: queue) { live = $0; finish() }
240
+ list(Self.recentFlags, remote: name, queue: queue) { recent = $0; finish() }
241
+ }
242
+
152
243
  /// The model a new session starts on (`chi self --model`: the config's
153
244
  /// default, as chi resolves it), or nil when none is configured or chi
154
245
  /// can't say. Only a single line counts: nothing that looks like a whole
@@ -180,17 +271,38 @@ final class ChiRunner {
180
271
  }
181
272
  }
182
273
 
183
- private func list(_ flags: [String], completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
274
+ /// @param remote a remote's name: `--remote NAME` first, the remote
275
+ /// budget, and its two failure lines (no answer in time, or the first
276
+ /// line of chi's stderr: it names the remote and why)
277
+ private func list(_ flags: [String], remote: String? = nil, queue: DispatchQueue = .main,
278
+ completion: @escaping (Result<[LiveSession], ChiError>) -> Void) {
279
+ let prefix = remote.map { ["--remote", $0] } ?? []
280
+ let timeout = remote == nil ? self.timeout : Self.remoteBudget
184
281
  // Every project's sessions: the helper isn't in any one project (an
185
282
  // older chi ignores the flag).
186
- run(["sessions", "list"] + flags + ["--scope=all", "--format", "json"]) { result in
283
+ run(prefix + ["sessions", "list"] + flags + ["--scope=all", "--format", "json"], timeout: timeout,
284
+ queue: queue) { result in
187
285
  switch result {
188
286
  case .failure(let error): completion(.failure(error))
189
287
  case .success(let r):
190
- if r.timedOut { completion(.failure(.failed("chi sessions list took over \(Int(self.timeout)) s"))); return }
288
+ if r.timedOut {
289
+ let message = remote.map { "\($0): no answer within \(Int(timeout)) s" } ?? "chi sessions list took over \(Int(timeout)) s"
290
+ completion(.failure(.failed(message)))
291
+ return
292
+ }
191
293
  guard r.status == 0, let data = r.stdout.data(using: .utf8),
192
294
  let sessions = try? JSONDecoder().decode([LiveSession].self, from: data) else {
193
295
  let detail = r.stderr.isEmpty ? r.stdout : r.stderr
296
+ if let remote {
297
+ // chi's own line names the remote and why; Ruby's warnings
298
+ // ("…: warning: …") before it aren't the reason.
299
+ let line = r.status == 0 ? nil : detail.split(whereSeparator: \.isNewline).map(String.init)
300
+ .first { !$0.contains("warning:") && !$0.trimmingCharacters(in: .whitespaces).isEmpty }
301
+ let fallback = r.status == 0 ? "\(remote): chi sessions list answered no session list"
302
+ : "\(remote): chi sessions list failed (exit \(r.status))"
303
+ completion(.failure(.failed(line ?? fallback)))
304
+ return
305
+ }
194
306
  completion(.failure(.failed("chi sessions list failed (exit \(r.status)): \(detail.prefix(400))")))
195
307
  return
196
308
  }
@@ -88,9 +88,10 @@ enum ImageIntake {
88
88
  static let sentMaxAge: TimeInterval = 7 * 86_400
89
89
 
90
90
  /// The paths an agent paste names: an image the helper wrote is copied to
91
- /// sentDir first (the send deletes the original), with no spaces in its
92
- /// name; a file of the user's is named as it is. Call before any send
93
- /// job starts. An image that can't be copied is left out.
91
+ /// sentDir first (the helper deletes the original after the send; chi's
92
+ /// send copies what it reads and leaves it), with no spaces in its name;
93
+ /// a file of the user's is named as it is. Call before any send job
94
+ /// starts. An image that can't be copied is left out.
94
95
  static func keepForPaste(_ images: [PanelImage]) -> [String] {
95
96
  images.compactMap { image in
96
97
  guard image.temp else { return image.url.path }