@agfpd/iapeer 0.4.28 → 0.4.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
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
@@ -1291,6 +1291,32 @@ export function renderUsage(width: number = helpWidth()): string {
1291
1291
  return lines.join('\n') + '\n'
1292
1292
  }
1293
1293
 
1294
+ /** Focused help for ONE verb: the VERBS entry whose signature's first token equals `verb`,
1295
+ * rendered as its own `usage: iapeer <sig>` + wrapped description — or null when `verb` is
1296
+ * not a known verb (the caller then falls back to the full renderUsage). This is why
1297
+ * `iapeer connect telegram --help` prints connect's OWN usage instead of the whole verb list
1298
+ * (the prior behavior: `--help` anywhere dumped the general usage, burying the subcommand the
1299
+ * user asked about). First-token match is unambiguous — no two VERBS share a leading token. */
1300
+ export function renderVerbHelp(verb: string, width: number = helpWidth()): string | null {
1301
+ const entry = VERBS.find(v => v.sig.split(/\s+/)[0] === verb)
1302
+ if (!entry) return null
1303
+ const W = Math.max(40, width)
1304
+ const lines = [`usage: iapeer ${entry.sig}`, ...wrapText(entry.desc, W - 2).map(d => ` ${d}`)]
1305
+ return lines.join('\n') + '\n'
1306
+ }
1307
+
1308
+ /** Which verb's help an explicit help request targets, or null for the GENERAL usage:
1309
+ * - `<verb> --help` / `<verb> -h` → the verb (argv[0], when it is not itself a help token)
1310
+ * - `help <verb>` → argv[1]
1311
+ * - bare `help` / `--help` / `-h` → null (whole verb list)
1312
+ * An unknown target falls through to the general usage via renderVerbHelp returning null. */
1313
+ export function helpTargetVerb(argv: string[]): string | null {
1314
+ const first = argv[0]
1315
+ if (first && first !== 'help' && first !== '--help' && first !== '-h') return first
1316
+ if (first === 'help' && argv[1]) return argv[1]!
1317
+ return null
1318
+ }
1319
+
1294
1320
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
1295
1321
  const [verb, ...rest] = argv
1296
1322
  // CLI hygiene (design «Onboard костяка» §CLI-гигиена): an explicit help request —
@@ -1301,7 +1327,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1301
1327
  // match is safe: the look-ahead parser never consumes a `--`-token as a value, so
1302
1328
  // a LITERAL "--help" value is only expressible as `--key=--help` (not intercepted).
1303
1329
  if (verb === 'help' || argv.includes('--help') || argv.includes('-h')) {
1304
- process.stdout.write(renderUsage())
1330
+ // Per-verb help when a known verb is named (`connect telegram --help` → connect's own usage,
1331
+ // `help connect` → same); the general verb list only for a bare/unknown help request.
1332
+ const target = helpTargetVerb(argv)
1333
+ process.stdout.write((target && renderVerbHelp(target)) || renderUsage())
1305
1334
  return 0
1306
1335
  }
1307
1336
  const { positionals, flags } = parseArgs(rest)
package/src/init/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // persistent-peer install-gate class is gone). A cold-start claude session
10
10
  // (--dangerously-skip-permissions, as the launch primitive runs it) finds the
11
11
  // `.mcp.json` http server and send_to_peer is callable on its FIRST turn. The server's
12
- // project-approval is pre-granted by ensureClaudeProjectMcpEnabled (the
12
+ // project-approval is pre-granted by ensureClaudeProjectSettings (the
13
13
  // enableAllProjectMcpServers flag below); on a VIRGIN config without it the launch's
14
14
  // bootDialogKeys clears the one-time "new MCP servers found" dialog as a backstop.
15
15
  //
@@ -121,27 +121,33 @@ export function writeClaudeMcpConfig(cwd: string, personality: string, daemonUrl
121
121
  }
122
122
 
