@agfpd/iapeer 0.4.73 → 0.4.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,6 +107,8 @@ What this means for you:
107
107
  - You are trusting the model's judgment, and the doctrine you give each peer, the same way you would trust a developer with a shell on your account.
108
108
  - iapeer does **not** silently weaken your own interactive `claude` / `codex` setup. The bypass acceptance is recorded per peer, in that agent's own config; the project-MCP pre-approval is written **project-locally**, under the peer's folder — never to your global user settings.
109
109
 
110
+ **Want a human in the loop for a given peer?** Set its approval mode to `gated` — `iapeer approval-mode <peer> gated`. That peer launches **without** the bypass flag, and every blocking approval its runtime would raise is routed to you — a Telegram button, a click in the tray, or `iapeer approve` on the CLI — before the tool runs; your `allow`/`deny` (with a reason) flows back and the tool proceeds or is refused. The default stays `yolo` (bypass) for every peer; `gated` is opt-in, per peer. See [docs/17 — Human approval](docs/17-approval.md).
111
+
110
112
  Run iapeer on a host you control, give each peer only the access it needs, and treat a peer's instructions as security-relevant. `iapeer onboard` states this at setup; it's repeated here so the trade-off is explicit before you create your first agent. Details on every first-run dialog: [docs/07 — Install & update](docs/07-install-and-update.md#first-run-dialogs-on-a-clean-machine).
111
113
 
112
114
  ## Documentation
@@ -121,8 +123,6 @@ Planned, not yet built:
121
123
  - **Linux support** — iapeer runs on macOS today; Linux is planned.
122
124
  - **More agent runtimes** — `opencode` and `antigravity`, two further CLI runtimes alongside Claude Code and Codex.
123
125
  - **Discord runtime** — a human-presence runtime for Discord, alongside Telegram.
124
- - **Permissions gate** — finer control over what an agent is allowed to do.
125
- - **Approve permissions over Telegram** — approve an agent's permission requests from the chat; today permissions are auto-allowed by flags.
126
126
 
127
127
  ## License
128
128
 
package/docs/16-tray.md CHANGED
@@ -24,11 +24,16 @@ activation that also installs SwiftBar.app when it is absent.
24
24
  daemon any other way; the built-in TUI keeps its direct read path for that case, see
25
25
  [15](15-fleet-api.md)).
26
26
  - **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
27
- provider heartbeats, then one row per peer:
27
+ provider heartbeats, then one row per peer, in **two groups**: the `🔒` **launchd block**
28
+ (the human channel + service infra — non-clickable status rows) is pulled to the **top as
29
+ one contiguous block, before the first working agent**, regardless of live/asleep — so the
30
+ bar separates "service, don't touch" from the working agents at a glance. Working agents
31
+ follow. Within **each** group the order is live → asleep → stopped, then alphabetical.
28
32
  - `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
29
33
  `●` live · `○` asleep · `✕` stopped — green when any runtime is live. Badges: `👤`
30
- a human is attached, `🔒` launchd-managed (H4 read-only), `⏳N` an ephemeral peer's
31
- queue depth.
34
+ a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
35
+ human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
36
+ row reads as yolo), `⏳N` an ephemeral peer's queue depth.
32
37
  - **Click an agent peer → Terminal.app running `iapeer attach <peer>`.** The row itself is
33
38
  the attach action (no submenu, so a click attaches rather than merely expanding). The
34
39
  handoff goes through `iapeer tray attach-term`, which writes a per-peer `.command`
@@ -22,11 +22,16 @@ SwiftBar.app, если его нет.
22
22
  покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
23
23
  [15](15-fleet-api.md)).
24
24
  - **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
25
- и голоса, затем строка на пира:
25
+ и голоса, затем строка на пира, **двумя группами**: блок `🔒` **под launchd** (человеческий
26
+ канал + служебная инфра — некликабельные статус-строки) вынесен **наверх единым непрерывным
27
+ блоком, перед первым рабочим агентом**, независимо от live/asleep — так бар с одного взгляда
28
+ отделяет «служебное, не трогать» от рабочих агентов. Рабочие агенты идут следом. Внутри
29
+ **каждой** группы порядок: живой → спит → остановлен, затем по алфавиту.
26
30
  - `<peer> <рантайм><глиф> … <возраст>` с той же семантикой глифов, что в CLI —
27
31
  `●` живой · `○` спит · `✕` остановлен — зелёный, если хоть один рантайм живой. Бейджи:
28
- `👤` подключён человек, `🔒` под launchd (H4 read-only), `⏳N` глубина очереди эфемерного
29
- пира.
32
+ `👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
33
+ human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
34
+ `⏳N` глубина очереди эфемерного пира.
30
35
  - **Клик по агент-пиру → Terminal.app с `iapeer attach <peer>`.** Строка сама и есть действие
