@agfpd/iapeer 0.4.77 → 0.4.79

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
@@ -34,24 +34,28 @@ activation that also installs SwiftBar.app when it is absent.
34
34
  a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
35
35
  human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
36
36
  row reads as yolo), `⏳N` an ephemeral peer's queue depth.
37
- - **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
38
- the attach action (no submenu, so a click attaches rather than merely expanding). The
39
- handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
40
- launcher and `open`s it — `open <file>.command` hands the file to Terminal.app with
41
- **no Automation/Accessibility permission prompt**. This deliberately avoids SwiftBar's
42
- own `terminal=true`, whose Terminal launch drives a `Cmd-T` System-Events keystroke that
43
- silently no-ops unless SwiftBar holds Accessibility permission. **Launchd-managed infra
44
- peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
45
- lines. Lifecycle actions (Stop / Start / New / Interrupt / Refresh / Compact) live
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.
37
+ - **Each peer is a SUBMENU** — one unified list (no separate Manage section; the peer rows
38
+ and a parallel Manage list of every peer used to be a visible dupe). Clicking a peer
39
+ EXPANDS it; its actions are the children, in order:
40
+ - **Attach** (agent peers only) — the FIRST child. Runs `iapeer tray attach-term <peer>`,
41
+ which writes a per-peer `.command` launcher and `open`s it — `open <file>.command` hands
42
+ the file to Terminal.app with **no Automation/Accessibility permission prompt** (this
43
+ deliberately avoids SwiftBar's own `terminal=true`, whose Terminal launch drives a `Cmd-T`
44
+ System-Events keystroke that silently no-ops unless SwiftBar holds Accessibility). Attach
45
+ is now expand-then-Attach (two clicks) — the trade for a single dupe-free list.
46
+ - **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, each a
47
+ `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`. (No Wake item — a peer-row
48
+ click path already wakes a sleeping peer before it attaches, so it was redundant.)
49
+ - **Approval-mode toggle** (agent peers only, docs/17): reads the current mode and flips it
50
+ via the local `iapeer approval-mode <peer> <mode>` verb, applied on the peer's **next fresh
51
+ session** (a click never respawns a live session). The friction is asymmetric to the
52
+ security weight: `yolo → gated` (adds human approval) is one safe tap; `gated → yolo`
53
+ (REMOVES the approval perimeter) sits one level deeper behind an explicit red ⚠ confirm, so
54
+ it is never dropped by a stray click.
55
+
56
+ **Launchd-managed infra peers (telegram / notifier, `🔒`)** are not pty-attachable, so their
57
+ submenu carries **no Attach** and **no approval toggle** (always yolo) — just the lifecycle
58
+ commands (service Stop / Start etc.).
55
59
  - **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
56
60
  each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
57
61
  **verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
