@songsid/agend 2.1.6-beta.6 → 2.1.6-beta.8
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/claude-code.d.ts +25 -0
- package/dist/backend/claude-code.js +103 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +27 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +14 -0
- package/dist/channel/adapters/discord.js +59 -1
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +6 -0
- package/dist/channel/adapters/telegram.js +36 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/tool-router.js +20 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +18 -0
- package/dist/config-validator.js +3 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/connection-secrets.d.ts +96 -0
- package/dist/connection-secrets.js +25 -0
- package/dist/connection-secrets.js.map +1 -0
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +246 -80
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +171 -0
- package/dist/fleet-manager.js +954 -5
- package/dist/fleet-manager.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/provider-probe.d.ts +3 -0
- package/dist/provider-probe.js +12 -2
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +98 -0
- package/dist/provider-secret-registry.js +334 -0
- package/dist/provider-secret-registry.js.map +1 -0
- package/dist/quickstart-api.d.ts +1 -1
- package/dist/quickstart-api.js +14 -4
- package/dist/quickstart-api.js.map +1 -1
- package/dist/secret-store.d.ts +25 -0
- package/dist/secret-store.js +165 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/settings-api.d.ts +101 -0
- package/dist/settings-api.js +404 -4
- package/dist/settings-api.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +171 -6
- package/dist/usage/providers.d.ts +8 -6
- package/dist/usage/providers.js +83 -9
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +12 -0
- package/dist/usage/usage-api.js +55 -0
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -437,7 +437,23 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
437
437
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
438
438
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
439
439
|
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
440
|
-
|
|
440
|
+
/**
|
|
441
|
+
* How long a passive startup/resume transient may sit WITHOUT REPAINTING before
|
|
442
|
+
* delivery gives up. It is a stall budget, not a total: a CLI that is still
|
|
443
|
+
* painting its resume is making progress, and #826 is what happens when the two
|
|
444
|
+
* are confused — codex's resume is the only declared transient in the fleet
|
|
445
|
+
* (backend/codex.ts), a woken paused codex routinely takes longer than this to
|
|
446
|
+
* come back, and the flat 30s cap turned that into ❌ plus a discarded message
|
|
447
|
+
* on a pane that was seconds from ready.
|
|
448
|
+
*/
|
|
449
|
+
const INPUT_TRANSIENT_STALL_MS = 30_000;
|
|
450
|
+
/**
|
|
451
|
+
* The absolute ceiling for the same wait, so a transient that repaints forever
|
|
452
|
+
* (a spinner on a wedged resume) still ends in an honest failure. Bounded, as
|
|
453
|
+
* every other delivery wait is — just bounded by "no progress", not by a guess
|
|
454
|
+
* at how long a resume takes.
|
|
455
|
+
*/
|
|
456
|
+
const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
441
457
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
442
458
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
443
459
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
@@ -880,6 +896,11 @@ export class Daemon extends EventEmitter {
|
|
|
880
896
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
881
897
|
dialogParkedKey = null;
|
|
882
898
|
dialogParkedReported = false;
|
|
899
|
+
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
900
|
+
inputBlockedDialogKey = null;
|
|
901
|
+
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
902
|
+
autoResolvedDialogKey = null;
|
|
903
|
+
autoResolvedDialogGeneration = 0;
|
|
883
904
|
backgroundSessionRecoveryAttempted = false;
|
|
884
905
|
/** Whether the last spawn started a fresh session (not resumed). */
|
|
885
906
|
isNewSession = false;
|
|
@@ -2170,6 +2191,7 @@ export class Daemon extends EventEmitter {
|
|
|
2170
2191
|
if (!alive)
|
|
2171
2192
|
return;
|
|
2172
2193
|
const pane = await this.tmux.capturePane();
|
|
2194
|
+
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2173
2195
|
const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
|
|
2174
2196
|
if (interactivePrompt) {
|
|
2175
2197
|
this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
|
|
@@ -2186,7 +2208,7 @@ export class Daemon extends EventEmitter {
|
|
|
2186
2208
|
const hasPendingWork = this.pendingWork.hasPendingWork();
|
|
2187
2209
|
if (!hasPendingWork)
|
|
2188
2210
|
this.blockingProcessDetector.reset();
|
|
2189
|
-
const blockingProcess = hasPendingWork
|
|
2211
|
+
const blockingProcess = hasPendingWork && !inputBlockedDialog
|
|
2190
2212
|
? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
|
|
2191
2213
|
: null;
|
|
2192
2214
|
if (blockingProcess) {
|
|
@@ -2208,27 +2230,80 @@ export class Daemon extends EventEmitter {
|
|
|
2208
2230
|
blockingSeen = dialog;
|
|
2209
2231
|
if (dialog.holdOnly)
|
|
2210
2232
|
break; // recognised, deliberately not answered; trackDialogParked reports it
|
|
2233
|
+
const autoKey = dialog.autoResolutionKey;
|
|
2234
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2235
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2236
|
+
&& this.autoResolvedDialogKey === autoKey) {
|
|
2237
|
+
// A previous poll sent the safety choice but the CLI has not
|
|
2238
|
+
// repainted yet. Never send another Enter into the same screen.
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2211
2241
|
// These keys go straight into the pane. Sent while a message delivery is
|
|
2212
2242
|
// mid-transaction, an `Escape` wipes the pasted text and an `Enter`
|
|
2213
2243
|
// submits it half-composed — the user sees a message that vanished. Skip
|
|
2214
2244
|
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
2215
2245
|
// dialog will still be on screen next tick.
|
|
2246
|
+
let resolved = false;
|
|
2216
2247
|
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
2248
|
+
// Re-read under the lock: the first capture may have gone stale
|
|
2249
|
+
// while a delivery was finishing. A stale danger menu must never
|
|
2250
|
+
// receive a blind key sequence.
|
|
2251
|
+
const currentPane = await this.tmux.capturePane();
|
|
2252
|
+
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
2253
|
+
this.updateInputBlockedState(currentPane, dialogs);
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
if (dialog.verifyAfterKeys && autoKey
|
|
2257
|
+
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
2258
|
+
&& this.autoResolvedDialogKey === autoKey)
|
|
2259
|
+
return;
|
|
2260
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2261
|
+
this.autoResolvedDialogGeneration = this.spawnGeneration;
|
|
2262
|
+
this.autoResolvedDialogKey = autoKey;
|
|
2263
|
+
}
|
|
2217
2264
|
this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
|
|
2218
2265
|
const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
|
|
2219
2266
|
for (const key of dialog.keys) {
|
|
2267
|
+
let sent = false;
|
|
2220
2268
|
if (SPECIAL_KEYS.has(key)) {
|
|
2221
|
-
await this.tmux.sendSpecialKey(key);
|
|
2269
|
+
sent = await this.tmux.sendSpecialKey(key);
|
|
2222
2270
|
}
|
|
2223
2271
|
else {
|
|
2224
|
-
await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2272
|
+
sent = await this.tmux.pasteText(key, this.systemPasteOptions());
|
|
2273
|
+
}
|
|
2274
|
+
if (!sent) {
|
|
2275
|
+
if (dialog.verifyAfterKeys && autoKey) {
|
|
2276
|
+
this.autoResolvedDialogGeneration = 0;
|
|
2277
|
+
this.autoResolvedDialogKey = null;
|
|
2278
|
+
}
|
|
2279
|
+
return;
|
|
2225
2280
|
}
|
|
2226
2281
|
await new Promise(r => setTimeout(r, 200));
|
|
2227
2282
|
}
|
|
2283
|
+
if (dialog.verifyAfterKeys) {
|
|
2284
|
+
const afterKeysPane = await this.tmux.capturePane();
|
|
2285
|
+
const dialogStillActive = dialog.inputBlocked
|
|
2286
|
+
? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
|
|
2287
|
+
: Daemon.dialogMatches(dialog, afterKeysPane);
|
|
2288
|
+
if (dialogStillActive) {
|
|
2289
|
+
this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
this.updateInputBlockedState(afterKeysPane, dialogs);
|
|
2293
|
+
if (dialog.postDismissNotice) {
|
|
2294
|
+
resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
resolved = true;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2228
2300
|
});
|
|
2229
2301
|
if (!dismissed) {
|
|
2230
2302
|
this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
|
|
2231
2303
|
}
|
|
2304
|
+
else if (dialog.verifyAfterKeys && !resolved) {
|
|
2305
|
+
this.logger.warn({ dialog: dialog.description }, "Safety dialog choice was sent but follow-up verification or notice submission failed");
|
|
2306
|
+
}
|
|
2232
2307
|
this.trackDialogParked(blockingSeen);
|
|
2233
2308
|
return; // Dialog handled (or deliberately deferred): skip error checks this cycle
|
|
2234
2309
|
}
|
|
@@ -2826,6 +2901,15 @@ export class Daemon extends EventEmitter {
|
|
|
2826
2901
|
}
|
|
2827
2902
|
applyInstanceStateSnapshot(snapshot, pane) {
|
|
2828
2903
|
const previous = this.instanceState;
|
|
2904
|
+
// A live safety menu owns stdin. It may still contain Claude's persistent
|
|
2905
|
+
// `❯` ready marker, but that is not an idle turn and must not clear pending
|
|
2906
|
+
// work, retire Cancel, arm auto-pause, or enter the generic stuck path.
|
|
2907
|
+
// Keep the public execution state unchanged until the menu disappears; the
|
|
2908
|
+
// dedicated input_blocked event carries the reason to observers.
|
|
2909
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
2910
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2829
2913
|
this.instanceState = snapshot.state;
|
|
2830
2914
|
// OpenCode creates its session lazily on the first submitted message.
|
|
2831
2915
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
@@ -3299,6 +3383,7 @@ export class Daemon extends EventEmitter {
|
|
|
3299
3383
|
const captureStartedAt = Date.now();
|
|
3300
3384
|
try {
|
|
3301
3385
|
const pane = await this.tmux.capturePane();
|
|
3386
|
+
this.updateInputBlockedState(pane);
|
|
3302
3387
|
// Output received while capture-pane was in flight makes this snapshot
|
|
3303
3388
|
// stale. Its output handler has already armed a new debounce.
|
|
3304
3389
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
@@ -3535,6 +3620,10 @@ export class Daemon extends EventEmitter {
|
|
|
3535
3620
|
this.instanceStateSafetyListener = null;
|
|
3536
3621
|
}
|
|
3537
3622
|
handleStuckTransition(pane, snapshot, readyPattern) {
|
|
3623
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
3624
|
+
this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing stuck notification while CLI input is blocked by a dialog");
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3538
3627
|
const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
|
|
3539
3628
|
const diagnostic = {
|
|
3540
3629
|
backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
|
|
@@ -3834,6 +3923,28 @@ export class Daemon extends EventEmitter {
|
|
|
3834
3923
|
}
|
|
3835
3924
|
return formatted;
|
|
3836
3925
|
}
|
|
3926
|
+
/**
|
|
3927
|
+
* Whether ONE delivery reached a verdict, carried by that delivery.
|
|
3928
|
+
*
|
|
3929
|
+
* `deliverMessage` returns false for two unrelated things: a delivery that
|
|
3930
|
+
* cannot land (window gone, a pane that never came back, an unanswered
|
|
3931
|
+
* dialog) and a delivery that was never attempted (user cancel, a tmux storm,
|
|
3932
|
+
* shutdown). Only the first is a failure to tell the sender about; reporting
|
|
3933
|
+
* the second told a sending agent its message was lost while the fleet was
|
|
3934
|
+
* merely standing down (#826).
|
|
3935
|
+
*
|
|
3936
|
+
* It is per call and not a field on the daemon because deliveries do NOT all
|
|
3937
|
+
* share one lock: an ordinary delivery is serialised on `pasteLock`, a steer
|
|
3938
|
+
* on `steerLock`, and the two run concurrently by design. A field would let a
|
|
3939
|
+
* steer's verdict decide whether a queued message's sender is told its
|
|
3940
|
+
* delivery failed — a false ❌ carrying the wrong correlation id.
|
|
3941
|
+
*/
|
|
3942
|
+
failDelivery(verdict, status) {
|
|
3943
|
+
verdict.reached = true;
|
|
3944
|
+
if (status)
|
|
3945
|
+
this.emit("message_failed", status); // ❌
|
|
3946
|
+
return false;
|
|
3947
|
+
}
|
|
3837
3948
|
/** Tell the fleet when an already-accepted cross-instance pane write failed. */
|
|
3838
3949
|
reportCrossInstanceDeliveryFailure(meta, error) {
|
|
3839
3950
|
if (!meta.from_instance)
|
|
@@ -3889,10 +4000,14 @@ export class Daemon extends EventEmitter {
|
|
|
3889
4000
|
await this.wake();
|
|
3890
4001
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
3891
4002
|
return;
|
|
3892
|
-
|
|
4003
|
+
const verdict = { reached: false };
|
|
4004
|
+
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
3893
4005
|
this.markTurnStarted(meta, formatted);
|
|
3894
4006
|
}
|
|
3895
|
-
else if (this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4007
|
+
else if (verdict.reached && this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4008
|
+
// Same rule as the queued path: a steer that never got to try is not a
|
|
4009
|
+
// delivery failure. This holder is the steer's own, which is the point
|
|
4010
|
+
// — it runs on steerLock while a queued delivery runs on pasteLock.
|
|
3896
4011
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
3897
4012
|
}
|
|
3898
4013
|
}).catch(err => {
|
|
@@ -4022,10 +4137,16 @@ export class Daemon extends EventEmitter {
|
|
|
4022
4137
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
4023
4138
|
// the previous turn's tool list.
|
|
4024
4139
|
this.resetToolProgress();
|
|
4025
|
-
|
|
4140
|
+
const verdict = { reached: false };
|
|
4141
|
+
if (await this.deliverMessage(formatted, status, { deliveryEpoch, submissionId: meta.message_id, verdict })) {
|
|
4026
4142
|
this.markTurnStarted(meta, formatted);
|
|
4027
4143
|
}
|
|
4028
|
-
else if (meta.from_instance &&
|
|
4144
|
+
else if (meta.from_instance && verdict.reached
|
|
4145
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4146
|
+
// Only a delivery that reached a verdict is reported. A pane that is
|
|
4147
|
+
// not ready yet, a cancel, a storm hold or a shutdown all return
|
|
4148
|
+
// false without one, and telling the sender its message was lost
|
|
4149
|
+
// there would be the false ❌ of #826 in its other form.
|
|
4029
4150
|
this.reportCrossInstanceDeliveryFailure(meta);
|
|
4030
4151
|
}
|
|
4031
4152
|
}
|
|
@@ -4057,6 +4178,9 @@ export class Daemon extends EventEmitter {
|
|
|
4057
4178
|
* serial in both cases so separate PTY writes can never overlap.
|
|
4058
4179
|
*/
|
|
4059
4180
|
async deliverMessage(formatted, status, opts) {
|
|
4181
|
+
// The caller passes its own holder when it needs the answer; a system paste
|
|
4182
|
+
// that ignores the outcome gets a throwaway.
|
|
4183
|
+
const verdict = opts?.verdict ?? { reached: false };
|
|
4060
4184
|
const cancelled = () => opts?.deliveryEpoch !== undefined
|
|
4061
4185
|
&& !this.isDeliveryEpochCurrent(opts.deliveryEpoch);
|
|
4062
4186
|
if (cancelled() || this.stormWindow?.isStopped())
|
|
@@ -4080,7 +4204,7 @@ export class Daemon extends EventEmitter {
|
|
|
4080
4204
|
await this.waitForSpawnToSettle();
|
|
4081
4205
|
if (cancelled())
|
|
4082
4206
|
return false;
|
|
4083
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4207
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4084
4208
|
return false;
|
|
4085
4209
|
let windowId = this.getWindowId();
|
|
4086
4210
|
const supportsQueuedInput = this.backend?.supportsQueuedInput?.() === true;
|
|
@@ -4122,9 +4246,7 @@ export class Daemon extends EventEmitter {
|
|
|
4122
4246
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
4123
4247
|
// would land on top of it) — and instead of holding the queue silently.
|
|
4124
4248
|
this.logger.error("Pane still busy after the idle wait — reporting delivery failure");
|
|
4125
|
-
|
|
4126
|
-
this.emit("message_failed", status); // ❌
|
|
4127
|
-
return false;
|
|
4249
|
+
return this.failDelivery(verdict, status);
|
|
4128
4250
|
}
|
|
4129
4251
|
}
|
|
4130
4252
|
}
|
|
@@ -4145,9 +4267,7 @@ export class Daemon extends EventEmitter {
|
|
|
4145
4267
|
// recovery Enter, so exactly STRANDED_INPUT_MAX_ROUNDS of them go out.
|
|
4146
4268
|
if (round >= STRANDED_INPUT_MAX_ROUNDS) {
|
|
4147
4269
|
this.logger.error({ round }, "Input row still not clear after the stranded-text recovery budget — reporting delivery failure");
|
|
4148
|
-
|
|
4149
|
-
this.emit("message_failed", status); // ❌
|
|
4150
|
-
return false;
|
|
4270
|
+
return this.failDelivery(verdict, status);
|
|
4151
4271
|
}
|
|
4152
4272
|
const stranded = await this.submitStrandedInputIfAny(windowId);
|
|
4153
4273
|
if (cancelled())
|
|
@@ -4156,18 +4276,14 @@ export class Daemon extends EventEmitter {
|
|
|
4156
4276
|
break;
|
|
4157
4277
|
if (stranded === "failed") {
|
|
4158
4278
|
this.logger.error({ round }, "Could not submit the stranded text — reporting delivery failure");
|
|
4159
|
-
|
|
4160
|
-
this.emit("message_failed", status); // ❌
|
|
4161
|
-
return false;
|
|
4279
|
+
return this.failDelivery(verdict, status);
|
|
4162
4280
|
}
|
|
4163
4281
|
const readyAgain = await this.waitForPaneReadyForDelivery(windowId);
|
|
4164
4282
|
if (cancelled())
|
|
4165
4283
|
return false;
|
|
4166
4284
|
if (!readyAgain) {
|
|
4167
4285
|
this.logger.error("Pane never returned to its prompt after submitting stranded input — reporting delivery failure");
|
|
4168
|
-
|
|
4169
|
-
this.emit("message_failed", status); // ❌
|
|
4170
|
-
return false;
|
|
4286
|
+
return this.failDelivery(verdict, status);
|
|
4171
4287
|
}
|
|
4172
4288
|
}
|
|
4173
4289
|
}
|
|
@@ -4179,15 +4295,13 @@ export class Daemon extends EventEmitter {
|
|
|
4179
4295
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
4180
4296
|
if (cancelled())
|
|
4181
4297
|
return false;
|
|
4182
|
-
if (this.refuseFatalStartupDelivery(status))
|
|
4298
|
+
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
4183
4299
|
return false;
|
|
4184
4300
|
// A passive startup phase may paint after the outer readiness probe.
|
|
4185
4301
|
// It clears without input, so waiting under the pane lock cannot starve
|
|
4186
4302
|
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4187
4303
|
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4188
|
-
|
|
4189
|
-
this.emit("message_failed", status); // ❌
|
|
4190
|
-
return false;
|
|
4304
|
+
return this.failDelivery(verdict, status);
|
|
4191
4305
|
}
|
|
4192
4306
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
4193
4307
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
@@ -4198,7 +4312,7 @@ export class Daemon extends EventEmitter {
|
|
|
4198
4312
|
if (probe.state !== "clear")
|
|
4199
4313
|
return "dialog";
|
|
4200
4314
|
}
|
|
4201
|
-
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId);
|
|
4315
|
+
return this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict);
|
|
4202
4316
|
});
|
|
4203
4317
|
if (outcome !== "dialog")
|
|
4204
4318
|
return outcome;
|
|
@@ -4207,18 +4321,14 @@ export class Daemon extends EventEmitter {
|
|
|
4207
4321
|
// an honest failure rather than a message that never lands.
|
|
4208
4322
|
if (round + 1 >= LATE_DIALOG_WRITE_ROUNDS) {
|
|
4209
4323
|
this.logger.error({ rounds: round + 1 }, "A dialog kept appearing before the pane write — reporting delivery failure");
|
|
4210
|
-
|
|
4211
|
-
this.emit("message_failed", status); // ❌
|
|
4212
|
-
return false;
|
|
4324
|
+
return this.failDelivery(verdict, status);
|
|
4213
4325
|
}
|
|
4214
4326
|
this.logger.info("Dialog appeared before the pane write — waiting for it to clear");
|
|
4215
4327
|
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
|
|
4216
4328
|
if (cancelled())
|
|
4217
4329
|
return false;
|
|
4218
4330
|
if (!clear) {
|
|
4219
|
-
|
|
4220
|
-
this.emit("message_failed", status); // ❌
|
|
4221
|
-
return false;
|
|
4331
|
+
return this.failDelivery(verdict, status);
|
|
4222
4332
|
}
|
|
4223
4333
|
}
|
|
4224
4334
|
}
|
|
@@ -4253,13 +4363,47 @@ export class Daemon extends EventEmitter {
|
|
|
4253
4363
|
return null;
|
|
4254
4364
|
}
|
|
4255
4365
|
/** Capture failure is unknown, never evidence that input is available. */
|
|
4366
|
+
/**
|
|
4367
|
+
* The budget for waiting out a passive transient, measured in progress rather
|
|
4368
|
+
* than in wall clock.
|
|
4369
|
+
*
|
|
4370
|
+
* `observe(pane)` answers "may I keep waiting?". The stall timer restarts
|
|
4371
|
+
* every time the pane text changes, so a resume that is still painting never
|
|
4372
|
+
* runs out; a screen that has not changed for INPUT_TRANSIENT_STALL_MS, or a
|
|
4373
|
+
* transient that outlives the ceiling, does. Both ends stay bounded — the
|
|
4374
|
+
* point of #826 was never that the wait should be unlimited, it was that a
|
|
4375
|
+
* flat 30s could not tell "slow" from "stuck".
|
|
4376
|
+
*/
|
|
4377
|
+
transientProgressBudget(overallDeadline) {
|
|
4378
|
+
const ceiling = Math.min(overallDeadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4379
|
+
let lastPane = null;
|
|
4380
|
+
let stallDeadline = 0;
|
|
4381
|
+
const budget = {
|
|
4382
|
+
stalled: false,
|
|
4383
|
+
observe(pane) {
|
|
4384
|
+
const now = Date.now();
|
|
4385
|
+
if (pane !== lastPane) {
|
|
4386
|
+
lastPane = pane;
|
|
4387
|
+
stallDeadline = now + INPUT_TRANSIENT_STALL_MS;
|
|
4388
|
+
}
|
|
4389
|
+
if (now >= ceiling)
|
|
4390
|
+
return false;
|
|
4391
|
+
budget.stalled = now >= stallDeadline;
|
|
4392
|
+
return !budget.stalled;
|
|
4393
|
+
},
|
|
4394
|
+
};
|
|
4395
|
+
return budget;
|
|
4396
|
+
}
|
|
4256
4397
|
async probeInputTransient() {
|
|
4257
4398
|
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4258
4399
|
return { state: "clear" };
|
|
4259
4400
|
try {
|
|
4260
4401
|
const pane = await this.tmux.capturePane();
|
|
4261
4402
|
const transient = this.inputTransientInPane(pane);
|
|
4262
|
-
|
|
4403
|
+
// The pane text comes back with the verdict: a transient that is still
|
|
4404
|
+
// repainting is still working, and that is the only thing separating a
|
|
4405
|
+
// slow resume from a wedged one.
|
|
4406
|
+
return transient ? { state: "active", transient, pane } : { state: "clear" };
|
|
4263
4407
|
}
|
|
4264
4408
|
catch (err) {
|
|
4265
4409
|
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
@@ -4275,7 +4419,12 @@ export class Daemon extends EventEmitter {
|
|
|
4275
4419
|
const generation = this.inputTransientGuardGeneration;
|
|
4276
4420
|
if (generation === null || generation !== this.spawnGeneration)
|
|
4277
4421
|
return true;
|
|
4278
|
-
|
|
4422
|
+
// Same progress rule as the outer readiness wait, for the same reason: this
|
|
4423
|
+
// runs under the pane write lock, so a flat cap here turned a resume that
|
|
4424
|
+
// repainted one second too late into a discarded message. Holding the lock
|
|
4425
|
+
// while the pane is still painting costs nothing — every other writer is
|
|
4426
|
+
// waiting on the same screen.
|
|
4427
|
+
const budget = this.transientProgressBudget(Date.now() + timeoutMs);
|
|
4279
4428
|
let observedDescription = null;
|
|
4280
4429
|
for (;;) {
|
|
4281
4430
|
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
@@ -4293,12 +4442,18 @@ export class Daemon extends EventEmitter {
|
|
|
4293
4442
|
observedDescription = probe.transient.description;
|
|
4294
4443
|
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4295
4444
|
}
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4445
|
+
// "unknown" (capture-pane failed) has to consume the budget too, or an
|
|
4446
|
+
// unreadable pane would loop forever now that the flat deadline is gone.
|
|
4447
|
+
// It never "changes", so it stalls out on the same rule.
|
|
4448
|
+
if (!budget.observe(probe.state === "active" ? probe.pane : "\u0000unreadable")) {
|
|
4449
|
+
this.logger.error({
|
|
4450
|
+
phase, generation, transient: observedDescription,
|
|
4451
|
+
stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4452
|
+
ceilingMs: timeoutMs,
|
|
4453
|
+
}, "CLI input stayed unavailable — refusing to send Enter");
|
|
4299
4454
|
return false;
|
|
4300
4455
|
}
|
|
4301
|
-
await new Promise(r => setTimeout(r,
|
|
4456
|
+
await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
|
|
4302
4457
|
}
|
|
4303
4458
|
}
|
|
4304
4459
|
static dialogKey(dialog) {
|
|
@@ -4306,8 +4461,33 @@ export class Daemon extends EventEmitter {
|
|
|
4306
4461
|
}
|
|
4307
4462
|
/** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
|
|
4308
4463
|
static dialogMatches(dialog, pane) {
|
|
4464
|
+
dialog.pattern.lastIndex = 0;
|
|
4309
4465
|
return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
|
|
4310
4466
|
}
|
|
4467
|
+
/** Refresh the separate stdin-blocked observation from one pane capture. */
|
|
4468
|
+
updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
|
|
4469
|
+
const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
|
|
4470
|
+
const nextKey = active ? Daemon.dialogKey(active) : null;
|
|
4471
|
+
if (nextKey !== this.inputBlockedDialogKey) {
|
|
4472
|
+
this.inputBlockedDialogKey = nextKey;
|
|
4473
|
+
this.emit("input_blocked", {
|
|
4474
|
+
name: this.name,
|
|
4475
|
+
blocked: active !== null,
|
|
4476
|
+
description: active?.description,
|
|
4477
|
+
});
|
|
4478
|
+
}
|
|
4479
|
+
if (!active && this.autoResolvedDialogGeneration === this.spawnGeneration) {
|
|
4480
|
+
// The old screen is gone. A later dangerous command in the same spawn
|
|
4481
|
+
// must get its own one-shot answer.
|
|
4482
|
+
this.autoResolvedDialogKey = null;
|
|
4483
|
+
this.autoResolvedDialogGeneration = 0;
|
|
4484
|
+
}
|
|
4485
|
+
return active;
|
|
4486
|
+
}
|
|
4487
|
+
/** Whether a runtime prompt currently owns the pane's stdin. */
|
|
4488
|
+
isInputBlocked() {
|
|
4489
|
+
return this.inputBlockedDialogKey !== null;
|
|
4490
|
+
}
|
|
4311
4491
|
/**
|
|
4312
4492
|
* Tri-state liveness. tmux's own isWindowAlive folds every query failure
|
|
4313
4493
|
* into `false`, so a transient tmux hiccup would read as "the CLI died" —
|
|
@@ -4473,7 +4653,7 @@ export class Daemon extends EventEmitter {
|
|
|
4473
4653
|
const deadline = Date.now() + timeoutMs;
|
|
4474
4654
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4475
4655
|
let unknownStreak = 0;
|
|
4476
|
-
let
|
|
4656
|
+
let transientBudget = null;
|
|
4477
4657
|
for (;;) {
|
|
4478
4658
|
const remaining = deadline - Date.now();
|
|
4479
4659
|
if (remaining <= 0)
|
|
@@ -4486,15 +4666,19 @@ export class Daemon extends EventEmitter {
|
|
|
4486
4666
|
const transient = await this.probeInputTransient();
|
|
4487
4667
|
if (transient.state === "active") {
|
|
4488
4668
|
unknownStreak = 0;
|
|
4489
|
-
|
|
4490
|
-
if (
|
|
4491
|
-
this.logger.error({
|
|
4669
|
+
transientBudget ||= this.transientProgressBudget(deadline);
|
|
4670
|
+
if (!transientBudget.observe(transient.pane)) {
|
|
4671
|
+
this.logger.error({
|
|
4672
|
+
transient: transient.transient.description,
|
|
4673
|
+
stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
|
|
4674
|
+
ceilingMs: INPUT_TRANSIENT_WAIT_MS,
|
|
4675
|
+
}, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4492
4676
|
return false;
|
|
4493
4677
|
}
|
|
4494
4678
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4495
4679
|
continue;
|
|
4496
4680
|
}
|
|
4497
|
-
|
|
4681
|
+
transientBudget = null;
|
|
4498
4682
|
if (transient.state === "unknown") {
|
|
4499
4683
|
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4500
4684
|
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
@@ -4640,12 +4824,11 @@ export class Daemon extends EventEmitter {
|
|
|
4640
4824
|
* what to fix, and holding would only build a queue that floods the pane the
|
|
4641
4825
|
* moment the flag clears.
|
|
4642
4826
|
*/
|
|
4643
|
-
refuseFatalStartupDelivery(status) {
|
|
4827
|
+
refuseFatalStartupDelivery(verdict, status) {
|
|
4644
4828
|
if (!this.fatalStartupBlocked)
|
|
4645
4829
|
return false;
|
|
4646
4830
|
this.logger.error("Delivery refused — CLI is parked on a fatal startup screen");
|
|
4647
|
-
|
|
4648
|
-
this.emit("message_failed", status); // ❌
|
|
4831
|
+
this.failDelivery(verdict, status);
|
|
4649
4832
|
return true;
|
|
4650
4833
|
}
|
|
4651
4834
|
/**
|
|
@@ -4724,7 +4907,10 @@ export class Daemon extends EventEmitter {
|
|
|
4724
4907
|
}
|
|
4725
4908
|
return sent;
|
|
4726
4909
|
}
|
|
4727
|
-
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId
|
|
4910
|
+
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
4911
|
+
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
4912
|
+
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
4913
|
+
verdict = { reached: false }) {
|
|
4728
4914
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
4729
4915
|
let windowId = initialWindowId;
|
|
4730
4916
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
@@ -4743,9 +4929,9 @@ export class Daemon extends EventEmitter {
|
|
|
4743
4929
|
? "pasteBuffer failed — recovering window and backing off"
|
|
4744
4930
|
: "pasteBuffer failed — non-retryable tmux error");
|
|
4745
4931
|
if (!recoverable) {
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
return
|
|
4932
|
+
// A tmux error the window cannot be recovered from: the text will
|
|
4933
|
+
// never reach this pane, so it is a verdict like the others.
|
|
4934
|
+
return this.failDelivery(verdict, status);
|
|
4749
4935
|
}
|
|
4750
4936
|
windowId = (await this.recoverWindow()) ?? windowId;
|
|
4751
4937
|
if (attempt < maxAttempts)
|
|
@@ -4775,9 +4961,7 @@ export class Daemon extends EventEmitter {
|
|
|
4775
4961
|
}
|
|
4776
4962
|
let enterAt = Date.now();
|
|
4777
4963
|
if (!(await this.sendDeliveryEnter("initial-submit"))) {
|
|
4778
|
-
|
|
4779
|
-
this.emit("message_failed", status); // ❌
|
|
4780
|
-
return false;
|
|
4964
|
+
return this.failDelivery(verdict, status);
|
|
4781
4965
|
}
|
|
4782
4966
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
4783
4967
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
@@ -4860,9 +5044,7 @@ export class Daemon extends EventEmitter {
|
|
|
4860
5044
|
this.logger.warn("Message still in the input row after idle — submitting the existing text instead of pasting it again");
|
|
4861
5045
|
const strandedAt = Date.now();
|
|
4862
5046
|
if (!(await this.sendDeliveryEnter("native-queue-stranded-submit"))) {
|
|
4863
|
-
|
|
4864
|
-
this.emit("message_failed", status); // ❌
|
|
4865
|
-
return false;
|
|
5047
|
+
return this.failDelivery(verdict, status);
|
|
4866
5048
|
}
|
|
4867
5049
|
const afterEnter = await this.confirmSubmitted(signature, pasteBaseline);
|
|
4868
5050
|
if (afterEnter === "submitted") {
|
|
@@ -4877,9 +5059,7 @@ export class Daemon extends EventEmitter {
|
|
|
4877
5059
|
// message nobody submitted. Output is corroboration; text sitting in
|
|
4878
5060
|
// the input row is disqualifying, and disqualifying evidence wins.
|
|
4879
5061
|
this.logger.error({ afterEnter, strandedAt }, "Stranded message could not be submitted by Enter");
|
|
4880
|
-
|
|
4881
|
-
this.emit("message_failed", status); // ❌
|
|
4882
|
-
return false;
|
|
5062
|
+
return this.failDelivery(verdict, status);
|
|
4883
5063
|
}
|
|
4884
5064
|
// "unproven": nothing of ours is on screen — the paste itself was lost,
|
|
4885
5065
|
// so pasting it again cannot duplicate anything.
|
|
@@ -4888,16 +5068,12 @@ export class Daemon extends EventEmitter {
|
|
|
4888
5068
|
this.logger.error({
|
|
4889
5069
|
tmuxError: this.tmux.getLastPasteError?.() ?? "unknown tmux paste failure",
|
|
4890
5070
|
}, "Idle-gated redelivery paste failed after native-queue silent loss");
|
|
4891
|
-
|
|
4892
|
-
this.emit("message_failed", status); // ❌
|
|
4893
|
-
return false;
|
|
5071
|
+
return this.failDelivery(verdict, status);
|
|
4894
5072
|
}
|
|
4895
5073
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
4896
5074
|
const retryAt = Date.now();
|
|
4897
5075
|
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery"))) {
|
|
4898
|
-
|
|
4899
|
-
this.emit("message_failed", status); // ❌
|
|
4900
|
-
return false;
|
|
5076
|
+
return this.failDelivery(verdict, status);
|
|
4901
5077
|
}
|
|
4902
5078
|
if (windowId && this.controlClient) {
|
|
4903
5079
|
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
@@ -4912,9 +5088,7 @@ export class Daemon extends EventEmitter {
|
|
|
4912
5088
|
return true;
|
|
4913
5089
|
}
|
|
4914
5090
|
this.logger.error("Idle-gated redelivery also failed after native-queue silent loss");
|
|
4915
|
-
|
|
4916
|
-
this.emit("message_failed", status); // ❌
|
|
4917
|
-
return false;
|
|
5091
|
+
return this.failDelivery(verdict, status);
|
|
4918
5092
|
}
|
|
4919
5093
|
if (windowId && this.controlClient && this.backend?.dropsEnterWhileBusy?.() === true) {
|
|
4920
5094
|
// F2: output after Enter is necessary but not sufficient — the paste
|
|
@@ -4928,9 +5102,7 @@ export class Daemon extends EventEmitter {
|
|
|
4928
5102
|
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
4929
5103
|
const retryAt = Date.now();
|
|
4930
5104
|
if (promptBack && !(await this.sendDeliveryEnter("stranded-text-retry"))) {
|
|
4931
|
-
|
|
4932
|
-
this.emit("message_failed", status); // ❌
|
|
4933
|
-
return false;
|
|
5105
|
+
return this.failDelivery(verdict, status);
|
|
4934
5106
|
}
|
|
4935
5107
|
submitted = promptBack && await this.confirmSubmittedAfterEnter(windowId, retryAt, formatted);
|
|
4936
5108
|
}
|
|
@@ -4940,9 +5112,7 @@ export class Daemon extends EventEmitter {
|
|
|
4940
5112
|
}
|
|
4941
5113
|
else {
|
|
4942
5114
|
this.logger.error("Message pasted but never submitted (text still in the input row after Enter retry)");
|
|
4943
|
-
|
|
4944
|
-
this.emit("message_failed", status); // ❌
|
|
4945
|
-
return false;
|
|
5115
|
+
return this.failDelivery(verdict, status);
|
|
4946
5116
|
}
|
|
4947
5117
|
}
|
|
4948
5118
|
else if (windowId && this.controlClient) {
|
|
@@ -4967,9 +5137,7 @@ export class Daemon extends EventEmitter {
|
|
|
4967
5137
|
// forever and the next delivery pasted on top — submitting two messages
|
|
4968
5138
|
// as one. Say so instead.
|
|
4969
5139
|
this.logger.error("Message pasted but never submitted (no idle→busy after two Enters)");
|
|
4970
|
-
|
|
4971
|
-
this.emit("message_failed", status); // ❌
|
|
4972
|
-
return false;
|
|
5140
|
+
return this.failDelivery(verdict, status);
|
|
4973
5141
|
}
|
|
4974
5142
|
}
|
|
4975
5143
|
else {
|
|
@@ -4982,9 +5150,7 @@ export class Daemon extends EventEmitter {
|
|
|
4982
5150
|
return true;
|
|
4983
5151
|
}
|
|
4984
5152
|
this.logger.error("Message delivery failed after retries — window not ready");
|
|
4985
|
-
|
|
4986
|
-
this.emit("message_failed", status); // ❌
|
|
4987
|
-
return false;
|
|
5153
|
+
return this.failDelivery(verdict, status);
|
|
4988
5154
|
}
|
|
4989
5155
|
/**
|
|
4990
5156
|
* The single place a pasted message is judged submitted, shared by the
|