31
36
  attach (без подменю — клик аттачит, а не раскрывает). Передача идёт через `iapeer tray
32
37
  attach-term`: пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
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",
@@ -23,6 +23,23 @@ export interface NumberedModal {
23
23
 
24
24
  const CAP_LINES = 24
25
25
  const CAP_BYTES = 8192
26
+ // How far above the footer to scan for the modal's options — the real AskUserQuestion box (question +
27
+ // pill + N options each with a DESCRIPTION line + an inter-option separator) is a dozen-plus rows.
28
+ const MODAL_MAX_LINES = 40
29
+
30
+ /**
31
+ * Is `line` a claude/codex interactive-SELECT footer — the stable signature of a live numbered picker?
32
+ * AskUserQuestion: "Enter to select · ↑/↓ to navigate · Esc to cancel"; a resume/boot select: "Enter to
33
+ * confirm · Esc to cancel"; codex: "Press enter to confirm or esc to go back". Anchoring on this footer
34
+ * (boris's guidance — устойчивее чем «❯ внизу») is layout-robust: it survives the cursor sitting on ANY
35
+ * option and options being interleaved with description lines. The command-approval breaker's footer
36
+ * ("Esc to cancel · Tab to amend · ctrl+e to explain") deliberately does NOT match (no select/navigate
37
+ * hint) — it is a KNOWN class handled by its own taxonomy, and the structural guards below (a live
38
+ * glyph-selected option + ≥2 options + nothing below the footer) keep this from firing on prose.
39
+ */
40
+ export function isSelectFooter(line: string): boolean {
41
+ return /esc to (?:cancel|go back)/i.test(line) && /(?:enter to |to navigate|to select|to confirm|press enter)/i.test(line)
42
+ }
26
43
  // Unicode box-drawing block (U+2500–U+257F) — modal borders/separators; stripped for PARSING only
27
44
  // (the verbatim `content` keeps them).
28
45
  const BOX_DRAWING = /[─-╿]/g
@@ -65,37 +82,42 @@ function capBlock(block: string[]): string {
65
82
  */
66
83
  export function detectNumberedModal(pane: string, glyph: string): NumberedModal | null {
67
84
  const lines = pane.split(/\r?\n/)
68
- // bottom-most row carrying the select glyph.
69
- let selected = -1
70
- for (let i = 0; i < lines.length; i++) if (lines[i].includes(glyph)) selected = i
71
- if (selected < 0) return null
72
- // it must be a SELECTED numbered option (`glyph … N. …`) — the composer (`glyph <text>`) carries no
73
- // leading digit-dot, so an idle/ready pane and a mere quote-with-composer-below both fail here.
85
+ // 1. FOOTER anchor — the stable signature of a live interactive select (boris: устойчивее «❯ внизу»).
86
+ // The LAST footer line is THIS modal's (a scrolled-off older one sits above). Anchoring here is
87
+ // robust to the cursor sitting on ANY option and to options interleaved with DESCRIPTION lines /
88
+ // separators (the real AskUserQuestion layout — verified against a live 2.1.202 render).
89
+ let footerIdx = -1
90
+ for (let i = 0; i < lines.length; i++) if (isSelectFooter(lines[i])) footerIdx = i
91
+ if (footerIdx < 0) return null
92
+ // 2. COMPOSER-REPLACED guard — a LIVE blocking modal has NOTHING below its footer (the composer is
93
+ // gone). A mere QUOTE of a modal pasted into chat has the ready composer glyph rendered BELOW it, so
94
+ // ANY select glyph after the footer means "not a live block" → reject (the В40 quote class).
95
+ for (let i = footerIdx + 1; i < lines.length; i++) if (lines[i].includes(glyph)) return null
96
+ // 3. OPTIONS — scan a bounded WINDOW above the footer (NOT a contiguous run: the real modal interleaves
97
+ // each option with a wrapped description line and even an inter-option separator, and a blank can sit
98
+ // between the last option and the footer). Require a glyph-SELECTED option (a live cursor is on one)
99
+ // and ≥2 distinct numbered options. option1 = the "1." row CLOSEST to the footer (the modal's own —
100
+ // a stray transcript "1." further up loses to it).
101
+ const windowTop = Math.max(0, footerIdx - MODAL_MAX_LINES)
74
102
  const selectedRe = new RegExp(`${escapeForRegex(glyph)}\\s*\\d+[.)]\\s`)
75
- if (!selectedRe.test(lines[selected])) return null
76
- // The select glyph sits only on the SELECTED option; siblings render WITHOUT it (above and below). Grow
77
- // the contiguous OPTION RUN in BOTH directions from the selected row while each line parses as a
78
- // numbered option (an interleaved blank / non-option line ends the run). This captures every option,
79
- // not just the ones above the cursor.
80
- let optTop = selected
81
- let optBottom = selected
82
- while (optTop - 1 >= 0 && parseOption(lines[optTop - 1], glyph)) optTop--
83
- while (optBottom + 1 < lines.length && parseOption(lines[optBottom + 1], glyph)) optBottom++
84
- // need ≥2 DISTINCT numbered options (a real select, not a lone printed `N.` line).
85
103
  const nums = new Set<number>()
86
104
  let option1 = ''
87
- for (let i = optTop; i <= optBottom; i++) {
105
+ let hasSelected = false
106
+ let firstOptIdx = -1
107
+ for (let i = windowTop; i < footerIdx; i++) {
108
+ if (selectedRe.test(lines[i])) hasSelected = true
88
109
  const opt = parseOption(lines[i], glyph)
89
110
  if (!opt) continue
111
+ if (firstOptIdx < 0) firstOptIdx = i
90
112
  nums.add(opt.num)
91
- if (opt.num === 1 && !option1) option1 = opt.label
113
+ if (opt.num === 1) option1 = opt.label // last (closest-to-footer) wins
92
114
  }
93
- if (nums.size < 2) return null
94
- // Verbatim block shown to the human: the option run + a little context (the question above / a footer
95
- // below), then leading/trailing blank lines trimmed. Bounded by the caps.
96
- const start = Math.max(0, optTop - 3)
97
- const end = Math.min(lines.length - 1, optBottom + 2)
98
- const block = lines.slice(start, end + 1)
115
+ if (!hasSelected) return null // no live cursor on an option → a printed list, not a live select
116
+ if (nums.size < 2) return null // ≥2 options → a real select
117
+ // 4. Verbatim block shown to the human: from a couple of lines above the first option (the question) to
118
+ // the footer, blanks trimmed + capped.
119
+ const cTop = Math.max(windowTop, (firstOptIdx < 0 ? footerIdx : firstOptIdx) - 2)
120
+ const block = lines.slice(cTop, footerIdx + 1)
99
121
  while (block.length && block[0].trim() === '') block.shift()
100
122
  while (block.length && block[block.length - 1].trim() === '') block.pop()
101
123
  return { content: capBlock(block), option1: option1 || '(option 1)' }
@@ -122,6 +122,24 @@ export function exitLogPath(exitLogDir: string): string {
122
122
  // launch — bring up ONE session (runtime-agnostic via the adapter)
123
123
  // ─────────────────────────────────────────────────────────────────────────────
124
124
 
125
+ /**
126
+ * The intelligence gate — launch step (0), extracted as a PURE predicate so it is
127
+ * unit-testable in isolation (no bring-up, never touches the pty host). A channel
128
+ * adapter that declares `allowedIntelligences` (telegram/voicetalk → ['natural',
129
+ * 'absent']) refuses a peer whose nature is NOT in the set — and refuses too when
130
+ * the nature is unknown (cannot confirm). Returns the fail-loud reason string, or
131
+ * `null` when the gate PASSES (the peer may proceed to bring-up).
132
+ */
133
+ export function intelligenceGateReason(adapter: RuntimeAdapter, spec: LaunchSpec): string | null {
134
+ if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
135
+ return (
136
+ `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
137
+ `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`
138
+ )
139
+ }
140
+ return null
141
+ }
142
+
125
143
  export const launch: LaunchFn = async (
126
144
  spec: LaunchSpec,
127
145
  adapter: RuntimeAdapter,
@@ -137,13 +155,8 @@ export const launch: LaunchFn = async (
137
155
  // A channel runtime carries a human (natural) OR a faceless service bot (absent), never an
138
156
  // LLM agent (artificial). Ports the persistent-peer FATAL human-channel guard, relaxed to
139
157
  // first-class faceless service bots (docs/Рантайм-адаптеры).
140
- if (adapter.allowedIntelligences && (spec.intelligence == null || !adapter.allowedIntelligences.includes(spec.intelligence))) {
141
- return fail(
142
- identity,
143
- `runtime "${spec.runtime}" requires intelligence ∈ {${adapter.allowedIntelligences.join(', ')}}, ` +
144
- `peer "${spec.personality}" is ${spec.intelligence ?? 'unknown'} — refusing to launch`,
145
- )
146
- }
158
+ const gateReason = intelligenceGateReason(adapter, spec)
159
+ if (gateReason) return fail(identity, gateReason)
147
160
 
148
161
  // (0.5) Ensure the socket's PARENT dir exists before `tmux new-session -S <sock>` —
149
162
  // tmux does NOT create it and fails SILENTLY (the session "dies immediately")
@@ -43,6 +43,8 @@ export interface TrayPeer {
43
43
  last_active_ms?: number
44
44
  attached?: boolean
45
45
  launchd_managed?: boolean
46
+ /** Human-approval mode (docs/17). ABSENT ⇒ `yolo` (client obligation — a pre-approval daemon omits it). */
47
+ approval_mode?: 'yolo' | 'gated'
46
48
  wake_policy?: 'warm' | 'ephemeral'
47
49
  queue_depth?: number
48
50
  }
@@ -133,6 +135,12 @@ function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
133
135
 
134
136
  const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0, asleep: 1, stopped: 2 }
135
137
 
138
+ // 🔒 launchd-managed peers (the human channel + service infra — arthur/timer/watcher) are
139
+ // NON-clickable status rows. They form ONE contiguous block at the very TOP, before the first
140
+ // working agent, regardless of live/asleep — so the bar separates "service, don't touch" from the
141
+ // working agents at a glance (owner UX). `attachable` (= !launchd_managed) is the same predicate.
142
+ const lockRank = (p: TrayPeer): number => (p.launchd_managed ? 0 : 1)
143
+
136
144
  // ── renderers ─────────────────────────────────────────────────────────────────
137
145
 
138
146
  const MANAGE_COMMANDS: Array<{ cmd: string; label: string }> = [
@@ -150,6 +158,10 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
150
158
  const now = opts.now ?? Date.now()
151
159
  const bin = opts.binPath
152
160
  const peers = [...snapshot.peers].sort((a, b) => {
161
+ // primary: the 🔒 lock block first (service/human infra), working agents below
162
+ const l = lockRank(a) - lockRank(b)
163
+ if (l !== 0) return l
164
+ // secondary (within EACH block): the existing live→asleep→stopped, then alpha
153
165
  const r = RANK[aggregate(a)] - RANK[aggregate(b)]
154
166
  return r !== 0 ? r : a.personality.localeCompare(b.personality)
155
167
  })
@@ -241,9 +253,14 @@ function attachable(p: TrayPeer): boolean {
241
253
  function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
242
254
  const agg = aggregate(p)
243
255
  const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
256
+ // approval mode (docs/17): the whole fleet defaults to `yolo`, so showing it on every row is
257
+ // noise — mark ONLY the exception, `gated` (this peer's blocking approvals go to a human), with a
258
+ // 🛡 shield. No badge ⇒ yolo (the default; also how a pre-approval snapshot with the field absent
259
+ // renders). So the row reads the mode: 🛡 = gated, bare = yolo.
244
260
  const badges =
245
261
  (p.attached ? ' 👤' : '') +
246
262
  (p.launchd_managed ? ' 🔒' : '') +
263
+ (p.approval_mode === 'gated' ? ' 🛡' : '') +
247
264
  (p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
248
265
  const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
249
266
  // HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red