@agfpd/iapeer 0.4.63 → 0.4.65
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/15-fleet-api.md +2 -2
- package/docs/16-tray.md +88 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +2 -2
- 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 +113 -14
- package/src/supervisor/daemon.ts +64 -20
- package/src/tray/client.ts +233 -0
- package/src/tray/index.ts +146 -0
- package/src/tray/install.ts +266 -0
- package/src/tray/render.ts +243 -0
|
@@ -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
|
+
}
|