123
123
  /**
124
- * Idempotently enable project-scope MCP servers for a claude peer by merging
125
- * `"enableAllProjectMcpServers": true` into `<cwd>/.claude/settings.json`.
124
+ * Idempotently merge the project-LOCAL claude settings that pre-empt BLOCKING startup/
125
+ * mid-session modals for a headless peer, into `<cwd>/.claude/settings.json`:
126
+ *
127
+ * - `"enableAllProjectMcpServers": true` — on a VIRGIN claude config, the FIRST launch
128
+ * in a cwd carrying a `.mcp.json` (writeClaudeMcpConfig above) shows a BLOCKING "N new
129
+ * MCP servers found in this project" approval dialog BEFORE the input prompt; a headless
130
+ * peer has no one to approve it, so the wake stalls. This flag pre-approves the cwd's MCP
131
+ * servers so the dialog never appears (verified live, claude 2.1.183). Set unconditionally.
132
+ * - `"tui": "default"` — pins the CLASSIC renderer so claude never pops its one-time
133
+ * "Try the new fullscreen renderer?" upsell modal (a BLOCKING mid-session nag that froze
134
+ * live fleet peers; empirically an explicit tui choice suppresses it — verified live). This
135
+ * is the PREVENTION belt to the supervisor nag-watcher: init keeps the modal from appearing
136
+ * for a new peer at all, the watcher dismisses the class on every peer mid-session. Set ONLY
137
+ * when ABSENT — never override an explicit operator/peer tui choice already in the file.
126
138
  *
127
- * Why: on a VIRGIN claude config, the FIRST launch in a cwd carrying a `.mcp.json`
128
- * (writeClaudeMcpConfig above) shows a BLOCKING "N new MCP servers found in this
129
- * project" approval dialog BEFORE the input prompt — a headless peer has no one to
130
- * approve it, so the wake stalls. This project-LOCAL flag pre-approves the cwd's MCP
131
- * servers so the dialog never appears (verified live, claude 2.1.183). It is
132
139
  * PROJECT-scoped (this peer's cwd only) — it does NOT touch the user's GLOBAL
133
- * ~/.claude/settings.json, so it never alters the user's own claude environment (the
134
- * same constraint that forbids a global skipDangerousModePermissionPrompt). The launch
135
- * adapter's bootDialogKeys also Enter-confirms this dialog as a belt-and-suspenders
136
- * backstop, so a peer whose cwd predates this flag still boots.
140
+ * ~/.claude/settings.json, so it never alters the user's own claude environment (the same
141
+ * constraint that forbids a global skipDangerousModePermissionPrompt). Both modals also have
142
+ * runtime backstops (bootDialogKeys Enter-confirms the MCP dialog; the supervisor nag-watcher
143
+ * declines the fullscreen modal), so a peer whose cwd predates these keys still boots safely.
137
144
  *
138
- * NO-CLOBBER merge (the file may carry foreign blocks — plugin enables, statusline
139
- * wrappers, the native-memory lever): parse/patch + atomic write; a non-object
140
- * settings.json is REFUSED rather than overwritten. Returns the written path, or null
141
- * when already set / on a refusal / on any error (best-effort, never throws — the
142
- * bootDialogKeys backstop covers the dialog regardless).
145
+ * NO-CLOBBER merge (the file may carry foreign blocks — plugin enables, statusline wrappers,
146
+ * the native-memory lever): parse/patch + atomic write; a non-object settings.json is REFUSED
147
+ * rather than overwritten. Returns the written path, or null when nothing changed / on a refusal
148
+ * / on any error (best-effort, never throws — the runtime backstops cover the modals regardless).
143
149
  */
144
- export function ensureClaudeProjectMcpEnabled(cwd: string): string | null {
150
+ export function ensureClaudeProjectSettings(cwd: string): string | null {
145
151
  const path = claudeSettingsPath(cwd)
146
152
  try {
147
153
  let obj: Record<string, unknown> = {}
@@ -150,13 +156,21 @@ export function ensureClaudeProjectMcpEnabled(cwd: string): string | null {
150
156
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null // refuse to clobber a non-object
151
157
  obj = raw as Record<string, unknown>
152
158
  }
153
- if (obj.enableAllProjectMcpServers === true) return path // already enabled
154
- obj.enableAllProjectMcpServers = true
159
+ let changed = false
160
+ if (obj.enableAllProjectMcpServers !== true) {
161
+ obj.enableAllProjectMcpServers = true // pre-approve the cwd's MCP servers (no boot approval dialog)
162
+ changed = true
163
+ }
164
+ if (obj.tui === undefined) {
165
+ obj.tui = 'default' // classic renderer → no fullscreen-upsell modal; ABSENT-only (no override of an explicit choice)
166
+ changed = true
167
+ }
168
+ if (!changed) return path // both already in place — idempotent, no rewrite
155
169
  mkdirSync(dirname(path), { recursive: true })
156
170
  writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
157
171
  return path
158
172
  } catch {
159
- return null // best-effort; the bootDialogKeys backstop still clears the dialog
173
+ return null // best-effort; the bootDialogKeys / nag-watcher backstops still clear the modals
160
174
  }
161
175
  }
162
176
 
@@ -476,7 +490,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
476
490
  // Pre-approve the cwd's project MCP servers so the FIRST virgin-config launch does
477
491
  // not stall on the blocking "N new MCP servers found" dialog (project-local, never
478
492
  // touches the user's global settings; bootDialogKeys is the backstop).
479
- ensureClaudeProjectMcpEnabled(provisioned.cwd)
493
+ ensureClaudeProjectSettings(provisioned.cwd)
480
494
  }
