@songsid/agend 2.1.6-beta.12 → 2.1.6-beta.14
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/dist/backend/codex.d.ts +8 -0
- package/dist/backend/codex.js +385 -29
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -0
- package/dist/backend/kiro.js +129 -0
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +12 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +35 -7
- package/dist/daemon.js +406 -64
- package/dist/daemon.js.map +1 -1
- package/dist/instance-lifecycle.js +7 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +2 -0
- package/dist/locale.js.map +1 -1
- package/dist/muse-usage-relay.js +14 -2
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/tmux-manager.d.ts +2 -0
- package/dist/tmux-manager.js +20 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/usage/providers.js +10 -2
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +0 -12
- package/dist/usage/usage-api.js +24 -3
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +2 -1
package/dist/daemon.js
CHANGED
|
@@ -399,6 +399,8 @@ export class PendingWorkTracker {
|
|
|
399
399
|
const NORMAL_ENTER_SETTLE_MS = 500;
|
|
400
400
|
/** How long to keep looking for proof after a retry Enter before failing it. */
|
|
401
401
|
const POST_ENTER_PROOF_WINDOW_MS = 3_000;
|
|
402
|
+
/** A final bounded observation before an ambiguous Codex submit can be classified. */
|
|
403
|
+
const CODEX_LATE_PROOF_MS = 5_000;
|
|
402
404
|
const POST_ENTER_PROOF_POLL_MS = 250;
|
|
403
405
|
/** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
|
|
404
406
|
const BASELINE_CAPTURE_ATTEMPTS = 3;
|
|
@@ -423,6 +425,16 @@ const STARTUP_DIALOG_BUDGET_MS = 30_000;
|
|
|
423
425
|
const DIALOG_PARKED_NOTIFY_MS = 60_000;
|
|
424
426
|
/** How many "dialog painted just before the write → wait → retry" rounds a delivery tolerates. */
|
|
425
427
|
const LATE_DIALOG_WRITE_ROUNDS = 3;
|
|
428
|
+
/** How many times a delivery redoes itself when a spawn starts between its settle wait and its pane write. */
|
|
429
|
+
const DELIVERY_SPAWN_RACE_MAX_ROUNDS = 3;
|
|
430
|
+
/**
|
|
431
|
+
* After relay exhaustion, how long to wait for an authoritatively idle pane
|
|
432
|
+
* before giving up the immediate resume-direct (the fallback flag and the
|
|
433
|
+
* notification already cover the next natural restart). Muse turns can run
|
|
434
|
+
* for many minutes; polling keeps each check a fresh capture.
|
|
435
|
+
*/
|
|
436
|
+
const MUSE_DIRECT_RESUME_IDLE_BUDGET_MS = 10 * 60_000;
|
|
437
|
+
const MUSE_DIRECT_RESUME_IDLE_POLL_MS = 5_000;
|
|
426
438
|
/**
|
|
427
439
|
* Startup failed because the CLI's backend is unreachable (see backend-outage.ts).
|
|
428
440
|
* The session-id is deliberately KEPT; the fleet schedules a delayed retry.
|
|
@@ -568,7 +580,7 @@ export async function waitForPasteSettle(client, windowId, pasteStartedAt, fallb
|
|
|
568
580
|
}
|
|
569
581
|
}
|
|
570
582
|
/** Redact likely credentials and control sequences before pane text reaches logs. */
|
|
571
|
-
export function sanitizePaneTail(pane, lineCount = 5) {
|
|
583
|
+
export function sanitizePaneTail(pane, lineCount = 5, excludeLine) {
|
|
572
584
|
const secretAssignment = /\b(token|secret|password|passwd|api[_-]?key|authorization)\b\s*[:=]\s*\S+/gi;
|
|
573
585
|
const bearer = /\bBearer\s+\S+/gi;
|
|
574
586
|
const knownToken = /\b(?:sk-[A-Za-z0-9_-]+|ghp_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+|AKIA[A-Z0-9]{16})\b/g;
|
|
@@ -581,6 +593,10 @@ export function sanitizePaneTail(pane, lineCount = 5) {
|
|
|
581
593
|
lines.pop();
|
|
582
594
|
return lines
|
|
583
595
|
.slice(-lineCount)
|
|
596
|
+
// The proxy-reply chrome predicate sees only control-normalized lines,
|
|
597
|
+
// but must run BEFORE redaction. Redaction can replace a status-footer
|
|
598
|
+
// session UUID with [REDACTED], destroying its structural signature.
|
|
599
|
+
.filter(line => !excludeLine?.(line))
|
|
584
600
|
.map(line => line
|
|
585
601
|
.replace(bearer, "Bearer [REDACTED]")
|
|
586
602
|
.replace(secretAssignment, "$1=[REDACTED]")
|
|
@@ -597,12 +613,14 @@ export function sanitizePaneTail(pane, lineCount = 5) {
|
|
|
597
613
|
* final answer exists only on screen. Everything up to and including the last
|
|
598
614
|
* line of the inbound message we pasted is cut (the reply starts after it),
|
|
599
615
|
* lines with no letters or digits are dropped (borders, separators, spinners,
|
|
600
|
-
* bare prompts), and
|
|
601
|
-
* when
|
|
602
|
-
*
|
|
616
|
+
* bare prompts), and UI chrome is dropped by a backend-specific per-line
|
|
617
|
+
* filter when available (or a legacy single-line ready pattern). Whole-pane
|
|
618
|
+
* readiness regexes cannot identify individual prompt/footer lines. Returns
|
|
619
|
+
* null when what remains is trivial — a proxy message must carry an answer,
|
|
620
|
+
* not chrome. Secrets are redacted by sanitizePaneTail, same as stuck diagnostics.
|
|
603
621
|
*/
|
|
604
622
|
export function extractProxyReplyText(pane, opts = {}) {
|
|
605
|
-
const lines = sanitizePaneTail(pane, opts.maxLines ?? 40);
|
|
623
|
+
const lines = sanitizePaneTail(pane, opts.maxLines ?? 40, opts.isChromeLine);
|
|
606
624
|
const marker = opts.inboundMarker?.trim();
|
|
607
625
|
// Require a distinctive marker: a short one ("ok") would match agent text.
|
|
608
626
|
if (marker && marker.length >= 8) {
|
|
@@ -3214,7 +3232,11 @@ export class Daemon extends EventEmitter {
|
|
|
3214
3232
|
pane = await this.tmux?.capturePane();
|
|
3215
3233
|
if (!pane)
|
|
3216
3234
|
return;
|
|
3217
|
-
const text = extractProxyReplyText(pane, {
|
|
3235
|
+
const text = extractProxyReplyText(pane, {
|
|
3236
|
+
inboundMarker: target.inboundMarker,
|
|
3237
|
+
readyPattern: this.instanceStateReadyPattern,
|
|
3238
|
+
isChromeLine: line => this.backend?.isProxyReplyChromeLine?.(line) ?? false,
|
|
3239
|
+
});
|
|
3218
3240
|
if (!text) {
|
|
3219
3241
|
this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
|
|
3220
3242
|
return;
|
|
@@ -3952,14 +3974,16 @@ export class Daemon extends EventEmitter {
|
|
|
3952
3974
|
* steer's verdict decide whether a queued message's sender is told its
|
|
3953
3975
|
* delivery failed — a false ❌ carrying the wrong correlation id.
|
|
3954
3976
|
*/
|
|
3955
|
-
failDelivery(verdict, status) {
|
|
3977
|
+
failDelivery(verdict, status, phase = "unknown", proof = "undelivered") {
|
|
3956
3978
|
verdict.reached = true;
|
|
3979
|
+
verdict.phase = phase;
|
|
3980
|
+
verdict.proof = proof;
|
|
3957
3981
|
if (status)
|
|
3958
3982
|
this.emit("message_failed", status); // ❌
|
|
3959
3983
|
return false;
|
|
3960
3984
|
}
|
|
3961
3985
|
/** Tell the fleet when an already-accepted cross-instance pane write failed. */
|
|
3962
|
-
reportCrossInstanceDeliveryFailure(meta, error) {
|
|
3986
|
+
reportCrossInstanceDeliveryFailure(meta, verdict, error) {
|
|
3963
3987
|
if (!meta.from_instance)
|
|
3964
3988
|
return;
|
|
3965
3989
|
this.ipcServer?.broadcast({
|
|
@@ -3967,7 +3991,10 @@ export class Daemon extends EventEmitter {
|
|
|
3967
3991
|
senderSession: meta.from_instance,
|
|
3968
3992
|
targetInstance: this.name,
|
|
3969
3993
|
correlationId: meta.correlation_id ?? "unknown",
|
|
3970
|
-
|
|
3994
|
+
// Never use getLastPasteError as a generic fallback: it may describe an
|
|
3995
|
+
// earlier paste, and #910 was a post-submit proof failure, not a paste
|
|
3996
|
+
// rejection. Keep the sender diagnostic phase-specific and non-secret.
|
|
3997
|
+
error: `delivery failed: phase=${verdict?.phase ?? (error ? "exception" : "unknown")}; proof=${verdict?.proof ?? (error ? "threw" : "none")}`,
|
|
3971
3998
|
});
|
|
3972
3999
|
}
|
|
3973
4000
|
/**
|
|
@@ -4021,12 +4048,12 @@ export class Daemon extends EventEmitter {
|
|
|
4021
4048
|
// Same rule as the queued path: a steer that never got to try is not a
|
|
4022
4049
|
// delivery failure. This holder is the steer's own, which is the point
|
|
4023
4050
|
// — it runs on steerLock while a queued delivery runs on pasteLock.
|
|
4024
|
-
this.reportCrossInstanceDeliveryFailure(meta);
|
|
4051
|
+
this.reportCrossInstanceDeliveryFailure(meta, verdict);
|
|
4025
4052
|
}
|
|
4026
4053
|
}).catch(err => {
|
|
4027
4054
|
this.logger.warn({ err: err.message }, "steer delivery error");
|
|
4028
4055
|
if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4029
|
-
this.reportCrossInstanceDeliveryFailure(meta, err.message);
|
|
4056
|
+
this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
|
|
4030
4057
|
}
|
|
4031
4058
|
});
|
|
4032
4059
|
}
|
|
@@ -4160,7 +4187,7 @@ export class Daemon extends EventEmitter {
|
|
|
4160
4187
|
// not ready yet, a cancel, a storm hold or a shutdown all return
|
|
4161
4188
|
// false without one, and telling the sender its message was lost
|
|
4162
4189
|
// there would be the false ❌ of #826 in its other form.
|
|
4163
|
-
this.reportCrossInstanceDeliveryFailure(meta);
|
|
4190
|
+
this.reportCrossInstanceDeliveryFailure(meta, verdict);
|
|
4164
4191
|
}
|
|
4165
4192
|
}
|
|
4166
4193
|
finally {
|
|
@@ -4169,7 +4196,7 @@ export class Daemon extends EventEmitter {
|
|
|
4169
4196
|
}).catch(err => {
|
|
4170
4197
|
this.logger.warn({ err: err.message }, "pasteLock delivery error — chain continues");
|
|
4171
4198
|
if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4172
|
-
this.reportCrossInstanceDeliveryFailure(meta, err.message);
|
|
4199
|
+
this.reportCrossInstanceDeliveryFailure(meta, undefined, err.message);
|
|
4173
4200
|
}
|
|
4174
4201
|
});
|
|
4175
4202
|
this.logger.debug({ user: meta.user, text: content.slice(0, 100) }, "Queued channel message for delivery");
|
|
@@ -4214,7 +4241,14 @@ export class Daemon extends EventEmitter {
|
|
|
4214
4241
|
return false;
|
|
4215
4242
|
}
|
|
4216
4243
|
// Before anything reads the window id: a spawn in progress is about to change it.
|
|
4217
|
-
|
|
4244
|
+
// A false return means the cap expired with a spawn STILL running — the
|
|
4245
|
+
// generation captured below is in-progress evidence, not settled evidence,
|
|
4246
|
+
// so the critical section must still back out (see settledClean).
|
|
4247
|
+
const settledClean = await this.waitForSpawnToSettle();
|
|
4248
|
+
// Everything captured below (window id, readiness verdicts) belongs to
|
|
4249
|
+
// this spawn generation. A spawn that starts afterwards is detected inside
|
|
4250
|
+
// the critical section, where this delivery backs out and redoes itself.
|
|
4251
|
+
const settleGeneration = this.spawnGeneration;
|
|
4218
4252
|
if (cancelled())
|
|
4219
4253
|
return false;
|
|
4220
4254
|
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
@@ -4239,7 +4273,8 @@ export class Daemon extends EventEmitter {
|
|
|
4239
4273
|
// fall through to the wait.
|
|
4240
4274
|
const canHandOff = (supportsQueuedInput || opts?.steer)
|
|
4241
4275
|
&& readiness === "busy"
|
|
4242
|
-
&& (await this.probeBlockingDialog()).state === "clear"
|
|
4276
|
+
&& (await this.probeBlockingDialog()).state === "clear"
|
|
4277
|
+
&& await this.hasPositiveDeliveryInput();
|
|
4243
4278
|
if (canHandOff) {
|
|
4244
4279
|
// Native queue (codex), or an explicit /steer: hand the complete
|
|
4245
4280
|
// paste+Enter transaction to the busy CLI now. For steer this is the
|
|
@@ -4259,7 +4294,7 @@ export class Daemon extends EventEmitter {
|
|
|
4259
4294
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
4260
4295
|
// would land on top of it) — and instead of holding the queue silently.
|
|
4261
4296
|
this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
|
|
4262
|
-
return this.failDelivery(verdict, status);
|
|
4297
|
+
return this.failDelivery(verdict, status, "readiness", "timeout-before-write");
|
|
4263
4298
|
}
|
|
4264
4299
|
}
|
|
4265
4300
|
}
|
|
@@ -4280,7 +4315,7 @@ export class Daemon extends EventEmitter {
|
|
|
4280
4315
|
// recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
|
|
4281
4316
|
if (round >= STRANDED_INPUT_MAX_ROUNDS) {
|
|
4282
4317
|
this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
|
|
4283
|
-
return this.failDelivery(verdict, status);
|
|
4318
|
+
return this.failDelivery(verdict, status, "stranded-input", "retry-budget-exhausted");
|
|
4284
4319
|
}
|
|
4285
4320
|
const stranded = await this.submitStrandedInputIfAny(windowId);
|
|
4286
4321
|
if (cancelled())
|
|
@@ -4289,14 +4324,14 @@ export class Daemon extends EventEmitter {
|
|
|
4289
4324
|
break;
|
|
4290
4325
|
if (stranded === "failed") {
|
|
4291
4326
|
this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
|
|
4292
|
-
return this.failDelivery(verdict, status);
|
|
4327
|
+
return this.failDelivery(verdict, status, "stranded-input", "enter-failed");
|
|
4293
4328
|
}
|
|
4294
4329
|
const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
|
|
4295
4330
|
if (cancelled())
|
|
4296
4331
|
return false;
|
|
4297
4332
|
if (!readyAgain) {
|
|
4298
4333
|
this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
|
|
4299
|
-
return this.failDelivery(verdict, status);
|
|
4334
|
+
return this.failDelivery(verdict, status, "stranded-input", "prompt-timeout");
|
|
4300
4335
|
}
|
|
4301
4336
|
}
|
|
4302
4337
|
}
|
|
@@ -4308,13 +4343,22 @@ export class Daemon extends EventEmitter {
|
|
|
4308
4343
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
4309
4344
|
if (cancelled())
|
|
4310
4345
|
return false;
|
|
4346
|
+
// A spawn that started after the settle wait above invalidates the
|
|
4347
|
+
// window id and every readiness verdict since: the pane about to be
|
|
4348
|
+
// written may already belong to a replacement process. The same holds
|
|
4349
|
+
// when the settle wait itself timed out (!settledClean) or a spawn is
|
|
4350
|
+
// running right now — none of those is settled evidence. Never wait
|
|
4351
|
+
// here (startup dismissal needs this lock) — back out and redo the
|
|
4352
|
+
// delivery from the top, where the settle wait runs again outside it.
|
|
4353
|
+
if (!settledClean || settleGeneration !== this.spawnGeneration || this.spawning)
|
|
4354
|
+
return "spawn-started";
|
|
4311
4355
|
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4312
4356
|
return false;
|
|
4313
4357
|
// A passive startup phase may paint after the outer readiness probe.
|
|
4314
4358
|
// It clears without input, so waiting under the pane lock cannot starve
|
|
4315
4359
|
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4316
4360
|
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4317
|
-
return this.failDelivery(verdict, status);
|
|
4361
|
+
return this.failDelivery(verdict, status, "pre-write", "input-transient-timeout");
|
|
4318
4362
|
}
|
|
4319
4363
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
4320
4364
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
@@ -4325,8 +4369,22 @@ export class Daemon extends EventEmitter {
|
|
|
4325
4369
|
if (probe.state !== "clear")
|
|
4326
4370
|
return "dialog";
|
|
4327
4371
|
}
|
|
4372
|
+
if (!(await this.hasPositiveDeliveryInput()))
|
|
4373
|
+
return "dialog";
|
|
4328
4374
|
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
|
|
4329
4375
|
});
|
|
4376
|
+
if (outcome === "spawn-started") {
|
|
4377
|
+
// The pane changed under this delivery: its queued paste must not land
|
|
4378
|
+
// in the replacement process's first screen. Redo the whole delivery
|
|
4379
|
+
// (fresh window id, fresh probes) — bounded, so spawn churn ends in an
|
|
4380
|
+
// honest failure rather than a message that never lands.
|
|
4381
|
+
const retries = (opts?.spawnRetry ?? 0) + 1;
|
|
4382
|
+
if (retries > DELIVERY_SPAWN_RACE_MAX_ROUNDS) {
|
|
4383
|
+
this.logger.error({ rounds: retries }, "A spawn kept starting before the pane write — reporting delivery failure");
|
|
4384
|
+
return this.failDelivery(verdict, status);
|
|
4385
|
+
}
|
|
4386
|
+
return this.deliverMessage(formatted, status, { ...opts, spawnRetry: retries });
|
|
4387
|
+
}
|
|
4330
4388
|
if (outcome !== "dialog")
|
|
4331
4389
|
return outcome;
|
|
4332
4390
|
// Wait OUTSIDE the lock (holding it would starve the runtime dismisser),
|
|
@@ -4334,14 +4392,14 @@ export class Daemon extends EventEmitter {
|
|
|
4334
4392
|
// an honest failure rather than a message that never lands.
|
|
4335
4393
|
if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
|
|
4336
4394
|
this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
|
|
4337
|
-
return this.failDelivery(verdict, status);
|
|
4395
|
+
return this.failDelivery(verdict, status, "pre-write", "dialog-or-input-not-ready");
|
|
4338
4396
|
}
|
|
4339
|
-
this.logger.info("Dialog appeared before the pane write — waiting for
|
|
4397
|
+
this.logger.info("Dialog or input transition appeared before the pane write — waiting for readiness");
|
|
4340
4398
|
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
|
|
4341
4399
|
if (cancelled())
|
|
4342
4400
|
return false;
|
|
4343
4401
|
if (!clear) {
|
|
4344
|
-
return this.failDelivery(verdict, status);
|
|
4402
|
+
return this.failDelivery(verdict, status, "pre-write", "readiness-timeout");
|
|
4345
4403
|
}
|
|
4346
4404
|
}
|
|
4347
4405
|
}
|
|
@@ -4602,6 +4660,27 @@ export class Daemon extends EventEmitter {
|
|
|
4602
4660
|
async isPaneReadyForDelivery(windowId) {
|
|
4603
4661
|
return (await this.paneReadinessForDelivery(windowId)) === "ready";
|
|
4604
4662
|
}
|
|
4663
|
+
needsStartupInputProof() {
|
|
4664
|
+
return !!this.backend?.isDeliveryInputReadyPane
|
|
4665
|
+
&& this.inputTransientGuardGeneration === this.spawnGeneration;
|
|
4666
|
+
}
|
|
4667
|
+
/** Codex can paint a prompt before the TTY enters raw mode. Both are required. */
|
|
4668
|
+
async hasPositiveDeliveryInput() {
|
|
4669
|
+
if (!this.needsStartupInputProof())
|
|
4670
|
+
return true;
|
|
4671
|
+
const check = this.backend?.isDeliveryInputReadyPane;
|
|
4672
|
+
if (!check || !this.tmux)
|
|
4673
|
+
return !check;
|
|
4674
|
+
try {
|
|
4675
|
+
const mode = await this.tmux.getPaneInputMode?.();
|
|
4676
|
+
if (mode !== "raw")
|
|
4677
|
+
return false;
|
|
4678
|
+
return check.call(this.backend, await this.tmux.capturePane());
|
|
4679
|
+
}
|
|
4680
|
+
catch {
|
|
4681
|
+
return false;
|
|
4682
|
+
}
|
|
4683
|
+
}
|
|
4605
4684
|
/**
|
|
4606
4685
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
4607
4686
|
* input queue or steered (after its own dialog probe); "dialog", "transient"
|
|
@@ -4620,6 +4699,9 @@ export class Daemon extends EventEmitter {
|
|
|
4620
4699
|
return "transient";
|
|
4621
4700
|
if (transient.state === "unknown")
|
|
4622
4701
|
return "unknown";
|
|
4702
|
+
if (this.needsStartupInputProof()) {
|
|
4703
|
+
return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
|
|
4704
|
+
}
|
|
4623
4705
|
if (this.backend?.dropsEnterWhileBusy?.() !== true)
|
|
4624
4706
|
return "ready";
|
|
4625
4707
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
@@ -4664,7 +4746,8 @@ export class Daemon extends EventEmitter {
|
|
|
4664
4746
|
// screen", and a dialog disappears without any output edge the silence
|
|
4665
4747
|
// gate could see.
|
|
4666
4748
|
const deadline = Date.now() + timeoutMs;
|
|
4667
|
-
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true
|
|
4749
|
+
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true
|
|
4750
|
+
|| this.needsStartupInputProof();
|
|
4668
4751
|
let unknownStreak = 0;
|
|
4669
4752
|
let transientBudget = null;
|
|
4670
4753
|
for (;;) {
|
|
@@ -4787,11 +4870,12 @@ export class Daemon extends EventEmitter {
|
|
|
4787
4870
|
return "busy";
|
|
4788
4871
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
4789
4872
|
return "busy";
|
|
4873
|
+
if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
|
|
4874
|
+
return "busy";
|
|
4790
4875
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
4791
4876
|
return "stranded";
|
|
4792
|
-
// Kiro
|
|
4793
|
-
//
|
|
4794
|
-
// enabled solely to recover a positively identified old AgEnD strand.
|
|
4877
|
+
// Kiro uses the bottom row; Codex's positive prompt/footer check above
|
|
4878
|
+
// rules out a historical transcript echo or a transition/modal screen.
|
|
4795
4879
|
return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
|
|
4796
4880
|
}
|
|
4797
4881
|
/**
|
|
@@ -4841,7 +4925,7 @@ export class Daemon extends EventEmitter {
|
|
|
4841
4925
|
if (!this.fatalStartupBlocked)
|
|
4842
4926
|
return false;
|
|
4843
4927
|
this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
|
|
4844
|
-
this.failDelivery(verdict, status);
|
|
4928
|
+
this.failDelivery(verdict, status, "fatal-startup", "blocked-before-write");
|
|
4845
4929
|
return true;
|
|
4846
4930
|
}
|
|
4847
4931
|
/**
|
|
@@ -4944,7 +5028,7 @@ export class Daemon extends EventEmitter {
|
|
|
4944
5028
|
if (!recoverable) {
|
|
4945
5029
|
// A tmux error the window cannot be recovered from: the text will
|
|
4946
5030
|
// never reach this pane, so it is a verdict like the others.
|
|
4947
|
-
return this.failDelivery(verdict, status);
|
|
5031
|
+
return this.failDelivery(verdict, status, "paste", "non-retryable-tmux-error");
|
|
4948
5032
|
}
|
|
4949
5033
|
windowId = (await this.recoverWindow()) ?? windowId;
|
|
4950
5034
|
if (attempt < maxAttempts)
|
|
@@ -4974,7 +5058,7 @@ export class Daemon extends EventEmitter {
|
|
|
4974
5058
|
}
|
|
4975
5059
|
let enterAt = Date.now();
|
|
4976
5060
|
if (!(await this.sendDeliveryEnter("initial-submit"))) {
|
|
4977
|
-
return this.failDelivery(verdict, status);
|
|
5061
|
+
return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
|
|
4978
5062
|
}
|
|
4979
5063
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
4980
5064
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
@@ -5023,8 +5107,16 @@ export class Daemon extends EventEmitter {
|
|
|
5023
5107
|
// Our text is on the pane but cannot be shown to have left the input
|
|
5024
5108
|
// row — either the backend exposes no input row (only /steer reaches
|
|
5025
5109
|
// this path on such a backend) or the pre-paste pane was unreadable.
|
|
5026
|
-
//
|
|
5027
|
-
//
|
|
5110
|
+
// For Codex, this can also mean its current layout is not a trusted
|
|
5111
|
+
// input/queue frame. Do not turn that uncertainty into a false ✅.
|
|
5112
|
+
if (this.backend?.isDeliveryInputReadyPane) {
|
|
5113
|
+
this.logger.warn({ phase: "native-queue-proof", proof }, "Codex native-queue outcome uncertain — not re-pasting or confirming");
|
|
5114
|
+
verdict.phase = "native-queue-proof";
|
|
5115
|
+
verdict.proof = proof;
|
|
5116
|
+
return false;
|
|
5117
|
+
}
|
|
5118
|
+
// Re-pasting on a backend without structured Codex pane evidence
|
|
5119
|
+
// would risk duplication; retain its legacy best-effort behavior.
|
|
5028
5120
|
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
5029
5121
|
if (status)
|
|
5030
5122
|
this.emit("message_confirmed", status); // ✅ (best-effort)
|
|
@@ -5057,9 +5149,14 @@ export class Daemon extends EventEmitter {
|
|
|
5057
5149
|
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
5058
5150
|
const strandedAt = Date.now();
|
|
5059
5151
|
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
5060
|
-
return this.failDelivery(verdict, status);
|
|
5152
|
+
return this.failDelivery(verdict, status, "native-queue-submit", "tmux-send-keys-failed");
|
|
5061
5153
|
}
|
|
5062
|
-
|
|
5154
|
+
// The recovery Enter can be accepted before Codex paints its new
|
|
5155
|
+
// transcript. One immediate capture is not a failure verdict: give
|
|
5156
|
+
// that echo/queue a bounded chance to appear, without re-pasting.
|
|
5157
|
+
const afterEnter = this.backend?.isDeliveryInputReadyPane
|
|
5158
|
+
? await this.lateCodexSubmissionProof(signature, pasteBaseline, true)
|
|
5159
|
+
: await this.confirmSubmitted(signature, pasteBaseline);
|
|
5063
5160
|
if (afterEnter === "submitted") {
|
|
5064
5161
|
if (status)
|
|
5065
5162
|
this.emit("message_confirmed", status); // ✅
|
|
@@ -5071,8 +5168,23 @@ export class Daemon extends EventEmitter {
|
|
|
5071
5168
|
// another turn's output — and treating that as proof re-confirms a
|
|
5072
5169
|
// message nobody submitted. Output is corroboration; text sitting in
|
|
5073
5170
|
// the input row is disqualifying, and disqualifying evidence wins.
|
|
5171
|
+
if (this.backend?.isDeliveryInputReadyPane && afterEnter !== "stranded") {
|
|
5172
|
+
this.logger.warn({ phase: "native-queue-submit", proof: afterEnter, strandedAt }, "Codex recovery Enter outcome uncertain — no hard failure or duplicate paste");
|
|
5173
|
+
verdict.phase = "native-queue-submit";
|
|
5174
|
+
verdict.proof = afterEnter;
|
|
5175
|
+
return false;
|
|
5176
|
+
}
|
|
5074
5177
|
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
5075
|
-
return this.failDelivery(verdict, status);
|
|
5178
|
+
return this.failDelivery(verdict, status, "native-queue-submit", afterEnter);
|
|
5179
|
+
}
|
|
5180
|
+
if (this.backend?.isDeliveryInputReadyPane) {
|
|
5181
|
+
// In Codex a missing viewport echo is inconclusive, not a proof of
|
|
5182
|
+
// loss. Another paste could run the same request twice. Leave the
|
|
5183
|
+
// already-pasted delivery at 👀 and let the next observation decide.
|
|
5184
|
+
this.logger.warn({ phase: "native-queue-proof", proof: settled }, "Codex native-queue outcome uncertain — not re-pasting");
|
|
5185
|
+
verdict.phase = "native-queue-proof";
|
|
5186
|
+
verdict.proof = settled;
|
|
5187
|
+
return false;
|
|
5076
5188
|
}
|
|
5077
5189
|
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
5078
5190
|
// so pasting it again cannot duplicate anything.
|
|
@@ -5081,12 +5193,12 @@ export class Daemon extends EventEmitter {
|
|
|
5081
5193
|
this.logger.error({
|
|
5082
5194
|
tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
|
|
5083
5195
|
}, "Idle-gated redelivery paste failed after native-queue silent loss");
|
|
5084
|
-
return this.failDelivery(verdict, status);
|
|
5196
|
+
return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-paste-failed");
|
|
5085
5197
|
}
|
|
5086
5198
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
5087
5199
|
const retryAt = Date.now();
|
|
5088
5200
|
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
|
|
5089
|
-
return this.failDelivery(verdict, status);
|
|
5201
|
+
return this.failDelivery(verdict, status, "native-queue-redelivery", "tmux-send-keys-failed");
|
|
5090
5202
|
}
|
|
5091
5203
|
if (windowId && this.controlClient) {
|
|
5092
5204
|
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
@@ -5101,7 +5213,7 @@ export class Daemon extends EventEmitter {
|
|
|
5101
5213
|
return true;
|
|
5102
5214
|
}
|
|
5103
5215
|
this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
|
|
5104
|
-
return this.failDelivery(verdict, status);
|
|
5216
|
+
return this.failDelivery(verdict, status, "native-queue-redelivery", "not-submitted");
|
|
5105
5217
|
}
|
|
5106
5218
|
if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
|
|
5107
5219
|
// F2: output after Enter is necessary but not sufficient — the paste
|
|
@@ -5115,7 +5227,7 @@ export class Daemon extends EventEmitter {
|
|
|
5115
5227
|
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
5116
5228
|
const retryAt = Date.now();
|
|
5117
5229
|
if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
|
|
5118
|
-
return this.failDelivery(verdict, status);
|
|
5230
|
+
return this.failDelivery(verdict, status, "stranded-text-retry", "tmux-send-keys-failed");
|
|
5119
5231
|
}
|
|
5120
5232
|
submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
|
|
5121
5233
|
}
|
|
@@ -5125,7 +5237,7 @@ export class Daemon extends EventEmitter {
|
|
|
5125
5237
|
}
|
|
5126
5238
|
else {
|
|
5127
5239
|
this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
|
|
5128
|
-
return this.failDelivery(verdict, status);
|
|
5240
|
+
return this.failDelivery(verdict, status, "stranded-text-retry", "stranded");
|
|
5129
5241
|
}
|
|
5130
5242
|
}
|
|
5131
5243
|
else if (windowId && this.controlClient) {
|
|
@@ -5145,12 +5257,26 @@ export class Daemon extends EventEmitter {
|
|
|
5145
5257
|
this.emit("message_confirmed", status); // ✅
|
|
5146
5258
|
}
|
|
5147
5259
|
else {
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5260
|
+
const proof = this.backend?.isDeliveryInputReadyPane
|
|
5261
|
+
? await this.lateCodexSubmissionProof(signature, pasteBaseline)
|
|
5262
|
+
: await this.confirmSubmitted(signature, pasteBaseline);
|
|
5263
|
+
if (proof === "submitted") {
|
|
5264
|
+
if (status)
|
|
5265
|
+
this.emit("message_confirmed", status);
|
|
5266
|
+
return true;
|
|
5267
|
+
}
|
|
5268
|
+
if (this.backend?.isDeliveryInputReadyPane && proof !== "stranded") {
|
|
5269
|
+
// This is the observed #910 race: the CLI processed the message
|
|
5270
|
+
// although its echo had not appeared within the proof window. A
|
|
5271
|
+
// missing viewport signature cannot establish non-delivery; keep
|
|
5272
|
+
// 👀 and never emit the sender's hard ❌ or re-paste blindly.
|
|
5273
|
+
this.logger.warn({ phase: "post-submit-proof", proof }, "Codex delivery outcome uncertain — no hard failure or duplicate paste");
|
|
5274
|
+
verdict.phase = "post-submit-proof";
|
|
5275
|
+
verdict.proof = proof;
|
|
5276
|
+
return false;
|
|
5277
|
+
}
|
|
5278
|
+
this.logger.error({ phase: "post-submit-proof", proof }, "Message remains unsubmitted after Enter retry");
|
|
5279
|
+
return this.failDelivery(verdict, status, "post-submit-proof", proof);
|
|
5154
5280
|
}
|
|
5155
5281
|
}
|
|
5156
5282
|
else {
|
|
@@ -5163,7 +5289,7 @@ export class Daemon extends EventEmitter {
|
|
|
5163
5289
|
return true;
|
|
5164
5290
|
}
|
|
5165
5291
|
this.logger.error("Message delivery failed after retries — window not ready");
|
|
5166
|
-
return this.failDelivery(verdict, status);
|
|
5292
|
+
return this.failDelivery(verdict, status, "paste", "retry-budget-exhausted");
|
|
5167
5293
|
}
|
|
5168
5294
|
/**
|
|
5169
5295
|
* The single place a pasted message is judged submitted, shared by the
|
|
@@ -5221,6 +5347,42 @@ export class Daemon extends EventEmitter {
|
|
|
5221
5347
|
}
|
|
5222
5348
|
return busy;
|
|
5223
5349
|
}
|
|
5350
|
+
if (this.backend?.isDeliveryInputReadyPane) {
|
|
5351
|
+
// Codex's first post-wake redraw may hide the echo for a few seconds.
|
|
5352
|
+
// Absence from a viewport is NOT proof the paste was lost, so only a
|
|
5353
|
+
// positively identified strand authorizes another Enter. Never re-paste
|
|
5354
|
+
// here: the CLI may already be processing the unique message_id.
|
|
5355
|
+
let proof = "unproven";
|
|
5356
|
+
const firstDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
5357
|
+
for (;;) {
|
|
5358
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5359
|
+
if (proof === "submitted")
|
|
5360
|
+
return true;
|
|
5361
|
+
if (proof === "stranded" || Date.now() >= firstDeadline)
|
|
5362
|
+
break;
|
|
5363
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
5364
|
+
}
|
|
5365
|
+
if (proof !== "stranded")
|
|
5366
|
+
return false;
|
|
5367
|
+
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
5368
|
+
return false;
|
|
5369
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5370
|
+
if (proof === "submitted")
|
|
5371
|
+
return true;
|
|
5372
|
+
if (proof !== "stranded")
|
|
5373
|
+
return false;
|
|
5374
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
5375
|
+
return false;
|
|
5376
|
+
const retryDeadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
5377
|
+
for (;;) {
|
|
5378
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5379
|
+
if (proof === "submitted")
|
|
5380
|
+
return true;
|
|
5381
|
+
if (Date.now() >= retryDeadline)
|
|
5382
|
+
return false;
|
|
5383
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
5384
|
+
}
|
|
5385
|
+
}
|
|
5224
5386
|
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
5225
5387
|
return true;
|
|
5226
5388
|
// Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
|
|
@@ -5270,6 +5432,14 @@ export class Daemon extends EventEmitter {
|
|
|
5270
5432
|
: "unproven";
|
|
5271
5433
|
}
|
|
5272
5434
|
const after = this.paneEvidence(pane, signature);
|
|
5435
|
+
// A Codex layout without its current input/footer pair may show a quoted
|
|
5436
|
+
// or historical `›` row. Unless its active Working banner corroborates a
|
|
5437
|
+
// real turn, text on that unknown screen cannot prove it left stdin.
|
|
5438
|
+
if (this.backend?.isDeliveryInputReadyPane
|
|
5439
|
+
&& !this.backend.isDeliveryInputReadyPane(pane)
|
|
5440
|
+
&& !this.backend.getBusyPattern?.()?.test(pane)) {
|
|
5441
|
+
return after.payload > 0 ? "unverifiable" : "unproven";
|
|
5442
|
+
}
|
|
5273
5443
|
// 1. Disqualifying evidence, checked FIRST and never overridden by the
|
|
5274
5444
|
// corroborating evidence below: our text is sitting in the input row, so
|
|
5275
5445
|
// it was not submitted — whatever else is on screen.
|
|
@@ -5308,6 +5478,35 @@ export class Daemon extends EventEmitter {
|
|
|
5308
5478
|
this.logger.warn("Could not read the pane before pasting — this delivery cannot be verified either way");
|
|
5309
5479
|
return "unverifiable";
|
|
5310
5480
|
}
|
|
5481
|
+
/** Only positive echo/queue evidence may turn an ambiguous Codex write into ✅. */
|
|
5482
|
+
async lateCodexSubmissionProof(signature, baseline, waitThroughStranded = false) {
|
|
5483
|
+
const deadline = Date.now() + CODEX_LATE_PROOF_MS;
|
|
5484
|
+
let proof = "unproven";
|
|
5485
|
+
for (;;) {
|
|
5486
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
5487
|
+
// Ordinary idle-path proof may return a strand immediately. After a
|
|
5488
|
+
// native-queue recovery Enter, though, it can be the previous frame
|
|
5489
|
+
// still on screen; only a strand that survives the bounded repaint
|
|
5490
|
+
// window proves that Enter was swallowed.
|
|
5491
|
+
if (proof === "submitted" || (proof === "stranded" && !waitThroughStranded))
|
|
5492
|
+
return proof;
|
|
5493
|
+
if (signature.unique && this.tmux?.capturePaneWithHistory) {
|
|
5494
|
+
try {
|
|
5495
|
+
const history = await this.tmux.capturePaneWithHistory(300);
|
|
5496
|
+
const seen = this.paneEvidence(history, signature);
|
|
5497
|
+
const known = !this.backend?.isDeliveryInputReadyPane
|
|
5498
|
+
|| this.backend.isDeliveryInputReadyPane(history)
|
|
5499
|
+
|| this.backend.getBusyPattern?.()?.test(history);
|
|
5500
|
+
if (known && seen.payload > 0 && !seen.strandedInput)
|
|
5501
|
+
return this.submittedProof();
|
|
5502
|
+
}
|
|
5503
|
+
catch { /* a failed history read proves neither delivery nor loss */ }
|
|
5504
|
+
}
|
|
5505
|
+
if (Date.now() >= deadline)
|
|
5506
|
+
return proof;
|
|
5507
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5311
5510
|
/**
|
|
5312
5511
|
* What the pane currently shows of a given message. Taken once before the
|
|
5313
5512
|
* paste and once after, so confirmSubmitted can require a NEW marker or a NEW
|
|
@@ -5323,7 +5522,8 @@ export class Daemon extends EventEmitter {
|
|
|
5323
5522
|
const pane = await this.tmux.capturePane();
|
|
5324
5523
|
const evidence = this.paneEvidence(pane, signature);
|
|
5325
5524
|
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5326
|
-
if (prompt &&
|
|
5525
|
+
if (prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
|
|
5526
|
+
&& strandedAgendMessageInInput(pane, prompt)) {
|
|
5327
5527
|
// Whatever we paste now lands after it, and one Enter submits both as
|
|
5328
5528
|
// a single message. Nothing here can undo that; saying so beats
|
|
5329
5529
|
// letting two messages silently merge.
|
|
@@ -5344,7 +5544,8 @@ export class Daemon extends EventEmitter {
|
|
|
5344
5544
|
paneEvidence(pane, signature) {
|
|
5345
5545
|
const marker = this.backend?.getQueuedInputMarker?.();
|
|
5346
5546
|
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
5347
|
-
const input = prompt
|
|
5547
|
+
const input = prompt && (!this.backend?.isDeliveryInputReadyPane || this.backend.isDeliveryInputReadyPane(pane))
|
|
5548
|
+
? inputAreaText(pane, prompt) : null;
|
|
5348
5549
|
return {
|
|
5349
5550
|
queued: marker ? pane.split(/\r?\n/).filter(row => marker.test(row)).length : 0,
|
|
5350
5551
|
payload: countOccurrences(pane.replace(/\s+/g, ""), signature.value),
|
|
@@ -5395,6 +5596,13 @@ export class Daemon extends EventEmitter {
|
|
|
5395
5596
|
return false;
|
|
5396
5597
|
let proof = await this.confirmSubmitted(signature, baseline);
|
|
5397
5598
|
if (proof === "unverifiable") {
|
|
5599
|
+
if (this.backend?.isDeliveryInputReadyPane) {
|
|
5600
|
+
// A Codex screen without a structurally current input/footer pair is
|
|
5601
|
+
// not a successful snapshot restore. The old best-effort rule below
|
|
5602
|
+
// is only for backends that expose no readable input row at all.
|
|
5603
|
+
this.logger.warn({ label }, "Codex system paste could not be verified");
|
|
5604
|
+
return false;
|
|
5605
|
+
}
|
|
5398
5606
|
// No input row to read: this backend gets exactly what the old pasteText
|
|
5399
5607
|
// path gave it, including the unconditional second Enter for queue-less
|
|
5400
5608
|
// TUIs that swallow the first. Narrowing that to one Enter on the grounds
|
|
@@ -5996,26 +6204,33 @@ export class Daemon extends EventEmitter {
|
|
|
5996
6204
|
* Bounded, and called BEFORE the pane lock is taken — waiting on the spawn while
|
|
5997
6205
|
* holding the lock the spawn itself needs would deadlock.
|
|
5998
6206
|
*/
|
|
5999
|
-
|
|
6207
|
+
/**
|
|
6208
|
+
* Wait for an in-flight spawn to finish, up to the cap. Returns true when no
|
|
6209
|
+
* spawn is in flight afterwards; false when the cap expired with one still
|
|
6210
|
+
* running. A false return is NOT settled evidence — the caller must not
|
|
6211
|
+
* treat the current generation as a completed spawn.
|
|
6212
|
+
*/
|
|
6213
|
+
async waitForSpawnToSettle(capMs = SPAWN_SETTLE_MAX_WAIT_MS) {
|
|
6000
6214
|
const settled = this.spawnSettled;
|
|
6001
6215
|
if (!settled)
|
|
6002
|
-
return;
|
|
6216
|
+
return true;
|
|
6003
6217
|
this.logger.debug("Holding delivery until the CLI has finished starting up");
|
|
6004
6218
|
let timer;
|
|
6005
6219
|
const cap = new Promise(resolve => {
|
|
6006
|
-
timer = setTimeout(resolve,
|
|
6220
|
+
timer = setTimeout(() => resolve(false), capMs);
|
|
6007
6221
|
timer.unref?.();
|
|
6008
6222
|
});
|
|
6009
6223
|
try {
|
|
6010
|
-
await Promise.race([settled, cap]);
|
|
6224
|
+
const finished = await Promise.race([settled.then(() => true), cap]);
|
|
6225
|
+
if (!finished) {
|
|
6226
|
+
this.logger.warn("CLI still starting after the delivery hold — the pane is not settled evidence");
|
|
6227
|
+
}
|
|
6228
|
+
return finished;
|
|
6011
6229
|
}
|
|
6012
6230
|
finally {
|
|
6013
6231
|
if (timer)
|
|
6014
6232
|
clearTimeout(timer);
|
|
6015
6233
|
}
|
|
6016
|
-
if (this.spawning) {
|
|
6017
|
-
this.logger.warn("CLI still starting after the delivery hold — delivering anyway");
|
|
6018
|
-
}
|
|
6019
6234
|
}
|
|
6020
6235
|
/** Spawn a CLI window. Returns true if --resume was used successfully. */
|
|
6021
6236
|
/**
|
|
@@ -6321,20 +6536,63 @@ export class Daemon extends EventEmitter {
|
|
|
6321
6536
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
6322
6537
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
6323
6538
|
}
|
|
6539
|
+
/**
|
|
6540
|
+
* One fresh pane capture judged as ready && !busy && !dialog. Transcript
|
|
6541
|
+
* silence is deliberately NOT the gate: its monitor is inert for muse, so a
|
|
6542
|
+
* quiet-but-working pane would read as idle and the respawn below would kill
|
|
6543
|
+
* a live turn. Fail closed: an unreadable pane is never idle.
|
|
6544
|
+
*/
|
|
6545
|
+
async isPaneAuthoritativelyIdle() {
|
|
6546
|
+
const backend = this.backend;
|
|
6547
|
+
if (!this.tmux || !backend)
|
|
6548
|
+
return false;
|
|
6549
|
+
let pane;
|
|
6550
|
+
try {
|
|
6551
|
+
pane = await this.tmux.capturePane();
|
|
6552
|
+
}
|
|
6553
|
+
catch {
|
|
6554
|
+
return false;
|
|
6555
|
+
}
|
|
6556
|
+
if (backend.getBusyPattern?.()?.test(pane))
|
|
6557
|
+
return false;
|
|
6558
|
+
for (const dialog of this.deliveryBlockingDialogs()) {
|
|
6559
|
+
if (Daemon.dialogMatches(dialog, pane))
|
|
6560
|
+
return false;
|
|
6561
|
+
}
|
|
6562
|
+
if (!backend.getReadyPattern().test(pane))
|
|
6563
|
+
return false;
|
|
6564
|
+
return true;
|
|
6565
|
+
}
|
|
6566
|
+
/**
|
|
6567
|
+
* Poll the authoritative pane check until it reports idle or the budget runs
|
|
6568
|
+
* out. Each poll is a fresh capture, so a turn that ends mid-wait is seen.
|
|
6569
|
+
*/
|
|
6570
|
+
async waitForAuthoritativePaneIdle(budgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, pollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
|
|
6571
|
+
const deadline = Date.now() + budgetMs;
|
|
6572
|
+
for (;;) {
|
|
6573
|
+
if (await this.isPaneAuthoritativelyIdle())
|
|
6574
|
+
return true;
|
|
6575
|
+
if (Date.now() >= deadline)
|
|
6576
|
+
return false;
|
|
6577
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6324
6580
|
/**
|
|
6325
6581
|
* A relay crash must not make the daemon kill a live Muse process. The
|
|
6326
6582
|
* supervisor normally reclaims the same port; when that bounded recovery
|
|
6327
|
-
* fails,
|
|
6328
|
-
*
|
|
6329
|
-
*
|
|
6583
|
+
* fails, move Muse back to a direct connection once it is authoritatively
|
|
6584
|
+
* idle (session preserved via the existing resume path) and mark usage
|
|
6585
|
+
* unavailable. The fallback flag also covers every later natural spawn.
|
|
6586
|
+
* Never throws: the floor is the old behavior (flag set, relay stopped,
|
|
6587
|
+
* next restart direct).
|
|
6330
6588
|
*/
|
|
6331
|
-
switchMuseToDirect() {
|
|
6589
|
+
switchMuseToDirect(idleBudgetMs = MUSE_DIRECT_RESUME_IDLE_BUDGET_MS, idlePollMs = MUSE_DIRECT_RESUME_IDLE_POLL_MS) {
|
|
6332
6590
|
if (this.museRelayFallbackInFlight || this.startupAborted || this.backend?.binaryName !== "muse") {
|
|
6333
6591
|
return this.museRelayFallbackInFlight ?? Promise.resolve();
|
|
6334
6592
|
}
|
|
6335
6593
|
this.museRelayFallbackInFlight = (async () => {
|
|
6336
6594
|
this.museRelayFallback = true;
|
|
6337
|
-
this.logger.warn("Muse usage relay could not recover — usage is unavailable until
|
|
6595
|
+
this.logger.warn("Muse usage relay could not recover — usage is unavailable until Muse restarts direct");
|
|
6338
6596
|
try {
|
|
6339
6597
|
if (this.museUsageRelay) {
|
|
6340
6598
|
await this.museUsageRelay.stop().catch(() => { });
|
|
@@ -6344,6 +6602,60 @@ export class Daemon extends EventEmitter {
|
|
|
6344
6602
|
catch (err) {
|
|
6345
6603
|
this.logger.warn({ reason: err instanceof Error ? err.message : "relay stop failed" }, "Muse usage relay cleanup failed");
|
|
6346
6604
|
}
|
|
6605
|
+
// Say so where the operator is looking: without this, muse keeps running
|
|
6606
|
+
// with --base-url pointed at the dead relay port and every turn fails.
|
|
6607
|
+
this.emit("muse_relay_exhausted", { name: this.name });
|
|
6608
|
+
// An idle muse can be moved now; a busy, paused, or supervised-paused one
|
|
6609
|
+
// is left alone — pausing/wake respawns direct anyway via the fallback
|
|
6610
|
+
// flag, and interrupting a live turn (or fighting supervision) would be
|
|
6611
|
+
// worse than waiting for the next natural restart.
|
|
6612
|
+
if (this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" || !this.tmux)
|
|
6613
|
+
return;
|
|
6614
|
+
const generation = this.spawnGeneration;
|
|
6615
|
+
const idle = await this.waitForAuthoritativePaneIdle(idleBudgetMs, idlePollMs);
|
|
6616
|
+
// Another spawn (wake/restart) completed while waiting — it owns the pane
|
|
6617
|
+
// now, and respawning here would kill its fresh process mid-turn.
|
|
6618
|
+
if (generation !== this.spawnGeneration)
|
|
6619
|
+
return;
|
|
6620
|
+
if (!idle)
|
|
6621
|
+
return;
|
|
6622
|
+
if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
|
|
6623
|
+
return;
|
|
6624
|
+
// Final re-verification under pane-write exclusion: no delivery can paste
|
|
6625
|
+
// a new turn between this verdict and the respawn it authorizes. The
|
|
6626
|
+
// spawn itself runs outside the lock (its dialog dismissal re-takes it).
|
|
6627
|
+
let claimed = false;
|
|
6628
|
+
await this.paneWriteLock.run(async () => {
|
|
6629
|
+
const stillIdle = await this.isPaneAuthoritativelyIdle();
|
|
6630
|
+
if (generation !== this.spawnGeneration)
|
|
6631
|
+
return;
|
|
6632
|
+
if (!stillIdle)
|
|
6633
|
+
return;
|
|
6634
|
+
// The final capture awaited above: re-check the full guards here, not
|
|
6635
|
+
// just generation and idle — pause needs no generation bump to take
|
|
6636
|
+
// effect, and respawning a freshly paused pane strands its state.
|
|
6637
|
+
if (this.startupAborted || this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped")
|
|
6638
|
+
return;
|
|
6639
|
+
this.beginSpawn();
|
|
6640
|
+
claimed = true;
|
|
6641
|
+
});
|
|
6642
|
+
if (!claimed)
|
|
6643
|
+
return;
|
|
6644
|
+
try {
|
|
6645
|
+
this.saveSessionId();
|
|
6646
|
+
const ready = await this.trySpawn(true, this.wakeBudgetMs(30_000));
|
|
6647
|
+
this.transcriptMonitor?.resetOffset();
|
|
6648
|
+
if (ready)
|
|
6649
|
+
this.logger.info("Muse resumed direct after relay exhaustion (session preserved)");
|
|
6650
|
+
else
|
|
6651
|
+
this.logger.warn("Muse direct resume after relay exhaustion did not become ready — next natural restart retries");
|
|
6652
|
+
}
|
|
6653
|
+
catch (err) {
|
|
6654
|
+
this.logger.warn({ reason: err instanceof Error ? err.message : String(err) }, "Muse direct resume after relay exhaustion failed — next natural restart retries");
|
|
6655
|
+
}
|
|
6656
|
+
finally {
|
|
6657
|
+
this.endSpawn();
|
|
6658
|
+
}
|
|
6347
6659
|
})().finally(() => { this.museRelayFallbackInFlight = null; });
|
|
6348
6660
|
return this.museRelayFallbackInFlight;
|
|
6349
6661
|
}
|
|
@@ -6619,6 +6931,10 @@ export class Daemon extends EventEmitter {
|
|
|
6619
6931
|
let lastTransient = null;
|
|
6620
6932
|
let captureFailures = 0;
|
|
6621
6933
|
let attempts = 0;
|
|
6934
|
+
// A safety-critical startup choice is one-shot. If the CLI has not
|
|
6935
|
+
// repainted after Enter, let the following hold-only entry report it
|
|
6936
|
+
// instead of sending a second Enter into a possibly changed screen.
|
|
6937
|
+
const attemptedSafetyChoices = new Set();
|
|
6622
6938
|
do {
|
|
6623
6939
|
attempts++;
|
|
6624
6940
|
let pane;
|
|
@@ -6655,6 +6971,11 @@ export class Daemon extends EventEmitter {
|
|
|
6655
6971
|
lastDialog = null;
|
|
6656
6972
|
for (const dialog of startupDialogs) {
|
|
6657
6973
|
if (Daemon.dialogMatches(dialog, pane)) {
|
|
6974
|
+
if (dialog.autoResolutionKey
|
|
6975
|
+
&& (attemptedSafetyChoices.has(dialog.autoResolutionKey)
|
|
6976
|
+
|| (this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
6977
|
+
&& this.autoResolvedDialogKey === dialog.autoResolutionKey)))
|
|
6978
|
+
continue;
|
|
6658
6979
|
lastDialog = dialog;
|
|
6659
6980
|
cleanReadyPolls = 0;
|
|
6660
6981
|
// Start the parked clock for every delivery-blocking dialog, exact
|
|
@@ -6696,17 +7017,38 @@ export class Daemon extends EventEmitter {
|
|
|
6696
7017
|
// Restart is exactly when inbound messages pile up, and nothing gates
|
|
6697
7018
|
// delivery on `spawning`. Take the pane lock for the key sequence so a
|
|
6698
7019
|
// queued message cannot be pasted into a half-dismissed trust dialog.
|
|
6699
|
-
await this.paneWriteLock.run(async () => {
|
|
7020
|
+
const sent = await this.paneWriteLock.run(async () => {
|
|
7021
|
+
// The capture above may have gone stale while waiting for the
|
|
7022
|
+
// write lock. Trust/other safety prompts must still be the
|
|
7023
|
+
// CURRENT menu, with the same safe cursor, at the instant of
|
|
7024
|
+
// the key send. A changed pane falls through to the next scan.
|
|
7025
|
+
if (dialog.inputBlocked) {
|
|
7026
|
+
const currentPane = await this.tmux.capturePane();
|
|
7027
|
+
if (!Daemon.dialogMatches(dialog, currentPane))
|
|
7028
|
+
return false;
|
|
7029
|
+
}
|
|
7030
|
+
if (dialog.autoResolutionKey) {
|
|
7031
|
+
attemptedSafetyChoices.add(dialog.autoResolutionKey);
|
|
7032
|
+
this.autoResolvedDialogGeneration = this.spawnGeneration;
|
|
7033
|
+
this.autoResolvedDialogKey = dialog.autoResolutionKey;
|
|
7034
|
+
}
|
|
6700
7035
|
for (const key of dialog.keys) {
|
|
6701
7036
|
if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
|
|
6702
|
-
await this.tmux.sendSpecialKey(key)
|
|
7037
|
+
if (!await this.tmux.sendSpecialKey(key))
|
|
7038
|
+
return false;
|
|
6703
7039
|
}
|
|
6704
7040
|
else {
|
|
6705
|
-
await this.tmux.sendKeys(key)
|
|
7041
|
+
if (!await this.tmux.sendKeys(key))
|
|
7042
|
+
return false;
|
|
6706
7043
|
}
|
|
6707
7044
|
await new Promise(r => setTimeout(r, 200));
|
|
6708
7045
|
}
|
|
7046
|
+
return true;
|
|
6709
7047
|
});
|
|
7048
|
+
if (!sent) {
|
|
7049
|
+
matched = true;
|
|
7050
|
+
break;
|
|
7051
|
+
}
|
|
6710
7052
|
// Wait for next screen to render — bounded by what is left of the budget.
|
|
6711
7053
|
const renderWait = Math.max(0, Math.min(10_000, remaining()));
|
|
6712
7054
|
if (this.controlClient) {
|