@songsid/agend 2.1.6-beta.13 → 2.1.6-beta.15
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/README.md +0 -22
- package/dist/backend/codex.d.ts +12 -40
- package/dist/backend/codex.js +47 -326
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +1 -13
- package/dist/backend/types.js.map +1 -1
- package/dist/cli.js +1 -23
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +4 -6
- package/dist/daemon.js +174 -144
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +0 -2
- package/dist/fleet-manager.js +0 -37
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +0 -7
- package/dist/instance-lifecycle.js +2 -119
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +0 -4
- package/dist/locale.js.map +1 -1
- package/package.json +1 -1
- package/dist/backend/codex-session.d.ts +0 -45
- package/dist/backend/codex-session.js +0 -202
- package/dist/backend/codex-session.js.map +0 -1
package/dist/daemon.js
CHANGED
|
@@ -30,8 +30,6 @@ import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignat
|
|
|
30
30
|
import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
31
31
|
import { t } from "./locale.js";
|
|
32
32
|
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
33
|
-
import { CodexResumeConflictError, CodexResumeIdentityError, CodexResumeUnavailableError } from "./backend/codex-session.js";
|
|
34
|
-
import { codexResumeLockVisible } from "./backend/codex.js";
|
|
35
33
|
const __filename = fileURLToPath(import.meta.url);
|
|
36
34
|
const __dirname = dirname(__filename);
|
|
37
35
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -451,6 +449,10 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
451
449
|
}
|
|
452
450
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
453
451
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
452
|
+
/** A pasted Kiro message may be processing for longer than the retry window. */
|
|
453
|
+
const KIRO_SUBMISSION_OBSERVE_MS = 10 * 60_000;
|
|
454
|
+
const KIRO_SUBMISSION_POLL_MS = 1_000;
|
|
455
|
+
const KIRO_SUBMISSION_UNREADABLE_POLLS = 40;
|
|
454
456
|
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
455
457
|
/**
|
|
456
458
|
* How long a passive startup/resume transient may sit WITHOUT REPAINTING before
|
|
@@ -950,8 +952,6 @@ export class Daemon extends EventEmitter {
|
|
|
950
952
|
instanceStateBusyPattern = null;
|
|
951
953
|
instanceStateMonitorActive = false;
|
|
952
954
|
sessionCheckpointWarningEmitted = false;
|
|
953
|
-
codexSessionIdentityHoldNotified = false;
|
|
954
|
-
recoveredLegacyCodexSessionId = null;
|
|
955
955
|
statePollInFlight = false;
|
|
956
956
|
// One generation-scoped source of truth for human-turn reply completion.
|
|
957
957
|
// It also supplies the older dead-MCP/malformed-call recovery paths, so those
|
|
@@ -1207,11 +1207,6 @@ export class Daemon extends EventEmitter {
|
|
|
1207
1207
|
this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
|
|
1208
1208
|
}
|
|
1209
1209
|
async start() {
|
|
1210
|
-
// Do this before touching daemon.pid or a pre-existing window. An unknown
|
|
1211
|
-
// rollout format with an owned marker must not become a fresh session.
|
|
1212
|
-
if (this.backend?.hasInvalidSessionIdentity?.(this.config.working_directory)) {
|
|
1213
|
-
throw new CodexResumeIdentityError();
|
|
1214
|
-
}
|
|
1215
1210
|
mkdirSync(this.instanceDir, { recursive: true });
|
|
1216
1211
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
1217
1212
|
this.logger.info(`Starting ${this.name}`);
|
|
@@ -1221,16 +1216,8 @@ export class Daemon extends EventEmitter {
|
|
|
1221
1216
|
if (existsSync(crashStatePath)) {
|
|
1222
1217
|
const state = JSON.parse(readFileSync(crashStatePath, "utf-8"));
|
|
1223
1218
|
if (state.resumeDisabled) {
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
// owned Codex conversation. Try once, then hold with a visible
|
|
1227
|
-
// reason rather than cycling or choosing a new session.
|
|
1228
|
-
this.logger.warn("Previous crash loop detected — preserving explicit Codex resume");
|
|
1229
|
-
}
|
|
1230
|
-
else {
|
|
1231
|
-
this.skipResume = true;
|
|
1232
|
-
this.logger.warn("Previous crash loop detected — starting without resume");
|
|
1233
|
-
}
|
|
1219
|
+
this.skipResume = true;
|
|
1220
|
+
this.logger.warn("Previous crash loop detected — starting without resume");
|
|
1234
1221
|
}
|
|
1235
1222
|
unlinkSync(crashStatePath);
|
|
1236
1223
|
}
|
|
@@ -1358,12 +1345,7 @@ export class Daemon extends EventEmitter {
|
|
|
1358
1345
|
if (savedId) {
|
|
1359
1346
|
const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
|
|
1360
1347
|
if (await oldTmux.isWindowAlive()) {
|
|
1361
|
-
this.
|
|
1362
|
-
const recoveredPane = this.backend?.binaryName === "codex"
|
|
1363
|
-
? await oldTmux.capturePane().catch(() => undefined) : undefined;
|
|
1364
|
-
const recoveredId = this.checkpointLivePaneBeforeReplacement(recoveredPane);
|
|
1365
|
-
if (this.backend?.binaryName === "codex")
|
|
1366
|
-
this.recoveredLegacyCodexSessionId = recoveredId;
|
|
1348
|
+
this.saveSessionId();
|
|
1367
1349
|
await oldTmux.killWindow();
|
|
1368
1350
|
this.logger.info({ savedId }, "Killed old tmux window for fresh start");
|
|
1369
1351
|
}
|
|
@@ -1443,10 +1425,7 @@ export class Daemon extends EventEmitter {
|
|
|
1443
1425
|
this.guardian = new ContextGuardian(this.config.context_guardian, this.logger, statusFile);
|
|
1444
1426
|
this.guardian.startWatching();
|
|
1445
1427
|
this.guardian.on("status_update", () => {
|
|
1446
|
-
|
|
1447
|
-
// callback has no pane footer, so it cannot attest the current chat.
|
|
1448
|
-
if (this.backend?.binaryName !== "codex")
|
|
1449
|
-
this.saveSessionId();
|
|
1428
|
+
this.saveSessionId();
|
|
1450
1429
|
});
|
|
1451
1430
|
// Context rotation removed: all CLI backends have built-in auto-compact.
|
|
1452
1431
|
// Crash recovery (health check + respawn with snapshot) is retained below.
|
|
@@ -1990,8 +1969,7 @@ export class Daemon extends EventEmitter {
|
|
|
1990
1969
|
await this.tmux.killWindow();
|
|
1991
1970
|
}
|
|
1992
1971
|
// Detect claude-code background session conflict — recover without counting as crash
|
|
1993
|
-
if (
|
|
1994
|
-
&& (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
|
|
1972
|
+
if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
|
|
1995
1973
|
if (!this.backgroundSessionRecoveryAttempted) {
|
|
1996
1974
|
this.backgroundSessionRecoveryAttempted = true;
|
|
1997
1975
|
this.logger.warn("Detected lingering background agent session — starting fresh (no resume)");
|
|
@@ -2032,14 +2010,6 @@ export class Daemon extends EventEmitter {
|
|
|
2032
2010
|
// and skip resume so the next spawn starts fresh. (skipResume also stops
|
|
2033
2011
|
// saveSessionId below from resurrecting the id from statusline.json.)
|
|
2034
2012
|
if (this.paneSaysNoConversation(lastOutput)) {
|
|
2035
|
-
if (this.backend?.binaryName === "codex" && this.backend.hasSessionIdentity?.()) {
|
|
2036
|
-
// Codex may have changed its rollout format or emitted an error
|
|
2037
|
-
// that only resembles this generic pattern. A persisted UUID is
|
|
2038
|
-
// never discarded by a text heuristic; preserve and hold.
|
|
2039
|
-
this.healthCheckPaused = true;
|
|
2040
|
-
this.emitSupervisionEnded("Codex could not resume its owned session", "Inspect the preserved session ID/rollout and restart this instance manually.");
|
|
2041
|
-
return;
|
|
2042
|
-
}
|
|
2043
2013
|
this.setSessionAside("cli_reported_no_conversation");
|
|
2044
2014
|
this.skipResume = true;
|
|
2045
2015
|
}
|
|
@@ -2097,10 +2067,7 @@ export class Daemon extends EventEmitter {
|
|
|
2097
2067
|
this.logger.warn({ crashCount: this.crashCount, delay }, `${cliLabel} window died — respawning after backoff`);
|
|
2098
2068
|
await new Promise(r => setTimeout(r, delay));
|
|
2099
2069
|
try {
|
|
2100
|
-
|
|
2101
|
-
// Its last verified checkpoint stays as-is (or held if ambiguous).
|
|
2102
|
-
if (this.backend?.binaryName !== "codex")
|
|
2103
|
-
this.saveSessionId();
|
|
2070
|
+
this.saveSessionId();
|
|
2104
2071
|
this.transcriptMonitor?.resetOffset();
|
|
2105
2072
|
// Kill orphan MCP server from the crashed CLI session.
|
|
2106
2073
|
// MCP server writes its PID to channel.mcp.pid on startup.
|
|
@@ -2148,16 +2115,7 @@ export class Daemon extends EventEmitter {
|
|
|
2148
2115
|
this.stormWindow?.markRecovered(this.name);
|
|
2149
2116
|
}
|
|
2150
2117
|
catch (err) {
|
|
2151
|
-
if (err
|
|
2152
|
-
// A live owner is not a broken conversation. Stop automatic
|
|
2153
|
-
// respawns and preserve the explicit session for a human retry.
|
|
2154
|
-
this.healthCheckPaused = true;
|
|
2155
|
-
this.logger.warn({ ownerPid: err instanceof CodexResumeConflictError ? err.ownerPid : null }, "Codex resume cannot continue — supervision stopped");
|
|
2156
|
-
this.emitSupervisionEnded(err.message, err instanceof CodexResumeConflictError
|
|
2157
|
-
? "Close the other Codex owner, then restart this instance."
|
|
2158
|
-
: "Inspect the Codex pane and restart this instance after resolving the startup failure.");
|
|
2159
|
-
}
|
|
2160
|
-
else if (!this.handOffBackendUnreachableRespawn(err)) {
|
|
2118
|
+
if (!this.handOffBackendUnreachableRespawn(err)) {
|
|
2161
2119
|
this.logger.error({ err }, `Failed to respawn ${cliLabel} window`);
|
|
2162
2120
|
}
|
|
2163
2121
|
}
|
|
@@ -2706,9 +2664,7 @@ export class Daemon extends EventEmitter {
|
|
|
2706
2664
|
// Quit CLI FIRST — this kills MCP server child processes cleanly.
|
|
2707
2665
|
// IPC must stay open during quit so MCP servers receive the shutdown message.
|
|
2708
2666
|
if (this.tmux) {
|
|
2709
|
-
|
|
2710
|
-
? await this.tmux.capturePane().catch(() => undefined) : undefined;
|
|
2711
|
-
this.saveSessionId(codexPane);
|
|
2667
|
+
this.saveSessionId();
|
|
2712
2668
|
this.healthCheckPaused = true;
|
|
2713
2669
|
let killed = false;
|
|
2714
2670
|
const quitSent = await this.sendQuitSequence();
|
|
@@ -2851,9 +2807,7 @@ export class Daemon extends EventEmitter {
|
|
|
2851
2807
|
this.freezeRuntimeMonitors();
|
|
2852
2808
|
const transition = (async () => {
|
|
2853
2809
|
try {
|
|
2854
|
-
|
|
2855
|
-
? await this.tmux?.capturePane().catch(() => undefined) : undefined;
|
|
2856
|
-
this.saveSessionId(codexPane);
|
|
2810
|
+
this.saveSessionId();
|
|
2857
2811
|
await this.sendQuitSequence();
|
|
2858
2812
|
let exited = false;
|
|
2859
2813
|
for (let i = 0; i < 15; i++) {
|
|
@@ -2992,13 +2946,13 @@ export class Daemon extends EventEmitter {
|
|
|
2992
2946
|
return;
|
|
2993
2947
|
}
|
|
2994
2948
|
this.instanceState = snapshot.state;
|
|
2995
|
-
// OpenCode
|
|
2949
|
+
// OpenCode creates its session lazily on the first submitted message.
|
|
2996
2950
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
2997
2951
|
// fleet is SIGKILLed or the host reboots.
|
|
2998
2952
|
// Idle observations are safe checkpoints; the 60s safety sweep also gives
|
|
2999
2953
|
// us a bounded retry if session creation produced no visible state edge.
|
|
3000
|
-
if (snapshot.state === "idle" &&
|
|
3001
|
-
this.saveSessionId(
|
|
2954
|
+
if (snapshot.state === "idle" && this.backend?.binaryName === "opencode") {
|
|
2955
|
+
this.saveSessionId();
|
|
3002
2956
|
}
|
|
3003
2957
|
// Only a transition back to idle completes pending work. Repeated idle
|
|
3004
2958
|
// observations between enqueue and paste must not clear a newer inbound.
|
|
@@ -4294,6 +4248,8 @@ export class Daemon extends EventEmitter {
|
|
|
4294
4248
|
// A false return means the cap expired with a spawn STILL running — the
|
|
4295
4249
|
// generation captured below is in-progress evidence, not settled evidence,
|
|
4296
4250
|
// so the critical section must still back out (see settledClean).
|
|
4251
|
+
if (this.spawnSettled && status)
|
|
4252
|
+
this.emit("message_queued", status);
|
|
4297
4253
|
const settledClean = await this.waitForSpawnToSettle();
|
|
4298
4254
|
// Everything captured below (window id, readiness verdicts) belongs to
|
|
4299
4255
|
// this spawn generation. A spawn that starts afterwards is detected inside
|
|
@@ -4435,6 +4391,9 @@ export class Daemon extends EventEmitter {
|
|
|
4435
4391
|
}
|
|
4436
4392
|
return this.deliverMessage(formatted, status, { ...opts, spawnRetry: retries });
|
|
4437
4393
|
}
|
|
4394
|
+
if (typeof outcome === "object") {
|
|
4395
|
+
return this.observeKiroSubmission(formatted, outcome, status, verdict, cancelled);
|
|
4396
|
+
}
|
|
4438
4397
|
if (outcome !== "dialog")
|
|
4439
4398
|
return outcome;
|
|
4440
4399
|
// Wait OUTSIDE the lock (holding it would starve the runtime dismisser),
|
|
@@ -4963,6 +4922,126 @@ export class Daemon extends EventEmitter {
|
|
|
4963
4922
|
this.logger.warn("Could not read the pane after Enter — not confirming the submission");
|
|
4964
4923
|
return sawOutput && residue === "absent";
|
|
4965
4924
|
}
|
|
4925
|
+
/** Once Kiro returns to its prompt, its unique envelope id in history proves submission. */
|
|
4926
|
+
async kiroSubmissionEvidence(windowId, formatted, signature) {
|
|
4927
|
+
if (!this.tmux)
|
|
4928
|
+
return "unreadable";
|
|
4929
|
+
let pane;
|
|
4930
|
+
try {
|
|
4931
|
+
pane = await this.tmux.capturePane();
|
|
4932
|
+
}
|
|
4933
|
+
catch {
|
|
4934
|
+
return "unreadable";
|
|
4935
|
+
}
|
|
4936
|
+
const prompt = this.backend?.getBottomReadyPattern?.();
|
|
4937
|
+
if (prompt && bottomRowIsReady(pane, prompt)
|
|
4938
|
+
&& inputShowsPastedText(inputAreaText(pane, prompt) ?? "", signature.value))
|
|
4939
|
+
return "stranded";
|
|
4940
|
+
if (prompt && pasteLeftInInput(pane, prompt, formatted))
|
|
4941
|
+
return "stranded";
|
|
4942
|
+
// During generation the same text can still be typeahead for an older
|
|
4943
|
+
// turn. A history hit is safe only after this pane returns to its prompt.
|
|
4944
|
+
if (!signature.unique || !this.tmux.capturePaneWithHistory
|
|
4945
|
+
|| !(await this.isPaneReadyForDelivery(windowId)))
|
|
4946
|
+
return "unknown";
|
|
4947
|
+
try {
|
|
4948
|
+
const history = await this.tmux.capturePaneWithHistory(300);
|
|
4949
|
+
// History includes the CURRENT input row. The earlier viewport capture
|
|
4950
|
+
// and readiness probe may belong to an older frame: Kiro can finish a
|
|
4951
|
+
// turn and expose our unsent typeahead between those reads. Judge both
|
|
4952
|
+
// readiness and input residue on this same, final snapshot before its
|
|
4953
|
+
// message id can count as a submitted transcript echo.
|
|
4954
|
+
if (!prompt || !bottomRowIsReady(history, prompt))
|
|
4955
|
+
return "unknown";
|
|
4956
|
+
if (inputShowsPastedText(inputAreaText(history, prompt) ?? "", signature.value)
|
|
4957
|
+
|| pasteLeftInInput(history, prompt, formatted))
|
|
4958
|
+
return "stranded";
|
|
4959
|
+
if (countOccurrences(history.replace(/\s+/g, ""), signature.value) > 0)
|
|
4960
|
+
return "submitted";
|
|
4961
|
+
}
|
|
4962
|
+
catch { /* missing history cannot establish a verdict */ }
|
|
4963
|
+
return "unknown";
|
|
4964
|
+
}
|
|
4965
|
+
/** Observe the Kiro paste outside paneWriteLock; a long agent turn must not block dialog handling. */
|
|
4966
|
+
async observeKiroSubmission(formatted, pending, status, verdict, cancelled) {
|
|
4967
|
+
const deadline = Date.now() + KIRO_SUBMISSION_OBSERVE_MS;
|
|
4968
|
+
let retriedStrand = false;
|
|
4969
|
+
let unreadable = 0;
|
|
4970
|
+
const current = () => !cancelled()
|
|
4971
|
+
&& !this.spawning
|
|
4972
|
+
&& this.spawnGeneration === pending.spawnGeneration
|
|
4973
|
+
&& this.getWindowId() === pending.windowId
|
|
4974
|
+
&& this.tmux?.getWindowId() === pending.windowId;
|
|
4975
|
+
for (;;) {
|
|
4976
|
+
if (!current())
|
|
4977
|
+
return false;
|
|
4978
|
+
const evidence = await this.kiroSubmissionEvidence(pending.windowId, formatted, pending.signature);
|
|
4979
|
+
if (!current())
|
|
4980
|
+
return false;
|
|
4981
|
+
if (evidence === "submitted") {
|
|
4982
|
+
if (status)
|
|
4983
|
+
this.emit("message_confirmed", status);
|
|
4984
|
+
return true;
|
|
4985
|
+
}
|
|
4986
|
+
unreadable = evidence === "unreadable" ? unreadable + 1 : 0;
|
|
4987
|
+
if (unreadable >= KIRO_SUBMISSION_UNREADABLE_POLLS) {
|
|
4988
|
+
return this.failDelivery(verdict, status, "post-submit-proof", "pane-unreadable");
|
|
4989
|
+
}
|
|
4990
|
+
if (evidence === "stranded" && await this.isPaneReadyForDelivery(pending.windowId)) {
|
|
4991
|
+
if (!current())
|
|
4992
|
+
return false;
|
|
4993
|
+
if (retriedStrand)
|
|
4994
|
+
return this.failDelivery(verdict, status, "stranded-text-retry", "stranded");
|
|
4995
|
+
const retry = await this.paneWriteLock.run(async () => {
|
|
4996
|
+
if (!current())
|
|
4997
|
+
return "cancelled";
|
|
4998
|
+
const fresh = await this.kiroSubmissionEvidence(pending.windowId, formatted, pending.signature);
|
|
4999
|
+
if (!current())
|
|
5000
|
+
return "cancelled";
|
|
5001
|
+
if (fresh === "submitted")
|
|
5002
|
+
return "submitted";
|
|
5003
|
+
if (fresh !== "stranded" || !(await this.isPaneReadyForDelivery(pending.windowId)))
|
|
5004
|
+
return "changed";
|
|
5005
|
+
if (!current())
|
|
5006
|
+
return "cancelled";
|
|
5007
|
+
const sent = await this.sendDeliveryEnter("stranded-text-retry", current);
|
|
5008
|
+
if (!current())
|
|
5009
|
+
return "cancelled";
|
|
5010
|
+
return sent ? "sent" : "failed";
|
|
5011
|
+
});
|
|
5012
|
+
if (!current())
|
|
5013
|
+
return false;
|
|
5014
|
+
if (retry === "cancelled")
|
|
5015
|
+
return false;
|
|
5016
|
+
if (retry === "submitted") {
|
|
5017
|
+
if (status)
|
|
5018
|
+
this.emit("message_confirmed", status);
|
|
5019
|
+
return true;
|
|
5020
|
+
}
|
|
5021
|
+
if (retry === "failed")
|
|
5022
|
+
return this.failDelivery(verdict, status, "stranded-text-retry", "tmux-send-keys-failed");
|
|
5023
|
+
if (retry === "sent") {
|
|
5024
|
+
retriedStrand = true;
|
|
5025
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_WINDOW_MS));
|
|
5026
|
+
continue;
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
5029
|
+
const alive = await this.tmux?.isWindowAlive().catch(() => false);
|
|
5030
|
+
if (!current())
|
|
5031
|
+
return false;
|
|
5032
|
+
if (!alive)
|
|
5033
|
+
return this.failDelivery(verdict, status, "post-submit-proof", "window-gone");
|
|
5034
|
+
if (Date.now() >= deadline) {
|
|
5035
|
+
// A live, busy pane and no positive strand are still ambiguous. The
|
|
5036
|
+
// hang detector owns hung CLI recovery; elapsed time alone is not ❌.
|
|
5037
|
+
this.logger.warn("Kiro submission remains unproven; keeping the delivery queued without a false failure");
|
|
5038
|
+
verdict.phase = "post-submit-proof";
|
|
5039
|
+
verdict.proof = "unproven";
|
|
5040
|
+
return false;
|
|
5041
|
+
}
|
|
5042
|
+
await new Promise(r => setTimeout(r, KIRO_SUBMISSION_POLL_MS));
|
|
5043
|
+
}
|
|
5044
|
+
}
|
|
4966
5045
|
/**
|
|
4967
5046
|
* While the CLI is parked on a fatal startup screen (see fatalStartupBlocked),
|
|
4968
5047
|
* no pane write may happen: paste+Enter into the corrupt-config modal would
|
|
@@ -5042,9 +5121,13 @@ export class Daemon extends EventEmitter {
|
|
|
5042
5121
|
* precisely so the lock's scope is visible at the call site rather than being
|
|
5043
5122
|
* an invariant maintained by comments.
|
|
5044
5123
|
*/
|
|
5045
|
-
async sendDeliveryEnter(phase) {
|
|
5124
|
+
async sendDeliveryEnter(phase, stillCurrent) {
|
|
5046
5125
|
if (!(await this.waitForInputTransientToClear(phase)))
|
|
5047
5126
|
return false;
|
|
5127
|
+
// A recovery Enter may have waited for a transient while cancel or spawn
|
|
5128
|
+
// replaced the delivery. Check at the last point before the tmux write.
|
|
5129
|
+
if (stillCurrent && !stillCurrent())
|
|
5130
|
+
return false;
|
|
5048
5131
|
const sent = await this.tmux.sendSpecialKey("Enter");
|
|
5049
5132
|
if (!sent) {
|
|
5050
5133
|
this.logger.error({
|
|
@@ -5269,10 +5352,20 @@ export class Daemon extends EventEmitter {
|
|
|
5269
5352
|
// F2: output after Enter is necessary but not sufficient — the paste
|
|
5270
5353
|
// must also have LEFT the input row.
|
|
5271
5354
|
let submitted = await this.confirmSubmittedAfterEnter(windowId, enterAt, formatted);
|
|
5355
|
+
if (!submitted && signature.unique) {
|
|
5356
|
+
submitted = await this.kiroSubmissionEvidence(windowId, formatted, signature) === "submitted";
|
|
5357
|
+
if (!submitted) {
|
|
5358
|
+
// No output edge is not proof of a lost message. Leave the pane
|
|
5359
|
+
// lock before waiting through a long Kiro turn, so runtime dialog
|
|
5360
|
+
// handling can still make progress.
|
|
5361
|
+
if (status)
|
|
5362
|
+
this.emit("message_queued", status);
|
|
5363
|
+
return { kind: "kiro-pending", windowId, spawnGeneration: this.spawnGeneration, signature };
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5272
5366
|
if (!submitted) {
|
|
5273
|
-
//
|
|
5274
|
-
//
|
|
5275
|
-
// bounded, since we hold the pane lock here.
|
|
5367
|
+
// Legacy system pastes have no unique envelope id. Preserve their
|
|
5368
|
+
// existing bounded retry until they can be tied to a specific turn.
|
|
5276
5369
|
this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
|
|
5277
5370
|
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
5278
5371
|
const retryAt = Date.now();
|
|
@@ -6353,25 +6446,11 @@ export class Daemon extends EventEmitter {
|
|
|
6353
6446
|
if (!this.backend) {
|
|
6354
6447
|
throw new Error("No backend configured — cannot spawn CLI window");
|
|
6355
6448
|
}
|
|
6356
|
-
const attemptedResume = !this.skipResume
|
|
6357
|
-
&& (this.backend.canResume?.(this.config.working_directory) ?? true);
|
|
6449
|
+
const attemptedResume = !this.skipResume;
|
|
6358
6450
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
6359
6451
|
// conversation must come back from the backend before anything paints).
|
|
6360
6452
|
const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
|
|
6361
6453
|
let alive = await this.trySpawn(false, resumeBudget);
|
|
6362
|
-
if (!alive && attemptedResume && this.backend.binaryName === "codex") {
|
|
6363
|
-
const status = await this.tmux?.getPaneStatus().catch(() => null);
|
|
6364
|
-
if (status?.exitCode === 75)
|
|
6365
|
-
throw new CodexResumeConflictError(null);
|
|
6366
|
-
const pane = await this.tmux?.capturePane().catch(() => "");
|
|
6367
|
-
if (pane?.includes("[agend:codex-session-held]"))
|
|
6368
|
-
throw new CodexResumeConflictError(null);
|
|
6369
|
-
if (pane && codexResumeLockVisible(pane))
|
|
6370
|
-
throw new CodexResumeConflictError(null);
|
|
6371
|
-
// A failed explicit resume is not evidence that the conversation is
|
|
6372
|
-
// unusable. Never enter the generic abandon/--fresh recovery path.
|
|
6373
|
-
throw new CodexResumeUnavailableError();
|
|
6374
|
-
}
|
|
6375
6454
|
if (!alive && attemptedResume) {
|
|
6376
6455
|
// Resume failed. Before abandoning the session:
|
|
6377
6456
|
// 1. If the backend is unreachable (fleet-level memory, or this very pane
|
|
@@ -6565,9 +6644,7 @@ export class Daemon extends EventEmitter {
|
|
|
6565
6644
|
this.logger.debug({ err }, "IPC server close failed during startup abort");
|
|
6566
6645
|
}
|
|
6567
6646
|
this.ipcServer = null;
|
|
6568
|
-
|
|
6569
|
-
// pre-existing window-id still belongs to the old pane and must survive.
|
|
6570
|
-
for (const file of this.tmux ? ["daemon.pid", "window-id"] : ["daemon.pid"]) {
|
|
6647
|
+
for (const file of ["daemon.pid", "window-id"]) {
|
|
6571
6648
|
try {
|
|
6572
6649
|
unlinkSync(join(this.instanceDir, file));
|
|
6573
6650
|
}
|
|
@@ -6594,21 +6671,13 @@ export class Daemon extends EventEmitter {
|
|
|
6594
6671
|
* Returns true if CLI is ready, false if it failed or got stuck.
|
|
6595
6672
|
*/
|
|
6596
6673
|
async trySpawn(reuseWindow = false, startupTimeoutMs) {
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
// All successful launches, including wake's trySpawn(true), cross this
|
|
6605
|
-
// point. The previous checkpoint in spawnClaudeWindow missed wake and
|
|
6606
|
-
// kept reading the dead pane PID after a resumed session changed.
|
|
6607
|
-
if (ready && !this.fatalStartupBlocked && this.backend?.binaryName === "codex" && this.tmux) {
|
|
6608
|
-
this.backend.setActivePanePid?.(await TmuxManager.getPanePid(this.tmuxSessionName, this.tmux.getWindowId()));
|
|
6609
|
-
this.saveSessionId(await this.tmux.capturePane().catch(() => undefined));
|
|
6610
|
-
}
|
|
6611
|
-
return ready;
|
|
6674
|
+
if (!this.spawnGate)
|
|
6675
|
+
return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
|
|
6676
|
+
return this.spawnGate.run({
|
|
6677
|
+
instanceName: this.name,
|
|
6678
|
+
workingDirectory: this.config.working_directory,
|
|
6679
|
+
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
6680
|
+
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
6612
6681
|
}
|
|
6613
6682
|
/**
|
|
6614
6683
|
* One fresh pane capture judged as ready && !busy && !dialog. Transcript
|
|
@@ -6844,21 +6913,6 @@ export class Daemon extends EventEmitter {
|
|
|
6844
6913
|
}
|
|
6845
6914
|
this.backend.writeConfig(backendConfig);
|
|
6846
6915
|
this.backend.preTrust?.(this.config.working_directory);
|
|
6847
|
-
// Bounded preflight for an owner left by a restart or another app. Codex
|
|
6848
|
-
// must not be launched as a competing resumer. The command's flock is the
|
|
6849
|
-
// atomic second fence for a race after this observation.
|
|
6850
|
-
if (!backendConfig.skipResume && this.backend.resumeOwner) {
|
|
6851
|
-
let owner = null;
|
|
6852
|
-
for (let attempt = 0; attempt < 3; attempt++) {
|
|
6853
|
-
owner = this.backend.resumeOwner(this.config.working_directory);
|
|
6854
|
-
if (owner === null)
|
|
6855
|
-
break;
|
|
6856
|
-
if (attempt < 2)
|
|
6857
|
-
await new Promise(r => setTimeout(r, 1_000));
|
|
6858
|
-
}
|
|
6859
|
-
if (owner !== null)
|
|
6860
|
-
throw new CodexResumeConflictError(owner);
|
|
6861
|
-
}
|
|
6862
6916
|
// Resolve working directory (e.g. symlink for hidden paths)
|
|
6863
6917
|
const resolvedCwd = this.backend.resolveWorkingDirectory?.(this.config.working_directory, this.name) ?? this.config.working_directory;
|
|
6864
6918
|
// Generate a fresh per-instance agent token each spawn. agent-cli reads
|
|
@@ -7247,34 +7301,26 @@ export class Daemon extends EventEmitter {
|
|
|
7247
7301
|
}
|
|
7248
7302
|
catch { /* best effort */ }
|
|
7249
7303
|
}
|
|
7250
|
-
|
|
7251
|
-
saveSessionId(pane) {
|
|
7304
|
+
saveSessionId() {
|
|
7252
7305
|
// When a resume failure has forced a fresh start, don't persist the stale id
|
|
7253
7306
|
// back from statusline.json — that would re-arm --continue and re-loop.
|
|
7254
7307
|
if (this.skipResume)
|
|
7255
|
-
return
|
|
7308
|
+
return;
|
|
7256
7309
|
try {
|
|
7257
|
-
const sid = this.backend?.getSessionId(
|
|
7258
|
-
if (!sid)
|
|
7259
|
-
|
|
7260
|
-
this.codexSessionIdentityHoldNotified = true;
|
|
7261
|
-
this.emit("codex_session_identity_unconfirmed", { name: this.name });
|
|
7262
|
-
}
|
|
7263
|
-
return null;
|
|
7264
|
-
}
|
|
7265
|
-
this.codexSessionIdentityHoldNotified = false;
|
|
7310
|
+
const sid = this.backend?.getSessionId();
|
|
7311
|
+
if (!sid)
|
|
7312
|
+
return;
|
|
7266
7313
|
const path = join(this.instanceDir, "session-id");
|
|
7267
7314
|
// Idle observations happen after turns. Avoid needless writes (and mtime
|
|
7268
7315
|
// churn) while still updating the marker when /new changes the session.
|
|
7269
7316
|
try {
|
|
7270
7317
|
if (readFileSync(path, "utf-8").trim() === sid)
|
|
7271
|
-
return
|
|
7318
|
+
return;
|
|
7272
7319
|
}
|
|
7273
7320
|
catch { /* first checkpoint */ }
|
|
7274
7321
|
writeFileSync(path, sid);
|
|
7275
7322
|
this.sessionCheckpointWarningEmitted = false;
|
|
7276
7323
|
this.logger.debug("Session id checkpointed");
|
|
7277
|
-
return sid;
|
|
7278
7324
|
}
|
|
7279
7325
|
catch (err) {
|
|
7280
7326
|
// Session discovery is best-effort and must never break the pane state
|
|
@@ -7283,24 +7329,8 @@ export class Daemon extends EventEmitter {
|
|
|
7283
7329
|
this.sessionCheckpointWarningEmitted = true;
|
|
7284
7330
|
this.logger.warn({ err: err.message }, "Session id checkpoint failed");
|
|
7285
7331
|
}
|
|
7286
|
-
return null;
|
|
7287
7332
|
}
|
|
7288
7333
|
}
|
|
7289
|
-
checkpointLivePaneBeforeReplacement(pane) {
|
|
7290
|
-
// A pre-upgrade Codex pane can provide stronger, exact ownership
|
|
7291
|
-
// evidence than an old crash-loop heuristic. Discover it before
|
|
7292
|
-
// saveSessionId's skip-resume guard, then arm resume only if the
|
|
7293
|
-
// checkpoint was actually durable. Never fall back to --last.
|
|
7294
|
-
const exactCodexId = this.backend?.binaryName === "codex" ? this.backend.getSessionId(pane) : null;
|
|
7295
|
-
if (exactCodexId)
|
|
7296
|
-
this.skipResume = false;
|
|
7297
|
-
const recoveredId = this.saveSessionId(pane);
|
|
7298
|
-
if (exactCodexId && !recoveredId)
|
|
7299
|
-
this.skipResume = true;
|
|
7300
|
-
return recoveredId;
|
|
7301
|
-
}
|
|
7302
|
-
/** Only a live pre-upgrade pane can provide this positive migration proof. */
|
|
7303
|
-
getRecoveredLegacyCodexSessionId() { return this.recoveredLegacyCodexSessionId; }
|
|
7304
7334
|
readContextPercentage() {
|
|
7305
7335
|
return this.backend?.getContextUsage() ?? 0;
|
|
7306
7336
|
}
|