@agfpd/iapeer 0.4.81 → 0.4.83

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/16-tray.md CHANGED
@@ -80,12 +80,12 @@ whole dashboard twice.
80
80
 
81
81
  | Verb | What it does |
82
82
  |---|---|
83
- | `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. |
84
- | `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). |
83
+ | `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh, **and register the login-autostart LaunchAgent**. `--plugin-only` writes just the plugin file (no app, no launch, no autostart — what the foundation install does). Idempotent. |
84
+ | `iapeer tray uninstall` | Remove the plugin file, refresh SwiftBar, **and tear down the login-autostart LaunchAgent**. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
85
85
  | `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. |
86
86
  | `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
87
87
  | `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Resolve a pending human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` over the same unix-first fleet client. The menu's Allow/Deny items call these; the single-queue invariant means the resolution is seen by every channel (CLI, telegram). |
88
- | `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. |
88
+ | `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, **is the login autostart registered**. Repairs nothing. |
89
89
 
90
90
  ## How it is wired
91
91
 
@@ -102,6 +102,26 @@ plugin is installed into the user's own dir); otherwise a dedicated
102
102
  reads on launch (it is not sandboxed and stores no security-scoped bookmark), so the
103
103
  folder picker is skipped.
104
104
 
105
+ Login autostart: activating the tray registers a user LaunchAgent
106
+ `~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad, no KeepAlive) whose
107
+ `ProgramArguments` are `open -g -b com.ameba.SwiftBar` — so SwiftBar (and with it the
108
+ fleet dashboard + approval badge) comes back on its own after a reboot, with no manual
109
+ `open -a SwiftBar`. A **LaunchAgent** is used deliberately, not a System Events "Login
110
+ Item" nor SMAppService: a Login Item needs the invoking process to hold Automation TCC
111
+ over System Events (attributed to whatever parent runs the verb — fragile in a headless /
112
+ `iapeer update` context), and SMAppService can only register a helper the *target* app
113
+ ships (we cannot register the third-party SwiftBar with it from a CLI). The LaunchAgent
114
+ needs no TCC and reuses the foundation's idempotent plist lifecycle: the plist is
115
+ byte-stable (write-if-changed), carries the `com.iapeer.managed` ownership sentinel, and
116
+ is bootstrapped/cycled undead-safe — so re-activation never plants a duplicate. It is
117
+ **RunAtLoad-only** (fire once at login) rather than KeepAlive, because the `open` process
118
+ exits immediately once SwiftBar is up (KeepAlive would respawn-storm it) and supervising
119
+ SwiftBar itself would fight the user quitting it. `tray uninstall` boots it out and
120
+ removes the plist (only our own sentinel-marked file — a foreign plist at the label is
121
+ left untouched). The label lives in the foundation's own `com.agfpd.*` namespace, never
122
+ `com.iapeer.<personality>` (the persistent-peer fleet), so the H4 launchd-owned
123
+ sweep-guard can never mistake it for a peer.
124
+
105
125
  Auth follows the Fleet API: on an open-local host no bearer is needed; when the daemon is
106
126
  configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it too.
107
127
 
@@ -76,12 +76,12 @@ SwiftBar.app, если его нет.
76
76
 
77
77
  | Verb | Что делает |
78
78
  |---|---|
79
- | `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит. `--plugin-only` пишет только файл плагина (без приложения и запуска — так делает foundation-install). Идемпотентно. |
80
- | `iapeer tray uninstall` | Удалит файл плагина и обновит SwiftBar. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
79
+ | `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит **и зарегистрирует LaunchAgent автозапуска при логине**. `--plugin-only` пишет только файл плагина (без приложения, запуска и автозапуска — так делает foundation-install). Идемпотентно. |
80
+ | `iapeer tray uninstall` | Удалит файл плагина, обновит SwiftBar **и снесёт LaunchAgent автозапуска**. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
81
81
  | `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
82
82
  | `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
