@agfpd/iapeer 0.4.64 → 0.4.66
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/docs/12-cli-reference.md +12 -0
- package/docs/16-tray.md +88 -0
- package/docs/README.md +1 -0
- package/docs/ru/16-tray.md +83 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +96 -0
- package/src/daemon/index.ts +32 -0
- package/src/tray/client.ts +266 -0
- package/src/tray/index.ts +146 -0
- package/src/tray/install.ts +282 -0
- package/src/tray/render.ts +243 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// tray/install — idempotent lifecycle of the SwiftBar plugin that hosts the fleet
|
|
2
|
+
// dashboard. The plugin FILE is a thin shim: it execs the installed `iapeer` binary
|
|
3
|
+
// (`iapeer tray render --stream`), so ALL rendering/streaming logic lives in the
|
|
4
|
+
// binary (TypeScript, tested) rather than in brittle shell — the .sh is stable and
|
|
5
|
+
// carries only SwiftBar metadata.
|
|
6
|
+
//
|
|
7
|
+
// SwiftBar facts (verified against SwiftBar/PreferencesStore.swift):
|
|
8
|
+
// • PluginDirectory is a PLAIN String default (read via expandingTildeInPath), NOT
|
|
9
|
+
// a security-scoped bookmark — `defaults write com.ameba.SwiftBar PluginDirectory
|
|
10
|
+
// -string <dir>` set BEFORE first launch is honored and skips the folder picker.
|
|
11
|
+
// • SwiftBar is not sandboxed (it runs arbitrary scripts), so a hidden ~/.iapeer
|
|
12
|
+
// path is readable without a TCC prompt.
|
|
13
|
+
//
|
|
14
|
+
// Packaging (owner decision 05.07.2026): the tray ships WITH the foundation (same
|
|
15
|
+
// class as the `iapeer list` TUI — another face of the daemon). `iapeer install`
|
|
16
|
+
// drops the plugin file (inert without SwiftBar); `iapeer tray install [--app]` is
|
|
17
|
+
// the activation verb that also installs SwiftBar.app when absent (owner-sanctioned)
|
|
18
|
+
// and launches it. Tearing down the GUI layer NEVER touches the fleet.
|
|
19
|
+
//
|
|
20
|
+
// Paths route through cfg/env (the dedicated plugin dir is under resolveGlobalRoot,
|
|
21
|
+
// so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
|
|
22
|
+
// open) goes through an injectable Runner so tests stay hermetic.
|
|
23
|
+
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
25
|
+
import { spawnSync } from 'child_process'
|
|
26
|
+
import { homedir } from 'os'
|
|
27
|
+
import { join } from 'path'
|
|
28
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
29
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
30
|
+
import { resolveFleetAddress } from './client.ts'
|
|
31
|
+
|
|
32
|
+
/** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
|
|
33
|
+
* streaming process exits (daemon-down backoff); while the stream is alive it is
|
|
34
|
+
* ignored. The basename is stable — install rewrites the same file (idempotent). */
|
|
35
|
+
export const PLUGIN_BASENAME = 'iapeer.10s.sh'
|
|
36
|
+
const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
|
|
37
|
+
const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
|
|
38
|
+
|
|
39
|
+
export type RunResult = { status: number | null; stdout: string; stderr: string }
|
|
40
|
+
export type Runner = (cmd: string, args: string[]) => RunResult
|
|
41
|
+
|
|
42
|
+
const defaultRun: Runner = (cmd, args) => {
|
|
43
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' })
|
|
44
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The dedicated iapeer-owned plugin dir under ~/.iapeer (IAPEER_ROOT-isolated). */
|
|
48
|
+
export function dedicatedPluginDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
49
|
+
return join(resolveGlobalRoot(env), 'tray', 'plugins')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is SwiftBar.app installed? (either /Applications or ~/Applications). */
|
|
53
|
+
export function swiftBarInstalled(): boolean {
|
|
54
|
+
return SWIFTBAR_APP_PATHS.some(p => existsSync(p))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The plugin-dir SwiftBar is currently configured with (plain-string default,
|
|
58
|
+
* ~-expanded), or undefined when unset/unreadable. */
|
|
59
|
+
export function readSwiftBarPluginDir(run: Runner = defaultRun): string | undefined {
|
|
60
|
+
const r = run('defaults', ['read', SWIFTBAR_DOMAIN, 'PluginDirectory'])
|
|
61
|
+
if (r.status !== 0) return undefined
|
|
62
|
+
const raw = r.stdout.trim()
|
|
63
|
+
if (!raw) return undefined
|
|
64
|
+
return raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
|
|
68
|
+
return run('defaults', ['write', SWIFTBAR_DOMAIN, 'PluginDirectory', '-string', dir])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function installSwiftBarApp(run: Runner): RunResult {
|
|
72
|
+
// --no-quarantine: an automated install has no one to click through the Gatekeeper
|
|
73
|
+
// "app downloaded from the Internet" dialog — quarantine would stall first launch.
|
|
74
|
+
return run('brew', ['install', '--cask', '--no-quarantine', 'swiftbar'])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Strip any residual quarantine xattr from the app (belt-and-suspenders: an already
|
|
78
|
+
* brew-installed SwiftBar, or a brew that quarantined despite the flag, would else
|
|
79
|
+
* block first launch on a headless / no-click box). Best-effort. */
|
|
80
|
+
function dequarantineApp(run: Runner): void {
|
|
81
|
+
const app = SWIFTBAR_APP_PATHS.find(p => existsSync(p))
|
|
82
|
+
if (app) run('xattr', ['-dr', 'com.apple.quarantine', app])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function launchSwiftBar(run: Runner): void {
|
|
86
|
+
run('open', ['-g', '-a', 'SwiftBar'])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function refreshSwiftBar(run: Runner): void {
|
|
90
|
+
run('open', ['-g', 'swiftbar://refreshallplugins'])
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The plugin shim: SwiftBar metadata + exec of the streaming renderer. binPath is
|
|
94
|
+
* embedded absolute (SwiftBar's PATH is minimal, like launchd). */
|
|
95
|
+
export function buildPluginShim(binPath: string): string {
|
|
96
|
+
return [
|
|
97
|
+
'#!/bin/bash',
|
|
98
|
+
'# <xbar.title>iapeer fleet</xbar.title>',
|
|
99
|
+
'# <xbar.version>v1</xbar.version>',
|
|
100
|
+
'# <xbar.author>iapeer</xbar.author>',
|
|
101
|
+
'# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
|
|
102
|
+
'# <swiftbar.type>streamable</swiftbar.type>',
|
|
103
|
+
'# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
|
|
104
|
+
'# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
|
|
105
|
+
'# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
|
|
106
|
+
// The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it
|
|
107
|
+
// must never route through an HTTP proxy. Bun's fetch caches proxy config at process
|
|
108
|
+
// start and ignores NO_PROXY for loopback, so clearing the vars must happen in the
|
|
109
|
+
// ENV before the binary launches (a runtime delete is too late). unset here.
|
|
110
|
+
'unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy',
|
|
111
|
+
`exec "${binPath}" tray render --stream`,
|
|
112
|
+
'',
|
|
113
|
+
].join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
|
|
117
|
+
* into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
|
|
118
|
+
function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
|
|
119
|
+
if (env.IAPEER_TEST_SANDBOX === '1' && resolveGlobalRoot(env) === join(homedir(), '.iapeer')) {
|
|
120
|
+
throw new Error('refusing to install the tray plugin into the REAL ~/.iapeer under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface InstallTrayOptions {
|
|
125
|
+
env?: NodeJS.ProcessEnv
|
|
126
|
+
/** Install SwiftBar.app via brew when absent (owner-sanctioned). Default false. */
|
|
127
|
+
installApp?: boolean
|
|
128
|
+
/** Launch + refresh SwiftBar after writing the plugin. Default false. */
|
|
129
|
+
launch?: boolean
|
|
130
|
+
run?: Runner
|
|
131
|
+
/** Injectable app-presence probe (tests). Default: swiftBarInstalled(). */
|
|
132
|
+
probeApp?: () => boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface InstallTrayResult {
|
|
136
|
+
pluginFile: string
|
|
137
|
+
pluginDir: string
|
|
138
|
+
/** Whether the plugin file bytes changed (false = already current). */
|
|
139
|
+
wrote: boolean
|
|
140
|
+
/** How the plugin dir was chosen. */
|
|
141
|
+
dir: 'existing-swiftbar' | 'dedicated-configured' | 'dedicated-only'
|
|
142
|
+
app: 'present' | 'installed' | 'install-failed' | 'absent'
|
|
143
|
+
appReason?: string
|
|
144
|
+
launched: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Idempotently install the tray plugin. Steps (each safe to repeat):
|
|
149
|
+
* 1. install SwiftBar.app if absent && installApp (best-effort — a brew failure is
|
|
150
|
+
* reported, not thrown; the plugin file still lands so it activates later).
|
|
151
|
+
* 2. resolve the target dir: an already-configured SwiftBar PluginDirectory is
|
|
152
|
+
* RESPECTED (install into the user's dir); otherwise the dedicated ~/.iapeer dir,
|
|
153
|
+
* and — only when SwiftBar is present — point SwiftBar at it via defaults.
|
|
154
|
+
* 3. write the plugin shim (skip if byte-identical; always ensure +x).
|
|
155
|
+
* 4. launch + refresh SwiftBar when launch && SwiftBar present.
|
|
156
|
+
*/
|
|
157
|
+
export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
158
|
+
const env = opts.env ?? process.env
|
|
159
|
+
const run = opts.run ?? defaultRun
|
|
160
|
+
const probeApp = opts.probeApp ?? swiftBarInstalled
|
|
161
|
+
assertTraySandboxIsolated(env)
|
|
162
|
+
const binPath = iapeerBinPath(env)
|
|
163
|
+
|
|
164
|
+
// 1 — app
|
|
165
|
+
let app: InstallTrayResult['app'] = probeApp() ? 'present' : 'absent'
|
|
166
|
+
let appReason: string | undefined
|
|
167
|
+
if (app === 'absent' && opts.installApp) {
|
|
168
|
+
const r = installSwiftBarApp(run)
|
|
169
|
+
if (r.status === 0 || probeApp()) app = 'installed'
|
|
170
|
+
else {
|
|
171
|
+
app = 'install-failed'
|
|
172
|
+
appReason = (r.stderr || r.stdout || `brew exit ${r.status}`).trim().slice(0, 300)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const present = app === 'present' || app === 'installed'
|
|
176
|
+
|
|
177
|
+
// 2 — target dir
|
|
178
|
+
const existing = present ? readSwiftBarPluginDir(run) : undefined
|
|
179
|
+
let pluginDir: string
|
|
180
|
+
let dir: InstallTrayResult['dir']
|
|
181
|
+
if (existing && existsSync(existing)) {
|
|
182
|
+
pluginDir = existing
|
|
183
|
+
dir = 'existing-swiftbar'
|
|
184
|
+
} else {
|
|
185
|
+
pluginDir = dedicatedPluginDir(env)
|
|
186
|
+
if (present) {
|
|
187
|
+
setSwiftBarPluginDir(pluginDir, run)
|
|
188
|
+
dir = 'dedicated-configured'
|
|
189
|
+
} else {
|
|
190
|
+
dir = 'dedicated-only'
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
mkdirSync(pluginDir, { recursive: true })
|
|
194
|
+
|
|
195
|
+
// 3 — plugin file (idempotent write; always +x)
|
|
196
|
+
const pluginFile = join(pluginDir, PLUGIN_BASENAME)
|
|
197
|
+
const content = buildPluginShim(binPath)
|
|
198
|
+
let wrote = false
|
|
199
|
+
if (!existsSync(pluginFile) || readFileSync(pluginFile, 'utf8') !== content) {
|
|
200
|
+
writeFileSync(pluginFile, content, { mode: 0o755 })
|
|
201
|
+
wrote = true
|
|
202
|
+
} else {
|
|
203
|
+
try {
|
|
204
|
+
chmodSync(pluginFile, 0o755)
|
|
205
|
+
} catch {
|
|
206
|
+
/* best-effort */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 4 — launch + refresh
|
|
211
|
+
let launched = false
|
|
212
|
+
if (opts.launch && present) {
|
|
213
|
+
dequarantineApp(run)
|
|
214
|
+
launchSwiftBar(run)
|
|
215
|
+
refreshSwiftBar(run)
|
|
216
|
+
launched = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface UninstallTrayResult {
|
|
223
|
+
removed: string[]
|
|
224
|
+
/** SwiftBar was refreshed to drop the now-missing plugin. */
|
|
225
|
+
refreshed: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Remove the tray plugin file from EVERY dir it could live in (the dedicated dir and
|
|
230
|
+
* a configured SwiftBar dir) and refresh SwiftBar. DELIBERATELY leaves SwiftBar.app
|
|
231
|
+
* installed (the user may run other plugins) and NEVER touches the fleet — the daemon,
|
|
232
|
+
* TUI and delivery keep running (acceptance criterion: GUI teardown ≠ fleet teardown).
|
|
233
|
+
*/
|
|
234
|
+
export function uninstallTray(
|
|
235
|
+
opts: { env?: NodeJS.ProcessEnv; launch?: boolean; run?: Runner; probeApp?: () => boolean } = {},
|
|
236
|
+
): UninstallTrayResult {
|
|
237
|
+
const env = opts.env ?? process.env
|
|
238
|
+
const run = opts.run ?? defaultRun
|
|
239
|
+
assertTraySandboxIsolated(env)
|
|
240
|
+
const present = (opts.probeApp ?? swiftBarInstalled)()
|
|
241
|
+
const dirs = new Set<string>([dedicatedPluginDir(env)])
|
|
242
|
+
if (present) {
|
|
243
|
+
const existing = readSwiftBarPluginDir(run)
|
|
244
|
+
if (existing) dirs.add(existing)
|
|
245
|
+
}
|
|
246
|
+
const removed: string[] = []
|
|
247
|
+
for (const d of dirs) {
|
|
248
|
+
const f = join(d, PLUGIN_BASENAME)
|
|
249
|
+
if (existsSync(f)) {
|
|
250
|
+
rmSync(f, { force: true })
|
|
251
|
+
removed.push(f)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let refreshed = false
|
|
255
|
+
if (opts.launch && present) {
|
|
256
|
+
refreshSwiftBar(run)
|
|
257
|
+
refreshed = true
|
|
258
|
+
}
|
|
259
|
+
return { removed, refreshed }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface TrayStatus {
|
|
263
|
+
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
264
|
+
swiftbar: { installed: boolean; pluginDir?: string }
|
|
265
|
+
plugin: { installed: boolean; path?: string }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read-only diagnostics — never repairs anything. */
|
|
269
|
+
export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeApp?: () => boolean } = {}): TrayStatus {
|
|
270
|
+
const env = opts.env ?? process.env
|
|
271
|
+
const run = opts.run ?? defaultRun
|
|
272
|
+
const addr = resolveFleetAddress({ env })
|
|
273
|
+
const installed = (opts.probeApp ?? swiftBarInstalled)()
|
|
274
|
+
const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
|
|
275
|
+
const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
|
|
276
|
+
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
277
|
+
return {
|
|
278
|
+
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
279
|
+
swiftbar: { installed, pluginDir: cfgDir },
|
|
280
|
+
plugin: { installed: Boolean(found), path: found },
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// tray/render — PURE SwiftBar-format renderer for the fleet snapshot. No IO, fully
|
|
2
|
+
// deterministic (a fixed snapshot + `now` → a fixed string), so it is unit-tested
|
|
3
|
+
// directly. It consumes a snapshot modelled by its OWN local interfaces (the docs/15
|
|
4
|
+
// shape) and ignores every field it does not name — client obligation 2 (additive
|
|
5
|
+
// evolution: a new snapshot field must never break an old client). This module is the
|
|
6
|
+
// second consumer of docs/15 after fleet.ts wrote it — the contract dogfood.
|
|
7
|
+
//
|
|
8
|
+
// SwiftBar output grammar (see docs/16-tray.md): lines before the first `---` are the
|
|
9
|
+
// menu-bar title; lines after are the dropdown. Item params follow ` | key=value`.
|
|
10
|
+
// Submenu depth is a `--` prefix per level. Click actions: `bash=<bin> paramN=…
|
|
11
|
+
// terminal=<bool>` runs the binary (terminal=true opens Terminal.app — that is how a
|
|
12
|
+
// peer row hands off to `iapeer attach`, the terminal-handoff the API deliberately
|
|
13
|
+
// keeps client-side).
|
|
14
|
+
|
|
15
|
+
const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
16
|
+
|
|
17
|
+
const COLOR = {
|
|
18
|
+
meta: '#8b949e',
|
|
19
|
+
live: '#3fb950',
|
|
20
|
+
down: '#f85149',
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
// ── docs/15 snapshot shape (only the fields the tray reads) ───────────────────
|
|
24
|
+
|
|
25
|
+
export interface TrayRuntime {
|
|
26
|
+
runtime: string
|
|
27
|
+
status: 'live' | 'asleep' | 'stopped'
|
|
28
|
+
attached?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TrayPeer {
|
|
32
|
+
personality: string
|
|
33
|
+
intelligence?: string
|
|
34
|
+
runtimes: TrayRuntime[]
|
|
35
|
+
last_active_runtime?: string
|
|
36
|
+
last_active_ms?: number
|
|
37
|
+
attached?: boolean
|
|
38
|
+
launchd_managed?: boolean
|
|
39
|
+
wake_policy?: 'warm' | 'ephemeral'
|
|
40
|
+
queue_depth?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface TraySnapshot {
|
|
44
|
+
version?: string
|
|
45
|
+
host?: {
|
|
46
|
+
version?: string
|
|
47
|
+
uptimeSecs?: number
|
|
48
|
+
memory?: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
49
|
+
voice?: { provider: string; version: string; heartbeatAgeSecs: number | null } | null
|
|
50
|
+
fda?: boolean | null
|
|
51
|
+
}
|
|
52
|
+
peers: TrayPeer[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RenderOptions {
|
|
56
|
+
/** Absolute path to the installed `iapeer` binary (embedded into click actions). */
|
|
57
|
+
binPath: string
|
|
58
|
+
/** Clock for age rendering (injectable for tests). */
|
|
59
|
+
now?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── param serialization ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** Serialize SwiftBar item params. Simple tokens stay bare; anything else is
|
|
65
|
+
* double-quoted with embedded quotes escaped (SwiftBar accepts quoted values). */
|
|
66
|
+
function params(pairs: Record<string, string | number | boolean | undefined>): string {
|
|
67
|
+
const parts: string[] = []
|
|
68
|
+
for (const [k, v] of Object.entries(pairs)) {
|
|
69
|
+
if (v === undefined) continue
|
|
70
|
+
const s = String(v)
|
|
71
|
+
const val = /^[\w.#/@-]+$/.test(s) ? s : `"${s.replace(/"/g, '\\"')}"`
|
|
72
|
+
parts.push(`${k}=${val}`)
|
|
73
|
+
}
|
|
74
|
+
return parts.length ? ` | ${parts.join(' ')}` : ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One dropdown line at submenu `depth` (0 = top level). */
|
|
78
|
+
function line(depth: number, text: string, p: Record<string, string | number | boolean | undefined> = {}): string {
|
|
79
|
+
return `${'--'.repeat(depth)}${text}${params(p)}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A menu separator at submenu `depth`: the `--`×depth prefix + `---` (SwiftBar: a
|
|
83
|
+
* top-level separator is `---`, a depth-1 one is `-----`, etc.). */
|
|
84
|
+
function separator(depth: number): string {
|
|
85
|
+
return `${'--'.repeat(depth)}---`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── time helpers ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function fmtUptime(secs: number): string {
|
|
91
|
+
if (!Number.isFinite(secs) || secs < 0) return '?'
|
|
92
|
+
const d = Math.floor(secs / 86400)
|
|
93
|
+
const h = Math.floor((secs % 86400) / 3600)
|
|
94
|
+
const m = Math.floor((secs % 3600) / 60)
|
|
95
|
+
if (d > 0) return `${d}d ${h}h`
|
|
96
|
+
if (h > 0) return `${h}h ${m}m`
|
|
97
|
+
if (m > 0) return `${m}m`
|
|
98
|
+
return `${Math.floor(secs)}s`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function fmtAge(ms: number, now: number): string {
|
|
102
|
+
const secs = Math.floor((now - ms) / 1000)
|
|
103
|
+
if (!Number.isFinite(secs)) return ''
|
|
104
|
+
if (secs < 45) return 'now'
|
|
105
|
+
const m = Math.floor(secs / 60)
|
|
106
|
+
if (m < 60) return `${m}m ago`
|
|
107
|
+
const h = Math.floor(m / 60)
|
|
108
|
+
if (h < 24) return `${h}h ago`
|
|
109
|
+
return `${Math.floor(h / 24)}d ago`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── peer status aggregation ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
|
|
115
|
+
if (p.runtimes.some(r => r.status === 'live')) return 'live'
|
|
116
|
+
if (p.runtimes.some(r => r.status === 'asleep')) return 'asleep'
|
|
117
|
+
return 'stopped'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
|
|
121
|
+
|
|
122
|
+
// ── renderers ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
|
|
125
|
+
{ cmd: 'wake', label: 'Wake' },
|
|
126
|
+
{ cmd: 'stop', label: 'Stop' },
|
|
127
|
+
{ cmd: 'start', label: 'Start' },
|
|
128
|
+
{ cmd: 'new', label: 'New session (fresh)' },
|
|
129
|
+
{ cmd: 'interrupt', label: 'Interrupt' },
|
|
130
|
+
{ cmd: 'refresh', label: 'Refresh (soft-reload)' },
|
|
131
|
+
{ cmd: 'compact', label: 'Compact dialogue' },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/** The full SwiftBar plugin output for a reachable daemon. */
|
|
135
|
+
export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): string {
|
|
136
|
+
const now = opts.now ?? Date.now()
|
|
137
|
+
const bin = opts.binPath
|
|
138
|
+
const peers = [...snapshot.peers].sort((a, b) => {
|
|
139
|
+
const r = RANK[aggregate(a)] - RANK[aggregate(b)]
|
|
140
|
+
return r !== 0 ? r : a.personality.localeCompare(b.personality)
|
|
141
|
+
})
|
|
142
|
+
const liveCount = snapshot.peers.filter(p => aggregate(p) === 'live').length
|
|
143
|
+
|
|
144
|
+
const lines: string[] = []
|
|
145
|
+
// ── menu-bar title: antenna icon + live-peer count ──
|
|
146
|
+
lines.push(line(0, String(liveCount), { sfimage: 'antenna.radiowaves.left.and.right' }))
|
|
147
|
+
lines.push('---')
|
|
148
|
+
|
|
149
|
+
// ── host header ──
|
|
150
|
+
const ver = snapshot.host?.version ?? snapshot.version ?? '?'
|
|
151
|
+
const up = snapshot.host?.uptimeSecs !== undefined ? ` · up ${fmtUptime(snapshot.host.uptimeSecs)}` : ''
|
|
152
|
+
lines.push(line(0, `iapeer ${ver}${up}`, { color: COLOR.meta }))
|
|
153
|
+
const providers = renderProviders(snapshot.host)
|
|
154
|
+
if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
|
|
155
|
+
lines.push('---')
|
|
156
|
+
|
|
157
|
+
// ── peers ──
|
|
158
|
+
if (peers.length === 0) {
|
|
159
|
+
lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
|
|
160
|
+
}
|
|
161
|
+
for (const p of peers) {
|
|
162
|
+
lines.push(...renderPeerRow(p, bin, now))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── footer ──
|
|
166
|
+
lines.push('---')
|
|
167
|
+
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
168
|
+
lines.push(line(0, `iapeer · fleet dashboard`, { color: COLOR.meta, size: 11 }))
|
|
169
|
+
return lines.join('\n') + '\n'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderProviders(host: TraySnapshot['host']): string {
|
|
173
|
+
if (!host) return ''
|
|
174
|
+
const seg: string[] = []
|
|
175
|
+
const one = (label: string, slot: { version: string; heartbeatAgeSecs: number | null } | null | undefined): void => {
|
|
176
|
+
if (!slot) return
|
|
177
|
+
// fresh heartbeat (< 90s) → ●, declared-but-silent → ○
|
|
178
|
+
const glyph = slot.heartbeatAgeSecs !== null && slot.heartbeatAgeSecs < 90 ? '●' : '○'
|
|
179
|
+
seg.push(`${label} ${glyph} ${slot.version}`)
|
|
180
|
+
}
|
|
181
|
+
one('mem', host.memory)
|
|
182
|
+
one('voice', host.voice)
|
|
183
|
+
if (host.fda === false) seg.push('⚠ no full-disk-access')
|
|
184
|
+
return seg.join(' · ')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderPeerRow(p: TrayPeer, bin: string, now: number): string[] {
|
|
188
|
+
const agg = aggregate(p)
|
|
189
|
+
const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
|
|
190
|
+
const badges =
|
|
191
|
+
(p.attached ? ' 👤' : '') +
|
|
192
|
+
(p.launchd_managed ? ' 🔒' : '') +
|
|
193
|
+
(p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
|
|
194
|
+
const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
|
|
195
|
+
const label = `${p.personality} ${detail}${badges}${ageStr}`
|
|
196
|
+
const rowColor = agg === 'live' ? COLOR.live : COLOR.meta
|
|
197
|
+
|
|
198
|
+
const out: string[] = []
|
|
199
|
+
// The peer row IS the attach action: click → Terminal.app running `iapeer attach
|
|
200
|
+
// <peer>` (attach resolves its own runtime — default-anchored last-active, same as
|
|
201
|
+
// the CLI). terminal=true is the terminal-handoff docs/15 keeps client-side.
|
|
202
|
+
out.push(
|
|
203
|
+
line(0, label, {
|
|
204
|
+
color: rowColor,
|
|
205
|
+
bash: bin,
|
|
206
|
+
param1: 'attach',
|
|
207
|
+
param2: p.personality,
|
|
208
|
+
terminal: true,
|
|
209
|
+
}),
|
|
210
|
+
)
|
|
211
|
+
// Lifecycle controls live in a submenu (they route through the fleet command
|
|
212
|
+
// endpoints via `iapeer tray cmd …` — dogfooding POST /fleet/v1/peers/<p>/<cmd>).
|
|
213
|
+
out.push(line(1, 'Attach…', { bash: bin, param1: 'attach', param2: p.personality, terminal: true, sfimage: 'terminal' }))
|
|
214
|
+
out.push(separator(1))
|
|
215
|
+
for (const c of MANAGE_COMMANDS) {
|
|
216
|
+
out.push(
|
|
217
|
+
line(1, c.label, {
|
|
218
|
+
bash: bin,
|
|
219
|
+
param1: 'tray',
|
|
220
|
+
param2: 'cmd',
|
|
221
|
+
param3: c.cmd,
|
|
222
|
+
param4: p.personality,
|
|
223
|
+
terminal: false,
|
|
224
|
+
refresh: true,
|
|
225
|
+
}),
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The degraded output when no advertised daemon address answers (docs/15: "A
|
|
232
|
+
* dashboard that cannot reach any advertised address should render the daemon as
|
|
233
|
+
* down"). Distinct menu-bar icon + a red header; a Refresh item to retry. */
|
|
234
|
+
export function renderDaemonDown(reason: string, opts: RenderOptions): string {
|
|
235
|
+
const lines: string[] = []
|
|
236
|
+
lines.push(line(0, '', { sfimage: 'exclamationmark.triangle.fill', color: COLOR.down }))
|
|
237
|
+
lines.push('---')
|
|
238
|
+
lines.push(line(0, 'iapeer daemon unreachable', { color: COLOR.down }))
|
|
239
|
+
lines.push(line(0, reason, { color: COLOR.meta }))
|
|
240
|
+
lines.push('---')
|
|
241
|
+
lines.push(line(0, 'Refresh', { refresh: true, sfimage: 'arrow.clockwise' }))
|
|
242
|
+
return lines.join('\n') + '\n'
|
|
243
|
+
}
|