@agfpd/iapeer 0.4.100 → 0.4.102

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.
@@ -95,7 +95,7 @@ A peer's system prompt points here — `~/.iapeer/docs/`, one folder per package
95
95
  ```
96
96
 
97
97
  - **`peer-profile.json`** — the peer's card and the single source of truth about it (the fields are covered in [03 — Peers, runtimes, identity](03-peers-runtimes-identity.md)).
98
- - **`launch.env`** — a `KEY=VALUE` file for a specific peer+runtime pair: extra launch arguments (`PEER_START_ARGS`) and environment variables. So one peer can be given special launch flags without touching the rest.
98
+ - **`launch.env`** — a `KEY=VALUE` file for a specific peer+runtime pair: extra launch arguments (`PEER_START_ARGS`) and environment variables. So one peer can be given special launch flags without touching the rest. `create`/`init` scaffold it as an all-comment skeleton for every agentic runtime scope (behaves exactly like no file until edited), so fleet-wide launch.env operations never skip a peer for lack of the file; an existing file is never overwritten.
99
99
 
100
100
  A peer's working folder doesn't have to live under `~/.iapeer/peers/`. By default `iapeer create` puts it there, but any folder can be made a peer with `iapeer init` — then `.iapeer/` appears right inside it.
101
101
 
@@ -124,6 +124,46 @@ a stable *transition* clock, and the `updated_at_ms > since` boundary fires exac
124
124
  then falls behind forever. A goal blocked while the daemon was down is missed — the same
125
125
  honest boundary `peer-mute` already has (§7).
126
126
 
127
+ **Only the peer's CURRENT thread counts.** `thread_goals` is keyed by thread_id, so a thread the
128
+ peer has abandoned keeps its last goal row forever. Measured live 16.07.2026: a peer escaped a
129
+ blocked goal the only way its tools allowed — a fresh session — and ended up with two rows,
130
+ `blocked` on the dead thread and `active` on the live one. Reporting the fossil would tell the
131
+ owner an objective is stalled while the peer is working it. So a goal is attributed only on the
132
+ cwd's newest thread (`recency_at_ms`); a fossil is silent.
133
+
134
+ ### Recovery — `iapeer goal <peer> <resume|clear|pause>`
135
+
136
+ A notice states a fact the peer cannot state. For `peer-goal-stalled` the owner then hits a second
137
+ wall: **the peer cannot fix it either.** Codex splits goal control, and the model's half has no
138
+ exit — `update_goal` may only mark complete|blocked ("pause, resume, budget-limited, and
139
+ usage-limited status changes are controlled by the user or system"), and `create_goal` is refused
140
+ while a goal is unfinished. An agent peer whose goal blocks can only lie (mark an unachieved
141
+ objective `complete`, which its own prompt forbids) or restart its session and lose the thread.
142
+
143
+ The transition out exists — it is human-only. The TUI's `/goal [<objective>|clear|edit|pause|
144
+ resume]` calls `thread/goal/set`, an unconditional upsert that sets Active from any status, and
145
+ `thread_goal_actions.rs` treats `Paused | Blocked | UsageLimited` as resumable. The daemon is the
146
+ peer's keyboard, so the verb presses it:
147
+
148
+ | sub | effect |
149
+ |---|---|
150
+ | `resume` | the SAME goal, `blocked` → `active`; codex resumes injecting its own continuation turns |
151
+ | `clear` | the goal is removed ⇒ the peer's OWN `create_goal` stops being refused — the supported route to a *new* objective, authored by the agent rather than by us |
152
+ | `pause` | operator park |
153
+
154
+ `/goal <objective>` is deliberately NOT offered: replacing an unfinished goal opens a "Replace
155
+ goal?" modal, and blind-typing past a modal is how the wrong option gets picked. Subcommands are a
156
+ closed whitelist — caller text is never interpolated into a keystroke stream. A claude peer returns
157
+ `unsupported` (it has no thread goals; `/goal` would land as literal text in its composer).
158
+
159
+ **Success is gated on codex's goal store, never on keystrokes being accepted** — a pty write proves
160
+ the bytes left us, not that the TUI acted (§the delivery contour learned this the hard way). The
161
+ verb reads the goal row before and after and reports the transition it can see; a session that took
162
+ the text but never acted returns `unchanged`, not success. iapeer only ever READS codex's sqlite.
163
+
164
+ Verified live 16.07.2026 on a real codex peer: `blocked → active` in ~1 s, same `goal_id`, same
165
+ thread, no self-fresh, and auto-continuation genuinely resumed.
166
+
127
167
  ## 3. What the owner is told — and what is deliberately withheld
128
168
 
129
169
  A notice states who, which runtime, which error type, which model, and when the wall lifts.
@@ -95,7 +95,7 @@
95
95
  ```