83
83
  | `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
84
- | `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина. Ничего не чинит. |
84
+ | `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли автозапуск**. Ничего не чинит. |
85
85
 
86
86
  ## Как устроено
87
87
 
@@ -97,6 +97,25 @@ SwiftBar.app, если его нет.
97
97
  который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
98
98
  поэтому окно выбора папки пропускается.
99
99
 
100
+ Автозапуск при логине: активация трея регистрирует пользовательский LaunchAgent
101
+ `~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad, без KeepAlive), чьи
102
+ `ProgramArguments` — `open -g -b com.ameba.SwiftBar`, так что SwiftBar (а с ним fleet-дашборд
103
+ и бейдж аппрувов) сам поднимается после перезагрузки, без ручного `open -a SwiftBar`.
104
+ **LaunchAgent** выбран намеренно, не «Login Item» через System Events и не SMAppService: Login
105
+ Item требует, чтобы вызывающий процесс держал Automation-TCC на System Events (грант
106
+ атрибутируется тому родителю, что запустил verb — хрупко в headless / `iapeer update`
107
+ контексте), а SMAppService умеет регистрировать только хелпер, который несёт *само* целевое
108
+ приложение (сторонний SwiftBar так из CLI не зарегистрировать). LaunchAgent не требует TCC и
109
+ переиспользует идемпотентный plist-жизненный цикл foundation: plist байт-стабилен (пишется
110
+ только при изменении), несёт sentinel владения `com.iapeer.managed` и bootstrap'ится/
111
+ пересоздаётся undead-safe — повторная активация не плодит дубль. Он **RunAtLoad-only** (сработать
112
+ один раз при логине), а не KeepAlive, потому что процесс `open` завершается сразу, как SwiftBar
113
+ поднят (KeepAlive устроил бы respawn-шторм), а супервизия самого SwiftBar боролась бы с
114
+ пользователем, который его закрыл. `tray uninstall` делает bootout и удаляет plist (только наш
115
+ файл с sentinel — чужой plist на этом лейбле не трогается). Лейбл живёт в собственном namespace
116
+ foundation `com.agfpd.*`, никогда не `com.iapeer.<personality>` (флот persistent-пиров), поэтому
117
+ H4-страж launchd-owned пиров не может принять его за пира.
118
+
100
119
  Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
101
120
  `IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.
102
121
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.81",
3
+ "version": "0.4.83",
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
@@ -2581,6 +2581,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2581
2581
  ` plugin dir: ${r.pluginDir} (${r.dir})\n` +
2582
2582
  ` SwiftBar: ${r.app}${r.appReason ? ` — ${r.appReason}` : ''}\n` +
2583
2583
  (r.launched ? ' launched + refreshed SwiftBar\n' : '') +
2584
+ (r.autostart
2585
+ ? ` login autostart: ${r.autostart.state}${r.autostart.detail ? ` — ${r.autostart.detail}` : ''} (${r.autostart.plistPath})\n`
2586
+ : '') +
2584
2587
  (r.app === 'absent' || r.app === 'install-failed'
2585
2588
  ? ' (SwiftBar not active — run `iapeer tray install` to install it, then it appears in the menu bar)\n'
2586
2589
  : ''),
@@ -2592,6 +2595,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2592
2595
  out(
2593
2596
  (r.removed.length ? `removed:\n${r.removed.map(f => ` ${f}`).join('\n')}\n` : 'no tray plugin file found\n') +
2594
2597
  (r.refreshed ? ' refreshed SwiftBar\n' : '') +
2598
+ ` login autostart: ${r.autostart.state}${r.autostart.removed ? ` (${r.autostart.plistPath})` : ''}\n` +
2595
2599
  ' fleet untouched (daemon / TUI / delivery unaffected)\n',
2596
2600
  )
2597
2601
  return 0
@@ -2603,7 +2607,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2603
2607
  ` sock: ${s.daemon.sock ?? '—'}\n` +
2604
2608
  ` tcp: ${s.daemon.tcp ?? '—'}\n` +
2605
2609
  `SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