481
495
  // codex: write the token-free host-wide config.toml block (dummy bearer flips codex's
482
496
  // auth gate; the OPEN daemon authenticates by the identity header). send_to_peer works
@@ -136,6 +136,11 @@ export const claudeAdapter: RuntimeAdapter = {
136
136
  ghostTextSgr: ['2', '38;5;246'],
137
137
  },
138
138
 
139
+ // claude logs an accepted paste PROMPTLY (sub-second: a queue-operation when busy,
140
+ // the user-turn when idle), so the message-specific transcript confirm is both cheap
141
+ // and a swallow-guard (the false-OK class). Keep it. (See RuntimeAdapter.deliveryConfirm.)
142
+ deliveryConfirm: 'transcript',
143
+
139
144
  /**
140
145
  * argv = claudeBin + headless flags + (system-prompt-file when set) +
141
146
  * (--continue when resuming) + extraArgs.
@@ -177,6 +177,16 @@ export const codexAdapter: RuntimeAdapter = {
177
177
  ghostTextSgr: ['2', '38;5;246'],
178
178
  },
179
179
 
180
+ // codex writes NO prompt-acceptance record — its session-jsonl user-input appears only
181
+ // when the model TURN ingests the message (during a long turn, tens of seconds away;
182
+ // measured ~80s live 2026-06-25). But its input queue is DURABLE: a mid-turn submit is
183
+ // held and processed at the next turn boundary, never lost (verified live — an 80s turn
184
+ // still ingested + replied with the exact probe token, despite the send false-FAILing at
185
+ // the 8s grace). So the flushed socket-ack IS the delivery confirm; a transcript grace
186
+ // only false-FAILs a message that WILL be processed → wrong fallback escalation. A
187
+ // genuinely dead session still fails at the socket-ack. (See RuntimeAdapter.deliveryConfirm.)
188
+ deliveryConfirm: 'socket-ack',
189
+
180
190
  /**
181
191
  * argv = codexBin + (resume --last when spec.resume) + the TUI args +
182
192
  * (doctrine via -c when systemPromptFile set) + bypass + extraArgs. Order is
@@ -235,6 +235,32 @@ export interface RuntimeAdapter {
235
235
  */
236
236
  deliveryMarkers: DeliveryMarkers
237
237
 
