@agfpd/iapeer 0.4.74 → 0.4.76
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/README.md +2 -2
- package/docs/07-install-and-update.md +1 -1
- package/docs/16-tray.md +17 -4
- package/docs/ru/16-tray.md +17 -5
- package/package.json +1 -1
- package/src/cli/index.ts +14 -2
- package/src/launch/index.ts +20 -7
- package/src/onboard/tray.ts +101 -0
- package/src/tray/render.ts +64 -1
- package/src/tui/onboard/app.tsx +36 -7
- package/src/tui/onboard/run.tsx +26 -1
package/README.md
CHANGED
|
@@ -107,6 +107,8 @@ What this means for you:
|
|
|
107
107
|
- You are trusting the model's judgment, and the doctrine you give each peer, the same way you would trust a developer with a shell on your account.
|
|
108
108
|
- iapeer does **not** silently weaken your own interactive `claude` / `codex` setup. The bypass acceptance is recorded per peer, in that agent's own config; the project-MCP pre-approval is written **project-locally**, under the peer's folder — never to your global user settings.
|
|
109
109
|
|
|
110
|
+
**Want a human in the loop for a given peer?** Set its approval mode to `gated` — `iapeer approval-mode <peer> gated`. That peer launches **without** the bypass flag, and every blocking approval its runtime would raise is routed to you — a Telegram button, a click in the tray, or `iapeer approve` on the CLI — before the tool runs; your `allow`/`deny` (with a reason) flows back and the tool proceeds or is refused. The default stays `yolo` (bypass) for every peer; `gated` is opt-in, per peer. See [docs/17 — Human approval](docs/17-approval.md).
|
|
111
|
+
|
|
110
112
|
Run iapeer on a host you control, give each peer only the access it needs, and treat a peer's instructions as security-relevant. `iapeer onboard` states this at setup; it's repeated here so the trade-off is explicit before you create your first agent. Details on every first-run dialog: [docs/07 — Install & update](docs/07-install-and-update.md#first-run-dialogs-on-a-clean-machine).
|
|
111
113
|
|
|
112
114
|
## Documentation
|
|
@@ -121,8 +123,6 @@ Planned, not yet built:
|
|
|
121
123
|
- **Linux support** — iapeer runs on macOS today; Linux is planned.
|
|
122
124
|
- **More agent runtimes** — `opencode` and `antigravity`, two further CLI runtimes alongside Claude Code and Codex.
|
|
123
125
|
- **Discord runtime** — a human-presence runtime for Discord, alongside Telegram.
|
|
124
|
-
- **Permissions gate** — finer control over what an agent is allowed to do.
|
|
125
|
-
- **Approve permissions over Telegram** — approve an agent's permission requests from the chat; today permissions are auto-allowed by flags.
|
|
126
126
|
|
|
127
127
|
## License
|
|
128
128
|
|
|
@@ -35,7 +35,7 @@ The same operation is available as `iapeer install` if the binary is already in
|
|
|
35
35
|
iapeer onboard
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
`onboard` prepares the host for agents. First of all it **brings up the daemon** — loads the plist into launchd, after which launchd holds the daemon itself, bringing it back up on a crash and a reboot. No separate `launchctl` command is needed for this. Then onboard registers the extension marketplace in Claude and Codex, provisions the scheduler (`notifier`), creates a human peer on Telegram,
|
|
38
|
+
`onboard` prepares the host for agents. First of all it **brings up the daemon** — loads the plist into launchd, after which launchd holds the daemon itself, bringing it back up on a crash and a reboot. No separate `launchctl` command is needed for this. Then onboard registers the extension marketplace in Claude and Codex, provisions the scheduler (`notifier`), creates a human peer on Telegram, installs the memory provider and the voice backend, and offers the menu-bar tray face (SwiftBar). The daemon start always runs; the memory/voice/notifier/telegram steps are on by default and turned off with flags (`--no-notifier`, `--no-telegram`, `--no-memory`, `--no-voice`). The **tray** is the exception — default **off** (it pulls in a GUI app via Homebrew), opted into with `--tray` on the non-interactive path or a `[y/N]` prompt in the interactive wizard; without Homebrew it degrades to a soft skip (the plugin is left in place, install SwiftBar manually with `iapeer tray install --app`).
|
|
39
39
|
|
|
40
40
|
Check the daemon is alive and the host is ready:
|
|
41
41
|
|
package/docs/16-tray.md
CHANGED
|
@@ -24,11 +24,16 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
24
24
|
daemon any other way; the built-in TUI keeps its direct read path for that case, see
|
|
25
25
|
[15](15-fleet-api.md)).
|
|
26
26
|
- **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
|
|
27
|
-
provider heartbeats, then one row per peer
|
|
27
|
+
provider heartbeats, then one row per peer, in **two groups**: the `🔒` **launchd block**
|
|
28
|
+
(the human channel + service infra — non-clickable status rows) is pulled to the **top as
|
|
29
|
+
one contiguous block, before the first working agent**, regardless of live/asleep — so the
|
|
30
|
+
bar separates "service, don't touch" from the working agents at a glance. Working agents
|
|
31
|
+
follow. Within **each** group the order is live → asleep → stopped, then alphabetical.
|
|
28
32
|
- `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
|
|
29
33
|
`●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
|
|
30
|
-
a human is attached, `🔒` launchd-managed (H4 read-only),
|
|
31
|
-
|
|
34
|
+
a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
|
|
35
|
+
human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
|
|
36
|
+
row reads as yolo), `⏳N` an ephemeral peer's queue depth.
|
|
32
37
|
- **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
|
|
33
38
|
the attach action (no submenu, so a click attaches rather than merely expanding). The
|
|
34
39
|
handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
|
|
@@ -37,8 +42,16 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
37
42
|
own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
|
|
38
43
|
silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
|
|
39
44
|
peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
|
|
40
|
-
lines. Lifecycle actions (
|
|
45
|
+
lines. Lifecycle actions (Stop / Start / New / Interrupt / Refresh / Compact) live
|
|
41
46
|
in the **Manage** submenu, each a `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`.
|
|
47
|
+
(No Wake item — a peer-row click already wakes a sleeping peer before it attaches, so
|
|
48
|
+
it was redundant.) For an agentic peer the Manage submenu also carries an
|
|
49
|
+
**approval-mode toggle** (docs/17): it reads the current mode and flips it via the local
|
|
50
|
+
`iapeer approval-mode <peer> <mode>` verb, applied on the peer's **next fresh session**
|
|
51
|
+
(a click never respawns a live session). The friction is asymmetric to the security
|
|
52
|
+
weight: `yolo → gated` (adds human approval) is one safe tap; `gated → yolo` (REMOVES the
|
|
53
|
+
approval perimeter) sits behind the submenu as an explicit red ⚠ confirm, so it is never
|
|
54
|
+
dropped by a stray click.
|
|
42
55
|
- **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
|
|
43
56
|
each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
|
|
44
57
|
**verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -22,11 +22,16 @@ SwiftBar.app, если его нет.
|
|
|
22
22
|
покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
|
|
23
23
|
[15](15-fleet-api.md)).
|
|
24
24
|
- **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
|
|
25
|
-
и голоса, затем строка на
|
|
25
|
+
и голоса, затем строка на пира, **двумя группами**: блок `🔒` **под launchd** (человеческий
|
|
26
|
+
канал + служебная инфра — некликабельные статус-строки) вынесен **наверх единым непрерывным
|
|
27
|
+
блоком, перед первым рабочим агентом**, независимо от live/asleep — так бар с одного взгляда
|
|
28
|
+
отделяет «служебное, не трогать» от рабочих агентов. Рабочие агенты идут следом. Внутри
|
|
29
|
+
**каждой** группы порядок: живой → спит → остановлен, затем по алфавиту.
|
|
26
30
|
- `<peer> <рантайм><глиф> … <возраст>` с той же семантикой глифов, что в CLI —
|
|
27
31
|
`●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
|
|
28
|
-
`👤` подключён человек, `🔒` под launchd (H4 read-only),
|
|
29
|
-
|
|
32
|
+
`👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
|
|
33
|
+
human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
|
|
34
|
+
`⏳N` глубина очереди эфемерного пира.
|
|
30
35
|
- **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
|
|
31
36
|
attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
|
|
32
37
|
attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
|
|
@@ -34,8 +39,15 @@ SwiftBar.app, если его нет.
|
|
|
34
39
|
родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
|
|
35
40
|
Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
|
|
36
41
|
(telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
|
|
37
|
-
(
|
|
38
|
-
`POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
|
|
42
|
+
(Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
|
|
43
|
+
`POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — клик по строке
|
|
44
|
+
пира и так будит спящего перед аттачем, отдельный Wake был избыточен.) Для агентного пира
|
|
45
|
+
подменю Manage несёт ещё **переключатель approval-режима** (docs/17): читает текущий режим и
|
|
46
|
+
флипает его локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей
|
|
47
|
+
свежей сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу
|
|
48
|
+
безопасности: `yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo`
|
|
49
|
+
(СНИМАЕТ периметр approval) спрятан в подменю как явный красный ⚠ confirm, чтобы его не сбросить
|
|
50
|
+
случайным кликом.
|
|
39
51
|
- **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
|
|
40
52
|
ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
|
|
41
53
|
**дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1168,6 +1168,7 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
|
|
|
1168
1168
|
'plugin-only',
|
|
1169
1169
|
'remove-codesign-identity',
|
|
1170
1170
|
'stream',
|
|
1171
|
+
'tray',
|
|
1171
1172
|
'yes',
|
|
1172
1173
|
])
|
|
1173
1174
|
|
|
@@ -1219,8 +1220,8 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1219
1220
|
{ sig: 'help | --help | -h', desc: 'print this usage (works appended to any verb; executes nothing)' },
|
|
1220
1221
|
{ sig: 'daemon [--install-plist]', desc: 'run the host-wide HTTP-MCP router (launchd-held)' },
|
|
1221
1222
|
{
|
|
1222
|
-
sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--infra <csv>]',
|
|
1223
|
-
desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (
|
|
1223
|
+
sig: 'onboard [--accept-risk] [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--no-voice] [--voice <pkg>] [--tray] [--infra <csv>]',
|
|
1224
|
+
desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (default YES) → tray (default NO, --tray opts in). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
|
|
1224
1225
|
},
|
|
1225
1226
|
{ sig: 'status', desc: 'host snapshot: version, daemon health, memory + voice slots (<provider> | none)' },
|
|
1226
1227
|
{ sig: 'live-runtime <peer>', desc: 'print the peer\'s CURRENT live runtime (freshest pane-log among pid-alive sessions; NOT default_runtime). Exit 1 + no output if none alive' },
|
|
@@ -1526,6 +1527,17 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1526
1527
|
})
|
|
1527
1528
|
const voiceLabel = voice.provider ? `${voice.provider.provider} ${voice.provider.version}` : 'none'
|
|
1528
1529
|
out(`voice: ${voice.state}${voice.detail ? ` — ${voice.detail}` : ''} (slot: ${voiceLabel})\n`)
|
|
1530
|
+
// Tray face (SwiftBar menu-bar fleet dashboard): OPTIONAL and DEFAULT-NO on the
|
|
1531
|
+
// linear path (a GUI dependency — conservatively opt-in). `--tray` opts in;
|
|
1532
|
+
// without it the step is skipped (non-interactive never hangs — the step asks
|
|
1533
|
+
// nothing). The plugin FILE is already dropped by install; this is GUI activation.
|
|
1534
|
+
const { onboardTrayStep } = await import('../onboard/tray.ts')
|
|
1535
|
+
const tray = await onboardTrayStep({
|
|
1536
|
+
skip: flags.tray !== true,
|
|
1537
|
+
dryRun: flags['dry-run'] === true,
|
|
1538
|
+
env,
|
|
1539
|
+
})
|
|
1540
|
+
out(`tray: ${tray.state}${tray.detail ? ` — ${tray.detail}` : ''}\n`)
|
|
1529
1541
|
// Runtime auth readiness (clean-host prerequisite): a peer runs the runtime's
|
|
1530
1542
|
// interactive TUI; the launcher auto-clears first-run modals (theme/trust) but
|
|
1531
1543
|
// CANNOT complete a login OAuth flow. Warn for every INSTALLED runtime (marketplace
|
package/src/launch/index.ts
CHANGED
|
@@ -122,6 +122,24 @@ export function exitLogPath(exitLogDir: string): string {
|
|
|
122
122
|
// launch — bring up ONE session (runtime-agnostic via the adapter)
|
|
123
123
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The intelligence gate — launch step (0), extracted as a PURE predicate so it is
|
|
127
|
+
* unit-testable in isolation (no bring-up, never touches the pty host). A channel
|
|
128
|
+
* adapter that declares `allowedIntelligences` (telegram/voicetalk → ['natural',
|
|
129
|
+
* 'absent']) refuses a peer whose nature is NOT in the set — and refuses too when
|
|
130
|
+
* the nature is unknown (cannot confirm). Returns the fail-loud reason string, or
|
|
131
|
+
* `null` when the gate PASSES (the peer may proceed to bring-up).
|
|
132
|
+
*/
|
|
133
|
+
export function intelligenceGateReason(adapter: RuntimeAdapter, spec: LaunchSpec): string | null {
|
|
134
|
+
if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
|
|
135
|
+
return (
|
|
136
|
+
`runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
|
|
137
|
+
`peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
export const launch: LaunchFn = async (
|
|
126
144
|
spec: LaunchSpec,
|
|
127
145
|
adapter: RuntimeAdapter,
|
|
@@ -137,13 +155,8 @@ export const launch: LaunchFn = async (
|
|
|
137
155
|
// A channel runtime carries a human (natural) OR a faceless service bot (absent), never an
|
|
138
156
|
// LLM agent (artificial). Ports the persistent-peer FATAL human-channel guard, relaxed to
|
|
139
157
|
// first-class faceless service bots (docs/Рантайм-адаптеры).
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
identity,
|
|
143
|
-
`runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
|
|
144
|
-
`peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
|
|
145
|
-
)
|
|
146
|
-
}
|
|
158
|
+
const gateReason = intelligenceGateReason(adapter, spec)
|
|
159
|
+
if (gateReason) return fail(identity, gateReason)
|
|
147
160
|
|
|
148
161
|
// (0.5) Ensure the socket's PARENT dir exists before `tmux new-session -S <sock>` —
|
|
149
162
|
// tmux does NOT create it and fails SILENTLY (the session "dies immediately")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Tray-face onboard step (the SwiftBar menu-bar fleet dashboard).
|
|
2
|
+
//
|
|
3
|
+
// Host-phase, OPTIONAL, DEFAULT-NO (a GUI dependency — conservative: do not pull it
|
|
4
|
+
// onto the host without an explicit yes). This is a thin WRAPPER over the existing
|
|
5
|
+
// activation `installTray` (src/tray/install.ts) — the SAME mechanics as `iapeer tray
|
|
6
|
+
// install --app`: brew cask + dequarantine + launch + refresh — dressed in an
|
|
7
|
+
// onboard-shaped result vocabulary, with a SOFT no-Homebrew path (report + continue,
|
|
8
|
+
// NEVER fail the onboard) per the phase principle "unavailability is a soft skip".
|
|
9
|
+
//
|
|
10
|
+
// IDEMPOTENT: SwiftBar already installed → NOT reinstalled, only the plugin is
|
|
11
|
+
// (re)activated + launched. The plugin FILE itself is dropped by the foundation
|
|
12
|
+
// install step regardless (inert without SwiftBar); this step is the GUI activation.
|
|
13
|
+
//
|
|
14
|
+
// Outcome semantics: this step NEVER fails the onboard exit code — no tray is a fully
|
|
15
|
+
// valid state regardless of why (skipped / no brew / cask failure). Mirrors
|
|
16
|
+
// onboard/voice.ts deliberately (same shape), minus the provider-slot concepts.
|
|
17
|
+
|
|
18
|
+
import { isExecutable } from './index.ts'
|
|
19
|
+
import { installTray, swiftBarInstalled, type Runner } from '../tray/install.ts'
|
|
20
|
+
|
|
21
|
+
export interface TrayOnboardOptions {
|
|
22
|
+
/** --no-tray (wizard) / absence of --tray (linear): skip the step entirely. */
|
|
23
|
+
skip?: boolean
|
|
24
|
+
dryRun?: boolean
|
|
25
|
+
env?: NodeJS.ProcessEnv
|
|
26
|
+
/** Injectable side-effect runner (tests). Default: installTray's own spawnSync. */
|
|
27
|
+
run?: Runner
|
|
28
|
+
/** Injectable SwiftBar.app presence probe (tests). Default swiftBarInstalled(). */
|
|
29
|
+
probeApp?: () => boolean
|
|
30
|
+
/** Injectable Homebrew-availability probe (tests). Default: `brew` on PATH. */
|
|
31
|
+
brewAvailable?: () => boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TrayOnboardResult {
|
|
35
|
+
state:
|
|
36
|
+
| 'installed' // SwiftBar.app installed (brew cask) + plugin activated + launched
|
|
37
|
+
| 'activated' // SwiftBar already present → plugin activated + launched (idempotent, no reinstall)
|
|
38
|
+
| 'skipped-flag' // --no-tray / not opted-in
|
|
39
|
+
| 'skipped-no-brew' // SwiftBar absent AND no Homebrew — soft skip (plugin dropped inert; guidance given)
|
|
40
|
+
| 'install-failed' // brew ran and failed (network/cask) — soft (reported; plugin still lands)
|
|
41
|
+
| 'dry-run'
|
|
42
|
+
detail?: string
|
|
43
|
+
/** The resolved plugin file, when known. */
|
|
44
|
+
pluginFile?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DOCS = 'docs/16-tray.md'
|
|
48
|
+
|
|
49
|
+
export async function onboardTrayStep(opts: TrayOnboardOptions = {}): Promise<TrayOnboardResult> {
|
|
50
|
+
const env = opts.env ?? process.env
|
|
51
|
+
if (opts.skip) return { state: 'skipped-flag' }
|
|
52
|
+
const probeApp = opts.probeApp ?? swiftBarInstalled
|
|
53
|
+
const brewAvailable = opts.brewAvailable ?? ((): boolean => isExecutable('brew', env))
|
|
54
|
+
|
|
55
|
+
if (opts.dryRun) {
|
|
56
|
+
const present = probeApp()
|
|
57
|
+
return {
|
|
58
|
+
state: 'dry-run',
|
|
59
|
+
detail: present
|
|
60
|
+
? 'SwiftBar present — would activate the fleet plugin + launch'
|
|
61
|
+
: brewAvailable()
|
|
62
|
+
? 'would install SwiftBar.app (brew cask) + activate the fleet plugin + launch'
|
|
63
|
+
: 'no Homebrew — would drop the plugin inert + advise manual SwiftBar install',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const present = probeApp()
|
|
68
|
+
|
|
69
|
+
// SwiftBar absent AND no Homebrew → SOFT skip. The auto-install path is brew-only, so
|
|
70
|
+
// without it there is nothing to run; drop the plugin inert (it activates the moment
|
|
71
|
+
// SwiftBar appears), guide the user, and NEVER fail the onboard.
|
|
72
|
+
if (!present && !brewAvailable()) {
|
|
73
|
+
const r = installTray({ env, installApp: false, launch: false, run: opts.run, probeApp: opts.probeApp })
|
|
74
|
+
return {
|
|
75
|
+
state: 'skipped-no-brew',
|
|
76
|
+
pluginFile: r.pluginFile,
|
|
77
|
+
detail:
|
|
78
|
+
'Homebrew not found — the menu-bar app (SwiftBar) needs it. The fleet plugin is in place; ' +
|
|
79
|
+
'install SwiftBar manually (`brew install --cask swiftbar`, or https://swiftbar.app), then run ' +
|
|
80
|
+
`\`iapeer tray install --app\`. Details: ${DOCS}`,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Present → activate ONLY (installApp:false — never reinstall). Absent + brew → install the cask.
|
|
85
|
+
const r = installTray({ env, installApp: !present, launch: true, run: opts.run, probeApp: opts.probeApp })
|
|
86
|
+
if (r.app === 'install-failed') {
|
|
87
|
+
return {
|
|
88
|
+
state: 'install-failed',
|
|
89
|
+
pluginFile: r.pluginFile,
|
|
90
|
+
detail:
|
|
91
|
+
`SwiftBar install via brew failed (${r.appReason ?? 'unknown'}) — the fleet plugin is in place; ` +
|
|
92
|
+
`retry with \`iapeer tray install --app\` or install SwiftBar manually (${DOCS}).`,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const activatedMsg = r.launched ? 'plugin activated + SwiftBar launched' : 'plugin written (SwiftBar not launched)'
|
|
96
|
+
return {
|
|
97
|
+
state: present ? 'activated' : 'installed',
|
|
98
|
+
pluginFile: r.pluginFile,
|
|
99
|
+
detail: `${activatedMsg}${r.dir === 'existing-swiftbar' ? ' (your SwiftBar plugin dir)' : ''}`,
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/tray/render.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface TrayPeer {
|
|
|
43
43
|
last_active_ms?: number
|
|
44
44
|
attached?: boolean
|
|
45
45
|
launchd_managed?: boolean
|
|
46
|
+
/** Human-approval mode (docs/17). ABSENT ⇒ `yolo` (client obligation — a pre-approval daemon omits it). */
|
|
47
|
+
approval_mode?: 'yolo' | 'gated'
|
|
46
48
|
wake_policy?: 'warm' | 'ephemeral'
|
|
47
49
|
queue_depth?: number
|
|
48
50
|
}
|
|
@@ -133,10 +135,18 @@ function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
|
|
|
133
135
|
|
|
134
136
|
const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
|
|
135
137
|
|
|
138
|
+
// 🔒 launchd-managed peers (the human channel + service infra — arthur/timer/watcher) are
|
|
139
|
+
// NON-clickable status rows. They form ONE contiguous block at the very TOP, before the first
|
|
140
|
+
// working agent, regardless of live/asleep — so the bar separates "service, don't touch" from the
|
|
141
|
+
// working agents at a glance (owner UX). `attachable` (= !launchd_managed) is the same predicate.
|
|
142
|
+
const lockRank = (p: TrayPeer): number => (p.launchd_managed ? 0 : 1)
|
|
143
|
+
|
|
136
144
|
// ── renderers ─────────────────────────────────────────────────────────────────
|
|
137
145
|
|
|
138
146
|
const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
|
|
139
|
-
|
|
147
|
+
// NB: no 'Wake' — a peer-row click (attach) already wakes a sleeping peer before it
|
|
148
|
+
// connects, so a separate Wake item was redundant (owner decision). `tray cmd wake`
|
|
149
|
+
// stays available on the CLI / fleet API; only the menu item is dropped.
|
|
140
150
|
{ cmd: 'stop', label: 'Stop' },
|
|
141
151
|
{ cmd: 'start', label: 'Start' },
|
|
142
152
|
{ cmd: 'new', label: 'New session (fresh)' },
|
|
@@ -150,6 +160,10 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
150
160
|
const now = opts.now ?? Date.now()
|
|
151
161
|
const bin = opts.binPath
|
|
152
162
|
const peers = [...snapshot.peers].sort((a, b) => {
|
|
163
|
+
// primary: the 🔒 lock block first (service/human infra), working agents below
|
|
164
|
+
const l = lockRank(a) - lockRank(b)
|
|
165
|
+
if (l !== 0) return l
|
|
166
|
+
// secondary (within EACH block): the existing live→asleep→stopped, then alpha
|
|
153
167
|
const r = RANK[aggregate(a)] - RANK[aggregate(b)]
|
|
154
168
|
return r !== 0 ? r : a.personality.localeCompare(b.personality)
|
|
155
169
|
})
|
|
@@ -241,9 +255,14 @@ function attachable(p: TrayPeer): boolean {
|
|
|
241
255
|
function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
|
|
242
256
|
const agg = aggregate(p)
|
|
243
257
|
const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
|
|
258
|
+
// approval mode (docs/17): the whole fleet defaults to `yolo`, so showing it on every row is
|
|
259
|
+
// noise — mark ONLY the exception, `gated` (this peer's blocking approvals go to a human), with a
|
|
260
|
+
// 🛡 shield. No badge ⇒ yolo (the default; also how a pre-approval snapshot with the field absent
|
|
261
|
+
// renders). So the row reads the mode: 🛡 = gated, bare = yolo.
|
|
244
262
|
const badges =
|
|
245
263
|
(p.attached ? ' 👤' : '') +
|
|
246
264
|
(p.launchd_managed ? ' 🔒' : '') +
|
|
265
|
+
(p.approval_mode === 'gated' ? ' 🛡' : '') +
|
|
247
266
|
(p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
|
|
248
267
|
const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
|
|
249
268
|
// HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
|
|
@@ -278,10 +297,54 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
|
|
|
278
297
|
}),
|
|
279
298
|
)
|
|
280
299
|
}
|
|
300
|
+
// approval-mode toggle — only for agentic (attachable) peers; meaningless for the
|
|
301
|
+
// human channel / notifier infra (arthur/timer/watcher), which are always yolo.
|
|
302
|
+
if (attachable(p)) out.push(...renderApprovalToggle(p, bin))
|
|
281
303
|
}
|
|
282
304
|
return out
|
|
283
305
|
}
|
|
284
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The per-peer approval-mode control in the Manage submenu (docs/17): READS the current
|
|
309
|
+
* mode and FLIPS it via the local `approval-mode` verb.
|
|
310
|
+
*
|
|
311
|
+
* APPLY = next session, NOT now. The flip persists the mode + brings the runtime
|
|
312
|
+
* surfaces to it; it does NOT pass --now, so it takes effect on the peer's NEXT fresh
|
|
313
|
+
* session. A menu tap must never kill a live session mid-work (a respawn is a heavy,
|
|
314
|
+
* surprising side effect of a click). The item says "(next session)" so the wait is
|
|
315
|
+
* explicit.
|
|
316
|
+
*
|
|
317
|
+
* refresh=true is the ONE deliberate exception to the "no refresh in the streamable
|
|
318
|
+
* menu" rule: the flip runs the local verb (no daemon round-trip → NO SSE event), so
|
|
319
|
+
* the plugin must re-render to reflect the new mode.
|
|
320
|
+
*
|
|
321
|
+
* SECURITY-ASYMMETRIC friction (a mode-flip changes the security PERIMETER — unlike a
|
|
322
|
+
* one-off Deny): yolo→gated STRENGTHENS (adds human approval) → a single safe tap;
|
|
323
|
+
* gated→yolo WEAKENS (REMOVES human approval) → the current mode sits on a submenu and
|
|
324
|
+
* the flip is an explicit red ⚠ confirm CHILD, so the perimeter is never dropped by one
|
|
325
|
+
* stray click. SwiftBar has no native confirm dialog — the nested explicit item IS the
|
|
326
|
+
* confirm.
|
|
327
|
+
*/
|
|
328
|
+
function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
|
|
329
|
+
const flip = (target: 'gated' | 'yolo'): Record<string, string | boolean> => ({
|
|
330
|
+
bash: bin,
|
|
331
|
+
param1: 'approval-mode',
|
|
332
|
+
param2: p.personality,
|
|
333
|
+
param3: target,
|
|
334
|
+
terminal: false,
|
|
335
|
+
refresh: true, // local verb → no SSE event → re-render to show the new mode
|
|
336
|
+
})
|
|
337
|
+
if (p.approval_mode === 'gated') {
|
|
338
|
+
// WEAKEN — current on the parent (no action); the flip is an explicit red confirm child.
|
|
339
|
+
return [
|
|
340
|
+
line(2, 'Approval: gated 🛡', { color: COLOR.warn }),
|
|
341
|
+
line(3, '⚠ Switch to yolo — removes human approval (next session)', { color: COLOR.down, ...flip('yolo') }),
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
// STRENGTHEN (yolo → gated) — safe, one tap.
|
|
345
|
+
return [line(2, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
|
|
346
|
+
}
|
|
347
|
+
|
|
285
348
|
// ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────
|
|
286
349
|
|
|
287
350
|
/** Menu-bar pending badge: a red `N.circle.fill` SF Symbol (the iOS-notification look — a red circle
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -19,6 +19,8 @@ export interface WizardResult {
|
|
|
19
19
|
code: number
|
|
20
20
|
memoryConsent: boolean
|
|
21
21
|
voiceConsent: boolean
|
|
22
|
+
/** Install the SwiftBar menu-bar tray face (default-NO — a GUI dependency). */
|
|
23
|
+
trayConsent: boolean
|
|
22
24
|
advisories: string[]
|
|
23
25
|
summary: string[]
|
|
24
26
|
telegramConsent: boolean
|
|
@@ -28,7 +30,7 @@ export interface WizardResult {
|
|
|
28
30
|
hostRuntime?: OnboardRuntime
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'declined' | 'finishing'
|
|
33
|
+
type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'voice' | 'tray' | 'declined' | 'finishing'
|
|
32
34
|
type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
|
|
33
35
|
interface Step {
|
|
34
36
|
key: string
|
|
@@ -98,16 +100,20 @@ export function OnboardApp({
|
|
|
98
100
|
const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
|
|
99
101
|
const [telegramConsent, setTelegramConsent] = useState(false)
|
|
100
102
|
const [memoryConsent, setMemoryConsent] = useState(false)
|
|
103
|
+
const [voiceConsent, setVoiceConsent] = useState(false)
|
|
101
104
|
const spin = useSpinnerFrame(phase === 'running')
|
|
102
105
|
|
|
103
|
-
const finish = (
|
|
106
|
+
const finish = (trayConsent: boolean): void => {
|
|
104
107
|
const failed = steps.some(s => s.status === 'fail')
|
|
105
108
|
const summary = steps.map(s => {
|
|
106
109
|
const mark = s.status === 'ok' ? '✓' : s.status === 'warn' ? '!' : s.status === 'fail' ? '✗' : '·'
|
|
107
110
|
return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
|
|
108
111
|
})
|
|
109
112
|
summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
|
|
110
|
-
|
|
113
|
+
// voiceConsent is committed state by now (its phase completed a render cycle before
|
|
114
|
+
// tray); trayConsent is the last decision, passed directly (its setState wouldn't
|
|
115
|
+
// have flushed within this same input handler).
|
|
116
|
+
onResult({ code: failed ? 1 : 0, memoryConsent, voiceConsent, trayConsent, telegramConsent, advisories, summary, hostRuntime })
|
|
111
117
|
setPhase('finishing')
|
|
112
118
|
setTimeout(() => exit(), 30)
|
|
113
119
|
}
|
|
@@ -152,19 +158,34 @@ export function OnboardApp({
|
|
|
152
158
|
{ isActive: phase === 'memory' },
|
|
153
159
|
)
|
|
154
160
|
|
|
155
|
-
// Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install).
|
|
161
|
+
// Voice consent — DEFAULT-YES ([Y/n]: Enter or y → install). Then → tray.
|
|
156
162
|
useInput(
|
|
157
163
|
(input, key) => {
|
|
158
|
-
if (input === 'n' || input === 'N')
|
|
159
|
-
|
|
164
|
+
if (input === 'n' || input === 'N') {
|
|
165
|
+
setVoiceConsent(false)
|
|
166
|
+
setPhase('tray')
|
|
167
|
+
} else if (input === 'y' || input === 'Y' || key.return) {
|
|
168
|
+
setVoiceConsent(true)
|
|
169
|
+
setPhase('tray')
|
|
170
|
+
}
|
|
160
171
|
},
|
|
161
172
|
{ isActive: phase === 'voice' },
|
|
162
173
|
)
|
|
163
174
|
|
|
175
|
+
// Tray consent — DEFAULT-NO ([y/N]: Enter or n → skip; y → install). A GUI
|
|
176
|
+
// dependency (SwiftBar), so conservatively opt-IN. The LAST consent → finish.
|
|
177
|
+
useInput(
|
|
178
|
+
(input, key) => {
|
|
179
|
+
if (input === 'y' || input === 'Y') finish(true)
|
|
180
|
+
else if (input === 'n' || input === 'N' || key.return || key.escape) finish(false)
|
|
181
|
+
},
|
|
182
|
+
{ isActive: phase === 'tray' },
|
|
183
|
+
)
|
|
184
|
+
|
|
164
185
|
// Declined → exit 1 (no host mutation).
|
|
165
186
|
useEffect(() => {
|
|
166
187
|
if (phase !== 'declined') return
|
|
167
|
-
onResult({ code: 1, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
|
|
188
|
+
onResult({ code: 1, memoryConsent: false, voiceConsent: false, trayConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
|
|
168
189
|
const t = setTimeout(() => exit(), 30)
|
|
169
190
|
return () => clearTimeout(t)
|
|
170
191
|
}, [phase, onResult, exit])
|
|
@@ -377,6 +398,14 @@ export function OnboardApp({
|
|
|
377
398
|
<Text dimColor> A local text-to-speech / speech-to-text HTTP service. Per-agent voice tools are a separate `iapeer enable`. [Y/n]</Text>
|
|
378
399
|
</Box>
|
|
379
400
|
) : null}
|
|
401
|
+
{phase === 'tray' ? (
|
|
402
|
+
<Box marginTop={1} flexDirection="column">
|
|
403
|
+
<Text bold color="cyan">
|
|
404
|
+
? Install the menu-bar tray face (SwiftBar)?
|
|
405
|
+
</Text>
|
|
406
|
+
<Text dimColor> A live fleet dashboard in your macOS menu bar (installs SwiftBar via Homebrew). [y/N]</Text>
|
|
407
|
+
</Box>
|
|
408
|
+
) : null}
|
|
380
409
|
</Box>
|
|
381
410
|
)
|
|
382
411
|
}
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -71,7 +71,7 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
71
71
|
// wizard only runs on one), off under NO_COLOR.
|
|
72
72
|
const a = makeAnsi(colorEnabled(env, process.stdout))
|
|
73
73
|
|
|
74
|
-
let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
74
|
+
let result: WizardResult = { code: 0, memoryConsent: false, voiceConsent: false, trayConsent: false, telegramConsent: false, advisories: [], summary: [] }
|
|
75
75
|
try {
|
|
76
76
|
const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
|
|
77
77
|
await app.waitUntilExit()
|
|
@@ -164,6 +164,31 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
|
|
|
164
164
|
'\n' + paintResult('voice', 'skipped', ' — set it up later with `npx @agfpd/voice-connect init`', a) + '\n',
|
|
165
165
|
)
|
|
166
166
|
}
|
|
167
|
+
|
|
168
|
+
if (result.trayConsent) {
|
|
169
|
+
const { onboardTrayStep } = await import('../../onboard/tray.ts')
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
'\n' +
|
|
172
|
+
a.bold('Installing the menu-bar tray face (SwiftBar)…') +
|
|
173
|
+
'\n' +
|
|
174
|
+
a.dim(
|
|
175
|
+
'A live fleet dashboard in the macOS menu bar. Needs Homebrew to auto-install SwiftBar;\n' +
|
|
176
|
+
'without brew the plugin is dropped inert and you install SwiftBar manually (`iapeer tray install --app`).',
|
|
177
|
+
) +
|
|
178
|
+
'\n\n',
|
|
179
|
+
)
|
|
180
|
+
try {
|
|
181
|
+
const t = await onboardTrayStep({ env })
|
|
182
|
+
process.stdout.write('\n' + paintResult('tray', t.state, t.detail ? ` — ${t.detail}` : '', a) + '\n')
|
|
183
|
+
} catch (e) {
|
|
184
|
+
const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(activate later: `iapeer tray install --app`)')}`
|
|
185
|
+
process.stdout.write('\n' + paintResult('tray', 'step skipped', rest, a) + '\n')
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
'\n' + paintResult('tray', 'skipped', ' — add it later with `iapeer tray install --app`', a) + '\n',
|
|
190
|
+
)
|
|
191
|
+
}
|
|
167
192
|
} finally {
|
|
168
193
|
process.removeListener('SIGINT', ignore)
|
|
169
194
|
for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
|