2606
- `plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n`,
2610
+ `plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n` +
2611
+ `login autostart: ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
2607
2612
  )
2608
2613
  return 0
2609
2614
  }
@@ -215,13 +215,19 @@ export const launch: LaunchFn = async (
215
215
  // BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
216
216
  // surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
217
217
  // the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
218
- const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
219
218
  const isReady = await waitHostReady(
220
219
  { identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
221
220
  sleep,
222
221
  )
223
222
  if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
224
223
  if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
224
+ // В18 — snap the ready-gate baseline AFTER boot, not before. newestActivityMtime is a raw
225
+ // transcript-file mtime that BOOT ITSELF advances: codex writes its rollout-jsonl on start,
226
+ // a claude-resume re-saves the transcript. Captured before waitHostReady, those boot writes
227
+ // alone push mtime past the baseline, so the gate below fires on the boot write — NOT on the
228
+ // model PROCESSING firstMessage — returning ready/taskDelivered while the message is unseen
229
+ // (silent loss). Snapped here — post-boot, pre-deliver — only the model's own turn advances it.
230
+ const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
225
231
  // INSTRUMENTATION (premature-ready diagnosis): record the session-start boundary and how much THIS
226
232
  // session had written into the shared pane-log by the time the ready-gate fired. On a recurrence,
227
233
  // paneLogBytes ≈ paneLogStartByte (ready seen though the new session wrote ~nothing) localizes a
@@ -452,6 +452,57 @@ export function cycleLaunchdJob(
452
452
  })
453
453
  }
454
454
 
455
+ /**
456
+ * Idempotently ensure ANY launchd job is LOADED in the gui domain — bootstrap when
457
+ * absent, no-op when already loaded. The generic sibling of `bootstrapDaemon` for a
458
+ * caller-supplied label/plist pair (e.g. the tray SwiftBar-autostart LaunchAgent).
459
+ * The CALLER owns any ownership/fleet guard; this primitive only loads the concrete
460
+ * label/plist. Composed on bootstrapJobCore (undead-job-safe wait-for-gone + backoff).
461
+ * SANDBOX FAIL-SAFE: never calls launchctl under IAPEER_TEST_SANDBOX (`bootstrap
462
+ * gui/<uid>` is host-global regardless of IAPEER_ROOT) — consulting BOTH the passed and
463
+ * the process env, the same fail-closed rule as launchctlBootstrap.
464
+ */
465
+ export function bootstrapLaunchdJob(
466
+ label: string,
467
+ plistPath: string,
468
+ env: NodeJS.ProcessEnv = process.env,
469
+ ): BootstrapResult {
470
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
471
+ return { state: 'skipped-sandbox', label, detail: 'IAPEER_TEST_SANDBOX=1 — not loading a real launchd job' }
472
+ }
473
+ const uid = currentUid()
474
+ const core = bootstrapJobCore(uid, label, plistPath, {
475
+ run: args => {
476
+ const r = spawnSync('launchctl', args, { encoding: 'utf8' })
477
+ return { status: r.status, stderr: r.stderr ?? '' }
478
+ },
479
+ sleepMs: ms => spawnSync('sleep', [String(ms / 1000)]),
480
+ })
481
+ return core.state === 'failed' ? { state: 'failed', label, detail: core.detail } : { state: core.state, label }
482
+ }
483
+
484
+ export interface BootoutResult {
485
+ state: 'booted-out' | 'not-loaded' | 'skipped-sandbox' | 'failed'
486
+ detail?: string
487
+ }
488
+
489
+ /**
490
+ * Unload a launchd job from the gui domain (`launchctl bootout gui/<uid>/<label>`) —
491
+ * the teardown primitive (used to remove the tray SwiftBar-autostart LaunchAgent on
492
+ * `tray uninstall`). Idempotent: an un-loaded label is `not-loaded` (no-op). The CALLER
493
+ * removes the plist file (this only unloads the running job). Sandbox-safe: never calls
494
+ * launchctl under IAPEER_TEST_SANDBOX.
495
+ */
496
+ export function bootoutLaunchdJob(label: string, env: NodeJS.ProcessEnv = process.env): BootoutResult {
497
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
498
+ return { state: 'skipped-sandbox' }
499
+ }
500
+ const uid = currentUid()
501
+ if (!isLaunchdLoaded(label, uid)) return { state: 'not-loaded' }
502
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' })
503
+ return r.status === 0 ? { state: 'booted-out' } : { state: 'failed', detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
504
+ }
505
+
455
506
  /**
456
507
  * AUTO-bootstrap a freshly-provisioned foundation plist into the gui domain
457
508
  * (`launchctl bootstrap gui/<uid> <plist>`) — the "load it now, don't write-and-wait
@@ -21,12 +21,20 @@
21
21
  // so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
22
22
  // open) goes through an injectable Runner so tests stay hermetic.
23
23
 
24
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
24
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
25
25
  import { spawnSync } from 'child_process'
26
26
  import { homedir } from 'os'
27
27
  import { join } from 'path'
28
28
  import { resolveGlobalRoot } from '../storage/index.ts'
29
29
  import { iapeerBinPath } from '../install/index.ts'
30
+ import {
31
+ IAPEER_PLIST_OWNER_KEY,
32
+ bootoutLaunchdJob,
33
+ bootstrapLaunchdJob,
34
+ cycleLaunchdJob,
35
+ isFoundationOwnedPlist,
36
+ launchAgentsDir,
37
+ } from '../launch/launchd.ts'
30
38
  import { resolveFleetAddress } from './client.ts'
31
39
 
32
40
  /** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
@@ -34,6 +42,16 @@ import { resolveFleetAddress } from './client.ts'
34
42
  * ignored. The basename is stable — install rewrites the same file (idempotent). */
35
43
  export const PLUGIN_BASENAME = 'iapeer.10s.sh'
36
44
  const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
45
+ // SwiftBar's `defaults` domain IS its bundle identifier — reuse it for the autostart
46
+ // LaunchAgent's `open -b <bundleid>` (bundle-id launch survives the app moving/renaming
47
+ // between /Applications and ~/Applications, unlike a baked absolute path or `-a <name>`).
48
+ const SWIFTBAR_BUNDLE_ID = SWIFTBAR_DOMAIN
49
+ // The login-autostart LaunchAgent's label. Foundation namespace (`com.agfpd.*` = the
50
+ // foundation's own jobs), DELIBERATELY not `com.iapeer.<personality>` — that namespace
51
+ // is the persistent-peer fleet keyed on personality, and the tray autostart is neither a
52
+ // peer nor daemon-managed. Distinct label ⇒ it can never be mistaken for a peer plist by
53
+ // the H4 launchd-owned sweep-guard.
54
+ const SWIFTBAR_AUTOSTART_LABEL = 'com.agfpd.iapeer.tray'
37
55
  const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
38
56
 
39
57
  export type RunResult = { status: number | null; stdout: string; stderr: string }
@@ -114,6 +132,147 @@ export function buildPluginShim(binPath: string): string {
114
132
  ].join('\n')
115
133
  }
116
134
 
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+ // Login autostart — a RunAtLoad LaunchAgent that brings SwiftBar up at login, so the
137
+ // fleet dashboard + approval badge survive a reboot without a manual `open -a SwiftBar`.
138
+ //
139
+ // MECHANISM CHOICE (owner-relevant): a user LaunchAgent, not a System Events "Login
140
+ // Item" and not SMAppService.
141
+ // • System Events Login Item (osascript `make login item`) — the manual stop-gap —
142
+ // requires the INVOKING process to hold Automation TCC over System Events; that
143
+ // grant is attributed to whatever parent runs the activation verb (Terminal / the
144
+ // daemon / an onboard run), so it silently no-ops or prompts in a headless / update
145
+ // context. A LaunchAgent needs no TCC at all — pure filesystem + launchctl.
146
+ // • SMAppService (macOS 13+) is the modern path, but it registers a helper the TARGET
147
+ // app ships — we cannot register a THIRD-PARTY GUI app (SwiftBar) with it from a CLI.
148
+ // • The LaunchAgent reuses the foundation's proven idempotent plist lifecycle
149
+ // (write-if-changed + undead-safe bootstrap/cycle, sandbox-guarded) and carries the
150
+ // ownership sentinel so uninstall can recognize + remove exactly our artifact.
151
+ //
152
+ // RunAtLoad-ONLY, no KeepAlive: the ProgramArguments launch SwiftBar via `open` (which
153
+ // exits immediately once SwiftBar is up), so KeepAlive would respawn-storm `open` every
154
+ // throttle window. A login-autostart only needs to fire once per session; keeping
155
+ // SwiftBar itself supervised (relaunch-on-quit) would fight the user quitting it and
156
+ // duplicate SwiftBar's own launch-at-login — out of scope for "start it at login".
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+
159
+ /** Absolute path of the tray autostart LaunchAgent plist (IAPEER_LAUNCHAGENTS_DIR
160
+ * isolates it under test/sandbox; else ~/Library/LaunchAgents). */
161
+ export function swiftBarAutostartPlistPath(env: NodeJS.ProcessEnv = process.env): string {
162
+ return join(launchAgentsDir(env), `${SWIFTBAR_AUTOSTART_LABEL}.plist`)
163
+ }
164
+
165
+ /** Render the RunAtLoad SwiftBar-autostart plist. PURE/deterministic (golden-testable).
166
+ * Launch is `open -g -b <bundleid>`: LaunchServices activates the app correctly as a
167
+ * menu-bar agent (`-g` = don't steal focus), and bundle-id targeting survives the app
168
+ * moving. Carries the foundation ownership sentinel (inert `<key>` launchd ignores). */
169
+ export function renderSwiftBarAutostartPlist(): string {
170
+ return `<?xml version="1.0" encoding="UTF-8"?>
171
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
172
+ <plist version="1.0">
173
+ <dict>
174
+ <key>Label</key>
175
+ <string>${SWIFTBAR_AUTOSTART_LABEL}</string>
176
+ <key>${IAPEER_PLIST_OWNER_KEY}</key>
177
+ <true/>
178
+ <key>ProgramArguments</key>
179
+ <array>
180
+ <string>/usr/bin/open</string>
181
+ <string>-g</string>
182
+ <string>-b</string>
183
+ <string>${SWIFTBAR_BUNDLE_ID}</string>
184
+ </array>
185
+ <key>RunAtLoad</key>
186
+ <true/>
187
+ </dict>
188
+ </plist>
189
+ `
190
+ }
191
+
192
+ export type AutostartState =
193
+ | 'loaded' // bootstrapped now (was not loaded)
194
+ | 'already-loaded' // job already in the gui domain → no-op
195
+ | 'restarted' // plist changed → cycled (bootout+bootstrap) onto the new content
196
+ | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch a real plist / launchctl
197
+ | 'failed' // launchctl bootstrap/cycle failed
198
+ export interface EnsureAutostartResult {
199
+ plistPath: string
200
+ /** Whether the plist bytes changed (false = already current). */
201
+ wrote: boolean
202
+ state: AutostartState
203
+ detail?: string
204
+ }
205
+
206
+ /**
207
+ * Idempotently register the SwiftBar login-autostart LaunchAgent (write the plist if its
208
+ * bytes changed, then load/restart it). Safe to repeat — a changed plist is cycled, an
209
+ * unloaded one is bootstrapped, an unchanged-and-loaded one is a no-op (never a dup).
210
+ * SANDBOX FAIL-SAFE: under IAPEER_TEST_SANDBOX with no isolated IAPEER_LAUNCHAGENTS_DIR
211
+ * it writes NOTHING (a leaked real autostart would outlive the test) and touches no
212
+ * launchctl; with an isolated dir it writes there but still skips launchctl (host-global).
213
+ */
214
+ export function ensureSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): EnsureAutostartResult {
215
+ const plistPath = swiftBarAutostartPlistPath(env)
216
+ const content = renderSwiftBarAutostartPlist()
217
+ const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
218
+ const realAgents = join(homedir(), 'Library', 'LaunchAgents')
219
+ if (sandbox && plistPath.startsWith(`${realAgents}/`)) {
220
+ return {
221
+ plistPath,
222
+ wrote: false,
223
+ state: 'skipped-sandbox',
224
+ detail: 'IAPEER_TEST_SANDBOX=1 without IAPEER_LAUNCHAGENTS_DIR — refusing to write a real LaunchAgents plist',
225
+ }
226
+ }
227
+ mkdirSync(launchAgentsDir(env), { recursive: true })
228
+ let wrote = false
229
+ if (!existsSync(plistPath) || readFileSync(plistPath, 'utf8') !== content) {
230
+ // atomic write (tmp+rename): an interrupted direct write could leave a sentinel-less
231
+ // stub that the ownership guard then reads as foreign.
232
+ const tmp = `${plistPath}.tmp-${process.pid}`
233
+ writeFileSync(tmp, content, { mode: 0o644 })
234
+ renameSync(tmp, plistPath)
235
+ wrote = true
236
+ }
237
+ if (sandbox) return { plistPath, wrote, state: 'skipped-sandbox' }
238
+ // Live: load / restart onto the plist now on disk.
239
+ const bootstrap = (): EnsureAutostartResult => {
240
+ const bs = bootstrapLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env)
241
+ const state: AutostartState =
242
+ bs.state === 'loaded' ? 'loaded' : bs.state === 'already-loaded' ? 'already-loaded' : 'failed'
243
+ return { plistPath, wrote, state, detail: bs.detail }
244
+ }
245
+ if (!wrote) return bootstrap()
246
+ const cyc = cycleLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env)
247
+ if (cyc.state === 'restarted') return { plistPath, wrote, state: 'restarted' }
248
+ if (cyc.state === 'not-loaded') return bootstrap() // first install: nothing to cycle → load it
249
+ return { plistPath, wrote, state: 'failed', detail: cyc.detail }
250
+ }
251
+
252
+ export interface RemoveAutostartResult {
253
+ plistPath: string
254
+ removed: boolean
255
+ state: 'removed' | 'not-present' | 'foreign' | 'skipped-sandbox'
256
+ }
257
+
258
+ /**
259
+ * Tear down the SwiftBar login-autostart LaunchAgent (bootout the job, remove the plist)
260
+ * — the symmetric counterpart of ensureSwiftBarAutostart, run on `tray uninstall`. Only
261
+ * removes OUR plist (ownership sentinel present); a sentinel-less file at the label is
262
+ * left untouched (`foreign`). Sandbox-safe.
263
+ */
264
+ export function removeSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): RemoveAutostartResult {
265
+ const plistPath = swiftBarAutostartPlistPath(env)
266
+ const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
267
+ const realAgents = join(homedir(), 'Library', 'LaunchAgents')
268
+ if (sandbox && plistPath.startsWith(`${realAgents}/`)) return { plistPath, removed: false, state: 'skipped-sandbox' }
269
+ if (!existsSync(plistPath)) return { plistPath, removed: false, state: 'not-present' }
270
+ if (!isFoundationOwnedPlist(plistPath)) return { plistPath, removed: false, state: 'foreign' }
271
+ bootoutLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, env) // unload the running job (sandbox-safe no-op)
272
+ rmSync(plistPath, { force: true })
273
+ return { plistPath, removed: true, state: 'removed' }
274
+ }
275
+
117
276
  /** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
118
277
  * into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
119
278
  function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
@@ -143,6 +302,8 @@ export interface InstallTrayResult {
143
302
  app: 'present' | 'installed' | 'install-failed' | 'absent'
144
303
  appReason?: string
145
304
  launched: boolean
305
+ /** Login-autostart registration outcome (only when launched + SwiftBar present). */
306
+ autostart?: EnsureAutostartResult
146
307
  }
147
308
 
148
309
  /**
@@ -208,22 +369,28 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
208
369
  }
209
370
  }
210
371
 
211
- // 4 — launch + refresh
372
+ // 4 — launch + refresh + register login autostart (so a reboot brings the tray back).
212
373
  let launched = false
374
+ let autostart: EnsureAutostartResult | undefined
213
375
  if (opts.launch && present) {
214
376
  dequarantineApp(run)
215
377
  launchSwiftBar(run)
216
378
  refreshSwiftBar(run)
217
379
  launched = true
380
+ // Idempotent: the plist is byte-stable and the bootstrap is a no-op when loaded, so
381
+ // re-activation never plants a duplicate autostart.
382
+ autostart = ensureSwiftBarAutostart(env)
218
383
  }
219
384
 
220
- return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
385
+ return { pluginFile, pluginDir, wrote, dir, app, appReason, launched, autostart }
221
386
  }
222
387
 
223
388
  export interface UninstallTrayResult {
224
389
  removed: string[]
225
390
  /** SwiftBar was refreshed to drop the now-missing plugin. */
226
391
  refreshed: boolean
392
+ /** Login-autostart teardown outcome (the GUI-face activation is being removed). */
393
+ autostart: RemoveAutostartResult
227
394
  }
228
395
 
229
396
  /**
@@ -257,7 +424,11 @@ export function uninstallTray(
257
424
  refreshSwiftBar(run)
258
425
  refreshed = true
259
426
  }
260
- return { removed, refreshed }
427
+ // Tear down the login-autostart LaunchAgent too — removing the GUI face should not
428
+ // leave a reboot re-launching SwiftBar. Never touches the fleet (bootout of our own
429
+ // com.agfpd.* label only).
430
+ const autostart = removeSwiftBarAutostart(env)
431
+ return { removed, refreshed, autostart }
261
432
  }
262
433
 
263
434
  /**
@@ -294,6 +465,8 @@ export interface TrayStatus {
294
465
  daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
295
466
  swiftbar: { installed: boolean; pluginDir?: string }
296
467
  plugin: { installed: boolean; path?: string }
468
+ /** Login-autostart LaunchAgent: registered (our plist present) + its path. */
469
+ autostart: { registered: boolean; path?: string }
297
470
  }
298
471
 
299
472
  /** Read-only diagnostics — never repairs anything. */
@@ -305,9 +478,12 @@ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeA
305
478
  const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
306
479
  const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
307
480
  const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
481
+ const autostartPath = swiftBarAutostartPlistPath(env)
482
+ const autostartOurs = existsSync(autostartPath) && isFoundationOwnedPlist(autostartPath)
308
483
  return {
309
484
  daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
310
485
  swiftbar: { installed, pluginDir: cfgDir },
311
486
  plugin: { installed: Boolean(found), path: found },
487
+ autostart: { registered: autostartOurs, path: autostartOurs ? autostartPath : undefined },
312
488
  }
313
489
  }