@agfpd/iapeer 0.4.99 → 0.4.101
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 +104 -0
- package/src/daemon/goalwatch.ts +61 -1
- package/src/launch/adapters/codex.ts +41 -0
- package/src/launch/ptyHost.ts +30 -1
- package/src/transport/index.ts +56 -11
package/package.json
CHANGED
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;
|
package/src/daemon/goalwatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// - sessionAlive comes from client.ts (no @xterm);
|
|
12
12
|
// - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
-
import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
14
|
+
import { deliverToHost, sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
15
|
import { attachedPath, defaultRunDir, pidPath, readGeometry } from '../supervisor/paths.ts'
|
|
16
16
|
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
17
|
import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
|
|
@@ -177,3 +177,32 @@ export async function waitHostReady(
|
|
|
177
177
|
export function deliverHosted(identity: string, message: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
178
178
|
return deliverToHost(hostRunDir(env), identity, message)
|
|
179
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Re-send a BARE CR to a hosted session — the submit-retry behind the landed-confirm grace
|
|
183
|
+
* (transport/index.ts). NOT a delivery: it pastes nothing and carries no payload, it only presses
|
|
184
|
+
* Enter on whatever the composer already holds.
|
|
185
|
+
*
|
|
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):
|
|
189
|
+
*
|
|
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.
|
|
205
|
+
*/
|
|
206
|
+
export function resubmitHosted(identity: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
207
|
+
return sendControlToHost(hostRunDir(env), identity, [Buffer.from('\r', 'utf8')])
|
|
208
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
hostSessionAlive,
|
|
56
56
|
hostSessionToken,
|
|
57
57
|
listHostedPeers,
|
|
58
|
+
resubmitHosted,
|
|
58
59
|
} from '../launch/ptyHost.ts'
|
|
59
60
|
import { sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
60
61
|
// Origin-guard (docs/18) — reply-channel mechanic for agent→human sends. Lives in
|
|
@@ -183,20 +184,46 @@ const LIVENESS_POLL_MS = 100
|
|
|
183
184
|
// deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent loss, forbidden).
|
|
184
185
|
const CONFIRM_GRACE_MS = 4000
|
|
185
186
|
|
|
186
|
-
// ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// and the receiver's first output came at 19:35:21.7. The receiver writes the user-record when it
|
|
190
|
-
// BEGINS the turn, and loading/encoding the images pushed that start ~22 s out — so a 4 s grace
|
|
191
|
-
// expired on a message that had landed. Every att>0 delivery to a claude receiver false-FAILED,
|
|
192
|
-
// the sender retried on the lie, and the message duplicated in the receiver's context.
|
|
187
|
+
// ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments: an att>0 paste
|
|
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.
|
|
193
190
|
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
191
|
+
// CORRECTED 16.07.2026 — the model this number was first built on was WRONG. The original note here
|
|
192
|
+
// read the 15.07 att=4 measurement as "the receiver writes the user-record when it BEGINS the turn,
|
|
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 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.
|
|
199
|
+
//
|
|
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.
|
|
198
210
|
const CONFIRM_GRACE_ATTACHMENTS_MS = 60_000
|
|
199
211
|
|
|
212
|
+
// Submit-retry cadence — how often, WHILE NO RECORD HAS LANDED, the grace re-presses Enter on the
|
|
213
|
+
// receiver's composer (see the correction above; the primitive is ptyHost.resubmitHosted).
|
|
214
|
+
//
|
|
215
|
+
// WHY THIS IS SAFE. A bare CR reaches the composer in exactly one of two states: (a) it still holds
|
|
216
|
+
// our unsubmitted paste — the CR submits it, which is the entire point; or (b) it is empty because
|
|
217
|
+
// the turn already submitted — an empty composer ignores a CR. The loop is gated on `confirm()`
|
|
218
|
+
// being false, so we stop the instant the record appears.
|
|
219
|
+
//
|
|
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.
|
|
225
|
+
const RESUBMIT_INTERVAL_MS = 1500
|
|
226
|
+
|
|
200
227
|
function envNonNegative(raw: string | undefined): number | null {
|
|
201
228
|
const n = raw === undefined ? NaN : Number(raw)
|
|
202
229
|
return Number.isFinite(n) && n >= 0 ? n : null
|
|
@@ -246,6 +273,10 @@ export interface WarmDeliverSeam {
|
|
|
246
273
|
* loop (the grace is up to several seconds for codex, so a sync block would stall the whole router
|
|
247
274
|
* daemon for the duration of one delivery). */
|
|
248
275
|
sleep?: (ms: number) => Promise<void>
|
|
276
|
+
/** SUBMIT-RETRY: press Enter again on the receiver's composer while no record has landed. Default:
|
|
277
|
+
* the real `resubmitHosted` (a bare CR over the control channel). Tests inject a spy to assert the
|
|
278
|
+
* retry fires only while unconfirmed — and never once the record is in. */
|
|
279
|
+
resubmit?: (identity: string) => Promise<void>
|
|
249
280
|
}
|
|
250
281
|
|
|
251
282
|
/**
|
|
@@ -357,11 +388,25 @@ async function deliverViaHost(
|
|
|
357
388
|
// false-OK (silent loss the contract forbids).
|
|
358
389
|
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
|
|
359
390
|
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
391
|
+
const resubmit = seam.resubmit ?? (async (identity: string) => void (await resubmitHosted(identity, env)))
|
|
360
392
|
const graceMs = confirmGraceMs(envelopeHasAttachments(envelope))
|
|
361
393
|
const graceDeadline = monotonicMs() + graceMs
|
|
394
|
+
// SUBMIT-RETRY (see RESUBMIT_INTERVAL_MS). deliverToHost already pressed Enter once, on a fixed
|
|
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.
|
|
400
|
+
let nextResubmitAt = monotonicMs() + RESUBMIT_INTERVAL_MS
|
|
362
401
|
while (true) {
|
|
363
402
|
if (confirm(envelope)) return ok(undefined)
|
|
364
403
|
if (monotonicMs() >= graceDeadline) break
|
|
404
|
+
if (monotonicMs() >= nextResubmitAt) {
|
|
405
|
+
// confirm() was false microseconds ago, so the composer still holds our paste — or the record
|
|
406
|
+
// landed in that gap and this CR meets an empty composer, which ignores it.
|
|
407
|
+
await resubmit(target.address)
|
|
408
|
+
nextResubmitAt = monotonicMs() + RESUBMIT_INTERVAL_MS
|
|
409
|
+
}
|
|
365
410
|
await sleep(LIVENESS_POLL_MS)
|
|
366
411
|
}
|
|
367
412
|
return err(
|