96
96
 
97
97
  - **`peer-profile.json`** — карточка пира и единственный источник истины о нём (поля разобраны в [03 — Пиры, рантаймы, идентичность](03-пиры-рантаймы-идентичность.md)).
98
- - **`launch.env`** — файл вида `KEY=VALUE` для конкретной пары пир+рантайм: дополнительные аргументы запуска (`PEER_START_ARGS`) и переменные окружения. Так одному пиру можно задать особые флаги запуска, не трогая остальных.
98
+ - **`launch.env`** — файл вида `KEY=VALUE` для конкретной пары пир+рантайм: дополнительные аргументы запуска (`PEER_START_ARGS`) и переменные окружения. Так одному пиру можно задать особые флаги запуска, не трогая остальных. `create`/`init` скаффолдят его как скелет из одних комментариев для каждого агентского рантайм-скоупа (до правки ведёт себя ровно как отсутствие файла), поэтому массовые операции над launch.env не пропускают пира из-за отсутствия файла; существующий файл никогда не перезаписывается.
99
99
 
100
100
  Рабочая папка пира не обязана лежать в `~/.iapeer/peers/`. По умолчанию `iapeer create` кладёт её туда, но пиром можно сделать любую папку командой `iapeer init` — тогда `.iapeer/` появится прямо в ней.