@@ -4,7 +4,7 @@ A peer can run in one of two **approval modes**. In `yolo` (the default — ever
4
4
 
5
5
  This is a **normative contract**. The approval broker is the daemon's, the fleet endpoints and `ev` kinds below are the surface that operator faces (CLI, tray, the Telegram runtime) are written against — not the daemon source. The interception facts (which runtime event fires, in which shape) are pinned to **Claude Code 2.1.201** and **codex-cli 0.142.5**, taken from the live binaries + current runtime docs.
6
6
 
7
- `gated` means **«ask before acting»**, not «sandboxed». It removes the bypass so blocking questions surface; it does **not** add an OS sandbox (a separate axis, not in v1). On codex, gated is explicitly `approvals ON + sandbox OFF` (see the toggle table).
7
+ `gated` means **«ask before acting»** — blocking questions surface instead of being bypassed. On **claude**, gated stays sandbox-free (the `PermissionRequest` hook is the interceptor). On **codex**, gated is the EXEMPLAR (reframe 07.07): it runs `approvals ON + a workspace-write sandbox` so codex shows its **OWN native approval modal** in the TUI on a risky action — an attached human answers it right there — while the supervisor proxies that same modal to the bar. iapeer installs **no broker hook** on gated codex (a `PreToolUse` hook would silence the native modal). The shared memory vault (outside every peer cwd) is granted writable so routine memory writes never prompt. See the codex toggle table.
8
8
 
9
9
  ## The approval-mode toggle
10
10
 
@@ -37,17 +37,17 @@ The bypass is a **launch argument**, different per runtime, and the mode is a co
37
37
 
38
38
  **Why matcher-free `PermissionRequest`, not `PreToolUse`.** `PermissionRequest` fires **only when the runtime's permission config decided to prompt** — a call not covered by an allow/deny rule. So the policy of *what to ask* stays 100% at the runtime: a user tunes it with ordinary permission rules (a tool in `permissions.allow` is never asked → the hook never fires → auto-allowed). No class matcher to drift, no new tool slipping through, no hang. iapeer only seeds the hook + the one allow-rule for the peer's own MCP tool.
39
39
 
40
- **codex** (`PreToolUse` hook — verified live, codex-cli 0.142.5):
40
+ **codex** (native modal + supervisor proxy — codex-cli 0.142.5):
41
41
 
42
42
  | # | Surface | yolo | gated |
43
43
  |---|---|---|---|
44
44
  | X1 | argv `--dangerously-bypass-approvals-and-sandbox` | present | **removed** |
45
- | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — approvals **ON**, sandbox **OFF** (so loopback-MCP `send_to_peer` + writes to the memory vault outside cwd still work; `workspace-write` would cut the network and external paths). Session-scoped via `-c`, not host config. |
46
- | X3 | `PreToolUse` hook `<cwd>/.codex/hooks.json` + trust pre-seed in `~/.codex/config.toml [hooks.state]` | **absent** | **installed + trusted** (`preSeedCodexHooksTrust`; an untrusted codex hook is silently skipped) |
45
+ | X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=workspace-write -c sandbox_workspace_write.network_access=false` (+ `-c sandbox_workspace_write.writable_roots=[…]` when the host has a memory vault). approvals **ON**, an OS sandbox that makes cwd/`/tmp`/`$TMPDIR` + the vault silent and a risky write (outside those) or outbound network pop codex's **native modal**. The old `danger-full-access` was disproven by the live sandbox matrix (07.07): `send_to_peer` (MCP) is unaffected by the sandbox because Seatbelt wraps the shell CHILDREN codex spawns, not the codex process, so the loopback MCP call is never sandboxed; and the vault is restored via `writable_roots`. Session-scoped via `-c`, not host config. |
46
+ | X3 | `PreToolUse` broker hook | **absent** | **absent** — gated codex relies on its NATIVE modal + the supervisor proxy, so iapeer installs no broker hook (it would silence the native modal). A prior (pre-reframe) hook is removed on the next gated toggle. |
47
47
  | X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (both modes) | pre-trusted (matters more with approvals active) |
48
48
  | X5 | ready-gate `isInputReady` (`›` composer) | mode-independent | unchanged |
49
49
 
50
- The codex hook is matched by tool-name regex `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — the coarse class matcher lives on the runtime's own `hooks.json` (the user tunes it as ordinary runtime config).
50
+ The gated↔yolo difference for codex is thus ONLY the launch config + the profile field (no hooks.json either way), so the round-trip is byte-identical by construction. (The `iapeer approval-hook` binary + its codex `allow`→abstain shaping stay in the tree — still correct for any deny-by-rule hook an operator adds, and for claude — they are simply not installed by the codex gated toggle.)
51
51
 
52
52
  ### Idempotency and the application moment
53
53
 
@@ -62,7 +62,7 @@ All surfaces are read/applied **at session start**. A **live session keeps its l
62
62
  Both runtimes carry a Claude-compatible **hook** system that intercepts an approval programmatically and returns `allow`/`deny` + a reason + the structured action content. That is the primary mechanism. **pty screen-scrape is a backstop for one class only** — claude's `dangerous-rm` circuit-breaker, which sits *above* the permission layer and no hook can see.
63
63
 
64
64
  - **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
65
- - **codex `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies. Verified live (0.142.5): codex fires `PreToolUse` (not `PermissionRequest`) for tool calls with `tool_name:"Bash"` for shell; a `deny` hard-blocks the tool and the reason reaches the model even under `sandbox_mode=danger-full-access` — so under the gated config (danger-full-access + on-request → `permission_mode=bypassPermissions`) the hook is the SOLE gate.
65
+ - **codex** — gated codex is NOT hook-gated (reframe): it runs `approval_policy=on-request` + a `workspace-write` sandbox and shows its **own native modal**, which the supervisor pty-scrape catches and routes (see below). The `iapeer approval-hook` binary still exists and its codex path is correct (verified live 0.142.5: codex fires `PreToolUse` with `tool_name:"Bash"`; a `deny` hard-blocks the tool and the reason reaches the model; a codex `allow` is unsupported by codex's `PreToolUse` so the hook **abstains** on allow rather than emitting a rejected decision) — but the codex gated toggle does not install it. It remains available for a deny-by-rule hook an operator wires by hand.
66
66
  - **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and routes by class (see *Supervisor pty-scrape classification* below): the known auto-Yes classes are pressed under yolo (with an audit line) / brokered under gated, while `org-policy` and any unrecognized `unknown-modal` always go to the human.
67
67
 
68
68
  The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
@@ -81,6 +81,8 @@ The supervisor nag-watcher classifies every mid-session blocking TUI modal it ob
81
81
  - **`org-policy`** is a barrier ABOVE the peer (an MCP org-restriction). It is recognized but **always routed to the human** on both modes (the owner's org rule is human-only), with the precise 3-option keys. Previously it was excluded from the matcher and a yolo peer HUNG on it — now closed.
82
82
  - **`unknown-modal`** is the generic residue: a numbered-select modal (a new one Anthropic/OpenAI shipped) matching none of the known signatures. It is **always routed to the human** on both modes. Because the option layout is unknown, the injection is **binary** (v1): **Allow presses option 1** (the proceed/primary position in every known select; the position-robust affirmative) and **Deny presses `Escape`** (the universal modal cancel — it commits no numbered choice, so it can never accidentally affirm). The broker `content` carries **explicit button semantics** — the verbatim modal block plus a header stating exactly what Allow presses (`option 1: "<verbatim label>"`) and that Deny cancels via Esc — so the human decides informed, not on an abstract Allow/Deny. A **>2-way** choice on an unknown modal is a v2 extension (most approvals are binary). If a modal ignores the injected keys (Esc not honored), the daemon **bounds re-enqueue** (a few rounds) then **logs once and safe-parks** — the peer stays blocked (safe), never an infinite ask loop nor a blind Yes. Both claude (`❯`) and codex (`›`) are covered; codex's known boot dialogs are excluded (owned by the boot-driver).
83
83
 
84
+ > **This is exactly the path that surfaces gated codex's native approval modal** (the reframe). Verified live (codex-cli 0.142.5): a write outside the workspace/writable roots yields `Would you like to run the following command? … › 1. Yes, proceed (y) / 2. … / 3. No, and tell Codex what to do differently (esc) — Press enter to confirm or esc to cancel`. The footer matches the select-footer signature, so `detectNumberedModal('›')` catches it; **Allow = option 1** ("Yes, proceed") and **Deny = Esc** (which is codex's own "No" option here), so the generic binary injection is exactly right for it. So gated codex needs no bespoke breaker — it rides this same generic path, and an attached human can equally answer the modal in the TUI.
85
+
84
86
  The supervisor scrape is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm above the permission layer; a non-hookable modal such as codex MCP-elicitation). A blocking prompt that is NOT a numbered select (a free-form text-input prompt) is out of v1 scope — the numbered-select family is the approval/confirm class in practice.
85
87
 
86
88
  ## Nomenclature v1 and content per action (criterion «what is being approved»)
@@ -32,22 +32,28 @@ SwiftBar.app, если его нет.
32
32
  `👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
33
33
  human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
34
34
  `⏳N` глубина очереди эфемерного пира.
35
- - **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
36
- attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
37
- attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
38
- отдаёт файл Terminal.app **без запроса Automation/Accessibility**. Это намеренно обходит
39
- родной `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System
40
- Events и молча не срабатывает без права Accessibility у SwiftBar. **Launchd-инфра-пиры
41
- (telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
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
- случайным кликом.
35
+ - **Каждый пир — ПОДМЕНЮ** — один единый список (без отдельной секции Manage; строки пиров и
36
+ параллельный Manage-список всех пиров раньше были видимым дублем). Клик по пиру РАСКРЫВАЕТ
37
+ его; действия — дочерние пункты, по порядку:
38
+ - **Attach** (только агент-пиры) — ПЕРВЫЙ пункт. Запускает `iapeer tray attach-term <peer>`:
39
+ пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command` отдаёт файл
40
+ Terminal.app **без запроса Automation/Accessibility** (намеренно обходит родной
41
+ `terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System Events и
42
+ молча не срабатывает без права Accessibility). Attach теперь = раскрыть-и-Attach (два клика) —
43
+ плата за единый список без дубля.
44
+ - **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, каждое —
45
+ `POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — путь клика по
46
+ пиру и так будит спящего перед аттачем, отдельный Wake избыточен.)
47
+ - **Переключатель approval-режима** (только агент-пиры, docs/17): читает текущий режим и флипает
48
+ локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей свежей
49
+ сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу безопасности:
50
+ `yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo` (СНИМАЕТ
51
+ периметр approval) на уровень глубже за явным красным ⚠ confirm, чтобы не сбросить случайным
52
+ кликом.
53
+
54
+ **Launchd-инфра-пиры (telegram / notifier, `🔒`)** не pty-аттачатся — их подменю несёт **без
55
+ Attach** и **без approval-переключателя** (всегда yolo), только lifecycle-команды (Stop / Start
56
+ сервиса и т.д.).
51
57
  - **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
52
58
  ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
53
59
  **дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.77",
3
+ "version": "0.4.79",
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",
@@ -188,12 +188,21 @@ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.Pro
188
188
  if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
189
189
  else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
190
190
  } else if (rt === 'codex') {
191
- if (mode === 'gated') {
192
- const r = installCodexApproval(cwd, env)
193
- surfaces.push(`codex: ${r ? `hooks.json installed + trust ${r.trust?.state ?? '?'}` : 'hooks.json write SKIPPED'}`)
194
- } else {
195
- surfaces.push(`codex: ${removeCodexApproval(cwd, env) ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`)
196
- }
191
+ // Gated codex is the EXEMPLAR (docs/17 reframe, 07.07): it shows its OWN native approval modal
192
+ // (from the on-request / workspace-write launch config in codex buildArgv) and the SUPERVISOR
193
+ // proxies that modal to the bar (unknownBlockingModal → routeCircuitBreaker). So iapeer installs
194
+ // NO broker PreToolUse hook on gated codex — the hook would SILENCE the native modal (its whole
195
+ // pre-reframe purpose). We still REMOVE any hook a prior (pre-reframe) gated toggle left behind, so
196
+ // an existing peer migrates cleanly to the native-modal model. yolo is identical (no hook). The
197
+ // gated↔yolo difference for codex is now ONLY the launch config + the profile field, not hooks.json
198
+ // — so the round-trip stays byte-identical. (claude keeps its PermissionRequest broker hook — its
199
+ // native prompt is what that hook cleanly intercepts; only codex switched to the native-modal model.)
200
+ const removed = removeCodexApproval(cwd, env)
201
+ surfaces.push(
202
+ mode === 'gated'
203
+ ? `codex: native modal + supervisor proxy, no broker hook${removed ? ' (cleared prior hook)' : ''}`
204
+ : `codex: ${removed ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`,
205
+ )
197
206
  }
