@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 +1 -1
- package/src/cli/index.ts +30 -1
- package/src/init/index.ts +36 -22
- package/src/launch/adapters/claude.ts +5 -0
- package/src/launch/adapters/codex.ts +10 -0
- package/src/launch/types.ts +26 -0
- package/src/transport/index.ts +42 -28
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
|
125
|
-
*
|
|
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
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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
|
-
|
|
154
|
-
obj.enableAllProjectMcpServers
|
|
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
|
|
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
|
-
|
|
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
|
package/src/launch/types.ts
CHANGED
|
@@ -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
|
package/src/transport/index.ts
CHANGED
|
@@ -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.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
const
|
|
168
|
-
|
|
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
|
|
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).
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// The receiver's own assistant/tool turn never reproduces the
|
|
257
|
-
// it cannot forge a confirm. No such record within the grace →
|
|
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(
|
|
275
|
+
const graceMs = confirmGraceMs()
|
|
262
276
|
const graceDeadline = monotonicMs() + graceMs
|
|
263
277
|
while (true) {
|
|
264
278
|
if (confirm(envelope)) return ok(undefined)
|