modelstat 0.0.39 → 0.0.41
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.
- package/dist/cli.mjs +262 -84
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -2
- package/scripts/postinstall.mjs +19 -114
- package/vendor/tray-mac/Sources/ModelstatTray/main.swift +133 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelstat",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
4
4
|
"description": "modelstat companion — reads local AI-tool usage and ships tokenised events to modelstat.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"pack:tarball": "pnpm run build && npm pack --pack-destination ../..",
|
|
70
70
|
"install:local": "bash ./scripts/install-local.sh",
|
|
71
71
|
"postinstall": "node ./scripts/postinstall.mjs",
|
|
72
|
-
"typecheck": "tsc --noEmit"
|
|
72
|
+
"typecheck": "tsc --noEmit",
|
|
73
|
+
"test": "node --import tsx --test src/**/*.test.ts"
|
|
73
74
|
}
|
|
74
75
|
}
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -132,7 +132,6 @@ async function main() {
|
|
|
132
132
|
|
|
133
133
|
async function rebootServiceIfInstalled() {
|
|
134
134
|
const stateDir = join(homedir(), ".modelstat");
|
|
135
|
-
const installedBundle = join(stateDir, "bin", "modelstat.mjs");
|
|
136
135
|
const launchdPlist = join(
|
|
137
136
|
homedir(),
|
|
138
137
|
"Library",
|
|
@@ -186,31 +185,29 @@ async function rebootServiceIfInstalled() {
|
|
|
186
185
|
// first, escalate to SIGKILL if it's still around 2 s later.
|
|
187
186
|
await killStaleDaemon(stateDir);
|
|
188
187
|
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
188
|
+
// Re-stage the installed bundle AND its native summariser runtime in
|
|
189
|
+
// ONE canonical step by spawning the freshly-unpacked bundle's own
|
|
190
|
+
// `_setup-runtime` command. That runs the SAME service.ts staging the
|
|
191
|
+
// `modelstat connect` path uses: it copies dist/cli.mjs over
|
|
192
|
+
// ~/.modelstat/bin/modelstat.mjs and `npm install`s the complete
|
|
193
|
+
// node-llama-cpp closure (incl. this platform's prebuilt binary) into
|
|
194
|
+
// ~/.modelstat/bin/node_modules. freshBundle runs from the just-
|
|
195
|
+
// unpacked npm tree, so its installNativeRuntime() pins the version.
|
|
196
|
+
//
|
|
197
|
+
// This replaces the old hand-copy + symlink-the-npm-cache approach,
|
|
198
|
+
// whose symlinks dangled the moment the cache/global was pruned — the
|
|
199
|
+
// opposite of self-contained. One mechanism, every platform.
|
|
200
|
+
console.log("[modelstat] staging new bundle + native runtime…");
|
|
201
|
+
const setup = spawnSync(process.execPath, [freshBundle, "_setup-runtime"], {
|
|
202
|
+
stdio: "inherit",
|
|
203
|
+
timeout: 300_000,
|
|
204
|
+
});
|
|
205
|
+
if (setup.status !== 0) {
|
|
199
206
|
console.warn(
|
|
200
|
-
`[modelstat] couldn't
|
|
207
|
+
`[modelstat] couldn't stage the new bundle/runtime (exit ${setup.status ?? "?"}); the service may still be on the previous build`,
|
|
201
208
|
);
|
|
202
209
|
}
|
|
203
210
|
|
|
204
|
-
// The bundle is portable EXCEPT for `node-llama-cpp` and its
|
|
205
|
-
// platform-specific native sub-packages — they're marked external
|
|
206
|
-
// in tsup so they're resolved at runtime via Node's `node_modules`
|
|
207
|
-
// walk-up. From `~/.modelstat/bin/modelstat.mjs` there's no parent
|
|
208
|
-
// `node_modules` to find them in, so the bundled summariser dies
|
|
209
|
-
// at preflight with "Cannot find package 'node-llama-cpp'". Set up
|
|
210
|
-
// a sibling `node_modules` directory containing the runtime
|
|
211
|
-
// dependencies, sourced from this package's own install location.
|
|
212
|
-
await setupNativeNodeModules(installedBundle);
|
|
213
|
-
|
|
214
211
|
// Start back up. `modelstat start` re-runs preflight (incl. the
|
|
215
212
|
// processing-version reconcile that wipes cursors when we ship a
|
|
216
213
|
// new pipeline), so the upgrade picks up the new behaviour
|
|
@@ -224,98 +221,6 @@ async function rebootServiceIfInstalled() {
|
|
|
224
221
|
console.log("[modelstat] ✓ background service restarted with new build");
|
|
225
222
|
}
|
|
226
223
|
|
|
227
|
-
/**
|
|
228
|
-
* Make `node-llama-cpp` and its platform-specific native sibling
|
|
229
|
-
* packages reachable from `~/.modelstat/bin/modelstat.mjs` so the
|
|
230
|
-
* bundle's runtime `import("node-llama-cpp")` resolves.
|
|
231
|
-
*
|
|
232
|
-
* Strategy:
|
|
233
|
-
* - Find the source `node_modules` directory in this package's
|
|
234
|
-
* install (npm flat layout puts it at <pkg>/.. for global
|
|
235
|
-
* installs; pnpm/bun layouts vary but the same walk-up works
|
|
236
|
-
* since postinstall always runs from the package root).
|
|
237
|
-
* - For each runtime dep we care about (`node-llama-cpp` plus
|
|
238
|
-
* every `@node-llama-cpp/*` and `@reflink/*` sub-package),
|
|
239
|
-
* symlink it into `~/.modelstat/bin/node_modules/<name>`.
|
|
240
|
-
* Symlinks are free + fast; if symlinking fails (filesystem
|
|
241
|
-
* boundary, permissions) fall back to a recursive copy with
|
|
242
|
-
* `dereference:true` so the resulting tree has no dangling
|
|
243
|
-
* refs into the source location.
|
|
244
|
-
*
|
|
245
|
-
* Idempotent: each upgrade clears the destination first.
|
|
246
|
-
*/
|
|
247
|
-
async function setupNativeNodeModules(installedBundle) {
|
|
248
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
249
|
-
// Find a parent `node_modules` containing `node-llama-cpp`. The
|
|
250
|
-
// walk handles npm flat (<prefix>/lib/node_modules), pnpm flat
|
|
251
|
-
// (<prefix>/lib/node_modules with peer hoisting), and the npx
|
|
252
|
-
// cache layout (~/.npm/_npx/<hash>/node_modules).
|
|
253
|
-
let srcNodeModules = null;
|
|
254
|
-
for (let dir = here, i = 0; i < 8; i++) {
|
|
255
|
-
const probe = join(dir, "node_modules", "node-llama-cpp", "package.json");
|
|
256
|
-
if (existsSync(probe)) {
|
|
257
|
-
srcNodeModules = join(dir, "node_modules");
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
const up = resolve(dir, "..");
|
|
261
|
-
if (up === dir) break;
|
|
262
|
-
dir = up;
|
|
263
|
-
}
|
|
264
|
-
if (!srcNodeModules) {
|
|
265
|
-
console.warn(
|
|
266
|
-
"[modelstat] couldn't locate node-llama-cpp in this install — bundled summariser will fail at preflight. Re-install with `npm i -g modelstat` or report a bug.",
|
|
267
|
-
);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const fs = await import("node:fs/promises");
|
|
272
|
-
const destDir = join(dirname(installedBundle), "node_modules");
|
|
273
|
-
await fs.mkdir(destDir, { recursive: true });
|
|
274
|
-
|
|
275
|
-
// Discover every native package we need: the main one + every
|
|
276
|
-
// platform-specific sibling (the binary blob is in one of the
|
|
277
|
-
// `@node-llama-cpp/*` packages chosen by node-llama-cpp's
|
|
278
|
-
// optional-dependencies resolution at install time).
|
|
279
|
-
const wanted = new Set(["node-llama-cpp"]);
|
|
280
|
-
const all = await fs
|
|
281
|
-
.readdir(srcNodeModules, { withFileTypes: true })
|
|
282
|
-
.catch(() => []);
|
|
283
|
-
for (const e of all) {
|
|
284
|
-
if (!e.isDirectory()) continue;
|
|
285
|
-
if (!e.name.startsWith("@")) continue;
|
|
286
|
-
if (
|
|
287
|
-
e.name === "@node-llama-cpp" ||
|
|
288
|
-
e.name === "@reflink"
|
|
289
|
-
) {
|
|
290
|
-
// Scoped dirs contain multiple packages — copy/symlink the
|
|
291
|
-
// whole scope dir so all platform variants are reachable.
|
|
292
|
-
wanted.add(e.name);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
for (const pkg of wanted) {
|
|
297
|
-
const src = join(srcNodeModules, pkg);
|
|
298
|
-
const dest = join(destDir, pkg);
|
|
299
|
-
if (!existsSync(src)) continue;
|
|
300
|
-
try {
|
|
301
|
-
await fs.rm(dest, { recursive: true, force: true });
|
|
302
|
-
} catch {
|
|
303
|
-
/* ignore */
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
await fs.symlink(src, dest, "dir");
|
|
307
|
-
} catch {
|
|
308
|
-
try {
|
|
309
|
-
await fs.cp(src, dest, { recursive: true, dereference: true });
|
|
310
|
-
} catch (err2) {
|
|
311
|
-
console.warn(
|
|
312
|
-
`[modelstat] couldn't link ${pkg}: ${err2 && err2.message ? err2.message : err2}`,
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
224
|
/**
|
|
320
225
|
* Kill any modelstat-related process the service supervisor didn't
|
|
321
226
|
* reap. Two passes:
|
|
@@ -95,6 +95,12 @@ struct LocalStatsCounters: Decodable {
|
|
|
95
95
|
let files_unchanged: Int?
|
|
96
96
|
let events_uploaded: Int?
|
|
97
97
|
let batches_uploaded: Int?
|
|
98
|
+
/// Lifetime count of cognition segments uploaded from this machine
|
|
99
|
+
/// (persisted by the daemon, so it survives restarts).
|
|
100
|
+
let segments_sent: Int?
|
|
101
|
+
/// Segments in the batch being uploaded right now — a gauge that
|
|
102
|
+
/// drops back to 0 between bursts. Usually 0 when idle.
|
|
103
|
+
let segments_sending: Int?
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
@MainActor
|
|
@@ -105,7 +111,15 @@ final class TrayController: NSObject {
|
|
|
105
111
|
private var daemon: Process?
|
|
106
112
|
private var paused = false
|
|
107
113
|
private var latest: AgentStats?
|
|
108
|
-
|
|
114
|
+
/// Live local heartbeat, read straight from ~/.modelstat/last-status.json
|
|
115
|
+
/// on the fast timer. Decoupled from `latest` (the slower, network-backed
|
|
116
|
+
/// `stats --json` shell-out) so the menu's numbers move every second.
|
|
117
|
+
private var localLatest: LocalStatus?
|
|
118
|
+
/// Advances once per fast tick to drive the "alive" pulse on the status
|
|
119
|
+
/// line — a cheap, honest signal that the agent is doing work right now.
|
|
120
|
+
private var spinnerTick = 0
|
|
121
|
+
private var fastTimer: Timer?
|
|
122
|
+
private var slowTimer: Timer?
|
|
109
123
|
|
|
110
124
|
// Menu items we update on every poll
|
|
111
125
|
private let statusMI = NSMenuItem(title: "Loading…", action: nil, keyEquivalent: "")
|
|
@@ -128,9 +142,28 @@ final class TrayController: NSObject {
|
|
|
128
142
|
buildMenu()
|
|
129
143
|
startDaemon()
|
|
130
144
|
refreshStats()
|
|
131
|
-
|
|
132
|
-
|
|
145
|
+
tickLocal()
|
|
146
|
+
|
|
147
|
+
// Two cadences, both registered in `.common` mode so they keep firing
|
|
148
|
+
// while the menu is open and being tracked — a plain
|
|
149
|
+
// `Timer.scheduledTimer` only runs in `.default` mode, so the dropdown
|
|
150
|
+
// froze the moment you opened it. That alone made a working agent look
|
|
151
|
+
// stuck.
|
|
152
|
+
// · fast (1s): read last-status.json directly — cheap, no subprocess
|
|
153
|
+
// — and re-render so phase/segment numbers tick live.
|
|
154
|
+
// · slow (15s): shell out to `modelstat stats --json` for the
|
|
155
|
+
// network-backed paired/claimed/device/analyzed data that barely
|
|
156
|
+
// changes (and, for claimed devices, costs a 404 round-trip).
|
|
157
|
+
let fast = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
158
|
+
MainActor.assumeIsolated { self?.tickLocal() }
|
|
133
159
|
}
|
|
160
|
+
RunLoop.main.add(fast, forMode: .common)
|
|
161
|
+
fastTimer = fast
|
|
162
|
+
let slow = Timer(timeInterval: 15.0, repeats: true) { [weak self] _ in
|
|
163
|
+
MainActor.assumeIsolated { self?.refreshStats() }
|
|
164
|
+
}
|
|
165
|
+
RunLoop.main.add(slow, forMode: .common)
|
|
166
|
+
slowTimer = slow
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
private func configureStatusItem() {
|
|
@@ -148,13 +181,23 @@ final class TrayController: NSObject {
|
|
|
148
181
|
statusItem.button?.toolTip = "modelstat"
|
|
149
182
|
}
|
|
150
183
|
|
|
184
|
+
/// The five non-clickable info rows at the top of the menu, in order.
|
|
185
|
+
private var infoItems: [NSMenuItem] {
|
|
186
|
+
[statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// Set an info row's title, hiding the row when the title is empty.
|
|
190
|
+
/// A disabled `NSMenuItem` with an empty title still takes a full
|
|
191
|
+
/// row of height — that was the stray blank space under "Claimed ✓".
|
|
192
|
+
private func setInfo(_ item: NSMenuItem, _ title: String) {
|
|
193
|
+
item.title = title
|
|
194
|
+
item.isHidden = title.isEmpty
|
|
195
|
+
}
|
|
196
|
+
|
|
151
197
|
private func buildMenu() {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
pipelineMI.isEnabled = false
|
|
156
|
-
detectedMI.isEnabled = false
|
|
157
|
-
for mi in [statusMI, deviceMI, analyzedMI, pipelineMI, detectedMI] {
|
|
198
|
+
for mi in infoItems {
|
|
199
|
+
mi.isEnabled = false
|
|
200
|
+
mi.isHidden = mi.title.isEmpty
|
|
158
201
|
menu.addItem(mi)
|
|
159
202
|
}
|
|
160
203
|
menu.addItem(NSMenuItem.separator())
|
|
@@ -231,6 +274,26 @@ final class TrayController: NSObject {
|
|
|
231
274
|
daemon = nil
|
|
232
275
|
}
|
|
233
276
|
|
|
277
|
+
// ── Live local heartbeat (fast path) ────────────────────────────
|
|
278
|
+
|
|
279
|
+
/// Read ~/.modelstat/last-status.json directly and re-render. Runs every
|
|
280
|
+
/// second from a `.common`-mode timer, so it updates the dropdown even
|
|
281
|
+
/// while it's open. No subprocess, no network — just a few-KB JSON read.
|
|
282
|
+
/// The file's top-level shape is the daemon's heartbeat body, which
|
|
283
|
+
/// matches `LocalStatus`, so we decode it straight.
|
|
284
|
+
private func tickLocal() {
|
|
285
|
+
guard !paused else { return }
|
|
286
|
+
spinnerTick &+= 1
|
|
287
|
+
let path = ("~/.modelstat/last-status.json" as NSString).expandingTildeInPath
|
|
288
|
+
if let data = FileManager.default.contents(atPath: path),
|
|
289
|
+
let ls = try? JSONDecoder().decode(LocalStatus.self, from: data)
|
|
290
|
+
{
|
|
291
|
+
localLatest = ls
|
|
292
|
+
}
|
|
293
|
+
// Re-render even if the read failed so the pulse keeps moving.
|
|
294
|
+
renderStats()
|
|
295
|
+
}
|
|
296
|
+
|
|
234
297
|
// ── Polling `modelstat stats --json` ────────────────────────────
|
|
235
298
|
|
|
236
299
|
private func refreshStats() {
|
|
@@ -268,16 +331,19 @@ final class TrayController: NSObject {
|
|
|
268
331
|
}
|
|
269
332
|
|
|
270
333
|
private func renderStats() {
|
|
334
|
+
// Paused: togglePaused() owns the status line ("Paused"); don't let the
|
|
335
|
+
// fast tick clobber it with a stale phase from the file.
|
|
336
|
+
guard !paused else { return }
|
|
271
337
|
guard let s = latest else {
|
|
272
|
-
statusMI
|
|
338
|
+
setInfo(statusMI, "Loading…")
|
|
273
339
|
return
|
|
274
340
|
}
|
|
341
|
+
// Prefer the live file read (fast timer) over the network shell-out's
|
|
342
|
+
// embedded copy, which can be up to 15s stale.
|
|
343
|
+
let local = localLatest ?? s.local
|
|
275
344
|
if s.paired == false {
|
|
276
|
-
statusMI
|
|
277
|
-
deviceMI
|
|
278
|
-
analyzedMI.title = ""
|
|
279
|
-
pipelineMI.title = ""
|
|
280
|
-
detectedMI.title = ""
|
|
345
|
+
setInfo(statusMI, "Not paired — run `npx modelstat@latest`")
|
|
346
|
+
for mi in [deviceMI, analyzedMI, pipelineMI, detectedMI] { setInfo(mi, "") }
|
|
281
347
|
claimMI.title = "Open modelstat.ai"
|
|
282
348
|
copyClaimMI.isHidden = true
|
|
283
349
|
return
|
|
@@ -287,26 +353,36 @@ final class TrayController: NSObject {
|
|
|
287
353
|
// Falls back to the device-view's reported agent_status. If
|
|
288
354
|
// both are missing we say "running" rather than "starting" so
|
|
289
355
|
// the menu doesn't lie about the daemon's state.
|
|
290
|
-
let phase =
|
|
291
|
-
let phaseMsg =
|
|
356
|
+
let phase = local?.status ?? s.device?.agent_status ?? "running"
|
|
357
|
+
let phaseMsg = local?.message
|
|
358
|
+
// Pulse the leading dot while the agent is actively working so the
|
|
359
|
+
// menu reads as alive even on the rare beat where the numbers don't
|
|
360
|
+
// change. Steady dot when idle/watching/offline.
|
|
361
|
+
let dot = isActivePhase(phase) ? (spinnerTick % 2 == 0 ? "●" : "○") : "●"
|
|
292
362
|
if let m = phaseMsg, !m.isEmpty {
|
|
293
|
-
statusMI
|
|
363
|
+
setInfo(statusMI, "\(dot) \(phase) — \(m)")
|
|
294
364
|
} else {
|
|
295
|
-
statusMI
|
|
365
|
+
setInfo(statusMI, "\(dot) \(phase)")
|
|
296
366
|
}
|
|
297
367
|
|
|
298
368
|
if s.claimed == true {
|
|
299
369
|
// Claimed device: device-view 404s for the tray (no auth) so
|
|
300
370
|
// we lean entirely on the local heartbeat snapshot for live
|
|
301
|
-
// numbers, plus point the menu items at the dashboard.
|
|
302
|
-
|
|
371
|
+
// numbers, plus point the menu items at the dashboard. Keep the
|
|
372
|
+
// reassuring "Claimed ✓" and append the agent version when the
|
|
373
|
+
// local snapshot carries it.
|
|
374
|
+
if let v = local?.agent_version, !v.isEmpty {
|
|
375
|
+
setInfo(deviceMI, "Claimed ✓ · \(v)")
|
|
376
|
+
} else {
|
|
377
|
+
setInfo(deviceMI, "Claimed ✓ — synced to your account")
|
|
378
|
+
}
|
|
303
379
|
claimMI.title = "Open dashboard"
|
|
304
380
|
copyClaimMI.isHidden = true
|
|
305
381
|
} else {
|
|
306
382
|
// Unclaimed: device-view fills in the rich numbers.
|
|
307
383
|
let host = s.device?.hostname ?? "unknown"
|
|
308
384
|
let os = s.device?.os_family ?? ""
|
|
309
|
-
deviceMI
|
|
385
|
+
setInfo(deviceMI, "\(host) · \(os)")
|
|
310
386
|
claimMI.title = "Open device page"
|
|
311
387
|
copyClaimMI.isHidden = (s.claim_url == nil || s.claim_url?.isEmpty == true)
|
|
312
388
|
}
|
|
@@ -321,43 +397,58 @@ final class TrayController: NSObject {
|
|
|
321
397
|
let proc = a.processing ?? 0
|
|
322
398
|
let done = a.finished ?? cnt
|
|
323
399
|
let breakdown = proc > 0 ? " (\(done) finished · \(proc) processing)" : ""
|
|
324
|
-
analyzedMI
|
|
400
|
+
setInfo(analyzedMI, "\(cnt) sessions\(breakdown) · \(fmtTokens(tok)) tokens · $\(usd)")
|
|
325
401
|
} else {
|
|
326
|
-
analyzedMI
|
|
402
|
+
setInfo(analyzedMI, "")
|
|
327
403
|
}
|
|
328
404
|
|
|
329
|
-
// Pipeline activity —
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
405
|
+
// Pipeline activity — segments are the headline (what the user
|
|
406
|
+
// asked to see): how many are uploading right now, and how many
|
|
407
|
+
// have been sent in total. Events / files trail as context. All
|
|
408
|
+
// sourced from the local heartbeat mirror so it works for both
|
|
409
|
+
// claimed and unclaimed devices.
|
|
410
|
+
if let c = local?.stats {
|
|
411
|
+
let sending = c.segments_sending ?? 0
|
|
412
|
+
let sent = c.segments_sent ?? 0
|
|
333
413
|
let events = c.events_uploaded ?? 0
|
|
334
|
-
let batches = c.batches_uploaded ?? 0
|
|
335
414
|
let scanned = c.files_scanned ?? 0
|
|
336
|
-
let queue =
|
|
415
|
+
let queue = local?.queue_size ?? 0
|
|
337
416
|
var bits: [String] = []
|
|
338
|
-
if
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
if scanned > 0 { bits.append("\(scanned) files
|
|
417
|
+
if sending > 0 { bits.append("↑ \(sending) sending") }
|
|
418
|
+
if sent > 0 { bits.append("\(fmtCount(sent)) segments sent") }
|
|
419
|
+
if events > 0 { bits.append("\(fmtCount(events)) events") }
|
|
420
|
+
if scanned > 0 { bits.append("\(scanned) files") }
|
|
342
421
|
if queue > 0 { bits.append("\(queue) in queue") }
|
|
343
|
-
pipelineMI
|
|
422
|
+
setInfo(pipelineMI, bits.joined(separator: " · "))
|
|
344
423
|
} else {
|
|
345
|
-
pipelineMI
|
|
424
|
+
setInfo(pipelineMI, "")
|
|
346
425
|
}
|
|
347
426
|
|
|
348
427
|
// What the agent found on this machine — installations +
|
|
349
428
|
// identities (Claude Keychain, Codex JWT, …). Mirror of the
|
|
350
429
|
// discover() output the daemon ran at startup.
|
|
351
|
-
if let c =
|
|
430
|
+
if let c = local?.stats {
|
|
352
431
|
let installs = c.installations_detected ?? 0
|
|
353
432
|
let ids = c.identities_detected ?? 0
|
|
354
433
|
if installs > 0 || ids > 0 {
|
|
355
|
-
detectedMI
|
|
434
|
+
setInfo(detectedMI, "\(installs) tools · \(ids) accounts detected")
|
|
356
435
|
} else {
|
|
357
|
-
detectedMI
|
|
436
|
+
setInfo(detectedMI, "")
|
|
358
437
|
}
|
|
359
438
|
} else {
|
|
360
|
-
detectedMI
|
|
439
|
+
setInfo(detectedMI, "")
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/// Phases where the agent is doing visible work right now — drives the
|
|
444
|
+
/// pulsing status dot. "watching"/"idle" are healthy-but-quiet (steady
|
|
445
|
+
/// dot); "offline"/"error" are problems (steady dot, not a busy pulse).
|
|
446
|
+
private func isActivePhase(_ phase: String) -> Bool {
|
|
447
|
+
switch phase {
|
|
448
|
+
case "starting", "discovering", "scanning", "processing", "uploading":
|
|
449
|
+
return true
|
|
450
|
+
default:
|
|
451
|
+
return false
|
|
361
452
|
}
|
|
362
453
|
}
|
|
363
454
|
|
|
@@ -427,7 +518,8 @@ final class TrayController: NSObject {
|
|
|
427
518
|
|
|
428
519
|
@objc private func quit() {
|
|
429
520
|
stopDaemon()
|
|
430
|
-
|
|
521
|
+
fastTimer?.invalidate()
|
|
522
|
+
slowTimer?.invalidate()
|
|
431
523
|
NSApp.terminate(nil)
|
|
432
524
|
}
|
|
433
525
|
}
|