@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.
@@ -94,3 +94,15 @@ Commands an agent runs in its own session (require `PEER_IDENTITY` in the enviro
94
94
  | `run-infra <name> <runtime>` | An infrastructure peer's entry point; launched from its launchd plist. |
95
95
 
96
96
  These two commands are invoked by launchd, not the operator directly.
97
+
98
+ ## Tray (menu-bar dashboard)
99
+
100
+ | Command | What it does |
101
+ |---|---|
102
+ | `tray install [--plugin-only]` | Install the SwiftBar fleet dashboard: install SwiftBar.app when absent (owner-sanctioned), write the plugin, launch + refresh. `--plugin-only` writes just the plugin file. Idempotent. |
103
+ | `tray uninstall` | Remove the plugin file + refresh SwiftBar. Never touches the fleet; leaves SwiftBar.app installed. |
104
+ | `tray render [--stream]` | Print the SwiftBar plugin output (`--stream` = the streamable loop the plugin runs). |
105
+ | `tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions. |
106
+ | `tray status` | Read-only: fleet API reachability, SwiftBar install + plugin dir, plugin file presence. |
107
+
108
+ The tray is the first external [Fleet API](15-fleet-api.md) client — see [16 — Tray](16-tray.md).
@@ -0,0 +1,88 @@
1
+ # 16 — Tray (menu-bar fleet dashboard)
2
+
3
+ A macOS menu-bar dashboard of the fleet — the peer list of `iapeer list` rendered as a
4
+ [SwiftBar](https://github.com/swiftbar/SwiftBar) plugin. It is the **first external
5
+ client of the [Fleet API](15-fleet-api.md)**: it discovers the daemon via `router.json`,
6
+ reads `/fleet/v1/snapshot`, follows `/fleet/v1/events`, and issues commands over
7
+ `/fleet/v1/peers/<peer>/<cmd>` — all over HTTP, against the normative contract, with no
8
+ knowledge of daemon internals. If the daemon and the contract ever diverge, the tray is
9
+ the second consumer that feels it (the first being the daemon itself, which wrote the
10
+ snapshot).
11
+
12
+ Packaging: the tray ships **with the foundation** — it is another face of the daemon, the
13
+ same class as the `iapeer list` TUI, not a separate project. `iapeer install` drops the
14
+ plugin file (inert until SwiftBar is present); `iapeer tray install` is the one-time
15
+ activation that also installs SwiftBar.app when it is absent.
16
+
17
+ ## What you see
18
+
19
+ - **Menu bar:** an antenna glyph + the count of peers with a live session. When no
20
+ advertised daemon address answers, a red warning triangle instead (the daemon is down —
21
+ a purely API-fed dashboard cannot show a broken daemon any other way; the built-in TUI
22
+ keeps its direct read path for that case, see [15](15-fleet-api.md)).
23
+ - **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
24
+ provider heartbeats, then one row per peer:
25
+ - `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
26
+ `●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
27
+ a human is attached, `🔒` launchd-managed (H4 read-only), `⏳N` an ephemeral peer's
28
+ queue depth.
29
+ - **Click a peer → Terminal.app running `iapeer attach <peer>`.** Attach is a terminal
30
+ handoff the API deliberately keeps client-side (a PTY inside a GUI is out of scope);
31
+ the same row's submenu carries the lifecycle actions (Wake / Stop / Start / New /
32
+ Interrupt / Refresh / Compact), each a `POST /fleet/v1/peers/<peer>/<cmd>` via
33
+ `iapeer tray cmd`.
34
+
35
+ The list updates itself: the plugin is **streamable** — it subscribes to the event stream
36
+ and re-fetches the snapshot on every change (a peer waking, an operator stop/start, a
37
+ death), coalescing bursts. A ~15 s heartbeat keeps ages fresh and self-heals a missed
38
+ event; the stream reconnects across daemon restarts. No manual refresh needed (there is a
39
+ Refresh item anyway).
40
+
41
+ ## Verbs
42
+
43
+ | Verb | What it does |
44
+ |---|---|
45
+ | `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh. `--plugin-only` writes just the plugin file (no app, no launch — what the foundation install does). Idempotent. |
46
+ | `iapeer tray uninstall` | Remove the plugin file and refresh SwiftBar. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
47
+ | `iapeer tray render [--stream]` | Print the SwiftBar plugin output. `--stream` is the streamable loop the plugin runs; the one-shot form is the poll fallback and the test surface. |
48
+ | `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
49
+ | `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present. Repairs nothing. |
50
+
51
+ ## How it is wired
52
+
53
+ The SwiftBar plugin file (`iapeer.10s.sh` in the plugin dir) is a thin shim: SwiftBar
54
+ metadata (`<swiftbar.type>streamable</swiftbar.type>`) plus one line —
55
+ `exec "<~/.local/bin/iapeer>" tray render --stream`. All rendering, streaming and
56
+ command logic lives in the compiled binary (typed, tested); the `.sh` is stable and
57
+ carries no logic, so an `iapeer update` never has to rewrite it.
58
+
59
+ Plugin directory: an already-configured SwiftBar `PluginDirectory` is respected (the
60
+ plugin is installed into the user's own dir); otherwise a dedicated
61
+ `~/.iapeer/tray/plugins/`, and SwiftBar is pointed at it via
62
+ `defaults write com.ameba.SwiftBar PluginDirectory` — a plain-string default SwiftBar
63
+ reads on launch (it is not sandboxed and stores no security-scoped bookmark), so the
64
+ folder picker is skipped.
65
+
66
+ Auth follows the Fleet API: on an open-local host no bearer is needed; when the daemon is
67
+ configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it too.
68
+
69
+ Config: `IAPEER_TRAY_HEARTBEAT_MS` tunes the heartbeat cadence (default 15 s, clamped
70
+ 1 s…10 min).
71
+
72
+ ## Client obligations
73
+
74
+ Because the tray is a Fleet API client, it obeys the normative rules in
75
+ [15 — Fleet API §Client obligations](15-fleet-api.md): unknown `ev` kinds and unknown
76
+ JSON fields are ignored (additive evolution), events are at-least-once (the snapshot is
77
+ the state, events are change hints), and a missing `fleet: 1` marker in `router.json`
78
+ means "no fleet API" — the tray degrades to the daemon-down state rather than probing
79
+ endpoints.
80
+
81
+ ## Boundaries
82
+
83
+ - **GUI teardown ≠ fleet teardown.** Removing the plugin (or quitting SwiftBar) leaves the
84
+ daemon, the fleet and the CLI/TUI fully working — the dashboard is an optional face.
85
+ - **Attach is not an API command** — it is a terminal handoff the client performs.
86
+ - Native app, pet overlay and approval buttons are later phases (a native `iapeer-tray`
87
+ Swift app is considered only if the SwiftBar version proves insufficient); all would be
88
+ further thin clients of the same Fleet API.
package/docs/README.md CHANGED
@@ -77,6 +77,7 @@ The sections are layered: from "install and use" to "understand the architecture
77
77
  - [13 — Architecture and contract](13-architecture-and-contract.md) — for plugin and runtime-package developers
78
78
  - [14 — Configuration and logs](14-configuration-and-logs.md) — environment variables, logs, diagnostics
79
79
  - [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
80
+ - [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
80
81
 
81
82
  ## Status
82
83
 
@@ -0,0 +1,83 @@
1
+ # 16 — Трей (дашборд флота в меню-баре)
2
+
3
+ Дашборд флота в меню-баре macOS — список пиров `iapeer list`, отрисованный как плагин
4
+ [SwiftBar](https://github.com/swiftbar/SwiftBar). Это **первый внешний клиент
5
+ [Fleet API](15-fleet-api.md)**: он находит демон через `router.json`, читает
6
+ `/fleet/v1/snapshot`, следит за `/fleet/v1/events` и шлёт команды через
7
+ `/fleet/v1/peers/<peer>/<cmd>` — целиком по HTTP, против нормативного контракта, без знания
8
+ внутренностей демона. Если демон и контракт разойдутся, трей — второй потребитель, который
9
+ это почувствует (первый — сам демон, который снапшот и пишет).
10
+
11
+ Упаковка: трей поставляется **вместе с фундаментом** — это ещё одно «лицо» демона, тот же
12
+ класс, что TUI `iapeer list`, а не отдельный проект. `iapeer install` кладёт файл плагина
13
+ (инертен, пока нет SwiftBar); `iapeer tray install` — разовая активация, которая доустановит
14
+ SwiftBar.app, если его нет.
15
+
16
+ ## Что видно
17
+
18
+ - **Меню-бар:** глиф-антенна + число пиров с живой сессией. Если ни один объявленный адрес
19
+ демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
20
+ покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
21
+ [15](15-fleet-api.md)).
22
+ - **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
23
+ и голоса, затем строка на пира:
24
+ - `<peer> <рантайм><глиф> … <возраст>` с той же семантикой глифов, что в CLI —
25
+ `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
26
+ `👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
27
+ пира.
28
+ - **Клик по пиру → Terminal.app с `iapeer attach <peer>`.** Attach — терминальная передача,
29
+ которую API намеренно оставляет клиенту (PTY внутри GUI вне рамок); подменю той же строки
30
+ несёт lifecycle-действия (Wake / Stop / Start / New / Interrupt / Refresh / Compact),
31
+ каждое — `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
32
+
33
+ Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
34
+ снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
35
+ всплески. Хартбит ~15 с держит возрасты свежими и самозалечивает пропущенное событие; поток
36
+ переподключается через рестарты демона. Ручной refresh не нужен (пункт Refresh всё же есть).
37
+
38
+ ## Verb'ы
39
+
40
+ | Verb | Что делает |
41
+ |---|---|
42
+ | `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит. `--plugin-only` пишет только файл плагина (без приложения и запуска — так делает foundation-install). Идемпотентно. |
43
+ | `iapeer tray uninstall` | Удалит файл плагина и обновит SwiftBar. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
44
+ | `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
45
+ | `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
46
+ | `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина. Ничего не чинит. |
47
+
48
+ ## Как устроено
49
+
50
+ Файл плагина SwiftBar (`iapeer.10s.sh` в каталоге плагинов) — тонкий шим: метаданные SwiftBar
51
+ (`<swiftbar.type>streamable</swiftbar.type>`) плюс одна строка —
52
+ `exec "<~/.local/bin/iapeer>" tray render --stream`. Вся отрисовка, стриминг и команды живут
53
+ в скомпилированном бинаре (типизировано, покрыто тестами); `.sh` стабилен и логики не несёт,
54
+ поэтому `iapeer update` его не переписывает.
55
+
56
+ Каталог плагинов: уже настроенный `PluginDirectory` SwiftBar уважается (плагин ставится в
57
+ каталог пользователя); иначе — выделенный `~/.iapeer/tray/plugins/`, и SwiftBar нацеливается на
58
+ него через `defaults write com.ameba.SwiftBar PluginDirectory` — обычный строковый default,
59
+ который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
60
+ поэтому окно выбора папки пропускается.
61
+
62
+ Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
63
+ `IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.
64
+
65
+ Конфиг: `IAPEER_TRAY_HEARTBEAT_MS` настраивает каденцию хартбита (по умолчанию 15 с, зажато
66
+ 1 с…10 мин).
67
+
68
+ ## Обязанности клиента
69
+
70
+ Поскольку трей — клиент Fleet API, он соблюдает нормативные правила из
71
+ [15 — Fleet API §Client obligations](15-fleet-api.md): неизвестные виды `ev` и неизвестные
72
+ поля JSON игнорируются (аддитивная эволюция), события at-least-once (снапшот — состояние,
73
+ события — подсказки об изменении), а отсутствие маркера `fleet: 1` в `router.json` означает
74
+ «нет fleet API» — трей деградирует в состояние «демон недоступен», а не зондирует эндпоинты.
75
+
76
+ ## Границы
77
+
78
+ - **Снос GUI ≠ снос флота.** Удаление плагина (или выход из SwiftBar) оставляет демон, флот
79
+ и CLI/TUI полностью рабочими — дашборд опционален.
80
+ - **Attach — не команда API**, это терминальная передача, которую выполняет клиент.
81
+ - Нативное приложение, питомец-оверлей и кнопки подтверждений — более поздние фазы (нативное
82
+ Swift-приложение `iapeer-tray` рассматривается, только если версии на SwiftBar не хватит);
83
+ все они — тоже тонкие клиенты того же Fleet API.
package/docs/ru/README.md CHANGED
@@ -77,6 +77,7 @@ iapeer send assistant --message "привет, ты на связи?"
77
77
  - [13 — Архитектура и контракт](13-архитектура-и-контракт.md) — для разработчиков плагинов и рантайм-пакетов
78
78
  - [14 — Конфигурация и журналы](14-конфигурация-и-журналы.md) — переменные окружения, журналы, диагностика
79
79
  - [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
80
+ - [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
80
81
 
81
82
  ## Статус
82
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.64",
3
+ "version": "0.4.66",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -1165,7 +1165,9 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
1165
1165
  'no-telegram',
1166
1166
  'no-voice',
1167
1167
  'npx',
1168
+ 'plugin-only',
1168
1169
  'remove-codesign-identity',
1170
+ 'stream',
1169
1171
  'yes',
1170
1172
  ])
1171
1173
 
@@ -1275,6 +1277,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1275
1277
  sig: 'supervisor up|start|attach|list|kill <sess> [runtime]',
1276
1278
  desc: 'DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)',
1277
1279
  },
1280
+ {
1281
+ sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|status',
1282
+ desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; install activates it (installs SwiftBar when absent). See docs/16.',
1283
+ },
1278
1284
  ]
1279
1285
 
1280
1286
  /** Greedy word-wrap to `width` columns. A token longer than `width` keeps its own
@@ -2275,6 +2281,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2275
2281
  // docs on its OWN install, version = its binary). install runs from source, so
2276
2282
  // docs/ sits at <entry>/../../docs. Best-effort: never fails the install.
2277
2283
  const docs = scaffoldHostDocs('iapeer', join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docs'), env)
2284
+ // Ship the tray face WITH the foundation (owner decision — same class as the
2285
+ // `iapeer list` TUI). Plugin-file ONLY: drop/refresh the SwiftBar plugin shim
2286
+ // (into a configured SwiftBar dir if present, else the dedicated ~/.iapeer dir).
2287
+ // Inert without SwiftBar; NEVER auto-installs the GUI here (that is the explicit
2288
+ // `iapeer tray install`). Best-effort — a hiccup never fails the foundation install.
2289
+ let trayLine = ''
2290
+ try {
2291
+ const { installTray } = await import('../tray/index.ts')
2292
+ const t = installTray({ env, installApp: false, launch: false })
2293
+ trayLine = ` tray plugin: ${t.wrote ? 'written' : 'unchanged'} → ${t.pluginFile} (${t.dir})\n`
2294
+ } catch (e) {
2295
+ trayLine = ` tray plugin: skipped (${e instanceof Error ? e.message : String(e)})\n`
2296
+ }
2278
2297
  const { path: plist, changed: plistChanged } = installDaemonPlist({ env })
2279
2298
  const signingLine =
2280
2299
  r.signing == null
@@ -2291,6 +2310,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2291
2310
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
2292
2311
  ` host doctrine: ${hostDoctrine.path}${hostDoctrine.created ? ' (stub created — fill it in)' : ' (kept)'}\n` +
2293
2312
  ` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
2313
+ trayLine +
2294
2314
  ` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
2295
2315
  ` (NOT loaded — a live daemon migration is a separate step: launchctl bootstrap gui/$(id -u) ${plist})\n`,
2296
2316
  )
@@ -2469,6 +2489,82 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2469
2489
  const { runSupervisorCli } = await import('../supervisor/index.ts')
2470
2490
  return await runSupervisorCli(rest)
2471
2491
  }
2492
+ case 'tray': {
2493
+ // Ф1 of iapeer-tray — the SwiftBar fleet dashboard, an EXTERNAL fleet-API
2494
+ // client (docs/15). Subcommands: render[--stream] · cmd · install · uninstall
2495
+ // · status. The plugin file is a shim that execs `tray render --stream`; all
2496
+ // rendering/streaming lives here (tested TS), the .sh carries only metadata.
2497
+ const tray = await import('../tray/index.ts')
2498
+ const sub = positionals[0]
2499
+ switch (sub) {
2500
+ case 'render': {
2501
+ if (flags.stream === true) {
2502
+ await tray.streamTray(env) // SwiftBar streamable loop — runs until the process is killed
2503
+ return 0
2504
+ }
2505
+ out(await tray.renderTrayOnce(env))
2506
+ return 0
2507
+ }
2508
+ case 'cmd': {
2509
+ const command = positionals[1]
2510
+ const peer = positionals[2]
2511
+ const runtime = positionals[3]
2512
+ if (!command || !peer) {
2513
+ return argErr(errOut, 'usage: iapeer tray cmd <wake|stop|start|new|refresh|interrupt|compact> <peer> [runtime]')
2514
+ }
2515
+ let r: Awaited<ReturnType<typeof tray.trayCmd>>
2516
+ try {
2517
+ r = await tray.trayCmd(env, command, peer, runtime)
2518
+ } catch (e) {
2519
+ errOut(`tray cmd: ${e instanceof Error ? e.message : String(e)}\n`)
2520
+ return 1
2521
+ }
2522
+ out(`${JSON.stringify(r.body)}\n`)
2523
+ return r.ok ? 0 : 1
2524
+ }
2525
+ case 'install': {
2526
+ // Explicit activation verb: installs SwiftBar.app when absent
2527
+ // (owner-sanctioned) + launches, unless --plugin-only (file only).
2528
+ const pluginOnly = flags['plugin-only'] === true
2529
+ const r = tray.installTray({ env, installApp: !pluginOnly, launch: !pluginOnly })
2530
+ out(
2531
+ `tray plugin ${r.wrote ? 'written' : 'unchanged'}: ${r.pluginFile}\n` +
2532
+ ` plugin dir: ${r.pluginDir} (${r.dir})\n` +
2533
+ ` SwiftBar: ${r.app}${r.appReason ? ` — ${r.appReason}` : ''}\n` +
2534
+ (r.launched ? ' launched + refreshed SwiftBar\n' : '') +
2535
+ (r.app === 'absent' || r.app === 'install-failed'
2536
+ ? ' (SwiftBar not active — run `iapeer tray install` to install it, then it appears in the menu bar)\n'
2537
+ : ''),
2538
+ )
2539
+ return 0
2540
+ }
2541
+ case 'uninstall': {
2542
+ const r = tray.uninstallTray({ env, launch: true })
2543
+ out(
2544
+ (r.removed.length ? `removed:\n${r.removed.map(f => ` ${f}`).join('\n')}\n` : 'no tray plugin file found\n') +
2545
+ (r.refreshed ? ' refreshed SwiftBar\n' : '') +
2546
+ ' fleet untouched (daemon / TUI / delivery unaffected)\n',
2547
+ )
2548
+ return 0
2549
+ }
2550
+ case 'status': {
2551
+ const s = tray.trayStatus({ env })
2552
+ out(
2553
+ `daemon fleet API: ${s.daemon.fleet ? `up (v${s.daemon.version ?? '?'})` : 'DOWN / no fleet:1'}\n` +
2554
+ ` sock: ${s.daemon.sock ?? '—'}\n` +
2555
+ ` tcp: ${s.daemon.tcp ?? '—'}\n` +
2556
+ `SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
2557
+ `plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n`,
2558
+ )
2559
+ return 0
2560
+ }
2561
+ default:
2562
+ return argErr(
2563
+ errOut,
2564
+ 'usage: iapeer tray <render [--stream] | cmd <command> <peer> [runtime] | install [--plugin-only] | uninstall | status>',
2565
+ )
2566
+ }
2567
+ }
2472
2568
  default: {
2473
2569
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
2474
2570
  // the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
@@ -351,6 +351,26 @@ export function daemonDiscoveryPath(options: StorageOptions = {}): string {
351
351
  return join(pluginStateDir('iapeer', options), 'router.json')
352
352
  }
353
353
 
354
+ /**
355
+ * Normalize an HTTP request-target to origin-form (path[+query]) for path routing.
356
+ * A request that traversed an HTTP proxy arrives in ABSOLUTE-FORM (RFC 7230 §5.3.2:
357
+ * "GET http://host/path"), and Node exposes the whole target as `req.url` — so a
358
+ * path check like `req.url.startsWith('/fleet/')` misses and the request wrongly
359
+ * falls to the MCP catch-all (→ 406). Any client with HTTP(S)_PROXY set is affected
360
+ * (Bun's fetch proxies loopback even when NO_PROXY lists 127.0.0.1). Origin-form and
361
+ * unparseable targets pass through unchanged. Exported for tests. */
362
+ export function normalizeRequestTarget(url: string | undefined): string | undefined {
363
+ if (url && /^https?:\/\//i.test(url)) {
364
+ try {
365
+ const u = new URL(url)
366
+ return u.pathname + u.search
367
+ } catch {
368
+ /* unparseable — leave as received */
369
+ }
370
+ }
371
+ return url
372
+ }
373
+
354
374
  export interface DaemonHandle {
355
375
  socketPath?: string
356
376
  host?: string
@@ -498,6 +518,18 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
498
518
  // gate first, then the fleet-API branch (when wired), then stateless MCP —
499
519
  // a fresh Server + transport per request, fully isolated.
500
520
  const handle = (req: IncomingMessage, res: ServerResponse): void => {
521
+ // RFC 7230 §5.3.2 request-target hygiene (FIRST — before ANY path routing). A
522
+ // request that traversed an HTTP proxy carries the ABSOLUTE-FORM target
523
+ // ("GET http://127.0.0.1:8765/fleet/v1/snapshot"), and Node puts that whole string
524
+ // in req.url — so path routing (/fleet vs /mcp) would miss and fall through to MCP
525
+ // (→ a 406 "must accept text/event-stream" for a plain GET). This bites any client
526
+ // whose environment has HTTP(S)_PROXY set: Bun's fetch (and others) route loopback
527
+ // through the proxy even when NO_PROXY lists 127.0.0.1 (Bun ignores NO_PROXY). We
528
+ // can't fix every client, so the daemon normalizes to origin-form here — a proxied
529
+ // request then routes identically to a direct one, for BOTH the fleet and MCP
530
+ // surfaces. (live-proven: HTTP_PROXY pointed at the daemon itself reproduced the
531
+ // 406; normalization restores /fleet routing.)
532
+ req.url = normalizeRequestTarget(req.url)
501
533
  // H8 bearer layer (FIRST, before any MCP/fleet work): when a token is configured,
502
534
  // reject anything not carrying `Authorization: Bearer <token>`. Off when unset.
503
535
  if (bearer && req.headers.authorization !== `Bearer ${bearer}`) {