238
+ /**
239
+ * How a delivered envelope's LANDING is confirmed AFTER the socket-ack, in
240
+ * deliverViaHost (transport). Owned by the adapter because it is a property of
241
+ * HOW the runtime logs an accepted message, not transport policy. A router
242
+ * (kind:'router') is always socket-ack by structure and short-circuits before
243
+ * this field is read; it refines the TUI runtimes. Absent → 'transcript' (the
244
+ * strict default: a new/unknown runtime gets the swallow-guarding confirm, never
245
+ * a weaker one).
246
+ * - 'transcript' (claude): the runtime writes an acceptance record CARRYING the
247
+ * envelope PROMPTLY — sub-second: a queue-operation when busy, the user-turn
248
+ * when idle. So a message-specific transcript-carries-envelope check within a
249
+ * short grace BOTH proves landing AND catches a paste swallowed at a turn
250
+ * boundary (the false-OK class, incident 2026-06-23). Cheap and safe → keep it.
251
+ * - 'socket-ack' (codex): the runtime writes NO acceptance record — its
252
+ * session-jsonl user-input appears only when the model TURN INGESTS the
253
+ * message, which during a long turn is many tens of seconds away (measured ~80s
254
+ * live, 2026-06-25). But its input queue is DURABLE: a mid-turn submit is HELD
255
+ * and processed at the next turn boundary, never lost (verified live — an 80s
256
+ * turn still ingested + replied with the exact probe token). So the flushed
257
+ * socket-ack (bytes left us AND drained to the pty) IS the delivery confirm; a
258
+ * transcript grace here only ever FALSE-FAILs a message that WILL be processed,
259
+ * wrongly escalating to a fallback peer. A genuinely dead session still fails
260
+ * at the socket-ack (no socket / stalled flush) → really-dead still escalates.
261
+ */
262
+ deliveryConfirm?: 'transcript' | 'socket-ack'
263
+
238
264
  /**
239
265
  * If set, launch REFUSES unless the peer's intelligence equals this value
240
266
  * (fail-loud). telegram → 'natural' — it is a human channel; launching an
@@ -157,20 +157,19 @@ export function resolvePeerDeliveryTarget(
157
157
  const LIVENESS_POLL_MS = 100
158
158
 
159
159
  // Confirm grace — how long deliverViaHost polls for a transcript record CARRYING our envelope before
160
- // declaring a false-FAIL. Per-RUNTIME because the runtimes log an accepted injection at different
161
- // times: claude writes the queue-operation (busy) / user-turn (idle) record PROMPTLY (sub-second), so
162
- // a short grace suffices; codex writes its session-jsonl user-input only at model-turn-START (TTFT
163
- // measured ~4-6s after submit), so it needs a longer window or every codex delivery would false-FAIL
164
- // before the first write. `IAP_HOST_LIVENESS_GRACE_MS` overrides BOTH (operator calibration; tests set
165
- // 0 for an immediate deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent
166
- // loss, which the contract forbids).
167
- const CONFIRM_GRACE_CLAUDE_MS = 4000
168
- const CONFIRM_GRACE_CODEX_MS = 8000
169
- function confirmGraceMs(runtime: string): number {
160
+ // declaring a false-FAIL. This applies ONLY to the 'transcript'-confirm runtimes (claude): claude
161
+ // writes the queue-operation (busy) / user-turn (idle) record PROMPTLY (sub-second), so a short grace
162
+ // suffices and the confirm doubles as a swallow-guard. codex is 'socket-ack' (adapter.deliveryConfirm)
163
+ // and never reaches this loop — its input queue is durable but it logs the user-input only at ingest
164
+ // (tens of seconds into a long turn), so a grace would only false-FAIL a message that WILL be processed.
165
+ // `IAP_HOST_LIVENESS_GRACE_MS` overrides (operator calibration; tests set 0 for an immediate
166
+ // deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent loss, forbidden).
167
+ const CONFIRM_GRACE_MS = 4000
168
+ function confirmGraceMs(): number {
170
169
  const raw = process.env.IAP_HOST_LIVENESS_GRACE_MS
171
170
  const n = raw === undefined ? NaN : Number(raw)
172
171
  if (Number.isFinite(n) && n >= 0) return n
173
- return runtime === 'codex' ? CONFIRM_GRACE_CODEX_MS : CONFIRM_GRACE_CLAUDE_MS
172
+ return CONFIRM_GRACE_MS
174
173
  }
175
174
 
176
175
  // Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
@@ -204,13 +203,17 @@ export interface WarmDeliverSeam {
204
203
  }
205
204
 
206
205
  /**
207
- * Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Landing is
208
- * CONFIRMED MESSAGE-SPECIFICALLY — by a NEW transcript record CARRYING this envelope appearing past the
209
- * pre-deliver baseline, NOT the socket-ack (a flushed CR proves the bytes left us, not that the session
210
- * accepted them) and NOT a bare mtime advance (the receiver's OWN concurrent turn bumps mtime even when
211
- * our paste was swallowed — the false-OK class the contract forbids). A router (telegram/notifier) has
212
- * no transcript → confirmed by the socket-ack (liveness is structural via launchd KeepAlive). See
213
- * `deliverViaHost`.
206
+ * Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Confirmation is
207
+ * adapter-driven (RuntimeAdapter.deliveryConfirm):
208
+ * - 'transcript' (claude): CONFIRMED MESSAGE-SPECIFICALLY — by a NEW transcript record CARRYING this
209
+ * envelope appearing past the pre-deliver baseline, NOT the socket-ack (a flushed CR proves the
210
+ * bytes left us, not that the session accepted them) and NOT a bare mtime advance (the receiver's
211
+ * OWN concurrent turn bumps mtime even when our paste was swallowed — the false-OK class, forbidden).
212
+ * - 'socket-ack' (codex): confirmed by the socket-ack — its input queue is durable (a mid-turn submit
213
+ * is never lost) and it logs no prompt-acceptance record, so a transcript grace would only false-FAIL.
214
+ * - router (telegram/notifier): no transcript → confirmed by the socket-ack (liveness is structural
215
+ * via launchd KeepAlive).
216
+ * See `deliverViaHost`.
214
217
  */
