@songsid/agend 2.3.2 → 2.3.3-beta.2
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/agent-cli-instructions.md +2 -2
- package/dist/agent-whats-new.d.ts +21 -0
- package/dist/agent-whats-new.js +52 -23
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/authority/authority-state.js +1 -1
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/web-authority.d.ts +9 -4
- package/dist/authority/web-authority.js +10 -5
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/claude-code.d.ts +13 -1
- package/dist/backend/claude-code.js +20 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-session-lookup.d.ts +16 -3
- package/dist/backend/codex-session-lookup.js +45 -11
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/kiro-reconcile-store.js +8 -1
- package/dist/backend/kiro-reconcile-store.js.map +1 -1
- package/dist/backend/kiro.d.ts +0 -5
- package/dist/backend/kiro.js +0 -10
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +7 -4
- package/dist/backend/types.js.map +1 -1
- package/dist/cli.js +18 -3
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.js +4 -5
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/cross-instance-envelope.d.ts +10 -5
- package/dist/cross-instance-envelope.js +20 -17
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/daemon.d.ts +90 -63
- package/dist/daemon.js +412 -317
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.d.ts +8 -1
- package/dist/daily-summary.js +8 -5
- package/dist/daily-summary.js.map +1 -1
- package/dist/delivery-outbox.d.ts +31 -22
- package/dist/delivery-outbox.js +119 -161
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-outbox.test.js +10 -25
- package/dist/delivery-outbox.test.js.map +1 -1
- package/dist/fleet-level-config.js +1 -1
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +4 -21
- package/dist/fleet-manager.js +38 -159
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-service-owner.d.ts +11 -0
- package/dist/fleet-service-owner.js +3 -0
- package/dist/fleet-service-owner.js.map +1 -0
- package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +1 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +1 -1
- package/dist/general-knowledge/skills/session-management/SKILL.md +1 -1
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +4 -5
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -5
- package/dist/instance-lifecycle.js +23 -5
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.js +6 -7
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-reconcile.js +3 -3
- package/dist/kiro-reconcile.js.map +1 -1
- package/dist/locale.js +8 -52
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -1
- package/dist/log-service.js.map +1 -1
- package/dist/needs-you-hub.js +4 -1
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +3 -1
- package/dist/needs-you-live.js +24 -2
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +6 -1
- package/dist/needs-you.js +4 -0
- package/dist/needs-you.js.map +1 -1
- package/dist/outbound-handlers.js +1 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/scheduler/task-board.d.ts +17 -25
- package/dist/scheduler/task-board.js +29 -39
- package/dist/scheduler/task-board.js.map +1 -1
- package/dist/sd-notify.d.ts +13 -14
- package/dist/sd-notify.js +62 -28
- package/dist/sd-notify.js.map +1 -1
- package/dist/settings-api.d.ts +0 -1
- package/dist/settings-api.js +1 -14
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-effect.js +0 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/setup-wizard.d.ts +3 -0
- package/dist/setup-wizard.js +4 -2
- package/dist/setup-wizard.js.map +1 -1
- package/dist/task-board-waker.d.ts +40 -1
- package/dist/task-board-waker.js +110 -9
- package/dist/task-board-waker.js.map +1 -1
- package/dist/tmux-control.d.ts +1 -1
- package/dist/tmux-control.js +3 -2
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.js +18 -16
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.d.ts +2 -0
- package/dist/tmux-read.js +7 -1
- package/dist/tmux-read.js.map +1 -1
- package/dist/topic-commands.d.ts +2 -0
- package/dist/topic-commands.js +2 -1
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-monitor.js +17 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/types.d.ts +7 -10
- package/dist/ui/panel-log.js +2 -2
- package/dist/ui/settings-appearance.js +3 -35
- package/dist/ui/settings-registry.js +0 -1
- package/dist/ui/settings-strings.js +4 -4
- package/dist/ui/settings-web-authority.js +4 -3
- package/dist/ui/shared/app-i18n.js +2 -2
- package/dist/update-check.d.ts +13 -4
- package/dist/update-check.js +17 -4
- package/dist/update-check.js.map +1 -1
- package/dist/web-experimental.d.ts +6 -2
- package/dist/web-experimental.js +8 -4
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-settings-patch.d.ts +0 -1
- package/dist/web-settings-patch.js +3 -4
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/workflow-templates/default.md +2 -2
- package/dist/working-recovery.d.ts +0 -2
- package/dist/working-recovery.js +2 -23
- package/dist/working-recovery.js.map +1 -1
- package/launcher/agend-install.cjs +7 -1
- package/launcher/fleet-service-owner.cjs +138 -0
- package/package.json +1 -1
- package/dist/task-board-summary.d.ts +0 -101
- package/dist/task-board-summary.js +0 -177
- package/dist/task-board-summary.js.map +0 -1
- package/dist/upgrade-notices.d.ts +0 -17
- package/dist/upgrade-notices.js +0 -93
- package/dist/upgrade-notices.js.map +0 -1
package/dist/daemon.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readActiveKiroModel } from "./backend/kiro.js";
|
|
2
2
|
import { dashboardAccessOf } from "./dashboard-permissions.js";
|
|
3
3
|
import { sendPackSources } from "./web-office-pack-read.js";
|
|
4
|
-
import {
|
|
4
|
+
import { agendVersion, planWhatsNew, readWhatsNewRecord, recordWhatsNew, withWhatsNew } from "./agent-whats-new.js";
|
|
5
5
|
import { join, dirname, resolve } from "node:path";
|
|
6
6
|
import { checkoutTarget, readCheckouts, releaseCandidate, reposRoot, verifyWorktree, writeCheckouts } from "./repo-worktree-guard.js";
|
|
7
7
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, unlinkSync, rmSync, appendFileSync, statSync, renameSync } from "node:fs";
|
|
@@ -24,8 +24,9 @@ import { TranscriptMonitor } from "./transcript-monitor.js";
|
|
|
24
24
|
import { createTranscriptSource } from "./transcript-sources.js";
|
|
25
25
|
import { TranscriptTurnLedger } from "./transcript-turns.js";
|
|
26
26
|
import { credentialProfileStoreHome, resolveCredentialProfile } from "./backend/credential-profile.js";
|
|
27
|
-
import { resolveToolSet } from "./tool-permissions.js";
|
|
28
|
-
import {
|
|
27
|
+
import { resolveToolSet, toolsFor } from "./tool-permissions.js";
|
|
28
|
+
import { TOOLS, toolsWithCanvasWorker } from "./channel/mcp-tools.js";
|
|
29
|
+
import { dashboardAccessProblem, dashboardFeaturesOff, withoutFeatures } from "./dashboard-permissions.js";
|
|
29
30
|
import { getAgendHome } from "./paths.js";
|
|
30
31
|
import { readCanvasWorkerEnabled, scopedFleetConfigEnvironment } from "./web-experimental.js";
|
|
31
32
|
import { readAgentEffect, sendEffectInParts } from "./web-effect-upload.js";
|
|
@@ -67,8 +68,6 @@ import { claudeConfigDirFor, claudeProfileConfigDir, claudeSwitchDialog, claudeS
|
|
|
67
68
|
import { MuseBackend } from "./backend/muse.js";
|
|
68
69
|
import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
|
|
69
70
|
import { unrecognisedPromptShape } from "./unrecognised-prompt.js";
|
|
70
|
-
/** #2140: deliveries that may carry an unwritten what's-new notice before it is recorded anyway. */
|
|
71
|
-
const WHATS_NEW_MAX_UNWRITTEN = 3;
|
|
72
71
|
const __filename = fileURLToPath(import.meta.url);
|
|
73
72
|
const __dirname = dirname(__filename);
|
|
74
73
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -113,7 +112,6 @@ const TURN_OUTBOUND_TOOLS = new Set([
|
|
|
113
112
|
// These actions reach the human-facing channel and satisfy reply-completion:
|
|
114
113
|
// a deliberate reaction or edit is a valid response even without new text.
|
|
115
114
|
const TURN_COMPLETION_TOOLS = new Set(["reply", "react", "edit_message"]);
|
|
116
|
-
const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
117
115
|
/**
|
|
118
116
|
* #1241: how long an idle edge must persist — with no reply and no further
|
|
119
117
|
* observed work — before the reply guard declares the turn a miss. A claude
|
|
@@ -136,50 +134,12 @@ const TRANSCRIPT_RUNNING_HOLD_MAX_MS = 30 * 60_000;
|
|
|
136
134
|
*/
|
|
137
135
|
export const TRANSCRIPT_OPEN_TURN_QUIET_MS = 15 * 60_000;
|
|
138
136
|
const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
|
|
139
|
-
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
140
137
|
/** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed; #1647 a raw paste's text). */
|
|
141
138
|
function transcriptMarkerEvidence(kind, rawPaste = false) {
|
|
142
139
|
if (rawPaste)
|
|
143
140
|
return "transcript-turn-user-entry-raw-paste-text";
|
|
144
141
|
return kind === "user" ? "transcript-marker" : kind === "absorbed" ? "transcript-marker-absorbed" : "transcript-marker-queued";
|
|
145
142
|
}
|
|
146
|
-
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir, backendSource) {
|
|
147
|
-
const source = backendSource ? backendSource : binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
148
|
-
? "AGENTS.md"
|
|
149
|
-
: binaryName === "kiro-cli"
|
|
150
|
-
? `.kiro/steering/agend-${instanceName}.md`
|
|
151
|
-
: binaryName === "agy"
|
|
152
|
-
? ".agents/AGENTS.md"
|
|
153
|
-
: join(instanceDir, "fleet-instructions.md");
|
|
154
|
-
return `[system] Your AgEnD instructions have been updated. Reload only ${source}; do not scan other instruction directories. Do not reply to this message.`;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Whether this backend has to be told in its pane that its instructions moved.
|
|
158
|
-
*
|
|
159
|
-
* A backend that re-reads them on resume already received the new ones from the
|
|
160
|
-
* resume itself, so the notice is pure interruption. Both places that can send
|
|
161
|
-
* it — the warmup paste and the deferred pendingInstructionsNotice — ask through
|
|
162
|
-
* here, so the two cannot drift apart again: the warmup path used to omit this
|
|
163
|
-
* check entirely and told claude-code to reload instructions it had just read.
|
|
164
|
-
*/
|
|
165
|
-
export function backendNeedsPaneReloadNotice(backend) {
|
|
166
|
-
return !backend?.instructionsReloadedOnResume;
|
|
167
|
-
}
|
|
168
|
-
/**
|
|
169
|
-
* What the warmup should do about changed instructions.
|
|
170
|
-
*
|
|
171
|
-
* `skip` — nothing to say, or this backend learns of the change by itself.
|
|
172
|
-
* `defer` — worth saying, but nobody is talking to the instance; say it when a
|
|
173
|
-
* real message next arrives rather than provoking an unsolicited reply.
|
|
174
|
-
* `paste` — say it now, into a pane that already has a delivery in flight.
|
|
175
|
-
*/
|
|
176
|
-
export function warmupNoticeAction(opts) {
|
|
177
|
-
if (!opts.warmupNeeded)
|
|
178
|
-
return "skip";
|
|
179
|
-
if (!backendNeedsPaneReloadNotice(opts.backend))
|
|
180
|
-
return "skip";
|
|
181
|
-
return opts.pasteQueueDepth === 0 ? "defer" : "paste";
|
|
182
|
-
}
|
|
183
143
|
export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
|
|
184
144
|
export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
|
|
185
145
|
export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
|
|
@@ -189,6 +149,14 @@ export const PERIODIC_REDRAW_PROBE_MS = 25;
|
|
|
189
149
|
const KIRO_COMPACT_PANE_POLL_MS = 1_000;
|
|
190
150
|
/** Do not turn a streaming Codex turn into one capture-pane per burst. */
|
|
191
151
|
export const STRUCTURED_IDLE_PROBE_MS = 500;
|
|
152
|
+
/** #2770 (ii): how often output may schedule a look for a layout that repaints while idle (CliBackend.periodicRedrawFrom). */
|
|
153
|
+
export const PERIODIC_LAYOUT_LOOK_MS = 5_000;
|
|
154
|
+
/** #2770: how often a delivery waiting for idle asks again whether it can be handed off instead. */
|
|
155
|
+
export const HANDOFF_RECHECK_MS = 1_000;
|
|
156
|
+
/** #2770: how often a wait for idle asks its stop condition (a cancel, a hand-off that became possible). */
|
|
157
|
+
export const WAIT_STOP_POLL_MS = 250;
|
|
158
|
+
/** #2770: human messages a cancel dropped are told in one notice, this long after the last of them. */
|
|
159
|
+
export const CANCELLED_HUMANS_NOTICE_MS = 1_000;
|
|
192
160
|
/**
|
|
193
161
|
* An instance publishing more than this many execution-state edges inside the
|
|
194
162
|
* window is flapping; its edges stop being logged one by one (#1116). One
|
|
@@ -1295,6 +1263,8 @@ export class Daemon extends EventEmitter {
|
|
|
1295
1263
|
bootSkippedResume = false;
|
|
1296
1264
|
recoveryCrashLoopFresh = false;
|
|
1297
1265
|
recoveryFreshChoice = false;
|
|
1266
|
+
/** The current boot found a crash-loop marker and is attempting resume rather than going fresh immediately. */
|
|
1267
|
+
bootCrashLoopResume = false;
|
|
1298
1268
|
startupAborted = false;
|
|
1299
1269
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
1300
1270
|
dialogParkedSince = 0;
|
|
@@ -1398,6 +1368,10 @@ export class Daemon extends EventEmitter {
|
|
|
1398
1368
|
/** Consecutive structural idle captures for a noisy, periodically-redrawing
|
|
1399
1369
|
* pane. Two are required before a working -> idle transition. */
|
|
1400
1370
|
instanceStatePeriodicIdleConfirmations = 0;
|
|
1371
|
+
/** #2770 (ii): the latest capture showed a layout that repaints while idle (CliBackend.periodicRedrawFrom). */
|
|
1372
|
+
periodicRedrawLayout = false;
|
|
1373
|
+
/** When output last scheduled a look for that layout (monotonic ms), so a busy pane is not captured on every chunk. */
|
|
1374
|
+
periodicLayoutLookAt = -Infinity;
|
|
1401
1375
|
/** Recent execution-state edge times, for the flap log limiter (#1116). */
|
|
1402
1376
|
stateEdgeTimes = [];
|
|
1403
1377
|
stateEdgesNotLogged = 0;
|
|
@@ -1439,7 +1413,6 @@ export class Daemon extends EventEmitter {
|
|
|
1439
1413
|
/** Prevent a visible stale XML fragment from being recovered on later turns. */
|
|
1440
1414
|
lastMalformedToolCallSignature;
|
|
1441
1415
|
proxyReplySeq = 0;
|
|
1442
|
-
lastReplyDropWarningAt = 0;
|
|
1443
1416
|
/**
|
|
1444
1417
|
* #1241: an idle edge that has not yet proven the turn ended. Armed instead
|
|
1445
1418
|
* of recovering immediately; a steady idle snapshot past confirmAt promotes
|
|
@@ -1525,6 +1498,8 @@ export class Daemon extends EventEmitter {
|
|
|
1525
1498
|
authFailureUnresolved = false;
|
|
1526
1499
|
/** One login-screen auth report per spawn — the screen persists across polls. */
|
|
1527
1500
|
loginScreenReported = false;
|
|
1501
|
+
/** #2795: the auth pattern match the token-free check is still to judge; it arms nothing until confirmAuthFailure(). */
|
|
1502
|
+
unverifiedAuthMatch = null;
|
|
1528
1503
|
/**
|
|
1529
1504
|
* While the lifecycle is still starting this daemon, `pty_error` has no
|
|
1530
1505
|
* listener and `InstanceLifecycle.daemons` has no entry — so an incident the
|
|
@@ -1583,19 +1558,12 @@ export class Daemon extends EventEmitter {
|
|
|
1583
1558
|
deliveryWritesStopping = false;
|
|
1584
1559
|
/** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
|
|
1585
1560
|
recentPastes = [];
|
|
1586
|
-
pendingInstructionsUpdate;
|
|
1587
|
-
pendingInstructionsNotice = false;
|
|
1588
1561
|
/** #1633: the one-time "what's new for you" notice, pasted before the next real delivery; recorded once pasted. */
|
|
1589
1562
|
pendingWhatsNew = null;
|
|
1590
|
-
/** #2140: deliveries tried for the pending what's-new notice that never reached the pane (capped: never before every message). */
|
|
1591
|
-
whatsNewUnwritten = 0;
|
|
1592
|
-
// Whether the warmup steering-reload notice should be injected after spawn.
|
|
1593
|
-
// Set in trySpawn by comparing the freshly-built instructions against the
|
|
1594
|
-
// last value the agent was told about (prev-instructions). Skipped when
|
|
1595
|
-
// unchanged so agents don't waste 10-30s re-reading identical steering.
|
|
1596
|
-
warmupNeeded = false;
|
|
1597
|
-
lastBuiltInstructions = "";
|
|
1598
1563
|
pasteQueueDepth = 0;
|
|
1564
|
+
/** #2770: a person's messages a cancel dropped before they reached the CLI, told together (noteCancelledHuman). */
|
|
1565
|
+
cancelledHumans = [];
|
|
1566
|
+
cancelledHumansTimer = null;
|
|
1599
1567
|
firstDeliveryDelay = new FirstDeliveryDelay();
|
|
1600
1568
|
/** Orders /steer pastes against each other. Deliberately NOT pasteLock:
|
|
1601
1569
|
* a steer must not queue behind the normal deliveries it exists to
|
|
@@ -1799,6 +1767,59 @@ export class Daemon extends EventEmitter {
|
|
|
1799
1767
|
// old transcript, so pre-ready scans must not lower this seeded baseline.
|
|
1800
1768
|
seededErrorCountBaselines = new Map();
|
|
1801
1769
|
lastDetectedErrorType = null;
|
|
1770
|
+
/**
|
|
1771
|
+
* #2822: Claude's per-model usage limit (`You've reached your <model> limit.`), known only from Claude's own record of
|
|
1772
|
+
* the failed call in its transcript (`isApiErrorMessage`, a 429) — never from pane text, which an agent can print. It
|
|
1773
|
+
* names no reset time. While it holds it is told once, and the reply guard stays quiet while the transcript's newest
|
|
1774
|
+
* event is that error; any later assistant output (of any turn, from a person or not) ends it. The order is Claude's
|
|
1775
|
+
* own: a 429 is recorded within a second, often before AgEnD has armed the turn, so no AgEnD-side clock is used.
|
|
1776
|
+
*/
|
|
1777
|
+
usageLimitHold = null;
|
|
1778
|
+
/** Transcript events in Claude's order (prompts, output, every API error): the newest, and the latest limit error. */
|
|
1779
|
+
transcriptEventSeq = 0;
|
|
1780
|
+
usageLimitErrorSeq = 0;
|
|
1781
|
+
static MODEL_USAGE_LIMIT = /^You['’]ve reached your ([A-Za-z0-9][A-Za-z0-9 .-]{0,40}?) limit\./;
|
|
1782
|
+
/** #2822: the usage-limit hold, while it lasts — Needs you lists it. */
|
|
1783
|
+
usageHold() {
|
|
1784
|
+
return this.usageLimitHold ? { ...this.usageLimitHold } : null;
|
|
1785
|
+
}
|
|
1786
|
+
/** #2822: Claude recorded a failed API call. */
|
|
1787
|
+
noteClaudeApiError(text, status) {
|
|
1788
|
+
const seq = ++this.transcriptEventSeq;
|
|
1789
|
+
const match = status === 429 ? Daemon.MODEL_USAGE_LIMIT.exec(text.trim()) : null;
|
|
1790
|
+
if (!match)
|
|
1791
|
+
return;
|
|
1792
|
+
this.usageLimitErrorSeq = seq;
|
|
1793
|
+
if (this.usageLimitHold)
|
|
1794
|
+
return; // told already: one notice per hold
|
|
1795
|
+
const model = match[1].trim();
|
|
1796
|
+
this.usageLimitHold = { model, since: Date.now() };
|
|
1797
|
+
const message = t("inst.model_usage_limit", model);
|
|
1798
|
+
this.logger.warn({ model }, "Usage-limit hold started (reset time unknown)");
|
|
1799
|
+
this.emit("usage_hold", { name: this.name, held: true });
|
|
1800
|
+
this.emit("pty_error", { name: this.name, type: "quota", action: "notify", message, skipCooldown: true, skipRecoveryWait: true });
|
|
1801
|
+
}
|
|
1802
|
+
/** #2822: Claude produced output (text or a tool call): the model answers again, so a hold ends. */
|
|
1803
|
+
noteClaudeOutput() {
|
|
1804
|
+
++this.transcriptEventSeq;
|
|
1805
|
+
if (!this.usageLimitHold)
|
|
1806
|
+
return;
|
|
1807
|
+
this.logger.info({ model: this.usageLimitHold.model }, "Usage-limit hold cleared — Claude answered again");
|
|
1808
|
+
this.usageLimitHold = null;
|
|
1809
|
+
this.emit("usage_hold", { name: this.name, held: false });
|
|
1810
|
+
}
|
|
1811
|
+
/** #2822: a prompt reached Claude (recorded in its transcript). */
|
|
1812
|
+
noteClaudePrompt() {
|
|
1813
|
+
++this.transcriptEventSeq;
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* #2822: the turn ended on the limit error — the transcript's newest event of any kind. A prompt after it (a new
|
|
1817
|
+
* message the error is older than), output after it (the model answered) or another API error after it (a 500, a
|
|
1818
|
+
* different 429) means the limit error is not this turn's end.
|
|
1819
|
+
*/
|
|
1820
|
+
personTurnEndedOnUsageLimit() {
|
|
1821
|
+
return this.usageLimitErrorSeq > 0 && this.usageLimitErrorSeq === this.transcriptEventSeq;
|
|
1822
|
+
}
|
|
1802
1823
|
static errorPatternKey(ep) {
|
|
1803
1824
|
return `${ep.type}:${ep.pattern.source}`;
|
|
1804
1825
|
}
|
|
@@ -1838,21 +1859,14 @@ export class Daemon extends EventEmitter {
|
|
|
1838
1859
|
return super.emit(event, ...args);
|
|
1839
1860
|
}
|
|
1840
1861
|
/**
|
|
1841
|
-
* Undo
|
|
1842
|
-
* probe
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
1846
|
-
* daemon permanently suspicious of an auth failure that never existed. That
|
|
1847
|
-
* state is not inert: authFailureUnresolved suppresses the stuck/hang
|
|
1848
|
-
* notification and holds MCP auto-restart (a later real MCP death is then read
|
|
1849
|
-
* as already-confirmed auth trouble rather than re-verified), and the recovery
|
|
1850
|
-
* gate suppresses further error detection until a ready pattern shows up.
|
|
1851
|
-
*
|
|
1852
|
-
* Only what an auth match armed is rolled back. If some other pattern has
|
|
1853
|
-
* since armed the recovery gate, that one is still live and stays.
|
|
1862
|
+
* Undo an auth suspicion once the backend's token-free probe has said the credentials are fine. Since #2795 a pane
|
|
1863
|
+
* pattern match arms nothing before that probe (its pending match is just dropped); what is left to undo is a
|
|
1864
|
+
* login-screen report, which arms authFailureUnresolved itself. That state is not inert: it suppresses the
|
|
1865
|
+
* stuck/hang notification and holds MCP auto-restart. If some other pattern has armed the recovery gate, that one
|
|
1866
|
+
* is still live and stays.
|
|
1854
1867
|
*/
|
|
1855
1868
|
clearSuspectedAuthFailure() {
|
|
1869
|
+
this.unverifiedAuthMatch = null;
|
|
1856
1870
|
if (!this.authFailureUnresolved && !this.loginScreenReported)
|
|
1857
1871
|
return false;
|
|
1858
1872
|
this.authFailureUnresolved = false;
|
|
@@ -1864,6 +1878,29 @@ export class Daemon extends EventEmitter {
|
|
|
1864
1878
|
this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
|
|
1865
1879
|
return true;
|
|
1866
1880
|
}
|
|
1881
|
+
armErrorRecoveryGate(key, type, now) {
|
|
1882
|
+
this.errorWaitingForRecovery = true;
|
|
1883
|
+
this.errorDetectedAt = now;
|
|
1884
|
+
this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
|
|
1885
|
+
this.activeErrorPatternKey = key;
|
|
1886
|
+
this.lastDetectedErrorType = type;
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* #2795: the token-free check did not vouch for the credentials behind an auth pattern match. Only now does the
|
|
1890
|
+
* match become an auth failure: the state the detector used to arm at match time. Remembered for as long as it is
|
|
1891
|
+
* unresolved — an auth-broken CLI cannot finish the turn it is holding, so every other detector would otherwise
|
|
1892
|
+
* keep reporting the same dead instance. A login-screen report arms its own state and has no match to confirm.
|
|
1893
|
+
*/
|
|
1894
|
+
confirmAuthFailure() {
|
|
1895
|
+
const match = this.unverifiedAuthMatch;
|
|
1896
|
+
if (!match)
|
|
1897
|
+
return false;
|
|
1898
|
+
this.unverifiedAuthMatch = null;
|
|
1899
|
+
if (match.gate)
|
|
1900
|
+
this.armErrorRecoveryGate(match.key, "auth_error", match.at);
|
|
1901
|
+
this.authFailureUnresolved = true;
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1867
1904
|
clearErrorRecoveryGate() {
|
|
1868
1905
|
this.errorWaitingForRecovery = false;
|
|
1869
1906
|
this.errorDetectedAt = 0;
|
|
@@ -2283,19 +2320,40 @@ export class Daemon extends EventEmitter {
|
|
|
2283
2320
|
this.bootSkippedResume = false;
|
|
2284
2321
|
this.recoveryCrashLoopFresh = false;
|
|
2285
2322
|
this.recoveryFreshChoice = false;
|
|
2323
|
+
this.bootCrashLoopResume = false;
|
|
2286
2324
|
try {
|
|
2287
2325
|
if (existsSync(crashStatePath)) {
|
|
2288
2326
|
const content = readFileSync(crashStatePath, "utf-8");
|
|
2289
2327
|
try {
|
|
2290
2328
|
const state = JSON.parse(content);
|
|
2291
|
-
if (state?.
|
|
2329
|
+
if (state?.resumeFailed === true) {
|
|
2330
|
+
// The previous start tried resume after a crash loop but the resumed
|
|
2331
|
+
// session died before reaching ready. Go fresh this time.
|
|
2292
2332
|
this.skipResume = true;
|
|
2293
2333
|
this.bootSkippedResume = true;
|
|
2294
|
-
this.recoveryCrashLoopFresh =
|
|
2334
|
+
this.recoveryCrashLoopFresh = true;
|
|
2335
|
+
this.recoveryFreshChoice = false;
|
|
2336
|
+
this.logger.warn("Previously resumed session died quickly after a crash loop — starting fresh");
|
|
2337
|
+
}
|
|
2338
|
+
else if (state?.resumeDisabled === true) {
|
|
2339
|
+
// Crash loop detected, but the session is not proven broken.
|
|
2340
|
+
// Try resume first; if it dies before reaching ready, the next
|
|
2341
|
+
// boot will see resumeFailed and go fresh (PR #2809).
|
|
2342
|
+
const isCrashLoop = (state.reason === undefined || state.reason === "crash_loop")
|
|
2295
2343
|
&& Number.isInteger(state.crashesInWindow) && state.crashesInWindow >= 3
|
|
2296
2344
|
&& Number.isFinite(state.lastCrashAt) && state.lastCrashAt > 0;
|
|
2297
|
-
|
|
2298
|
-
|
|
2345
|
+
if (isCrashLoop) {
|
|
2346
|
+
this.bootCrashLoopResume = true;
|
|
2347
|
+
this.logger.warn("Previous crash loop detected — attempting resume (will fall back to fresh if session is broken)");
|
|
2348
|
+
}
|
|
2349
|
+
else {
|
|
2350
|
+
// Explicit fresh-start request (not a crash loop); honour it.
|
|
2351
|
+
this.skipResume = true;
|
|
2352
|
+
this.bootSkippedResume = true;
|
|
2353
|
+
this.recoveryCrashLoopFresh = false;
|
|
2354
|
+
this.recoveryFreshChoice = true;
|
|
2355
|
+
this.logger.warn("Previous crash loop detected — starting without resume");
|
|
2356
|
+
}
|
|
2299
2357
|
}
|
|
2300
2358
|
}
|
|
2301
2359
|
finally {
|
|
@@ -2441,16 +2499,11 @@ export class Daemon extends EventEmitter {
|
|
|
2441
2499
|
catch { /* may not exist */ }
|
|
2442
2500
|
}
|
|
2443
2501
|
// #1209: conditional continuation for the turn interrupted by the last
|
|
2444
|
-
// restart, if the gates allow. Detached
|
|
2502
|
+
// restart, if the gates allow. Detached; the gate
|
|
2445
2503
|
// consumes its one-shot marker first and never throws into boot.
|
|
2446
2504
|
const legacyResume = this.maybeResumeInterruptedTurn().catch(() => "unknown");
|
|
2447
|
-
// Warmup: wait for CLI idle, then trigger steering reload — but only when
|
|
2448
|
-
// the instructions actually changed since the agent last saw them.
|
|
2449
|
-
// Skipping the no-op reload saves 10-30s of agent time on every restart
|
|
2450
|
-
// where instructions are unchanged.
|
|
2451
|
-
const warmup = this.runWarmupInstructionNotice();
|
|
2452
2505
|
const recoveryOwner = JSON.stringify(this.interactionOwner());
|
|
2453
|
-
void Promise.allSettled([legacyResume
|
|
2506
|
+
void Promise.allSettled([legacyResume]).then(() => {
|
|
2454
2507
|
if (JSON.stringify(this.interactionOwner()) === recoveryOwner) {
|
|
2455
2508
|
this.recoveryBootWorkPending = false;
|
|
2456
2509
|
this.recoveryBootSettled = true;
|
|
@@ -2488,6 +2541,7 @@ export class Daemon extends EventEmitter {
|
|
|
2488
2541
|
.catch(e => this.logger.debug({ err: e.message }, "Ack react failed"));
|
|
2489
2542
|
};
|
|
2490
2543
|
this.transcriptMonitor.on("tool_use", (name, input) => {
|
|
2544
|
+
this.noteClaudeOutput();
|
|
2491
2545
|
this.logger.debug({ tool: name }, "Tool use");
|
|
2492
2546
|
ackIfPending();
|
|
2493
2547
|
this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
|
|
@@ -2501,7 +2555,10 @@ export class Daemon extends EventEmitter {
|
|
|
2501
2555
|
// show only elapsed time than to leave a stale "Bash: npm test" on screen.
|
|
2502
2556
|
this.publishActivity(null);
|
|
2503
2557
|
});
|
|
2558
|
+
this.transcriptMonitor.on("api_error", (text, status) => this.noteClaudeApiError(text, status));
|
|
2559
|
+
this.transcriptMonitor.on("user_prompt", () => this.noteClaudePrompt());
|
|
2504
2560
|
this.transcriptMonitor.on("assistant_text", (text) => {
|
|
2561
|
+
this.noteClaudeOutput();
|
|
2505
2562
|
this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
|
|
2506
2563
|
ackIfPending();
|
|
2507
2564
|
this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
|
|
@@ -3848,6 +3905,14 @@ export class Daemon extends EventEmitter {
|
|
|
3848
3905
|
rebasedSeededBaselines.add(key);
|
|
3849
3906
|
}
|
|
3850
3907
|
}
|
|
3908
|
+
// #2795: an auth match the token-free check has not judged yet holds the other detectors, as the armed gate did
|
|
3909
|
+
// before: a real auth failure's text also matches generic patterns (kiro's "having trouble responding"). It is not
|
|
3910
|
+
// an error state — nobody is warned — and it is bounded like the gate.
|
|
3911
|
+
if (this.unverifiedAuthMatch?.gate) {
|
|
3912
|
+
if (now - this.unverifiedAuthMatch.at < Daemon.ERROR_RECOVERY_TIMEOUT_MS)
|
|
3913
|
+
return;
|
|
3914
|
+
this.unverifiedAuthMatch = null;
|
|
3915
|
+
}
|
|
3851
3916
|
// State: waiting for recovery. A missing/outdated ready pattern must not
|
|
3852
3917
|
// suppress every future error forever, so the gate has a hard deadline.
|
|
3853
3918
|
if (this.errorWaitingForRecovery) {
|
|
@@ -3993,25 +4058,20 @@ export class Daemon extends EventEmitter {
|
|
|
3993
4058
|
}
|
|
3994
4059
|
/** Arm the recovery gates and emit one detected error pattern. */
|
|
3995
4060
|
emitErrorPattern(ep, key, pane, now) {
|
|
4061
|
+
// #2795: auth text on the pane proves nothing about the credentials (22 of 22 such matches on the main host were
|
|
4062
|
+
// conversation text). It arms nothing here; it only asks the lifecycle's token-free check, and a failing check
|
|
4063
|
+
// arms the auth state through confirmAuthFailure().
|
|
4064
|
+
if (ep.type === "auth_error")
|
|
4065
|
+
this.unverifiedAuthMatch = { key, at: now, gate: !ep.skipRecoveryWait };
|
|
3996
4066
|
// skipRecoveryWait: this error self-recovers (e.g. a timeout — Kiro is
|
|
3997
4067
|
// back at its prompt immediately). Keep monitoring so the next occurrence
|
|
3998
4068
|
// can fire without waiting for a possibly startup-only ready pattern.
|
|
3999
|
-
if (!ep.skipRecoveryWait)
|
|
4000
|
-
this.
|
|
4001
|
-
this.errorDetectedAt = now;
|
|
4002
|
-
this.errorRecoveryDeadlineAt = now + Daemon.ERROR_RECOVERY_TIMEOUT_MS;
|
|
4003
|
-
this.activeErrorPatternKey = key;
|
|
4004
|
-
this.lastDetectedErrorType = ep.type;
|
|
4005
|
-
}
|
|
4069
|
+
else if (!ep.skipRecoveryWait)
|
|
4070
|
+
this.armErrorRecoveryGate(key, ep.type, now);
|
|
4006
4071
|
this.lastErrorNotifiedAt.set(key, now);
|
|
4007
4072
|
if (ep.action === "failover")
|
|
4008
4073
|
this.lastFailoverAt = now;
|
|
4009
4074
|
const message = this.resolveErrorMessage(pane, ep);
|
|
4010
|
-
// Remember an auth failure for as long as it is unresolved. An auth-broken
|
|
4011
|
-
// CLI cannot finish the turn it is holding, so every other detector in this
|
|
4012
|
-
// scan would otherwise keep reporting the same dead instance forever.
|
|
4013
|
-
if (ep.type === "auth_error")
|
|
4014
|
-
this.authFailureUnresolved = true;
|
|
4015
4075
|
this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
|
|
4016
4076
|
this.lastErrorEpisode = { key, pattern: ep.pattern, pane, spawn: this.spawnGeneration, fence: this.launchFenceEpoch };
|
|
4017
4077
|
this.emit("pty_error", { name: this.name, ...ep, message });
|
|
@@ -5186,6 +5246,7 @@ export class Daemon extends EventEmitter {
|
|
|
5186
5246
|
this.pausePending = false;
|
|
5187
5247
|
this.pauseReconfirmAuth = false;
|
|
5188
5248
|
this.authFailureUnresolved = false;
|
|
5249
|
+
this.unverifiedAuthMatch = null;
|
|
5189
5250
|
this.loginScreenReported = false;
|
|
5190
5251
|
this.fatalStartupReported = false;
|
|
5191
5252
|
this.fatalStartupBlocked = false;
|
|
@@ -5681,6 +5742,15 @@ export class Daemon extends EventEmitter {
|
|
|
5681
5742
|
this.turnReplyGuard.complete(turn.generation);
|
|
5682
5743
|
return;
|
|
5683
5744
|
}
|
|
5745
|
+
// #2822: a turn whose newest transcript event is Claude's usage-limit error could not have replied — no recovery
|
|
5746
|
+
// turn, no drop report: the limit is told once (the notice and Needs you). Only that record counts: pane text and
|
|
5747
|
+
// an error from before this turn began do not.
|
|
5748
|
+
if (this.personTurnEndedOnUsageLimit()) {
|
|
5749
|
+
this.logger.info({ correlationId: turn.target.correlationId }, "Turn ended on Claude's usage-limit error — no reply recovery");
|
|
5750
|
+
this.clearReplyGuardConfirm();
|
|
5751
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
5752
|
+
return;
|
|
5753
|
+
}
|
|
5684
5754
|
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
5685
5755
|
// third turn or guess at terminal text; make the failure visible instead.
|
|
5686
5756
|
if (turn.phase === "recovering") {
|
|
@@ -6030,20 +6100,7 @@ export class Daemon extends EventEmitter {
|
|
|
6030
6100
|
reason,
|
|
6031
6101
|
message: formatReplyDropDetection(turn, reason),
|
|
6032
6102
|
});
|
|
6033
|
-
|
|
6034
|
-
}
|
|
6035
|
-
/** Channel notice for a reply-drop. `rateLimit` shares the per-instance cooldown (#2544). */
|
|
6036
|
-
deliverReplyDropNotice(key, kind, title, turn, reason, rateLimit) {
|
|
6037
|
-
if (rateLimit) {
|
|
6038
|
-
const now = Date.now();
|
|
6039
|
-
if (this.lastReplyDropWarningAt !== 0
|
|
6040
|
-
&& now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS) {
|
|
6041
|
-
this.logger.debug({ reason, kind }, "Reply-drop channel notice rate-limited");
|
|
6042
|
-
return;
|
|
6043
|
-
}
|
|
6044
|
-
this.lastReplyDropWarningAt = now;
|
|
6045
|
-
}
|
|
6046
|
-
void this.deliverDaemonReply(t(key), kind, title, turn.target, true);
|
|
6103
|
+
// #2802: the event stays (Log tab); the person is no longer told in the topic or the web chat.
|
|
6047
6104
|
}
|
|
6048
6105
|
/** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
|
|
6049
6106
|
async sendProxyReply(pane, target) {
|
|
@@ -6076,7 +6133,7 @@ export class Daemon extends EventEmitter {
|
|
|
6076
6133
|
}
|
|
6077
6134
|
}
|
|
6078
6135
|
/** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
|
|
6079
|
-
deliverDaemonReply(body, requestPrefix, logLabel, target
|
|
6136
|
+
deliverDaemonReply(body, requestPrefix, logLabel, target) {
|
|
6080
6137
|
const args = { text: body };
|
|
6081
6138
|
const chatId = target?.chatId ?? this.lastChatId;
|
|
6082
6139
|
const threadId = target?.threadId ?? this.lastThreadId;
|
|
@@ -6153,7 +6210,6 @@ export class Daemon extends EventEmitter {
|
|
|
6153
6210
|
args,
|
|
6154
6211
|
fleetRequestId: fleetReqId,
|
|
6155
6212
|
adapterId,
|
|
6156
|
-
statusOnly,
|
|
6157
6213
|
});
|
|
6158
6214
|
}
|
|
6159
6215
|
catch {
|
|
@@ -6329,7 +6385,9 @@ export class Daemon extends EventEmitter {
|
|
|
6329
6385
|
// changed when tmux reported output (observedChangeAt), but idle/stuck are
|
|
6330
6386
|
// decisions about *now*. The old double-observe expressed that by calling
|
|
6331
6387
|
// observe twice, which silently disabled the "content moved" branch.
|
|
6332
|
-
|
|
6388
|
+
if (this.backend?.periodicRedrawFrom)
|
|
6389
|
+
this.periodicRedrawLayout = this.backend.periodicRedrawFrom(pane);
|
|
6390
|
+
const structuredPeriodicIdle = this.periodicIdleProof();
|
|
6333
6391
|
// #1641: the transcript outranks the pane: while it shows the turn running, no reading of this pane is idle (codex
|
|
6334
6392
|
// hides its status row while streaming, leaving the idle composer and footer in view mid-turn).
|
|
6335
6393
|
const transcriptTurn = this.transcriptTurnState();
|
|
@@ -6521,7 +6579,7 @@ export class Daemon extends EventEmitter {
|
|
|
6521
6579
|
return;
|
|
6522
6580
|
this.instanceStateLastOutputAt = event.at;
|
|
6523
6581
|
this.interactionOutputRevision++;
|
|
6524
|
-
const canProvePeriodicIdle = !!this.
|
|
6582
|
+
const canProvePeriodicIdle = !!this.periodicIdleProof();
|
|
6525
6583
|
if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
|
|
6526
6584
|
// Do not emit idle→working solely because agy repainted an identical
|
|
6527
6585
|
// footer. Codex additionally uses a structural probe while working: two
|
|
@@ -6539,6 +6597,25 @@ export class Daemon extends EventEmitter {
|
|
|
6539
6597
|
this.applyInstanceStateSnapshot(snapshot);
|
|
6540
6598
|
this.scheduleInstanceStateIdleCapture();
|
|
6541
6599
|
this.scheduleInstanceStateStuckDeadline(event.at);
|
|
6600
|
+
// #2770 (ii): a layout that repaints while idle (Claude's background-agent list) never leaves the quiet window the
|
|
6601
|
+
// idle capture waits for, so nothing would look at the pane again before the 60 s sweep. Look now and then.
|
|
6602
|
+
if (this.backend?.periodicRedrawFrom && performance.now() - this.periodicLayoutLookAt >= PERIODIC_LAYOUT_LOOK_MS) {
|
|
6603
|
+
this.periodicLayoutLookAt = performance.now();
|
|
6604
|
+
this.scheduleInstanceStateOutputProbe(STRUCTURED_IDLE_PROBE_MS);
|
|
6605
|
+
}
|
|
6606
|
+
}
|
|
6607
|
+
/**
|
|
6608
|
+
* The backend's structural idle proof for periodically repainting panes, when it applies to the current layout: always
|
|
6609
|
+
* for a backend that repaints in every layout (Codex, Muse), only while the latest capture showed such a layout for one
|
|
6610
|
+
* that names it (CliBackend.periodicRedrawFrom — Claude's background-agent list, #2770 (ii)).
|
|
6611
|
+
*/
|
|
6612
|
+
periodicIdleProof() {
|
|
6613
|
+
const proof = this.backend?.isPeriodicRedrawIdlePane;
|
|
6614
|
+
if (!proof)
|
|
6615
|
+
return undefined;
|
|
6616
|
+
if (this.backend?.periodicRedrawFrom && !this.periodicRedrawLayout)
|
|
6617
|
+
return undefined;
|
|
6618
|
+
return proof;
|
|
6542
6619
|
}
|
|
6543
6620
|
bindInstanceStateOutputListener(windowId) {
|
|
6544
6621
|
if (!this.controlClient || !this.instanceStateMonitorActive)
|
|
@@ -6555,6 +6632,7 @@ export class Daemon extends EventEmitter {
|
|
|
6555
6632
|
startInstanceStateMonitor() {
|
|
6556
6633
|
if (this.runtimeMonitorsFrozen || !this.tmux || !this.backend || this.instanceStateMonitorActive)
|
|
6557
6634
|
return;
|
|
6635
|
+
this.periodicRedrawLayout = false; // a new launch's layout is not the old one's
|
|
6558
6636
|
const rawConfig = this.config.hang_detector;
|
|
6559
6637
|
const timeoutMinutes = rawConfig?.timeout_minutes;
|
|
6560
6638
|
this.instanceStateStuckTimeoutMs = typeof timeoutMinutes === "number" && timeoutMinutes > 0
|
|
@@ -6971,6 +7049,26 @@ export class Daemon extends EventEmitter {
|
|
|
6971
7049
|
return { receiptId: id, verificationId: meta.verification_id, expectedWireHash: meta.human_wire_sha256,
|
|
6972
7050
|
ownerKey: meta.human_owner_key, current: () => this.captureHumanReceiptOwner() === daemonOwner };
|
|
6973
7051
|
}
|
|
7052
|
+
/**
|
|
7053
|
+
* #2770: a person's message a cancel dropped before it reached the CLI is told, not left as a silent "uncertain":
|
|
7054
|
+
* the messages dropped together go out as one `human_messages_cancelled` (the fleet marks each ❌ and says so once).
|
|
7055
|
+
* Agents' messages are not a person's, and a durable row (delivery_id) is retried by the outbox: neither is ❌'d. A
|
|
7056
|
+
* message with no chat to mark has nowhere to say it.
|
|
7057
|
+
*/
|
|
7058
|
+
noteCancelledHuman(meta) {
|
|
7059
|
+
if (meta.from_instance || meta.delivery_id || !meta.chat_id || !meta.message_id)
|
|
7060
|
+
return;
|
|
7061
|
+
this.cancelledHumans.push({ chatId: meta.chat_id, messageId: meta.message_id, ...(meta.thread_id ? { threadId: meta.thread_id } : {}) });
|
|
7062
|
+
if (this.cancelledHumansTimer)
|
|
7063
|
+
clearTimeout(this.cancelledHumansTimer);
|
|
7064
|
+
this.cancelledHumansTimer = setTimeout(() => {
|
|
7065
|
+
this.cancelledHumansTimer = null;
|
|
7066
|
+
const messages = this.cancelledHumans.splice(0);
|
|
7067
|
+
if (messages.length)
|
|
7068
|
+
this.emit("human_messages_cancelled", { messages });
|
|
7069
|
+
}, CANCELLED_HUMANS_NOTICE_MS);
|
|
7070
|
+
this.cancelledHumansTimer.unref?.();
|
|
7071
|
+
}
|
|
6974
7072
|
finishHumanReceipt(meta, verdict) {
|
|
6975
7073
|
if (!meta.human_receipt_id || !meta.human_owner_key)
|
|
6976
7074
|
return;
|
|
@@ -7045,6 +7143,7 @@ export class Daemon extends EventEmitter {
|
|
|
7045
7143
|
steerMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
|
|
7046
7144
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7047
7145
|
this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
|
|
7146
|
+
this.noteCancelledHuman(meta);
|
|
7048
7147
|
return;
|
|
7049
7148
|
}
|
|
7050
7149
|
// The fleet facade prevents a steer from overtaking an item still in its
|
|
@@ -7068,14 +7167,17 @@ export class Daemon extends EventEmitter {
|
|
|
7068
7167
|
this.steerLock = this.steerLock.then(async () => {
|
|
7069
7168
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7070
7169
|
this.deferDurableDelivery(meta, "delivery epoch changed before steer began");
|
|
7170
|
+
this.noteCancelledHuman(meta);
|
|
7071
7171
|
return;
|
|
7072
7172
|
}
|
|
7073
7173
|
await this.wake();
|
|
7074
7174
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7075
7175
|
this.deferDurableDelivery(meta, "delivery epoch changed while waking for steer");
|
|
7176
|
+
this.noteCancelledHuman(meta);
|
|
7076
7177
|
return;
|
|
7077
7178
|
}
|
|
7078
7179
|
const verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
|
|
7180
|
+
let delivered = false;
|
|
7079
7181
|
try {
|
|
7080
7182
|
if (await this.deliverMessage(formatted, status, {
|
|
7081
7183
|
steer: true,
|
|
@@ -7085,6 +7187,7 @@ export class Daemon extends EventEmitter {
|
|
|
7085
7187
|
verdict,
|
|
7086
7188
|
durableAttempt: durableAttempt || undefined,
|
|
7087
7189
|
})) {
|
|
7190
|
+
delivered = true;
|
|
7088
7191
|
this.finishDurableSubmission(durableAttempt, verdict);
|
|
7089
7192
|
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
7090
7193
|
}
|
|
@@ -7114,6 +7217,8 @@ export class Daemon extends EventEmitter {
|
|
|
7114
7217
|
}
|
|
7115
7218
|
finally {
|
|
7116
7219
|
this.finishHumanReceipt(meta, verdict);
|
|
7220
|
+
if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
7221
|
+
this.noteCancelledHuman(meta);
|
|
7117
7222
|
}
|
|
7118
7223
|
}).catch(err => {
|
|
7119
7224
|
this.deferDurableDelivery(meta, `target wake/steer failed before pane submission: ${err.message}`);
|
|
@@ -7132,6 +7237,7 @@ export class Daemon extends EventEmitter {
|
|
|
7132
7237
|
btwMessage(content, meta, deliveryEpoch = this.deliveryEpoch) {
|
|
7133
7238
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7134
7239
|
this.finishHumanReceipt(meta, { reached: false });
|
|
7240
|
+
this.noteCancelledHuman(meta);
|
|
7135
7241
|
return;
|
|
7136
7242
|
}
|
|
7137
7243
|
this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
|
|
@@ -7142,22 +7248,28 @@ export class Daemon extends EventEmitter {
|
|
|
7142
7248
|
this.steerLock = this.steerLock.then(async () => {
|
|
7143
7249
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7144
7250
|
this.finishHumanReceipt(meta, { reached: false });
|
|
7251
|
+
this.noteCancelledHuman(meta);
|
|
7145
7252
|
return;
|
|
7146
7253
|
}
|
|
7147
7254
|
await this.wake();
|
|
7148
7255
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7149
7256
|
this.finishHumanReceipt(meta, { reached: false });
|
|
7257
|
+
this.noteCancelledHuman(meta);
|
|
7150
7258
|
return;
|
|
7151
7259
|
}
|
|
7152
7260
|
const verdict = { reached: false, humanReceipt: this.humanReceiptAttempt(meta, formatted) };
|
|
7261
|
+
let delivered = false;
|
|
7153
7262
|
try {
|
|
7154
7263
|
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id,
|
|
7155
7264
|
...(meta.verification_id ? { humanVerificationId: meta.verification_id } : {}), verdict })) {
|
|
7265
|
+
delivered = true;
|
|
7156
7266
|
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
7157
7267
|
}
|
|
7158
7268
|
}
|
|
7159
7269
|
finally {
|
|
7160
7270
|
this.finishHumanReceipt(meta, verdict);
|
|
7271
|
+
if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
7272
|
+
this.noteCancelledHuman(meta);
|
|
7161
7273
|
}
|
|
7162
7274
|
}).catch(err => {
|
|
7163
7275
|
this.finishHumanReceipt(meta, { reached: false });
|
|
@@ -7173,6 +7285,7 @@ export class Daemon extends EventEmitter {
|
|
|
7173
7285
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7174
7286
|
this.logger.info("Pending channel delivery dropped by user cancel");
|
|
7175
7287
|
this.deferDurableDelivery(meta, "delivery epoch changed before queue admission");
|
|
7288
|
+
this.noteCancelledHuman(meta);
|
|
7176
7289
|
return;
|
|
7177
7290
|
}
|
|
7178
7291
|
if (!this.tmux) {
|
|
@@ -7194,10 +7307,6 @@ export class Daemon extends EventEmitter {
|
|
|
7194
7307
|
this.logger.warn({ err: err.message }, "Failed to persist last inbound timestamp");
|
|
7195
7308
|
}
|
|
7196
7309
|
}
|
|
7197
|
-
if (this.pendingInstructionsUpdate) {
|
|
7198
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
|
|
7199
|
-
this.pendingInstructionsUpdate = undefined;
|
|
7200
|
-
}
|
|
7201
7310
|
this.pendingWork.recordInbound();
|
|
7202
7311
|
// v3: record user messages for rotation snapshot
|
|
7203
7312
|
this.recordRecentUserMessage(content, meta);
|
|
@@ -7236,6 +7345,7 @@ export class Daemon extends EventEmitter {
|
|
|
7236
7345
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
7237
7346
|
let durable = null;
|
|
7238
7347
|
let verdict = { reached: false };
|
|
7348
|
+
let delivered = false;
|
|
7239
7349
|
try {
|
|
7240
7350
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
7241
7351
|
this.logger.info("Pending channel delivery dropped by user cancel");
|
|
@@ -7247,44 +7357,11 @@ export class Daemon extends EventEmitter {
|
|
|
7247
7357
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
7248
7358
|
return;
|
|
7249
7359
|
}
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
}
|
|
7256
|
-
const whatsNew = this.pendingWhatsNew ? this.currentWhatsNew(this.pendingWhatsNew) : null;
|
|
7257
|
-
if (whatsNew) {
|
|
7258
|
-
// #1633: here, inside the delivery lock and ahead of a real delivery — never into a busy turn, never to an
|
|
7259
|
-
// instance nobody writes to. #2140: told at most once. Once its paste reached the pane it is recorded, proven
|
|
7260
|
-
// submitted or not. A system paste has no unique id: its only pane proof is its opening line on the screen, and
|
|
7261
|
-
// a long notice (every entry since an old session began) scrolls that line above the captured rows once Claude
|
|
7262
|
-
// paints it — unproven, though the agent read it. One that never reached the pane (cancelled, never idle) is
|
|
7263
|
-
// tried again, at most WHATS_NEW_MAX_UNWRITTEN times, then recorded with a warning.
|
|
7264
|
-
const attempt = { reached: false };
|
|
7265
|
-
const pasted = await this.deliverMessage(whatsNew.notice, undefined, { deliveryEpoch, verdict: attempt });
|
|
7266
|
-
if (this.pendingWhatsNew === whatsNew) {
|
|
7267
|
-
const written = pasted || attempt.paneWriteStarted === true;
|
|
7268
|
-
if (!written)
|
|
7269
|
-
this.whatsNewUnwritten++;
|
|
7270
|
-
if (written || this.whatsNewUnwritten >= WHATS_NEW_MAX_UNWRITTEN) {
|
|
7271
|
-
if (!pasted) {
|
|
7272
|
-
this.logger.warn({ entries: whatsNew.ids, written, attempts: this.whatsNewUnwritten, phase: attempt.phase, proof: attempt.proof }, written ? "what's-new notice pasted but its submission could not be proven — recorded so it is not sent again"
|
|
7273
|
-
: "what's-new notice did not reach the pane after several deliveries — recorded so it is not sent again");
|
|
7274
|
-
}
|
|
7275
|
-
this.pendingWhatsNew = null;
|
|
7276
|
-
this.whatsNewUnwritten = 0;
|
|
7277
|
-
try {
|
|
7278
|
-
recordWhatsNew(this.instanceDir, whatsNew.ids, whatsNew.version);
|
|
7279
|
-
}
|
|
7280
|
-
catch (err) {
|
|
7281
|
-
this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
|
|
7282
|
-
}
|
|
7283
|
-
}
|
|
7284
|
-
}
|
|
7285
|
-
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
7286
|
-
return;
|
|
7287
|
-
}
|
|
7360
|
+
// #2800: with this real message, in the same paste — never a paste or turn of its own: after its delivery marker
|
|
7361
|
+
// (transcript proof anchors on it), before the message. A person's message with a verified receipt is pasted
|
|
7362
|
+
// byte for byte (its proof is the wire's hash), so the notice waits for the next delivery without one.
|
|
7363
|
+
const whatsNew = this.pendingWhatsNew && !meta.human_receipt_id ? this.currentWhatsNew(this.pendingWhatsNew) : null;
|
|
7364
|
+
const pasted = whatsNew ? withWhatsNew(formatted, whatsNew.notice) : formatted;
|
|
7288
7365
|
const status = channelStatus(meta);
|
|
7289
7366
|
// A fresh delivery begins a fresh turn — its bubble must not inherit
|
|
7290
7367
|
// the previous turn's tool list.
|
|
@@ -7296,13 +7373,24 @@ export class Daemon extends EventEmitter {
|
|
|
7296
7373
|
return;
|
|
7297
7374
|
}
|
|
7298
7375
|
durable = durableAttempt;
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7376
|
+
let sent = false;
|
|
7377
|
+
try {
|
|
7378
|
+
sent = await this.deliverMessage(pasted, status, {
|
|
7379
|
+
deliveryEpoch,
|
|
7380
|
+
submissionId: meta.message_id,
|
|
7381
|
+
...(meta.verification_id ? { humanVerificationId: meta.verification_id } : {}),
|
|
7382
|
+
verdict,
|
|
7383
|
+
durableAttempt: durableAttempt || undefined,
|
|
7384
|
+
});
|
|
7385
|
+
}
|
|
7386
|
+
finally {
|
|
7387
|
+
// #2800: told once — recorded as soon as it went out with this message, proven submitted or not, also when
|
|
7388
|
+
// a later step of the write threw; a paste that never reached the pane keeps it for the next message.
|
|
7389
|
+
if (whatsNew && (sent || verdict.paneWriteStarted))
|
|
7390
|
+
this.recordWhatsNewSent(whatsNew);
|
|
7391
|
+
}
|
|
7392
|
+
if (sent) {
|
|
7393
|
+
delivered = true;
|
|
7306
7394
|
this.finishDurableSubmission(durable, verdict);
|
|
7307
7395
|
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
7308
7396
|
}
|
|
@@ -7348,6 +7436,8 @@ export class Daemon extends EventEmitter {
|
|
|
7348
7436
|
}
|
|
7349
7437
|
finally {
|
|
7350
7438
|
this.finishHumanReceipt(meta, verdict);
|
|
7439
|
+
if (!delivered && !verdict.paneWriteStarted && !this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
7440
|
+
this.noteCancelledHuman(meta);
|
|
7351
7441
|
this.pasteQueueDepth--;
|
|
7352
7442
|
}
|
|
7353
7443
|
}).catch(err => {
|
|
@@ -7419,6 +7509,22 @@ export class Daemon extends EventEmitter {
|
|
|
7419
7509
|
// normal Enter input has steering/interrupt semantics in several other CLIs.
|
|
7420
7510
|
let readiness = "ready";
|
|
7421
7511
|
const gateWindowId = windowId;
|
|
7512
|
+
const handOffEligible = !!(supportsQueuedInput || opts?.steer || composerQueue);
|
|
7513
|
+
const handOffAdmissible = async () => (await this.probeBlockingDialog()).state === "clear"
|
|
7514
|
+
&& await this.hasPositiveDeliveryInput(true)
|
|
7515
|
+
&& ((!opts?.steer && !composerQueue) || (this.launchTakesSteer() && await this.steerComposerAdmits(false, opts?.steer === true)));
|
|
7516
|
+
// #2770: a delivery waiting for idle holds the paste queue, and every message behind it. It stops waiting at once
|
|
7517
|
+
// on a cancel, and as soon as the busy pane can take it the way it would have on arrival (a box that reads again, a
|
|
7518
|
+
// dialog gone) — rather than for the end of whatever keeps the pane moving (2026-10-27: 11 minutes).
|
|
7519
|
+
let handOffCheckedAt = -Infinity;
|
|
7520
|
+
const waitStop = async () => {
|
|
7521
|
+
if (cancelled())
|
|
7522
|
+
return true;
|
|
7523
|
+
if (!handOffEligible || !gateWindowId || performance.now() - handOffCheckedAt < HANDOFF_RECHECK_MS)
|
|
7524
|
+
return false;
|
|
7525
|
+
handOffCheckedAt = performance.now();
|
|
7526
|
+
return (await this.paneReadinessForDelivery(gateWindowId)) === "busy" && await handOffAdmissible() && !cancelled();
|
|
7527
|
+
};
|
|
7422
7528
|
if (gateWindowId && this.controlClient)
|
|
7423
7529
|
readiness = await this.paneReadinessForDelivery(gateWindowId);
|
|
7424
7530
|
if (readiness !== "ready") {
|
|
@@ -7431,11 +7537,7 @@ export class Daemon extends EventEmitter {
|
|
|
7431
7537
|
// fall through to the wait.
|
|
7432
7538
|
// #2676: an ordinary message goes into a mode-switched composer (kiro TUI) mid-turn too, on a launch whose busy
|
|
7433
7539
|
// composer AgEnD drives, by the steer path's own gates and proof — when its box can show it is this paste.
|
|
7434
|
-
const canHandOff =
|
|
7435
|
-
&& readiness === "busy"
|
|
7436
|
-
&& (await this.probeBlockingDialog()).state === "clear"
|
|
7437
|
-
&& await this.hasPositiveDeliveryInput(true)
|
|
7438
|
-
&& ((!opts?.steer && !composerQueue) || (this.launchTakesSteer() && await this.steerComposerAdmits(false, opts?.steer === true)));
|
|
7540
|
+
const canHandOff = handOffEligible && readiness === "busy" && await handOffAdmissible();
|
|
7439
7541
|
if (canHandOff) {
|
|
7440
7542
|
// Native queue (codex), or an explicit /steer: hand the complete
|
|
7441
7543
|
// paste+Enter transaction to the busy CLI now. For steer this is the
|
|
@@ -7449,10 +7551,14 @@ export class Daemon extends EventEmitter {
|
|
|
7449
7551
|
}
|
|
7450
7552
|
else {
|
|
7451
7553
|
this.logger.debug("CLI busy — queuing message until idle");
|
|
7452
|
-
const becameIdle = await this.waitForPaneReadyForDelivery(gateWindowId);
|
|
7554
|
+
const becameIdle = await this.waitForPaneReadyForDelivery(gateWindowId, undefined, waitStop);
|
|
7453
7555
|
if (cancelled())
|
|
7454
7556
|
return false;
|
|
7455
|
-
if (
|
|
7557
|
+
if (becameIdle === "stopped") {
|
|
7558
|
+
handingOffToNativeQueue = true;
|
|
7559
|
+
this.logger.info("The busy CLI can take this delivery now — handing it off instead of waiting for idle (#2770)");
|
|
7560
|
+
}
|
|
7561
|
+
else if (!becameIdle) {
|
|
7456
7562
|
// The pane never freed up. Report the failure instead of pasting into a
|
|
7457
7563
|
// wedged CLI (where the text would sit unsubmitted and the next message
|
|
7458
7564
|
// would land on top of it) — and instead of holding the queue silently.
|
|
@@ -7597,9 +7703,13 @@ export class Daemon extends EventEmitter {
|
|
|
7597
7703
|
return this.failDelivery(verdict, status, "pre-write", "dialog-or-input-not-ready");
|
|
7598
7704
|
}
|
|
7599
7705
|
this.logger.info("Dialog or input transition appeared before the pane write — waiting for readiness");
|
|
7600
|
-
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId) : false;
|
|
7706
|
+
const clear = gateWindowId ? await this.waitForPaneReadyForDelivery(gateWindowId, undefined, waitStop) : false;
|
|
7601
7707
|
if (cancelled())
|
|
7602
7708
|
return false;
|
|
7709
|
+
if (clear === "stopped") {
|
|
7710
|
+
handingOffToNativeQueue = true;
|
|
7711
|
+
continue;
|
|
7712
|
+
}
|
|
7603
7713
|
if (!clear) {
|
|
7604
7714
|
return this.failDelivery(verdict, status, "pre-write", "readiness-timeout");
|
|
7605
7715
|
}
|
|
@@ -8736,13 +8846,7 @@ export class Daemon extends EventEmitter {
|
|
|
8736
8846
|
return "busy";
|
|
8737
8847
|
}
|
|
8738
8848
|
}
|
|
8739
|
-
|
|
8740
|
-
* Like waitForPaneIdleForDelivery, plus the bottom-anchored prompt check for
|
|
8741
|
-
* Enter-dropping TUIs. Polls at a coarse cadence (each poll is a capture-pane)
|
|
8742
|
-
* only after the pane is already quiet, so the common fast-idle delivery still
|
|
8743
|
-
* costs a single capture.
|
|
8744
|
-
*/
|
|
8745
|
-
async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000) {
|
|
8849
|
+
async waitForPaneReadyForDelivery(windowId, timeoutMs = 30 * 60_000, stop) {
|
|
8746
8850
|
// Every backend polls: readiness now also means "no blocking dialog on
|
|
8747
8851
|
// screen", and a dialog disappears without any output edge the silence
|
|
8748
8852
|
// gate could see.
|
|
@@ -8755,9 +8859,10 @@ export class Daemon extends EventEmitter {
|
|
|
8755
8859
|
const remaining = deadline - Date.now();
|
|
8756
8860
|
if (remaining <= 0)
|
|
8757
8861
|
return false;
|
|
8758
|
-
const idle = await this.waitForPaneIdleForDelivery(windowId, remaining)
|
|
8759
|
-
|
|
8760
|
-
|
|
8862
|
+
const idle = stop ? await this.waitForPaneIdleForDelivery(windowId, remaining, stop)
|
|
8863
|
+
: await this.waitForPaneIdleForDelivery(windowId, remaining);
|
|
8864
|
+
if (idle !== true)
|
|
8865
|
+
return idle;
|
|
8761
8866
|
// The idle wait just resolved, so re-reading the silence gate would only
|
|
8762
8867
|
// add a stale-cache race; ask the pane the remaining questions directly.
|
|
8763
8868
|
const transient = await this.probeInputTransient();
|
|
@@ -8777,6 +8882,8 @@ export class Daemon extends EventEmitter {
|
|
|
8777
8882
|
this.retireInputTransients("transient stalled");
|
|
8778
8883
|
return false;
|
|
8779
8884
|
}
|
|
8885
|
+
if (stop && await stop())
|
|
8886
|
+
return "stopped";
|
|
8780
8887
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
8781
8888
|
continue;
|
|
8782
8889
|
}
|
|
@@ -8786,6 +8893,8 @@ export class Daemon extends EventEmitter {
|
|
|
8786
8893
|
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
8787
8894
|
return false;
|
|
8788
8895
|
}
|
|
8896
|
+
if (stop && await stop())
|
|
8897
|
+
return "stopped";
|
|
8789
8898
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
8790
8899
|
continue;
|
|
8791
8900
|
}
|
|
@@ -8807,6 +8916,8 @@ export class Daemon extends EventEmitter {
|
|
|
8807
8916
|
unknownStreak = 0;
|
|
8808
8917
|
}
|
|
8809
8918
|
}
|
|
8919
|
+
if (stop && await stop())
|
|
8920
|
+
return "stopped";
|
|
8810
8921
|
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
8811
8922
|
}
|
|
8812
8923
|
}
|
|
@@ -9214,9 +9325,11 @@ export class Daemon extends EventEmitter {
|
|
|
9214
9325
|
return observedAt >= lastOutputAt
|
|
9215
9326
|
&& observedAt >= this.controlClient.getObservationResetAt();
|
|
9216
9327
|
}
|
|
9217
|
-
waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000) {
|
|
9328
|
+
waitForPaneIdleForDelivery(windowId, timeoutMs = 30 * 60_000, stop) {
|
|
9218
9329
|
if (!this.controlClient)
|
|
9219
9330
|
return Promise.resolve(true);
|
|
9331
|
+
if (stop)
|
|
9332
|
+
return this.waitForPaneIdleOrStop(windowId, timeoutMs, stop);
|
|
9220
9333
|
if (!this.canTrustRedrawIdleState()) {
|
|
9221
9334
|
return this.controlClient.waitUntilIdle(windowId, timeoutMs);
|
|
9222
9335
|
}
|
|
@@ -9236,6 +9349,44 @@ export class Daemon extends EventEmitter {
|
|
|
9236
9349
|
}, timeoutMs);
|
|
9237
9350
|
});
|
|
9238
9351
|
}
|
|
9352
|
+
/**
|
|
9353
|
+
* #2770: waitForPaneIdleForDelivery that also ends as soon as `stop` says so ("stopped"). The same idle readings
|
|
9354
|
+
* (tmux silence, or the state machine where its idle is trusted), polled here so nothing outlives a stopped wait.
|
|
9355
|
+
*/
|
|
9356
|
+
waitForPaneIdleOrStop(windowId, timeoutMs, stop) {
|
|
9357
|
+
const control = this.controlClient;
|
|
9358
|
+
const trusted = this.canTrustRedrawIdleState();
|
|
9359
|
+
if (trusted && this.isPaneIdleForDelivery(windowId))
|
|
9360
|
+
return Promise.resolve(true);
|
|
9361
|
+
return new Promise(resolve => {
|
|
9362
|
+
let done = false, asking = false, askedAt = -Infinity;
|
|
9363
|
+
const finish = (outcome) => {
|
|
9364
|
+
if (done)
|
|
9365
|
+
return;
|
|
9366
|
+
done = true;
|
|
9367
|
+
clearInterval(check);
|
|
9368
|
+
clearTimeout(timer);
|
|
9369
|
+
resolve(outcome);
|
|
9370
|
+
};
|
|
9371
|
+
// The same idle readings as the plain wait: tmux silence (abandoned once this wait ends), or the state machine.
|
|
9372
|
+
if (!trusted) {
|
|
9373
|
+
control.waitUntilIdle(windowId, timeoutMs, () => done).then(idle => finish(idle), () => finish(false));
|
|
9374
|
+
}
|
|
9375
|
+
const check = setInterval(() => {
|
|
9376
|
+
if (trusted && this.isPaneIdleForDelivery(windowId)) {
|
|
9377
|
+
finish(true);
|
|
9378
|
+
return;
|
|
9379
|
+
}
|
|
9380
|
+
if (asking || performance.now() - askedAt < WAIT_STOP_POLL_MS)
|
|
9381
|
+
return;
|
|
9382
|
+
asking = true;
|
|
9383
|
+
askedAt = performance.now();
|
|
9384
|
+
stop().then(stopNow => { asking = false; if (stopNow)
|
|
9385
|
+
finish("stopped"); }, err => { asking = false; this.logger.debug({ err }, "Delivery wait stop check failed — still waiting"); });
|
|
9386
|
+
}, 50);
|
|
9387
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
9388
|
+
});
|
|
9389
|
+
}
|
|
9239
9390
|
/**
|
|
9240
9391
|
* Antigravity has always opted into pane-state delivery readiness because its
|
|
9241
9392
|
* footer redraws while idle. Codex's structural idle proof is useful only
|
|
@@ -9248,7 +9399,7 @@ export class Daemon extends EventEmitter {
|
|
|
9248
9399
|
canTrustRedrawIdleState() {
|
|
9249
9400
|
if (this.backend?.hasPeriodicPaneRedraw?.() === true)
|
|
9250
9401
|
return true;
|
|
9251
|
-
return this.instanceStateMonitorActive && !!this.
|
|
9402
|
+
return this.instanceStateMonitorActive && !!this.periodicIdleProof();
|
|
9252
9403
|
}
|
|
9253
9404
|
/**
|
|
9254
9405
|
* The write half of a delivery: paste → Enter → confirm, with retries.
|
|
@@ -11229,10 +11380,6 @@ export class Daemon extends EventEmitter {
|
|
|
11229
11380
|
const snapshot = this.buildSnapshotPrompt();
|
|
11230
11381
|
if (!snapshot || !this.tmux)
|
|
11231
11382
|
return;
|
|
11232
|
-
if (this.pendingInstructionsUpdate) {
|
|
11233
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.pendingInstructionsUpdate);
|
|
11234
|
-
this.pendingInstructionsUpdate = undefined;
|
|
11235
|
-
}
|
|
11236
11383
|
// Small delay to let the CLI fully render its ready prompt
|
|
11237
11384
|
await new Promise(r => setTimeout(r, 1_000));
|
|
11238
11385
|
admission?.();
|
|
@@ -11505,6 +11652,8 @@ export class Daemon extends EventEmitter {
|
|
|
11505
11652
|
// as this backend's and resumed by the next start.
|
|
11506
11653
|
this.startupAdmission?.();
|
|
11507
11654
|
this.setAsideForeignSession();
|
|
11655
|
+
// A crash-loop-resume boot should attempt resume even though skipResume is false:
|
|
11656
|
+
// bootCrashLoopResume set at boot means we had a crash loop but try resume first.
|
|
11508
11657
|
const attemptedResume = !this.skipResume;
|
|
11509
11658
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
11510
11659
|
// conversation must come back from the backend before anything paints).
|
|
@@ -11589,6 +11738,20 @@ export class Daemon extends EventEmitter {
|
|
|
11589
11738
|
}
|
|
11590
11739
|
this.setSessionAside(proven ? "cli_reported_no_conversation" : "resume_failed_repeatedly");
|
|
11591
11740
|
this.skipResume = true;
|
|
11741
|
+
// If this was a crash-loop-resume attempt, the session is now proven
|
|
11742
|
+
// broken. Persist resumeFailed so the next restart goes fresh without
|
|
11743
|
+
// attempting resume again.
|
|
11744
|
+
if (this.bootCrashLoopResume) {
|
|
11745
|
+
try {
|
|
11746
|
+
writeFileSync(join(this.instanceDir, "crash-state.json"), JSON.stringify({
|
|
11747
|
+
resumeFailed: true,
|
|
11748
|
+
failedAt: Date.now(),
|
|
11749
|
+
}));
|
|
11750
|
+
}
|
|
11751
|
+
catch { /* best effort */ }
|
|
11752
|
+
this.logger.warn("Crash-loop-resume: session abandoned — next boot will go fresh");
|
|
11753
|
+
this.bootCrashLoopResume = false;
|
|
11754
|
+
}
|
|
11592
11755
|
}
|
|
11593
11756
|
// A fresh start that also failed retries once, as before. It never clears
|
|
11594
11757
|
// a session: nothing about a failed fresh launch says the stored
|
|
@@ -11606,9 +11769,37 @@ export class Daemon extends EventEmitter {
|
|
|
11606
11769
|
this.startupAdmission?.();
|
|
11607
11770
|
throw new Error("CLI failed to start after retry");
|
|
11608
11771
|
}
|
|
11772
|
+
// Fresh fallback succeeded. Clear any resumeFailed marker written during this
|
|
11773
|
+
// boot's setSessionAside path, so the next normal restart is not forced fresh.
|
|
11774
|
+
// NOTE: bootCrashLoopResume is already false here (cleared by setSessionAside path),
|
|
11775
|
+
// so check the marker directly rather than relying on the flag.
|
|
11776
|
+
try {
|
|
11777
|
+
const p = join(this.instanceDir, "crash-state.json");
|
|
11778
|
+
if (existsSync(p)) {
|
|
11779
|
+
const s = JSON.parse(readFileSync(p, "utf-8"));
|
|
11780
|
+
if (s?.resumeFailed === true)
|
|
11781
|
+
unlinkSync(p);
|
|
11782
|
+
}
|
|
11783
|
+
}
|
|
11784
|
+
catch { /* best effort */ }
|
|
11609
11785
|
}
|
|
11610
11786
|
else if (attemptedResume) {
|
|
11611
11787
|
resumedSuccessfully = true;
|
|
11788
|
+
if (this.bootCrashLoopResume) {
|
|
11789
|
+
// Resume succeeded after a crash loop — clear any stale resumeFailed marker
|
|
11790
|
+
// that might have been written by a concurrent write or a previous attempt,
|
|
11791
|
+
// so the next restart is not forced fresh.
|
|
11792
|
+
this.bootCrashLoopResume = false;
|
|
11793
|
+
try {
|
|
11794
|
+
const p = join(this.instanceDir, "crash-state.json");
|
|
11795
|
+
if (existsSync(p)) {
|
|
11796
|
+
const s = JSON.parse(readFileSync(p, "utf-8"));
|
|
11797
|
+
if (s?.resumeFailed === true)
|
|
11798
|
+
unlinkSync(p);
|
|
11799
|
+
}
|
|
11800
|
+
}
|
|
11801
|
+
catch { /* best effort */ }
|
|
11802
|
+
}
|
|
11612
11803
|
this.clearUnprovenResumeFailures();
|
|
11613
11804
|
// A resume needs the backend: its success is positive proof the backend
|
|
11614
11805
|
// is reachable again (a fresh prompt is local and proves nothing).
|
|
@@ -12064,10 +12255,13 @@ export class Daemon extends EventEmitter {
|
|
|
12064
12255
|
}
|
|
12065
12256
|
/** Kill the entire process tree of the current tmux pane (CLI + MCP server). */
|
|
12066
12257
|
async killProcessTree(signal = "SIGTERM", reason = "unspecified") {
|
|
12067
|
-
|
|
12258
|
+
const windowId = this.tmux?.getWindowId();
|
|
12259
|
+
// Before createWindow succeeds there is no pane owned by this daemon.
|
|
12260
|
+
// tmux interprets an empty selector as the session's active window.
|
|
12261
|
+
if (!windowId?.trim())
|
|
12068
12262
|
return;
|
|
12069
12263
|
try {
|
|
12070
|
-
const pid = await TmuxManager.getPanePid(this.tmuxSessionName,
|
|
12264
|
+
const pid = await TmuxManager.getPanePid(this.tmuxSessionName, windowId);
|
|
12071
12265
|
if (pid) {
|
|
12072
12266
|
this.noteStopRequest(signal, reason);
|
|
12073
12267
|
process.kill(-pid, signal);
|
|
@@ -12239,113 +12433,40 @@ export class Daemon extends EventEmitter {
|
|
|
12239
12433
|
return this.museRelayFallbackInFlight;
|
|
12240
12434
|
}
|
|
12241
12435
|
/**
|
|
12242
|
-
*
|
|
12243
|
-
*
|
|
12244
|
-
*
|
|
12245
|
-
* testable: warmupNoticeAction's own tests pass even if this call site stops
|
|
12246
|
-
* consulting it, which is exactly the bug this path had — the decision was
|
|
12247
|
-
* made inline and never asked whether the backend needed telling at all.
|
|
12248
|
-
*
|
|
12249
|
-
* Never throws: a missed reload notice must not fail a spawn.
|
|
12250
|
-
*/
|
|
12251
|
-
async runWarmupInstructionNotice() {
|
|
12252
|
-
try {
|
|
12253
|
-
const action = warmupNoticeAction({
|
|
12254
|
-
warmupNeeded: this.warmupNeeded,
|
|
12255
|
-
backend: this.backend ?? undefined,
|
|
12256
|
-
pasteQueueDepth: this.pasteQueueDepth,
|
|
12257
|
-
});
|
|
12258
|
-
if (action === "skip") {
|
|
12259
|
-
this.logger.debug(this.warmupNeeded
|
|
12260
|
-
? "Warmup skipped — this backend reloads instructions on resume"
|
|
12261
|
-
: "Warmup skipped — instructions unchanged");
|
|
12262
|
-
// When the backend learns of the change on its own, record what it now
|
|
12263
|
-
// holds: leaving prev-instructions behind would keep every later
|
|
12264
|
-
// restart comparing against a value the agent has long since replaced.
|
|
12265
|
-
if (this.warmupNeeded) {
|
|
12266
|
-
try {
|
|
12267
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
12268
|
-
}
|
|
12269
|
-
catch { /* best effort */ }
|
|
12270
|
-
}
|
|
12271
|
-
return;
|
|
12272
|
-
}
|
|
12273
|
-
if (action === "defer") {
|
|
12274
|
-
this.logger.debug("Warmup deferred — no pending inbound messages");
|
|
12275
|
-
// Convert to pendingInstructionsNotice so it fires on next real message.
|
|
12276
|
-
this.pendingInstructionsNotice = true;
|
|
12277
|
-
try {
|
|
12278
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
12279
|
-
}
|
|
12280
|
-
catch { }
|
|
12281
|
-
return;
|
|
12282
|
-
}
|
|
12283
|
-
const wid = existsSync(join(this.instanceDir, "window-id"))
|
|
12284
|
-
? readFileSync(join(this.instanceDir, "window-id"), "utf-8").trim() : "";
|
|
12285
|
-
if (wid && this.controlClient) {
|
|
12286
|
-
await this.controlClient.waitForIdle(wid, 120_000);
|
|
12287
|
-
}
|
|
12288
|
-
else {
|
|
12289
|
-
await new Promise(r => setTimeout(r, 5000));
|
|
12290
|
-
}
|
|
12291
|
-
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
12292
|
-
// delivery is already in flight or queued. Without the lock the notice and
|
|
12293
|
-
// that delivery race into the same pane.
|
|
12294
|
-
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
|
|
12295
|
-
if (!told) {
|
|
12296
|
-
// Recording the new instructions here would mark the agent as told about
|
|
12297
|
-
// a notice it never received, and every later restart would then skip
|
|
12298
|
-
// the reload. Leave the snapshot behind and hand it to the deferred
|
|
12299
|
-
// notice, which fires on the next real message.
|
|
12300
|
-
this.logger.warn("Instruction reload notice not confirmed — deferring it to the next message");
|
|
12301
|
-
this.pendingInstructionsNotice = true;
|
|
12302
|
-
return;
|
|
12303
|
-
}
|
|
12304
|
-
// Record the value the agent has now been told about so the next
|
|
12305
|
-
// unchanged restart skips the reload.
|
|
12306
|
-
try {
|
|
12307
|
-
writeFileSync(join(this.instanceDir, "prev-instructions"), this.lastBuiltInstructions);
|
|
12308
|
-
}
|
|
12309
|
-
catch { /* best effort */ }
|
|
12310
|
-
this.logger.debug("Warmup sent after idle");
|
|
12311
|
-
}
|
|
12312
|
-
catch { /* non-fatal */ }
|
|
12313
|
-
}
|
|
12314
|
-
/**
|
|
12315
|
-
* #1633 (#1639 review): the pending notice as the record stands NOW, just before the paste — it was decided at launch,
|
|
12316
|
-
* and the record may have changed while the delivery waited. Unreadable → nothing is sent (and nothing pending);
|
|
12317
|
-
* entries recorded meanwhile are dropped from it; none left → nothing.
|
|
12436
|
+
* #1633 (#1639 review): the pending notice as things stand NOW, just before the paste — it was decided at launch, and
|
|
12437
|
+
* the record, a switch or the instance's tools may have changed while the delivery waited. Planned again from the
|
|
12438
|
+
* record (#2800: the running release only, tools it has now); nothing left to tell → recorded, nothing pending.
|
|
12318
12439
|
*/
|
|
12319
12440
|
currentWhatsNew(pending) {
|
|
12320
|
-
const
|
|
12321
|
-
|
|
12441
|
+
const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh: false, version: pending.version,
|
|
12442
|
+
roles: this.whatsNewRoles(), tools: this.whatsNewTools(), canvasWorker: readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")) });
|
|
12443
|
+
if (!plan || "silent" in plan) {
|
|
12322
12444
|
this.pendingWhatsNew = null;
|
|
12445
|
+
if (plan)
|
|
12446
|
+
this.recordWhatsNewSent({ ids: plan.silent, version: plan.version });
|
|
12323
12447
|
return null;
|
|
12324
12448
|
}
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12449
|
+
this.pendingWhatsNew = plan;
|
|
12450
|
+
return plan;
|
|
12451
|
+
}
|
|
12452
|
+
recordWhatsNewSent(told) {
|
|
12453
|
+
if (this.pendingWhatsNew && told.ids.every(id => this.pendingWhatsNew.ids.includes(id)))
|
|
12328
12454
|
this.pendingWhatsNew = null;
|
|
12329
|
-
|
|
12455
|
+
try {
|
|
12456
|
+
recordWhatsNew(this.instanceDir, told.ids, told.version);
|
|
12330
12457
|
}
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
// #2163: rebuilt as planned — only the entries this instance's roles are told; the rest are recorded with them.
|
|
12334
|
-
const roles = this.whatsNewRoles();
|
|
12335
|
-
const shown = agentWhatsNew(readCanvasWorkerEnabled(join(this.instanceDir, "..", ".."))).filter(e => ids.includes(e.id) && (!e.audience || e.audience.some(r => roles.includes(r))));
|
|
12336
|
-
if (!shown.length) {
|
|
12337
|
-
this.pendingWhatsNew = null;
|
|
12338
|
-
try {
|
|
12339
|
-
recordWhatsNew(this.instanceDir, ids, pending.version);
|
|
12340
|
-
}
|
|
12341
|
-
catch (err) {
|
|
12342
|
-
this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
|
|
12343
|
-
}
|
|
12344
|
-
return null;
|
|
12458
|
+
catch (err) {
|
|
12459
|
+
this.logger.warn({ err }, "what's-new record not written — the notice may repeat once");
|
|
12345
12460
|
}
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
12461
|
+
}
|
|
12462
|
+
/**
|
|
12463
|
+
* #2800: the tools this instance's agend MCP server lists now — the same filters (tool set, dashboard switches, the
|
|
12464
|
+
* Canvas Worker switch) — so a notice names only changes to tools it has.
|
|
12465
|
+
*/
|
|
12466
|
+
whatsNewTools() {
|
|
12467
|
+
const allowed = toolsFor(resolveToolSet(this.config, this.name));
|
|
12468
|
+
const listed = withoutFeatures(TOOLS.filter(t => allowed.has(t.name)), dashboardFeaturesOff(dashboardAccessOf(this.config)));
|
|
12469
|
+
return new Set(toolsWithCanvasWorker(readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")), listed).map(t => t.name));
|
|
12349
12470
|
}
|
|
12350
12471
|
/**
|
|
12351
12472
|
* #2163: who this instance is to the what's-new notices — a ClassicBot room's agent, General, or a fleet worker (with
|
|
@@ -12362,7 +12483,8 @@ export class Daemon extends EventEmitter {
|
|
|
12362
12483
|
/** #1633: what this instance has not been told about its AgEnD version yet (see agent-whats-new.ts). */
|
|
12363
12484
|
prepareWhatsNew(fresh, version = agendVersion()) {
|
|
12364
12485
|
try {
|
|
12365
|
-
const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh, version, roles: this.whatsNewRoles(),
|
|
12486
|
+
const plan = planWhatsNew({ record: readWhatsNewRecord(this.instanceDir), fresh, version, roles: this.whatsNewRoles(),
|
|
12487
|
+
tools: this.whatsNewTools(), canvasWorker: readCanvasWorkerEnabled(join(this.instanceDir, "..", "..")) });
|
|
12366
12488
|
if (!plan) {
|
|
12367
12489
|
this.pendingWhatsNew = null;
|
|
12368
12490
|
return;
|
|
@@ -12373,8 +12495,7 @@ export class Daemon extends EventEmitter {
|
|
|
12373
12495
|
return;
|
|
12374
12496
|
}
|
|
12375
12497
|
this.pendingWhatsNew = plan;
|
|
12376
|
-
this.
|
|
12377
|
-
this.logger.info({ entries: plan.ids }, "what's-new notice pending for the next delivery");
|
|
12498
|
+
this.logger.debug({ entries: plan.ids }, "what's-new notice pending for the next delivery");
|
|
12378
12499
|
}
|
|
12379
12500
|
catch (err) {
|
|
12380
12501
|
this.logger.debug({ err }, "what's-new not prepared");
|
|
@@ -12385,41 +12506,15 @@ export class Daemon extends EventEmitter {
|
|
|
12385
12506
|
this.startupAdmission?.();
|
|
12386
12507
|
const launchFence = this.launchFenceEpoch;
|
|
12387
12508
|
const backendConfig = this.buildBackendConfig();
|
|
12388
|
-
// Compare freshly-built instructions against the last value the agent was
|
|
12389
|
-
// told about. Computed for ALL backends (not gated by
|
|
12390
|
-
// instructionsReloadedOnResume) so the warmup steering-reload can be
|
|
12391
|
-
// skipped when nothing changed.
|
|
12392
|
-
this.lastBuiltInstructions = backendConfig.instructions ?? "";
|
|
12393
12509
|
// #1633: decided before prev-instructions is first written — its absence is what says "never launched before".
|
|
12394
12510
|
this.prepareWhatsNew(!existsSync(join(this.instanceDir, "prev-instructions")));
|
|
12395
|
-
|
|
12396
|
-
|
|
12397
|
-
|
|
12511
|
+
// Record what this launch writes. #2787: a change is never announced in the pane; the CLI takes the new
|
|
12512
|
+
// instructions whenever it next reads its own file.
|
|
12513
|
+
if (backendConfig.instructions) {
|
|
12398
12514
|
try {
|
|
12399
|
-
|
|
12400
|
-
}
|
|
12401
|
-
catch { }
|
|
12402
|
-
if (!prev && backendConfig.instructions) {
|
|
12403
|
-
// First time (no prev-instructions file): write current hash, skip warmup.
|
|
12404
|
-
try {
|
|
12405
|
-
writeFileSync(prevFile, backendConfig.instructions);
|
|
12406
|
-
}
|
|
12407
|
-
catch { }
|
|
12408
|
-
this.warmupNeeded = false;
|
|
12409
|
-
}
|
|
12410
|
-
else {
|
|
12411
|
-
this.warmupNeeded = !!backendConfig.instructions && prev !== backendConfig.instructions;
|
|
12412
|
-
}
|
|
12413
|
-
// For backends that don't re-read instructions on resume (kiro/codex),
|
|
12414
|
-
// also notify the agent on next message instead of forcing a new
|
|
12415
|
-
// session. Resume is preserved so context isn't lost.
|
|
12416
|
-
if (!backendConfig.skipResume && backendNeedsPaneReloadNotice(this.backend) && this.warmupNeeded) {
|
|
12417
|
-
if (prev) {
|
|
12418
|
-
this.logger.info("Instructions changed — will notify agent on next message");
|
|
12419
|
-
this.pendingInstructionsNotice = true;
|
|
12420
|
-
}
|
|
12421
|
-
this.pendingInstructionsUpdate = backendConfig.instructions;
|
|
12515
|
+
writeFileSync(join(this.instanceDir, "prev-instructions"), backendConfig.instructions);
|
|
12422
12516
|
}
|
|
12517
|
+
catch { /* best effort */ }
|
|
12423
12518
|
}
|
|
12424
12519
|
// A backend that has to ask its CLI something before the launch command exists (OpenCode: does
|
|
12425
12520
|
// this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
|