101
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.100",
3
+ "version": "0.4.102",
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
@@ -607,6 +607,92 @@ export interface CompactPeerOutcome {
607
607
  * crashed / never-run / non-resumable target returns an honest "nothing to compact"
608
608
  * instead of starting a fresh empty session and compacting that.
609
609
  */
610
+ /** `iapeer goal` outcomes. `action` is what HAPPENED, never what we typed. */
611
+ export interface GoalPeerOutcome {
612
+ personality: string
613
+ runtime: string
614
+ action: 'changed' | 'cleared' | 'no-goal' | 'unchanged' | 'unsupported' | 'offline'
615
+ from?: string
616
+ to?: string
617
+ reason?: string
618
+ }
619
+
620
+ /** How long we wait for codex to actually apply `/goal <sub>` before calling it unchanged. */
621
+ const GOAL_APPLY_TIMEOUT_MS = 8000
622
+ const GOAL_APPLY_POLL_MS = 250
623
+
624
+ /**
625
+ * Drive a codex peer's thread goal out of a state its OWN tools cannot leave (docs/19 §2a).
626
+ *
627
+ * WHY THIS VERB EXISTS. Codex's goal control is split: the model may only mark complete|blocked,
628
+ * and `create_goal` is refused while a goal is unfinished — yet a transient turn error blocks a
629
+ * goal all by itself. So a headless peer can end up with a dead objective and NO way back except
630
+ * restarting its session and losing the thread. The transitions out of `blocked` exist, but only
631
+ * on the human's keyboard (`/goal resume|clear|pause`). We are the peer's keyboard.
632
+ *
633
+ * SUCCESS IS GATED ON THE GOAL STORE, not on keystrokes being accepted. Sending bytes into a pty
634
+ * proves nothing about whether the TUI acted on them — the delivery contour learned that the hard
635
+ * way. So we read codex's own goal row before and after, and report the transition we can SEE.
636
+ */
637
+ export async function goalPeer(
638
+ personality: string,
639
+ sub: string,
640
+ runtime: string | undefined,
641
+ opts: CliEnvOptions & {
642
+ controlFn?: typeof routeControl
643
+ /** Injected goal reader (tests). Default: codex's own goal store, read-only. */
644
+ readGoalFn?: (cwd: string) => Promise<{ status: string; threadId: string } | null>
645
+ sleepFn?: (ms: number) => Promise<void>
646
+ timeoutMs?: number
647
+ } = {},
648
+ ): Promise<GoalPeerOutcome> {
649
+ const env = opts.env ?? process.env
650
+ const cfg = loadLifecycleConfig(env)
651
+ const peer = findPeer(readPeersIndex({ env }), personality)
652
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
653
+ // Omitted runtime resolves the same way every operator verb resolves it (docs: the single
654
+ // resolvePeerRuntime authority) — never a hardcoded guess.
655
+ const rt: string = (runtime as Runtime | undefined) ?? resolvePeerRuntime(peer, cfg, env)
656
+ // Goals are a codex feature. Saying so beats typing '/goal' into a claude composer, where it
657
+ // would land as literal text in the peer's next message.
658
+ if (rt !== 'codex') {
659
+ return { personality, runtime: rt, action: 'unsupported', reason: `thread goals are a codex feature; "${personality}" is on ${rt}` }
660
+ }
661
+ const control = opts.controlFn ?? routeControl
662
+ const sleep = opts.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
663
+ const readGoal =
664
+ opts.readGoalFn ??
665
+ (async (cwd: string) => {
666
+ const { readGoalRows, readCurrentThreadByCwd, resolveGoalWatchPaths } = await import('../daemon/goalwatch.ts')
667
+ const home = resolveGoalWatchPaths(env).codexHome
668
+ const current = (await readCurrentThreadByCwd(home, [cwd])).get(cwd)
669
+ if (!current) return null
670
+ const row = (await readGoalRows(home)).find(g => g.threadId === current)
671
+ return row ? { status: row.status, threadId: row.threadId } : null
672
+ })
673
+
674
+ const before = await readGoal(peer.cwd)
675
+ if (!before) return { personality, runtime: rt, action: 'no-goal', reason: 'this peer has no goal on its current thread' }
676
+
677
+ const r = await control(personality, rt, { name: 'goal', args: [sub] })
678
+ if (!r.ok) return { personality, runtime: rt, action: 'offline', from: before.status, reason: r.error.message }
679
+
680
+ const deadline = Date.now() + (opts.timeoutMs ?? GOAL_APPLY_TIMEOUT_MS)
681
+ while (Date.now() < deadline) {
682
+ await sleep(GOAL_APPLY_POLL_MS)
683
+ const now = await readGoal(peer.cwd)
684
+ if (!now) return { personality, runtime: rt, action: 'cleared', from: before.status }
685
+ if (now.status !== before.status) return { personality, runtime: rt, action: 'changed', from: before.status, to: now.status }
686
+ }
687
+ return {
688
+ personality,
689
+ runtime: rt,
690
+ action: 'unchanged',
691
+ from: before.status,
692
+ reason: `goal still "${before.status}" ${GOAL_APPLY_TIMEOUT_MS}ms after /goal ${sub} — the session may be mid-turn (the composer takes the text but does not act on it until the turn ends)`,
693
+ }
694
+ }
695
+
610
696
  export async function compactPeer(
611
697
  personality: string,
612
698
  runtime: string | undefined,
@@ -2542,6 +2628,24 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2542
2628
  errOut(`compact: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
2543
2629
  return 1
2544
2630
  }
2631
+ case 'goal': {
2632
+ // Recovery for a codex thread goal the peer's OWN tools cannot fix (docs/19 §2a).
2633
+ const sub = positionals[1]
2634
+ if (!positionals[0] || !sub) {
2635
+ return argErr(errOut, 'goal needs a peer and a subcommand — usage: iapeer goal <peer> <resume|clear|pause> [runtime]')
2636
+ }
2637
+ const o = await goalPeer(positionals[0], sub, positionals[2], { env })
2638
+ if (o.action === 'changed') {
2639
+ out(`goal → ${o.personality} (${o.runtime}): ${o.from} → ${o.to}\n`)
2640
+ return 0
2641
+ }
2642
+ if (o.action === 'cleared') {
2643
+ out(`goal → ${o.personality} (${o.runtime}): ${o.from} → cleared (the peer can create_goal again)\n`)
2644
+ return 0
2645
+ }
2646
+ errOut(`goal: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
2647
+ return 1
2648
+ }
2545
2649
  case 'connect': {
2546
2650
  // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
2547
2651
  // `connect telegram <peer> [--token <t>]`. The human owes only the token;
@@ -128,6 +128,10 @@ export function selectStalledGoals(
128
128
  cwdByThread: Map<string, string>,
129
129
  peerByCwd: (cwd: string) => string | undefined,
130
130
  sinceMs: number,
131
+ /** cwd → the peer's CURRENT thread (see readCurrentThreadByCwd). A goal on any OTHER thread is
132
+ * an abandoned session's leftover and is NOT this peer's objective. Omitted → no gate (the
133
+ * pure-selection tests that predate the gate). */
134
+ currentThreadByCwd?: Map<string, string>,
131
135
  ): GoalDetection[] {
132
136
  const out: GoalDetection[] = []
133
137
  for (const g of goals) {
@@ -137,6 +141,12 @@ export function selectStalledGoals(
137
141
  if (!cwd) continue // thread not in the state DB → unattributable; ignore rather than guess
138
142
  const personality = peerByCwd(cwd)
139
143
  if (!personality) continue // a human's own codex thread — never notify
144
+ if (currentThreadByCwd) {
145
+ const current = currentThreadByCwd.get(cwd)
146
+ // A stalled goal on a thread the peer has moved on from is not news — it is a fossil. The
147
+ // peer may well be working the same objective on its current thread right now.
148
+ if (!current || current !== g.threadId) continue
149
+ }
140
150
  out.push({
141
151
  personality,
142
152
  runtime: 'codex',
@@ -270,6 +280,53 @@ export async function readThreadCwds(codexHome: string, threadIds: string[]): Pr
270
280
  return out
271
281
  }
272
282
 
283
+ /**
284
+ * The CURRENT thread of each cwd — the newest by codex's own recency clock.
285
+ *
286
+ * WHY THIS GATES THE NOTICE. A peer's goal rows outlive its threads: `thread_goals` is keyed by
287
+ * thread_id, so a thread the peer has ABANDONED keeps its last goal row forever. Measured live
288
+ * 16.07.2026: a peer escaped a blocked goal the only way its tools allowed — a fresh session —
289
+ * and ended up with TWO rows, `blocked` on the dead thread and `active` on the live one. Reporting
290
+ * the dead row would tell the owner an objective is stalled while the peer is, in fact, working it
291
+ * on its current thread. That notice would be one-shot and WRONG, which is worse than noisy: the
292
+ * whole contract of this surface is that a card means something真 (docs/19 §2a).
293
+ *
294
+ * So a goal counts only on the cwd's newest thread. `recency_at_ms` is codex's field for exactly
295
+ * this; `updated_at_ms` is the fallback when it is absent.
296
+ */
297
+ export async function readCurrentThreadByCwd(codexHome: string, cwds: string[]): Promise<Map<string, string>> {
298
+ const out = new Map<string, string>()
299
+ if (cwds.length === 0) return out
300
+ const path = findVersionedDb(codexHome, 'state')
301
+ if (!path) return out
302
+ const db = await openReadOnly(path)
303
+ if (!db) return out
304
+ try {
305
+ const holes = cwds.map(() => '?').join(',')
306
+ const rows = db
307
+ .query(
308
+ `SELECT id, cwd, COALESCE(recency_at_ms, updated_at_ms) AS rank FROM threads
309
+ WHERE cwd IN (${holes}) ORDER BY rank ASC`,
310
+ )
311
+ .all(...cwds) as Array<Record<string, unknown>>
312
+ // ASC + overwrite ⇒ the LAST write per cwd is the newest thread.
313
+ for (const r of rows) {
314
+ const id = str(r.id)
315
+ const cwd = str(r.cwd)
316
+ if (id && cwd) out.set(cwd, id)
317
+ }
318
+ } catch {
319
+ /* unrecognised schema → no gate we can trust → caller falls back to not notifying */
320
+ } finally {
321
+ try {
322
+ db.close()
323
+ } catch {
324
+ /* best-effort */
325
+ }
326
+ }
327
+ return out
328
+ }
329
+
273
330
  export interface GoalScanDeps extends GoalWatchPaths {
274
331
  /** personality ← cwd. Built from the registry by the composition point. */
275
332
  peerByCwd: (cwd: string) => string | undefined
@@ -286,7 +343,10 @@ export async function scanStalledGoals(deps: GoalScanDeps, sinceMs: number): Pro
286
343
  deps.codexHome,
287
344
  candidates.map(g => g.threadId).filter(Boolean),
288
345
  )
289
- return selectStalledGoals(candidates, cwds, deps.peerByCwd, floor)
346
+ // Gate on the cwd's CURRENT thread — a fossil goal on an abandoned session is not this peer's
347
+ // objective (see readCurrentThreadByCwd).
348
+ const current = await readCurrentThreadByCwd(deps.codexHome, [...new Set(cwds.values())])
349
+ return selectStalledGoals(candidates, cwds, deps.peerByCwd, floor, current)
290
350
  }
291
351
 
292
352
  // ─────────────────────────────────────────────────────────────────────────────
@@ -46,6 +46,19 @@ import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
46
46
  import { detectNumberedModal } from './modalDetect.ts'
47
47
  import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
48
48
 
49
+ /**
50
+ * The `/goal` subcommands we will type into a live codex session (see executeControl → 'goal').
51
+ *
52
+ * DIALOG-FREE ONLY. Codex's own usage line is `/goal [<objective>|clear|edit|pause|resume]`, but:
53
+ * - `<objective>` on an unfinished goal opens a "Replace goal?" modal (tui/thread_goal_actions.rs
54
+ * `should_confirm_before_replacing_goal` → true for every status except Complete);
55
+ * - `edit` opens $EDITOR.
56
+ * Both need us to blind-type past an interactive prompt, which is how a wrong option gets picked.
57
+ * `resume` / `clear` / `pause` apply straight through. `resume` is the one that matters: it is the
58
+ * documented user-side transition out of `blocked`, the state the model's own tools cannot leave.
59
+ */
60
+ const GOAL_SUBCOMMANDS = new Set(['resume', 'clear', 'pause'])
61
+
49
62
  // ─────────────────────────────────────────────────────────────────────────────
50
63
  // Boot dialog markers (watcher.codexUpdate/DirTrust/HooksReview, 253-261)
51
64
  // ─────────────────────────────────────────────────────────────────────────────
@@ -399,11 +412,39 @@ export const codexAdapter: RuntimeAdapter = {
399
412
  * conversation to prevent hitting the context limit») and a live run
400
413
  * yields «• Context compacted». The 0.136-era "codex has no /compact"
401
414
  * finding is stale; the mapping is keyboard-identical to claude's.
415
+ * - goal <sub> → type '/goal <sub>' then Enter. The recovery path for a thread goal a
416
+ * headless peer CANNOT fix with its own tools (docs/19 §2a). Codex exposes goal control
417
+ * asymmetrically: the MODEL's `update_goal` may only mark complete|blocked ("pause,
418
+ * resume, budget-limited, and usage-limited status changes are controlled by the user or
419
+ * system"), and `create_goal` refuses while a goal is unfinished ("complete the existing
420
+ * goal first"). So once a goal is `blocked` — which a transient turn error does BY ITSELF,
421
+ * without the model — an agent peer has no way back and must restart its session. The
422
+ * capability is not missing, it is HUMAN-ONLY: the TUI's `/goal [<objective>|clear|edit|
423
+ * pause|resume]` calls thread/goal/set, an unconditional upsert that sets Active from any
424
+ * status. We are the peer's keyboard, so we press it for them.
425
+ *
426
+ * Verified live 16.07.2026 on a real codex peer: a blocked goal went blocked → ACTIVE and
427
+ * codex resumed injecting its own continuation turns — same thread, same goal_id, no
428
+ * self-fresh, nothing written to codex's sqlite by us.
429
+ *
430
+ * Only DIALOG-FREE subcommands are allowed (see GOAL_SUBCOMMANDS). `/goal <objective>` is
431
+ * deliberately NOT offered: replacing an unfinished goal opens a "Replace goal?"
432
+ * confirmation, and blind-typing past a modal is how you submit the wrong answer. The
433
+ * supported way to a NEW objective is `clear` (no goal → the peer's own `create_goal`
434
+ * stops being refused), which keeps the objective the AGENT's to author, not ours.
402
435
  * - anything else → null.
403
436
  */
404
437
  executeControl(command: ControlCommand): ControlPlan | null {
405
438
  if (command.name === 'interrupt') return { sequence: [['Escape']] }
406
439
  if (command.name === 'compact') return { sequence: [['-l', '/compact'], ['Enter']], stepDelayMs: 300 }
440
+ if (command.name === 'goal') {
441
+ const sub = command.args?.[0]
442
+ // Closed set — never interpolate caller text into a keystroke stream. An objective is
443
+ // free-form and would need the modal above; a typo'd subcommand must fail here, not land
444
+ // as literal garbage in a live composer.
445
+ if (!sub || !GOAL_SUBCOMMANDS.has(sub)) return null
446
+ return { sequence: [['-l', `/goal ${sub}`], ['Enter']], stepDelayMs: 300 }
447
+ }
407
448
  return null
408
449
  },
409
450
  }
@@ -183,15 +183,25 @@ export function deliverHosted(identity: string, message: string, env: NodeJS.Pro
183
183
  * (transport/index.ts). NOT a delivery: it pastes nothing and carries no payload, it only presses
184
184
  * Enter on whatever the composer already holds.
185
185
  *
186
- * WHY THIS EXISTS. deliverToHost is paste → 300 ms settle → CR. When the paste carries attachments
187
- * the receiving TUI hoists them (reads + encodes the files, rewrites the composer), and the CR fired
188
- * on the fixed settle lands MID-HOIST and is swallowed — the composer keeps the envelope and NO turn
189
- * is ever submitted. Proven live 16.07.2026 on an idle claude receiver: att=4 (2.7 MB) sat unsubmitted
190
- * with a frozen transcript for 3m33s and moved only when an unrelated later delivery's CR arrived; the
191
- * SAME envelope with an 8 s settle submitted itself in 4 s (single-factor isolation).
186
+ * WHY THIS EXISTS. deliverToHost is paste → fixed 300 ms settle → CR. Sometimes that CR is EATEN: the
187
+ * composer keeps the envelope and NO turn is ever submitted, so the message is never processed and the
188
+ * landed-confirm has nothing to find. Measured live 16.07.2026 (claude receivers):
192
189
  *
193
- * Sent through the CONTROL channel (bare bytes, no bracketed paste) precisely because it must be a
194
- * keystroke, not a pasted literal.
190
+ * • FIRST attachment-bearing delivery into a session → CR eaten. Two independent sessions hung this
191
+ * way; one sat with a byte-frozen transcript for 180 s with NOTHING else sent, and moved only when
192
+ * an unrelated later delivery's CR arrived. The payload was never lost — it sat in the composer,
193
+ * still carrying its own original ts.
194
+ * • EVERY LATER attachment delivery into that same session → the first CR works (~0.7–1 s, even for
195
+ * 9.3 MB of fresh incompressible PNGs).
196
+ *
197
+ * WHY the first one is different is NOT known, and this code deliberately does not guess. An earlier
198
+ * reading — "hoisting is slow, so the CR lands mid-hoist" — was RETRACTED: it rested on a settle=8000
199
+ * run that looked decisive but was confounded (that session had already taken its first attachment, so
200
+ * any settle would have worked). Do not re-derive a hoist-duration constant from it.
201
+ *
202
+ * That unknown is exactly why the retry is cause-agnostic: it does not model WHY a CR was eaten, it
203
+ * just presses Enter again while nothing has landed. Sent through the CONTROL channel (bare bytes, no
204
+ * bracketed paste) because it must arrive as a keystroke, not a pasted literal.
195
205
  */
196
206
  export function resubmitHosted(identity: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
197
207
  return sendControlToHost(hostRunDir(env), identity, [Buffer.from('\r', 'utf8')])
@@ -197,6 +197,10 @@ export function ensureLocalRuntimeScopes(cwd: string, runtimes: readonly Runtime
197
197
  mkdirSync(root, { recursive: true, mode: DIR_MODE })
198
198
  for (const runtime of supportedLocalRuntimeScopes(runtimes)) {
199
199
  mkdirSync(join(root, runtime, PLUGINS_DIR, IAP_PLUGIN_DIR), { recursive: true, mode: DIR_MODE })
200
+ // Default launch.env skeleton — every agentic runtime scope carries the knob
201
+ // from birth, so fleet-wide launch.env operations (e.g. mass PEER_START_ARGS
202
+ // edits) never skip a peer for lack of the file. Existing files are untouched.
203
+ ensureLaunchEnv(cwd, runtime)
200
204
  }
201
205
  }
202
206
 
@@ -207,11 +211,45 @@ export function ensureLocalRuntimeScopes(cwd: string, runtimes: readonly Runtime
207
211
  export const LAUNCH_ENV_FILE = 'launch.env'
208
212
 
209
213
  /** `<cwd>/.iapeer/runtimes/<runtime>/launch.env` — the per-peer per-runtime launch
210
- * fragment (PEER_START_ARGS + extra peer env). Written by init, read at launch. */
214
+ * fragment (PEER_START_ARGS + extra peer env). Scaffolded as an all-comment
215
+ * skeleton by create/init (ensureLocalRuntimeScopes) for every agentic runtime
216
+ * scope; the operator uncomments/edits it. Read at launch; missing → empty. */
211
217
  export function peerLaunchEnvPath(cwd: string, runtime: Runtime): string {
212
218
  return join(cwd, IAPEER_DIR, RUNTIMES_DIR, runtime, LAUNCH_ENV_FILE)
213
219
  }
214
220
 
221
+ /** The default launch.env skeleton. ALL lines are comments — deliberately: it must
222
+ * parse to exactly the missing-file semantics ({startArgs: [], env: {}}, and
223
+ * crucially NO PEER_DISALLOWED_TOOLS key, whose present-empty ≠ absent is the
224
+ * opt-in gate). No trailing comments on the example lines — the parser keeps
225
+ * everything after `=` as the value, so `KEY=x # note` would poison the value. */
226
+ export const LAUNCH_ENV_TEMPLATE = `# launch.env — optional per-peer per-runtime launch overrides (iapeer, docs/06-storage).
227
+ # Format: one KEY=VALUE per line (bash-style; an \`export \` prefix is allowed).
228
+ # Blank lines and lines starting with # are ignored. Do NOT put a trailing
229
+ # comment after a value — everything after \`=\` is taken verbatim as the value.
230
+ # This skeleton is all comments: it behaves exactly like no file at all.
231
+ #
232
+ # Extra CLI args appended after the runtime's base argv (whitespace-split):
233
+ #PEER_START_ARGS=""
234
+ #
235
+ # claude only — --disallowedTools override. Key absent = adapter default for the
236
+ # whole fleet; present-empty ("") = omit the flag; a value = pass it verbatim:
237
+ #PEER_DISALLOWED_TOOLS=""
238
+ #
239
+ # Any other KEY=VALUE becomes an env var of the peer's session:
240
+ #MY_VAR=value
241
+ `
242
+
243
+ /** Create the default launch.env skeleton if (and only if) the file does not
244
+ * exist yet — an existing operator-edited file is NEVER touched. 0600 like the
245
+ * rest of the tree (an operator may later put secrets in it). */
246
+ export function ensureLaunchEnv(cwd: string, runtime: Runtime): void {
247
+ const path = peerLaunchEnvPath(cwd, runtime)
248
+ if (existsSync(path)) return
249
+ mkdirSync(dirname(path), { recursive: true, mode: DIR_MODE })
250
+ writeFileAtomic(path, LAUNCH_ENV_TEMPLATE)
251
+ }
252
+
215
253
  export interface LaunchEnv {
216
254
  /** Tokens from PEER_START_ARGS, appended AFTER the adapter's base argv flags. */
217
255
  startArgs: string[]
@@ -232,9 +270,10 @@ export interface LaunchEnv {
232
270
  * reproduced here with a whitespace split (faithful to that semantics; a value
233
271
  * with embedded spaces was never one arg in the bash either). Surrounding single/
234
272
  * double quotes around a value are stripped. Lines that are blank or start with
235
- * `#` are ignored. A missing/unreadable file → empty (no flags, no env). This is a
236
- * deliberately MINIMAL parser (assignments only — no command substitution, no
237
- * conditionals); init writes the file, so the format is controlled.
273
+ * `#` are ignored. A missing/unreadable file → empty (no flags, no env); the
274
+ * scaffolded all-comment skeleton (LAUNCH_ENV_TEMPLATE) parses to the same. This
275
+ * is a deliberately MINIMAL parser (assignments only — no command substitution, no
276
+ * conditionals); the format is a controlled convention, not arbitrary shell.
238
277
  */
239
278
  export function readLaunchEnv(cwd: string, runtime: Runtime): LaunchEnv {
240
279
  let text: string
@@ -185,24 +185,28 @@ const LIVENESS_POLL_MS = 100
185
185
  const CONFIRM_GRACE_MS = 4000
186
186
 
187
187
  // ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments: an att>0 paste
188
- // makes the receiving TUI hoist the files (read + encode, rewrite the composer) before any turn can
189
- // begin, which takes seconds.
188
+ // makes the receiving TUI ingest the files (hoist them into image blocks, rewrite the composer) before
189
+ // any turn can begin, so acceptance is not sub-second.
190
190
  //
191
191
  // CORRECTED 16.07.2026 — the model this number was first built on was WRONG. The original note here
192
192
  // read the 15.07 att=4 measurement as "the receiver writes the user-record when it BEGINS the turn,
193
193
  // and loading the images pushed that start ~22 s out", i.e. a LATE SELF-START that a longer grace
194
- // would catch. A confound-free repro disproved it: att=4 (2.7 MB) into a claude receiver idle 2m17s,
195
- // with NOTHING else sent, left the transcript byte-frozen for 180 s — the turn NEVER started. The
196
- // 15.07 "22 s self-start" was almost certainly the same confound (an unrelated later delivery poked
197
- // the session). The real mechanism is a SWALLOWED SUBMIT, not a slow write: deliverToHost is
198
- // paste → 300 ms settle → CR, and with attachments the CR lands mid-hoist and is eaten. Proven by
199
- // single-factor isolation: the SAME envelope with an 8 s settle submitted itself in 4 s.
194
+ // would catch. A confound-free repro disproved it: att=4 into a claude receiver idle 2m17s, with
195
+ // NOTHING else sent, left the transcript byte-frozen for 180 s — the turn NEVER started, and the
196
+ // message moved only when an unrelated later delivery poked the session. The 15.07 "22 s self-start"
197
+ // was almost certainly that same confound. There is no late self-start to wait for: the SUBMIT was
198
+ // eaten, so a passive grace of ANY size waits for a record that cannot exist.
200
199
  //
201
- // So a passive grace of ANY size is useless here — it waits for a record that cannot exist, because
202
- // nothing was ever submitted. What makes the grace meaningful is the SUBMIT-RETRY below: we keep
203
- // pressing Enter while no record has landed, so the CR that falls after the hoist finishes does the
204
- // work. 60 s is then an upper BOUND on hoisting a large payload (not a calibrated latency), and it
205
- // costs nothing on the happy path — the confirm returns the moment the record lands.
200
+ // WHICH deliveries get their CR eaten, measured: the FIRST attachment-bearing delivery into a session
201
+ // (reproduced on two independent sessions, and again on a purpose-built fresh one). Every LATER
202
+ // attachment delivery into the same session submits on the first CR in ~0.7–1 s, even at 9.3 MB. WHY
203
+ // the first differs is UNKNOWN — an intermediate "the CR lands mid-hoist" story was retracted as
204
+ // confounded, so no hoist-duration constant may be derived from it.
205
+ //
206
+ // What makes the grace meaningful is the SUBMIT-RETRY below, which needs no such answer: while no
207
+ // record has landed we keep pressing Enter, and whichever press finds the composer submittable does
208
+ // the work. 60 s is then just a generous give-up BOUND (not a calibrated latency), and it costs
209
+ // nothing on the happy path — the confirm returns the moment the record lands.
206
210
  const CONFIRM_GRACE_ATTACHMENTS_MS = 60_000
207
211
 
208
212
  // Submit-retry cadence — how often, WHILE NO RECORD HAS LANDED, the grace re-presses Enter on the
@@ -213,8 +217,11 @@ const CONFIRM_GRACE_ATTACHMENTS_MS = 60_000
213
217
  // the turn already submitted — an empty composer ignores a CR. The loop is gated on `confirm()`
214
218
  // being false, so we stop the instant the record appears.
215
219
  //
216
- // Deliberately NOT every poll (100 ms): a CR is a keystroke into a live human-visible session, so it
217
- // is paced. 1.5 s is far below any plausible hoist time yet spaces the presses.
220
+ // Deliberately NOT every poll (100 ms): a CR is a keystroke into a live, human-visible session, so the
221
+ // presses are spaced rather than machine-gunned. This is a PACING number only — it is explicitly NOT a
222
+ // "how long until the composer is submittable" estimate, because that duration is unknown and the whole
223
+ // point of retrying is not needing it. Raising or lowering it changes only how promptly a stuck message
224
+ // is rescued (measured live: a rescue landed at ~2.3 s with this value), never whether it is.
218
225
  const RESUBMIT_INTERVAL_MS = 1500
219
226
 
220
227
  function envNonNegative(raw: string | undefined): number | null {
@@ -385,10 +392,11 @@ async function deliverViaHost(
385
392
  const graceMs = confirmGraceMs(envelopeHasAttachments(envelope))
386
393
  const graceDeadline = monotonicMs() + graceMs
387
394
  // SUBMIT-RETRY (see RESUBMIT_INTERVAL_MS). deliverToHost already pressed Enter once, on a fixed
388
- // settle; if the paste was still hoisting, that CR was eaten and NOTHING is running. So the grace
389
- // does not merely WAIT for a record — while none has landed it keeps pressing Enter, and whichever
390
- // press falls after the hoist submits the composer. Self-correcting for any payload weight: there is
391
- // deliberately NO tuned "how long does hoisting take" constant anywhere in this path.
395
+ // settle; that CR is sometimes EATEN, and then NOTHING is running and no record can ever appear. So
396
+ // the grace does not merely WAIT for a record — while none has landed it keeps pressing Enter, and
397
+ // whichever press finds the composer submittable does the work. Cause-agnostic ON PURPOSE: it does
398
+ // not model WHY a CR was eaten (still unknown), so there is deliberately no tuned duration constant
399
+ // on this path — which is also what keeps it correct at any payload weight.
392
400
  let nextResubmitAt = monotonicMs() + RESUBMIT_INTERVAL_MS
393
401
  while (true) {
394
402
  if (confirm(envelope)) return ok(undefined)