198
207
  }
199
208
  return { mode, surfaces }
@@ -209,13 +209,32 @@ export const codexAdapter: RuntimeAdapter = {
209
209
  * NO currency — no marketplace/install/update on this path.
210
210
  */
211
211
  buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
212
- // Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current
213
- // fleet behavior). gated = NO bypass + `-c approval_policy=on-request` (the model
214
- // asks before acting → the PreToolUse/PermissionRequest hook intercepts) with
215
- // `-c sandbox_mode=danger-full-access` — gated gates APPROVALS, it does NOT add a
216
- // sandbox (workspace-write would sever the loopback-MCP send_to_peer + vault writes;
217
- // sandboxing is a separate axis, not v1). Session-scoped `-c`, never a host-config edit.
212
+ // Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current fleet behavior).
213
+ //
214
+ // gated = codex as the EXEMPLAR (docs/17 reframe, 07.07): the peer shows its OWN NATIVE approval
215
+ // modal in the TUI on a risky action — an attached human approves right there — while the supervisor
216
+ // proxies that same modal to the bar (unknownBlockingModal → routeCircuitBreaker → broker card). So
217
+ // gated launches WITHOUT the bypass and WITHOUT a broker PreToolUse hook (the hook would SILENCE the
218
+ // native modal — that was the pre-reframe design; setApprovalMode no longer installs it on gated
219
+ // codex). The config, session-scoped `-c` (never a host-config edit):
220
+ // - `approval_policy=on-request` — the model asks before a non-auto-approved action → native modal.
221
+ // - `sandbox_mode=workspace-write` — writes to the peer cwd (+ /tmp, $TMPDIR) are SILENT; a write
222
+ // OUTSIDE those is blocked → native modal. This SUPERSEDES the old `danger-full-access` (whose
223
+ // comment claimed ws-write would sever MCP + vault writes — DISPROVEN by the live sandbox matrix
224
+ // 07.07): send_to_peer (MCP) goes THROUGH regardless of network_access because Seatbelt wraps the
225
+ // shell CHILDREN codex spawns, not the codex process itself, so the loopback MCP call is never
226
+ // sandboxed; and the vault is restored to writable via writable_roots just below.
227
+ // - `sandbox_workspace_write.writable_roots=[…]` — the extra roots (the shared memory vault, which
228
+ // lives OUTSIDE every peer cwd) so ROUTINE memory writes stay silent instead of popping the modal.
229
+ // Omitted when spec.writableRoots is empty (no memory provider). The value is a TOML inline array
230
+ // of strings — JSON.stringify yields exactly that (paths quoted, `"`/`\` escaped); launch shell-
231
+ // quotes the whole `-c` element once, so spaces/`~` in the path arrive intact. Array `-c` form
232
+ // verified live (codex-cli 0.142.5).
233
+ // - `sandbox_workspace_write.network_access=false` — pin outbound network OFF (the ws-write default,
234
+ // set explicitly so a codex default change can't silently open it). Real outbound stays a risk →
235
+ // native modal; MCP send_to_peer is unaffected (see above).
218
236
  const gated = spec.approvalMode === 'gated'
237
+ const roots = spec.writableRoots ?? []
219
238
  return [
220
239
  cfg.codexBin,
221
240
  ...(spec.resume ? ['resume', '--last'] : []),
@@ -226,7 +245,15 @@ export const codexAdapter: RuntimeAdapter = {
226
245
  ? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
227
246
  : []),
228
247
  ...(gated
229
- ? ['-c', 'approval_policy=on-request', '-c', 'sandbox_mode=danger-full-access']
248
+ ? [
249
+ '-c',
250
+ 'approval_policy=on-request',
251
+ '-c',
252
+ 'sandbox_mode=workspace-write',
253
+ ...(roots.length > 0 ? ['-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`] : []),
254
+ '-c',
255
+ 'sandbox_workspace_write.network_access=false',
256
+ ]
230
257
  : ['--dangerously-bypass-approvals-and-sandbox']),
231
258
  ...(spec.extraArgs ?? []),
232
259
  ]
@@ -259,13 +286,16 @@ export const codexAdapter: RuntimeAdapter = {
259
286
  },
260
287
 
261
288
  /**
262
- * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness). codex carries no known
263
- * mid-session circuit-breaker (its tool approvals ride the PreToolUse hook under gated, and yolo
264
- * bypasses them), so it declares no blockingConfirm/nagDismissKeys — but a NON-hookable modal
265
- * (MCP-elicitation, a future codex prompt) can still surface on screen and hang the pty. This catches
266
- * that residue structurally (bottom-most `›` is a numbered option + ≥2 options; see modalDetect.ts),
267
- * excluding the known boot dialogs (owned by the boot-driver). The daemon routes it to the human with
268
- * explicit button semantics + the timing stuck-gate on top.
289
+ * GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness) — AND, since the gated reframe
290
+ * (07.07), the catcher for codex's OWN NATIVE approval modal. codex declares no blockingConfirm: under
291
+ * yolo the bypass flag means no modal ever shows; under gated codex shows its native `approval_policy=
292
+ * on-request` / sandbox modal, which is a numbered `›` select — so THIS structural detector (bottom-most
293
+ * `›` is a numbered option + ≥2 options; see modalDetect.ts) is what surfaces it to the human, exactly
294
+ * like a truly-unforeseen modal. The daemon routes it to the human (bar card) with explicit button
295
+ * semantics + the timing stuck-gate on top; an ATTACHED human can equally answer it in the TUI directly.
296
+ * Known boot dialogs (owned by the boot-driver) are excluded first. NB modalDetect keys on a SELECT
297
+ * FOOTER (isSelectFooter) — that the codex approval modal's footer matches it is a LIVE-verified
298
+ * invariant (docs/17 §gated-codex); a codex footer that ever diverges would need its own signature here.
269
299
  */
270
300
  unknownBlockingModal(pane: string): { content: string; option1: string } | null {
271
301
  if (isKnownCodexBlockingSignature(pane)) return null
@@ -175,6 +175,13 @@ export interface LaunchSpec {
175
175
  * requests route to a human via the daemon broker (a PreToolUse hook, installed
176
176
  * separately at toggle time, is the interceptor). buildArgv reads it. */
177
177
  approvalMode?: ApprovalMode
178
+ /** Extra sandbox-writable roots BEYOND the peer cwd, for a gated codex peer launched under
179
+ * `sandbox_mode=workspace-write` (docs/17). Consumed ONLY by codex's gated buildArgv (emitted as
180
+ * `-c sandbox_workspace_write.writable_roots=[…]`) — claude/router/yolo ignore it, the same
181
+ * codex-only shape as `disallowedTools` is claude-only. Resolved at spec-build from the host's
182
+ * memory provider (the shared vault lives OUTSIDE every peer cwd, so a memory write would otherwise
183
+ * hit codex's native modal); empty/omitted → no extra roots. */
184
+ writableRoots?: string[]
178
185
  }
179
186
 
180
187
  // ─────────────────────────────────────────────────────────────────────────────
@@ -28,6 +28,7 @@ import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/
28
28
  import { err, ok, type Result } from '../core/errors.ts'
29
29
  import { capPaneLogs } from '../launch/cmdlog.ts'
30
30
  import { resolveGlobalRoot } from '../storage/index.ts'
31
+ import { resolveMemoryWritableRoots } from '../status/index.ts'
31
32
  import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
32
33
  import {
33
34
  ephemeralQueueDepth,
@@ -1109,6 +1110,17 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1109
1110
  ? composePeerPrompt(peer, cwd, identity, cfg, env, peersIndex.peers.map(publicPeerSummary))
1110
1111
  : undefined
1111
1112
 
1113
+ // Human-approval mode from the LOCAL profile (docs/17): drives the runtime bypass flag +
1114
+ // gated permission/approval config in buildArgv. Default yolo. Resolved ONCE — also gates the
1115
+ // writable-roots resolution below.
1116
+ const approvalMode = peerApprovalMode(cwd)
1117
+ // Extra sandbox-writable roots for a GATED CODEX peer (docs/17): under sandbox_mode=workspace-write
1118
+ // a write outside the peer cwd (a memory note into the shared vault) is BLOCKED and pops codex's
1119
+ // native modal, so the vault is granted at launch. Resolved from the host's memory provider ONLY when
1120
+ // it can matter (codex + gated) — a yolo/claude wake never reads it. codex's gated buildArgv consumes
1121
+ // it; every other path ignores the field. Fail-open (empty when no memory provider).
1122
+ const writableRoots = runtime === 'codex' && approvalMode === 'gated' ? resolveMemoryWritableRoots(env) : undefined
1123
+
1112
1124
  // Hand the fully-resolved spec to the launch primitive (HOW). lifecycle has
1113
1125
  // made every WHEN/HOW-MANY decision (lock, registry, H4, runtime, resume).
1114
1126
  const spec: LaunchSpec = {
@@ -1124,9 +1136,8 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1124
1136
  // Carry the peer's nature so the launch primitive can enforce an adapter's
1125
1137
  // intelligence gate (telegram requires natural). From the registry record.
1126
1138
  intelligence: peer.intelligence,
1127
- // Human-approval mode from the LOCAL profile (docs/17): drives the runtime
1128
- // bypass flag + gated permission/approval config in buildArgv. Default yolo.
1129
- approvalMode: peerApprovalMode(cwd),
1139
+ approvalMode,
1140
+ ...(writableRoots && writableRoots.length > 0 ? { writableRoots } : {}),
1130
1141
  }
1131
1142
  const launchCfg: LaunchConfig = {
1132
1143
  claudeBin: cfg.claudeBin,
@@ -13,7 +13,7 @@
13
13
  import { closeSync, openSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { IAPEER_VERSION } from '../core/version.ts'
16
- import { resolveGlobalRoot } from '../storage/index.ts'
16
+ import { resolveGlobalRoot, pluginInstallDir } from '../storage/index.ts'
17
17
  import { daemonDiscoveryPath } from '../daemon/index.ts'
18
18
  import { waitForDaemonHealthy } from '../update/index.ts'
19
19
  import { iapeerBinPath } from '../install/index.ts'
@@ -112,6 +112,67 @@ export function heartbeatAgeSecs(provider: { heartbeat?: string }, nowMs: number
112
112
  }
113
113
  }
114
114
 
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+ // Sandbox writable roots for a gated codex peer (docs/17). A gated codex session
117
+ // launches under `sandbox_mode=workspace-write`: writes to its own cwd (+ /tmp,
118
+ // $TMPDIR) are silent, but a write OUTSIDE those — a memory note into the shared
119
+ // vault, which lives in iCloud OUTSIDE every peer cwd — is BLOCKED and pops codex's
120
+ // native approval modal. Routine memory writes must not prompt, so the vault is added
121
+ // to `sandbox_workspace_write.writable_roots` at launch. This resolves those extra
122
+ // writable root(s) for the host (empty when the host has no memory provider).
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ /** config.env key the memory provider records its vault path under. */
126
+ const MEMORY_VAULT_ENV_KEY = 'IAPEER_MEMORY_VAULT_PATH'
127
+
128
+ /**
129
+ * Extra sandbox-writable roots a gated codex peer needs beyond its cwd — the memory vault
130
+ * on a host that has a memory provider, else empty.
131
+ *
132
+ * INTERIM TECHDEBT (v1 — a durable, tracked debt, NOT a permanent design): the vault path is
133
+ * read from the provider's OPERATOR-OWNED `config.env` (`IAPEER_MEMORY_VAULT_PATH`), located via
134
+ * the provider name the memory SLOT declares. This deliberately BYPASSES the slot contract — the
135
+ * slot (`memory-provider.json`) is the ONLY declarative foundation↔provider boundary; `config.env`
136
+ * is the provider's PRIVATE surface. It is read-only and fail-open, but it IS a side-coupling that a
137
+ * future change to the slot contract must be aware of. Why the bypass: the slot is single-writer
138
+ * (the provider), so foundation cannot populate a vault field itself, and adding one is a cross-repo
139
+ * change to `@agfpd/iapeer-memory` + its release — a disproportionate cost that would block delivery
140
+ * on a foreign deploy. FOLLOW-UP (the way out of the debt): promote the data root INTO the slot (a
141
+ * `MemoryProvider.dataRoots` field the provider writes) so foundation reads it from the declared
142
+ * contract and this `config.env` read is retired.
143
+ *
144
+ * Fail-open at every step: no memory slot / no config.env / no key / a non-absolute value → `[]`. A
145
+ * gated codex peer still works with an empty result — only a cross-vault write would then prompt (a
146
+ * safe degraded state, never a break). Uses the injected `env` for path resolution (IAPEER_ROOT-aware
147
+ * → sandbox-isolated in tests, exactly like the rest of the storage layer).
148
+ */
149
+ export function resolveMemoryWritableRoots(env: NodeJS.ProcessEnv = process.env): string[] {
150
+ const slot = readMemoryProvider(env)
151
+ if (!slot) return [] // no memory provider on this host → no vault to grant
152
+ try {
153
+ const cfgPath = join(pluginInstallDir(slot.provider, { env }), 'config.env')
154
+ const text = readFileSync(cfgPath, 'utf8')
155
+ for (const rawLine of text.split(/\r?\n/)) {
156
+ const line = rawLine.trim()
157
+ if (!line || line.startsWith('#')) continue
158
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
159
+ if (!m || m[1] !== MEMORY_VAULT_ENV_KEY) continue
160
+ let value = m[2].trim()
161
+ if (
162
+ value.length >= 2 &&
163
+ ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
164
+ ) {
165
+ value = value.slice(1, -1)
166
+ }
167
+ // Absolute path only — a relative writable_root is meaningless to codex's sandbox.
168
+ return value.startsWith('/') ? [value] : []
169
+ }
170
+ return [] // key absent from config.env
171
+ } catch {
172
+ return [] // unreadable config.env — fail-open to bare
173
+ }
174
+ }
175
+
115
176
  // ─────────────────────────────────────────────────────────────────────────────
116
177
  // VOICE slot — the voice-connect provider declaration (the same declarative
117
178
  // contract as memory, two differences by design: NO provision/unprovision (voice
@@ -198,19 +198,17 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
198
198
  if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
199
199
  lines.push('---')
200
200
 
201
- // ── peers (each row is a DIRECT attach action — click → Terminal) ──
201
+ // ── peers — each peer is a SUBMENU: expand → Attach (first) + its actions. ONE list
202
+ // (no separate Manage section): the peer rows and their lifecycle used to be two
203
+ // parallel lists of every peer (a click-attach list + a Manage list) — a visible
204
+ // dupe. Folded here (owner decision — 2 clicks to attach: expand peer → Attach). ──
202
205
  if (peers.length === 0) {
203
206
  lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
204
207
  }
205
208
  for (const p of peers) {
206
- lines.push(renderPeerRow(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
209
+ lines.push(...renderPeerSubmenu(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
207
210
  }
208
211
 
209
- // ── Manage submenu (lifecycle commands per peer; kept off the rows so a row click
210
- // attaches instead of just expanding a submenu) ──
211
- lines.push('---')
212
- lines.push(...renderManage(peers, bin))
213
-
214
212
  // ── footer ──
215
213
  // No explicit "Refresh" item: the plugin is streamable + SSE-driven, so the menu is
216
214
  // always current on open — a manual refresh gives the user nothing and (being a
@@ -243,64 +241,52 @@ function attachable(p: TrayPeer): boolean {
243
241
  return !p.launchd_managed
244
242
  }
245
243
 
246
- /**
247
- * One peer row. For an ATTACHABLE peer the row IS a DIRECT attach action (no submenu,
248
- * so a click attaches instead of merely expanding): clicking runs `iapeer tray
249
- * attach-term <peer>` in the BACKGROUND (terminal=false), which opens a `.command` via
250
- * `open` — a system-Terminal handoff that needs NO Accessibility/Automation TCC. This
251
- * deliberately AVOIDS SwiftBar's own `terminal=true`, whose Terminal.app launch drives a
252
- * `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
253
- * Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
254
- */
255
- function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
256
- const agg = aggregate(p)
244
+ /** The status label for a peer's submenu-parent row: `<peer> <rt><glyph> … <badges> <age>`.
245
+ * Badges: 👤 attached · 🔒 launchd · 🛡 gated approval (docs/17; yolo=fleet default=no badge) ·
246
+ * ⏳N ephemeral queue. A pending approval prepends ⚠ + a 🔴N count so the owner sees WHICH peer
247
+ * waits (the Allow/Deny itself lives in the top approvals section, not here). */
248
+ function peerLabel(p: TrayPeer, now: number, pendingCount: number): string {
257
249
  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.
262
250
  const badges =
263
251
  (p.attached ? ' 👤' : '') +
264
252
  (p.launchd_managed ? ' 🔒' : '') +
265
253
  (p.approval_mode === 'gated' ? ' 🛡' : '') +
266
254
  (p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
267
255
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
268
- // HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
269
- // so the owner sees WHICH peer is waiting (owner criterion — the static-menu equivalent of a pulse;
270
- // SwiftBar cannot animate). The highlight wins over the live/meta color.
271
256
  const prefix = pendingCount > 0 ? '⚠ ' : ''
272
257
  const pendingBadge = pendingCount > 0 ? ` 🔴${pendingCount}` : ''
273
- const label = `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
274
- const rowColor = pendingCount > 0 ? COLOR.down : agg === 'live' ? COLOR.live : COLOR.meta
275
- if (!attachable(p)) return line(0, label, { color: rowColor })
276
- return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
258
+ return `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
277
259
  }
278
260
 
279
- /** The Manage submenu: one sub-submenu per peer with its lifecycle commands, each a
280
- * `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c> (terminal=false,
281
- * refresh to reflect the outcome). Kept OFF the peer rows so a row click attaches. */
282
- function renderManage(peers: TrayPeer[], bin: string): string[] {
283
- const out: string[] = [line(0, 'Manage', { sfimage: 'slider.horizontal.3' })]
284
- for (const p of peers) {
285
- out.push(line(1, p.personality))
286
- for (const c of MANAGE_COMMANDS) {
287
- // No refresh=true: the SSE stream reflects the command's lifecycle event on its
288
- // own (streamable is always fresh); a refresh would only restart the stream.
289
- out.push(
290
- line(2, c.label, {
291
- bash: bin,
292
- param1: 'tray',
293
- param2: 'cmd',
294
- param3: c.cmd,
295
- param4: p.personality,
296
- terminal: false,
297
- }),
298
- )
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))
261
+ /**
262
+ * One peer as a SUBMENU (single unified list — no separate Manage). The parent row is the
263
+ * status label (no direct action — clicking EXPANDS); the children are, in order:
264
+ * 1. **Attach** (agentic peers only) — the terminal handoff, unchanged (0.4.67): `iapeer tray
265
+ * attach-term <peer>` in the BACKGROUND (terminal=false) opens a `.command` via `open`, needing
266
+ * NO Accessibility/Automation TCC (avoids SwiftBar's `terminal=true` Cmd-T keystroke). This is
267
+ * the ONE trade Arthur accepted: attach is now expand-then-Attach (2 clicks) for a dupe-free list.
268
+ * 2. lifecycle commands (Stop/Start/New/Interrupt/Refresh/Compact) — `iapeer tray cmd <c> <peer>`.
269
+ * 3. approval-mode toggle (agentic peers only — docs/17).
270
+ *
271
+ * Launchd-managed peers (arthur/timer/watcher, 🔒): NO Attach (no pty session) and NO approval toggle
272
+ * (always yolo); they keep the lifecycle submenu (service Stop/Start etc — the SAME set the old Manage
273
+ * already offered them, just relocated under the peer). Per-infra-type trimming (e.g. dropping
274
+ * Interrupt/Compact for the human peer) is a separate call, out of scope for a de-dupe render change.
275
+ */
276
+ function renderPeerSubmenu(p: TrayPeer, bin: string, now: number, pendingCount = 0): string[] {
277
+ const rowColor = pendingCount > 0 ? COLOR.down : aggregate(p) === 'live' ? COLOR.live : COLOR.meta
278
+ const out: string[] = [line(0, peerLabel(p, now, pendingCount), { color: rowColor })]
279
+ // 1) Attach FIRST (agentic only — launchd infra/human peers have no pty session to attach)
280
+ if (attachable(p)) {
281
+ out.push(line(1, 'Attach', { bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false }))
282
+ }
283
+ // 2) lifecycle commands — each `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c>.
284
+ // No refresh=true: the SSE stream reflects the outcome (streamable is always fresh).
285
+ for (const c of MANAGE_COMMANDS) {
286
+ out.push(line(1, c.label, { bash: bin, param1: 'tray', param2: 'cmd', param3: c.cmd, param4: p.personality, terminal: false }))
303
287
  }
288
+ // 3) approval-mode toggle (agentic only)
289
+ if (attachable(p)) out.push(...renderApprovalToggle(p, bin, 1))
304
290
  return out
305
291
  }
306
292
 
@@ -325,7 +311,7 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
325
311
  * stray click. SwiftBar has no native confirm dialog — the nested explicit item IS the
326
312
  * confirm.
327
313
  */
328
- function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
314
+ function renderApprovalToggle(p: TrayPeer, bin: string, depth: number): string[] {
329
315
  const flip = (target: 'gated' | 'yolo'): Record<string, string | boolean> => ({
330
316
  bash: bin,
331
317
  param1: 'approval-mode',
@@ -337,12 +323,12 @@ function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
337
323
  if (p.approval_mode === 'gated') {
338
324
  // WEAKEN — current on the parent (no action); the flip is an explicit red confirm child.
339
325
  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') }),
326
+ line(depth, 'Approval: gated 🛡', { color: COLOR.warn }),
327
+ line(depth + 1, '⚠ Switch to yolo — removes human approval (next session)', { color: COLOR.down, ...flip('yolo') }),
342
328
  ]
343
329
  }
344
330
  // STRENGTHEN (yolo → gated) — safe, one tap.
345
- return [line(2, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
331
+ return [line(depth, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
346
332
  }
347
333
 
348
334
  // ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────