215
218
  export async function deliverWarm(
216
219
  target: DeliveryTarget,
@@ -244,21 +247,32 @@ async function deliverViaHost(
244
247
  // router's liveness is structural via launchd KeepAlive (not a model turn), so the ack IS the
245
248
  // delivery-level confirm (parity with the legacy tmux router C-j path).
246
249
  if (adapter.kind === 'router') return ok(undefined)
250
+ // 'socket-ack' TUI runtimes (codex): the input queue is DURABLE — a mid-turn submit is HELD and
251
+ // processed at the next turn boundary, however long that is (verified live 2026-06-25: an 80s turn
252
+ // still ingested + replied; the send false-FAILed at the 8s grace, the message was NOT lost). codex
253
+ // writes NO prompt-acceptance record (its user-input lands only at ingest), so a transcript grace
254
+ // can't tell "swallowed" from "queued-but-not-yet-ingested" within any useful window — it only
255
+ // false-FAILs a message that WILL be delivered, wrongly escalating to a fallback peer. The flushed
256
+ // socket-ack above IS the delivery confirm; a genuinely dead session already failed at deliverHosted
257
+ // (no socket / stalled flush) → really-dead still escalates. (claude stays on the strict
258
+ // message-specific confirm below — it logs acceptance sub-second, so that is cheap AND catches a
259
+ // swallowed paste, the false-OK class.) Driven by adapter.deliveryConfirm, not a hardcoded runtime.
260
+ if (adapter.deliveryConfirm === 'socket-ack') return ok(undefined)
247
261
  // socket-ack ≠ landed. With no transcript (direct callers/tests without a cwd) we can only trust the
248
262
  // flushed submit — confirmed-only.
249
263
  if (!cwd || !baseline) return ok(undefined)
250
- // MESSAGE-SPECIFIC landed-confirm — the false-OK killer. A bare transcript/pane-log mtime BUMP is
251
- // NOT proof the session took THIS message: a receiver in an active turn advances both mtimes with
252
- // its OWN rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23:
253
- // ok=true, message gone). The ONLY proof is a NEW transcript record CARRYING our envelope —
254
- // claude: a queue-operation `content` (busy → enqueued) or the user-turn message (idle),
255
- // codex: a user-input response_item (written at model-turn-start, ~4-6s — hence the codex grace).
256
- // The receiver's own assistant/tool turn never reproduces the `<iap from-personality=…>` wrapper, so
257
- // it cannot forge a confirm. No such record within the grace → false-FAIL (the sender retries) — NOT
258
- // a false-OK (silent loss the contract forbids).
264
+ // MESSAGE-SPECIFIC landed-confirm — the false-OK killer (claude / any 'transcript'-confirm runtime;
265
+ // codex took the socket-ack short-circuit above). A bare transcript/pane-log mtime BUMP is NOT proof
266
+ // the session took THIS message: a receiver in an active turn advances both mtimes with its OWN
267
+ // rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23: ok=true,
268
+ // message gone). The ONLY proof is a NEW transcript record CARRYING our envelope — for claude, a
269
+ // queue-operation `content` (busy → enqueued) or the user-turn message (idle), written sub-second so
270
+ // a short grace covers it. The receiver's own assistant/tool turn never reproduces the
271
+ // `<iap from-personality=…>` wrapper, so it cannot forge a confirm. No such record within the grace →
272
+ // false-FAIL (the sender retries) — NOT a false-OK (silent loss the contract forbids).
259
273
  const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload))
260
274
  const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
261
- const graceMs = confirmGraceMs(target.runtime)
275
+ const graceMs = confirmGraceMs()
262
276
  const graceDeadline = monotonicMs() + graceMs
263
277
  while (true) {
264
278
  if (confirm(envelope)) return ok(undefined)