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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +23 -3
- data/docs/cli.md +40 -13
- data/docs/desktop.md +51 -3
- data/docs/internals/participants.md +26 -1
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_manager.rb +39 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/update_command.rb +40 -13
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +205 -5
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +34 -6
- data/lib/samagotchi/web/public/app.js +106 -6
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -0
- data/lib/samagotchi/web/public/participant.js +46 -4
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/update_notice.js +6 -6
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/server.rb +116 -7
- data/lib/samagotchi/web/session_hub.rb +28 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +14 -7
- data/lib/samagotchi/worker.rb +57 -3
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
142
|
-
|
|
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
|
-
|
|
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"]
|
|
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 {
|
|
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
|
|
92
|
-
///
|
|
93
|
-
///
|
|
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 }
|