@sayknow-cli/coding-agent 0.5.0 → 0.5.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/CHANGELOG.md +157 -0
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/tools/ask.d.ts +164 -4
- package/package.json +10 -7
- package/src/commands/session.ts +88 -2
- package/src/config/model-registry.ts +12 -0
- package/src/config/telegram-autostart.ts +11 -4
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/session/agent-session.ts +31 -11
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/ask.ts +183 -10
- package/src/tools/eval.ts +2 -2
|
@@ -40,21 +40,27 @@ import {
|
|
|
40
40
|
resolveSkcTmuxCommand,
|
|
41
41
|
} from "../../skc-runtime/tmux-common";
|
|
42
42
|
import {
|
|
43
|
+
abandonIdentityCreate,
|
|
44
|
+
advanceIdentityCreatePhase,
|
|
43
45
|
captureOwnerGenerationBaseline,
|
|
44
46
|
classifyCgroup,
|
|
45
47
|
isExactScopedBootstrapSuccessReceipt,
|
|
46
48
|
type OwnerGenerationBaseline,
|
|
47
49
|
type OwnerIsolationProbe,
|
|
48
50
|
planTmuxOwnerIsolation,
|
|
51
|
+
releaseIdentityCreate,
|
|
49
52
|
replaceOwnerGeneration,
|
|
53
|
+
reserveIdentityCreate,
|
|
50
54
|
resolveManagedOwnerPredecessorSync,
|
|
51
55
|
type TmuxServerProof,
|
|
52
56
|
} from "../../skc-runtime/tmux-owner-isolation";
|
|
53
57
|
import {
|
|
58
|
+
abandonedIdentityCensus,
|
|
54
59
|
findSkcTmuxSessionByName,
|
|
55
60
|
forceCloseSkcTmuxSession,
|
|
56
61
|
listSkcTmuxSessions,
|
|
57
62
|
type SkcTmuxSessionStatus,
|
|
63
|
+
settleAbandonedIdentity,
|
|
58
64
|
} from "../../skc-runtime/tmux-sessions";
|
|
59
65
|
import type {
|
|
60
66
|
LifecycleErrorReason,
|
|
@@ -873,66 +879,103 @@ export function daemonSpawnCreate(
|
|
|
873
879
|
const { cwd, args } = buildCreateArgv(frame, ids);
|
|
874
880
|
const sessionStateFile = lifecycleRuntimeStateFile(cwd, ids.intendedSessionId, name);
|
|
875
881
|
const stateDir = path.dirname(sessionStateFile);
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
const
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
:
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
882
|
+
// Shared identity create fence, before the final baseline capture and any
|
|
883
|
+
// spawn preparation. The SDK-bus producer is a separate implementation
|
|
884
|
+
// from the notification one and must acquire it in its own right.
|
|
885
|
+
const identityKey = { stateDir, sessionId: ids.intendedSessionId, stateFile: sessionStateFile };
|
|
886
|
+
const reserved = reserveIdentityCreate(identityKey);
|
|
887
|
+
// A dead previous owner that reached the helper may have left an untagged
|
|
888
|
+
// child. Resolve it authority-first instead of treating the row as a
|
|
889
|
+
// permanent collision, which would brick this identity forever.
|
|
890
|
+
const settled = settleAbandonedIdentity(
|
|
891
|
+
reserved,
|
|
892
|
+
identityKey,
|
|
893
|
+
abandonedIdentityCensus(tmux, env, sessionStateFile),
|
|
894
|
+
);
|
|
895
|
+
// Fail closed: an unreachable fence cannot honor the one-create guarantee.
|
|
896
|
+
if (!settled.ok) throw new Error(`skc_lifecycle_identity_create_${settled.code}:${settled.diagnostic}`);
|
|
897
|
+
let reservation = settled.reservation;
|
|
898
|
+
let cleanupUncertain = false;
|
|
899
|
+
try {
|
|
900
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
|
|
901
|
+
const predecessor = resolveManagedOwnerPredecessorSync(stateDir, ids.intendedSessionId, previousBaseline);
|
|
902
|
+
const generation = crypto.randomUUID();
|
|
903
|
+
const runId = crypto.randomUUID();
|
|
904
|
+
const incarnation = crypto.randomUUID();
|
|
905
|
+
// Detached: no interactive TTY needed (daemon-safe). These values contain
|
|
906
|
+
// only opaque ids and paths needed by the resident sidecar to publish its
|
|
907
|
+
// exact-owner terminal verdict.
|
|
908
|
+
const childEnv: Record<string, string> = {
|
|
909
|
+
SKC_TMUX_LAUNCHED: "1",
|
|
910
|
+
SKC_NOTIFICATIONS: "1",
|
|
911
|
+
SKC_SESSION_ID: ids.intendedSessionId,
|
|
912
|
+
SKC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
|
|
913
|
+
[SKC_COORDINATOR_SESSION_ID_ENV]: ids.intendedSessionId,
|
|
914
|
+
[SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
|
|
915
|
+
[SKC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
916
|
+
[SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
917
|
+
[SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
918
|
+
SKC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["skc", ...args]),
|
|
919
|
+
[MANAGED_OWNER_RUN_ID_ENV]: runId,
|
|
920
|
+
[MANAGED_OWNER_INCARNATION_ENV]: incarnation,
|
|
921
|
+
...(predecessor
|
|
922
|
+
? {
|
|
923
|
+
[MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
|
|
924
|
+
[MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
|
|
925
|
+
[MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
|
|
926
|
+
[MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
|
|
927
|
+
[MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.SKC_SESSION_FILE ?? "",
|
|
928
|
+
}
|
|
929
|
+
: {}),
|
|
930
|
+
};
|
|
931
|
+
if (ids.startupPromptRef) childEnv.SKC_STARTUP_PROMPT_REF = ids.startupPromptRef;
|
|
932
|
+
const envPairs = Object.entries(childEnv)
|
|
933
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
934
|
+
.join(" ");
|
|
935
|
+
const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} skc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
|
|
936
|
+
// Record the attempt name BEFORE anything can spawn. Without this the
|
|
937
|
+
// reservation stays at `reserved`, and a successor would read a crash
|
|
938
|
+
// here as "no child can exist" and reclaim an identity that already
|
|
939
|
+
// has an untagged child.
|
|
940
|
+
reservation =
|
|
941
|
+
advanceIdentityCreatePhase(reservation, "helper_invoked", { attemptSessionName: name }) ??
|
|
942
|
+
(() => {
|
|
943
|
+
throw new Error("skc_lifecycle_identity_create_fence_lost");
|
|
944
|
+
})();
|
|
945
|
+
await completeLifecycleSpawnTransaction({
|
|
946
|
+
tmux,
|
|
947
|
+
env,
|
|
948
|
+
sessionId: ids.intendedSessionId,
|
|
949
|
+
generation,
|
|
950
|
+
stateDir,
|
|
951
|
+
cwd,
|
|
952
|
+
sessionName: name,
|
|
953
|
+
sessionStateFile,
|
|
954
|
+
argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
|
|
955
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
956
|
+
prepareSpawn: () => {
|
|
957
|
+
if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
|
|
958
|
+
},
|
|
959
|
+
previousBaseline,
|
|
960
|
+
});
|
|
928
961
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
962
|
+
return {
|
|
963
|
+
sessionId: ids.intendedSessionId,
|
|
964
|
+
tmuxSession: name,
|
|
965
|
+
sessionStateFile,
|
|
966
|
+
endpointUrl: "",
|
|
967
|
+
topicThreadId: "",
|
|
968
|
+
};
|
|
969
|
+
} catch (error) {
|
|
970
|
+
// `skc_lifecycle_cleanup_uncertain` means a child may survive that we
|
|
971
|
+
// could not remove. Keep the reservation so its evidence outlives us.
|
|
972
|
+
if (error instanceof AggregateError && /cleanup_uncertain/.test(String(error.message)))
|
|
973
|
+
cleanupUncertain = true;
|
|
974
|
+
throw error;
|
|
975
|
+
} finally {
|
|
976
|
+
if (cleanupUncertain) abandonIdentityCreate(reservation);
|
|
977
|
+
else releaseIdentityCreate(reservation);
|
|
978
|
+
}
|
|
936
979
|
};
|
|
937
980
|
}
|
|
938
981
|
|
|
@@ -1086,58 +1129,94 @@ export function daemonResumeSession(
|
|
|
1086
1129
|
const name = tmuxSessionNameFor(resumeId);
|
|
1087
1130
|
const sessionStateFile = lifecycleRuntimeStateFile(resolvedResumeCwd, resumeId, name);
|
|
1088
1131
|
const stateDir = path.dirname(sessionStateFile);
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
const
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
:
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
+
// Same shared fence: the cold restart reuses the historical id, so it can
|
|
1133
|
+
// collide with a restore of that exact identity.
|
|
1134
|
+
const identityKey = { stateDir, sessionId: resumeId, stateFile: sessionStateFile };
|
|
1135
|
+
const reserved = reserveIdentityCreate(identityKey);
|
|
1136
|
+
// A dead previous owner that reached the helper may have left an untagged
|
|
1137
|
+
// child. Resolve it authority-first instead of treating the row as a
|
|
1138
|
+
// permanent collision, which would brick this identity forever.
|
|
1139
|
+
const settled = settleAbandonedIdentity(
|
|
1140
|
+
reserved,
|
|
1141
|
+
identityKey,
|
|
1142
|
+
abandonedIdentityCensus(tmux, env, sessionStateFile),
|
|
1143
|
+
);
|
|
1144
|
+
// Fail closed: an unreachable fence cannot honor the one-create guarantee.
|
|
1145
|
+
if (!settled.ok) throw new Error(`skc_lifecycle_identity_create_${settled.code}:${settled.diagnostic}`);
|
|
1146
|
+
let reservation = settled.reservation;
|
|
1147
|
+
let cleanupUncertain = false;
|
|
1148
|
+
try {
|
|
1149
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
|
|
1150
|
+
const predecessor = resolveManagedOwnerPredecessorSync(stateDir, resumeId, previousBaseline);
|
|
1151
|
+
const generation = crypto.randomUUID();
|
|
1152
|
+
const runId = crypto.randomUUID();
|
|
1153
|
+
const incarnation = crypto.randomUUID();
|
|
1154
|
+
const childEnv: Record<string, string> = {
|
|
1155
|
+
SKC_TMUX_LAUNCHED: "1",
|
|
1156
|
+
SKC_NOTIFICATIONS: "1",
|
|
1157
|
+
[SKC_COORDINATOR_SESSION_ID_ENV]: resumeId,
|
|
1158
|
+
[SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
|
|
1159
|
+
[SKC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
1160
|
+
[SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
1161
|
+
[SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
1162
|
+
SKC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["skc", "--resume", resumeId]),
|
|
1163
|
+
[MANAGED_OWNER_RUN_ID_ENV]: runId,
|
|
1164
|
+
[MANAGED_OWNER_INCARNATION_ENV]: incarnation,
|
|
1165
|
+
...(predecessor
|
|
1166
|
+
? {
|
|
1167
|
+
[MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
|
|
1168
|
+
[MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
|
|
1169
|
+
[MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
|
|
1170
|
+
[MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
|
|
1171
|
+
[MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.SKC_SESSION_FILE ?? "",
|
|
1172
|
+
}
|
|
1173
|
+
: {}),
|
|
1174
|
+
};
|
|
1175
|
+
const envPairs = Object.entries(childEnv)
|
|
1176
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
1177
|
+
.join(" ");
|
|
1178
|
+
const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} skc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
|
|
1179
|
+
// Record the attempt name BEFORE anything can spawn. Without this the
|
|
1180
|
+
// reservation stays at `reserved`, and a successor would read a crash
|
|
1181
|
+
// here as "no child can exist" and reclaim an identity that already
|
|
1182
|
+
// has an untagged child.
|
|
1183
|
+
reservation =
|
|
1184
|
+
advanceIdentityCreatePhase(reservation, "helper_invoked", { attemptSessionName: name }) ??
|
|
1185
|
+
(() => {
|
|
1186
|
+
throw new Error("skc_lifecycle_identity_create_fence_lost");
|
|
1187
|
+
})();
|
|
1188
|
+
await completeLifecycleSpawnTransaction({
|
|
1189
|
+
tmux,
|
|
1190
|
+
env,
|
|
1191
|
+
sessionId: resumeId,
|
|
1192
|
+
generation,
|
|
1193
|
+
stateDir,
|
|
1194
|
+
cwd: resolvedResumeCwd,
|
|
1195
|
+
sessionName: name,
|
|
1196
|
+
sessionStateFile,
|
|
1197
|
+
argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
|
|
1198
|
+
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
1199
|
+
previousBaseline,
|
|
1200
|
+
});
|
|
1132
1201
|
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1202
|
+
return {
|
|
1203
|
+
sessionId: resumeId,
|
|
1204
|
+
tmuxSession: name,
|
|
1205
|
+
sessionStateFile,
|
|
1206
|
+
endpointUrl: "",
|
|
1207
|
+
topicThreadId: "",
|
|
1208
|
+
mode: "cold_restarted",
|
|
1209
|
+
};
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
// `skc_lifecycle_cleanup_uncertain` means a child may survive that we
|
|
1212
|
+
// could not remove. Keep the reservation so its evidence outlives us.
|
|
1213
|
+
if (error instanceof AggregateError && /cleanup_uncertain/.test(String(error.message)))
|
|
1214
|
+
cleanupUncertain = true;
|
|
1215
|
+
throw error;
|
|
1216
|
+
} finally {
|
|
1217
|
+
if (cleanupUncertain) abandonIdentityCreate(reservation);
|
|
1218
|
+
else releaseIdentityCreate(reservation);
|
|
1219
|
+
}
|
|
1141
1220
|
};
|
|
1142
1221
|
}
|
|
1143
1222
|
|
|
@@ -1547,6 +1547,27 @@ type SessionAdmissionLease = {
|
|
|
1547
1547
|
release(): void;
|
|
1548
1548
|
};
|
|
1549
1549
|
|
|
1550
|
+
const TODO_WRITE_INFRASTRUCTURE_ERROR =
|
|
1551
|
+
/(?:HTTP2StreamReset|ECONNRESET|ECONNREFUSED|ETIMEDOUT|network error|transport error|connection reset|stream reset|timed out|timeout|fetching https?:)/iu;
|
|
1552
|
+
const TODO_WRITE_ERROR_CONTEXT_LIMIT = 1_000;
|
|
1553
|
+
|
|
1554
|
+
export function buildTodoWriteFailureReminder(errorText: string | undefined, failureCount: number): string {
|
|
1555
|
+
const normalizedError = errorText?.trim().slice(0, TODO_WRITE_ERROR_CONTEXT_LIMIT);
|
|
1556
|
+
const shouldRetry =
|
|
1557
|
+
failureCount === 1 && (!normalizedError || !TODO_WRITE_INFRASTRUCTURE_ERROR.test(normalizedError));
|
|
1558
|
+
const directive = shouldRetry
|
|
1559
|
+
? "Correct the todo_write payload and retry once with a minimal argument object. If that retry fails, do not call todo_write again in this user turn; continue the requested work using a checklist in reasoning."
|
|
1560
|
+
: "Do not call todo_write again in this user turn. Continue the requested work now, track the remaining steps in reasoning, and report progress directly.";
|
|
1561
|
+
|
|
1562
|
+
return [
|
|
1563
|
+
"<system-reminder>",
|
|
1564
|
+
"todo_write failed, so todo progress is not visible to the user.",
|
|
1565
|
+
normalizedError ? `Failure: ${normalizedError}` : "Failure: todo_write returned an error.",
|
|
1566
|
+
directive,
|
|
1567
|
+
"</system-reminder>",
|
|
1568
|
+
].join("\n");
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1550
1571
|
export class AgentSession {
|
|
1551
1572
|
readonly agent: Agent;
|
|
1552
1573
|
readonly sessionManager: SessionManager;
|
|
@@ -1740,6 +1761,7 @@ export class AgentSession {
|
|
|
1740
1761
|
#fallbackInvocationId = 0;
|
|
1741
1762
|
// Todo completion reminder state
|
|
1742
1763
|
#todoReminderCount = 0;
|
|
1764
|
+
#todoWriteFailureCount = 0;
|
|
1743
1765
|
#deepInterviewUserIntentEpoch = 0;
|
|
1744
1766
|
#deepInterviewTurnOwnerEpoch = 0;
|
|
1745
1767
|
#deepInterviewGenuineUserMessageEpochs = new WeakMap<object, number>();
|
|
@@ -3794,24 +3816,21 @@ export class AgentSession {
|
|
|
3794
3816
|
if (toolName === "edit" && details?.path) {
|
|
3795
3817
|
this.#invalidateFileCacheForPath(details.path);
|
|
3796
3818
|
}
|
|
3797
|
-
if (toolName === "todo_write" && !isError
|
|
3798
|
-
this
|
|
3819
|
+
if (toolName === "todo_write" && !isError) {
|
|
3820
|
+
this.#todoWriteFailureCount = 0;
|
|
3821
|
+
if (Array.isArray(details?.phases)) {
|
|
3822
|
+
this.setTodoPhases(details.phases);
|
|
3823
|
+
}
|
|
3799
3824
|
}
|
|
3800
3825
|
if (toolName === "todo_write" && isError) {
|
|
3826
|
+
this.#todoWriteFailureCount++;
|
|
3801
3827
|
const errorText = content?.find(part => part.type === "text")?.text;
|
|
3802
|
-
const reminderText = [
|
|
3803
|
-
"<system-reminder>",
|
|
3804
|
-
"todo_write failed, so todo progress is not visible to the user.",
|
|
3805
|
-
errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
|
|
3806
|
-
"Fix the todo payload and call todo_write again before continuing.",
|
|
3807
|
-
"</system-reminder>",
|
|
3808
|
-
].join("\n");
|
|
3809
3828
|
await this.sendCustomMessage(
|
|
3810
3829
|
{
|
|
3811
3830
|
customType: "todo-write-error-reminder",
|
|
3812
|
-
content:
|
|
3831
|
+
content: buildTodoWriteFailureReminder(errorText, this.#todoWriteFailureCount),
|
|
3813
3832
|
display: false,
|
|
3814
|
-
details: { toolName, errorText },
|
|
3833
|
+
details: { toolName, errorText, failureCount: this.#todoWriteFailureCount },
|
|
3815
3834
|
},
|
|
3816
3835
|
{ deliverAs: "nextTurn" },
|
|
3817
3836
|
);
|
|
@@ -7793,6 +7812,7 @@ export class AgentSession {
|
|
|
7793
7812
|
this.#defaultFallbackChain().resetAttemptBudget();
|
|
7794
7813
|
this.#overflowMaintenanceAttempts = 0;
|
|
7795
7814
|
this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
|
|
7815
|
+
this.#todoWriteFailureCount = 0;
|
|
7796
7816
|
}
|
|
7797
7817
|
// Flush any pending bash messages before the new prompt
|
|
7798
7818
|
this.#flushPendingBashMessages();
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-generation evidence for reboot-only session restore.
|
|
3
|
+
*
|
|
4
|
+
* Restore exists to survive a reboot, and only a reboot. The eligibility rule is
|
|
5
|
+
* therefore narrow on purpose: a session may be restored only when the recorded
|
|
6
|
+
* and current boot values come from the SAME source and differ. Anything else —
|
|
7
|
+
* equal values, a missing or malformed record, an unreadable probe, or two
|
|
8
|
+
* different sources — is inconclusive and must never spawn.
|
|
9
|
+
*
|
|
10
|
+
* The same-source requirement is not pedantry. Linux can report `boot-id` at
|
|
11
|
+
* record time and fall back to `proc-btime` later; those two values are always
|
|
12
|
+
* unequal even on one boot, so comparing across sources would read every launch
|
|
13
|
+
* as a reboot and duplicate live sessions.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fsSync from "node:fs";
|
|
17
|
+
|
|
18
|
+
/** Where a boot value came from. Values are only ever compared within one source. */
|
|
19
|
+
export type BootGenerationSource = "darwin-kern-boottime" | "linux-boot-id" | "linux-proc-btime" | "unavailable";
|
|
20
|
+
|
|
21
|
+
export interface BootGeneration {
|
|
22
|
+
source: BootGenerationSource;
|
|
23
|
+
/** Opaque; only equality within the same source is meaningful. */
|
|
24
|
+
value: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BootComparison = "changed" | "same_boot" | "boot_unknown";
|
|
28
|
+
|
|
29
|
+
const BOOT_GENERATION_SCHEMA_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
export interface RecordedBootGeneration {
|
|
32
|
+
schema_version: number;
|
|
33
|
+
source: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BootGenerationProbeDeps {
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
readFile?: (file: string) => string;
|
|
40
|
+
runCommand?: (command: string, args: string[]) => { exitCode: number | null; stdout: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultReadFile(file: string): string {
|
|
44
|
+
return fsSync.readFileSync(file, "utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultRunCommand(command: string, args: string[]): { exitCode: number | null; stdout: string } {
|
|
48
|
+
const result = Bun.spawnSync({ cmd: [command, ...args], stdout: "pipe", stderr: "ignore" });
|
|
49
|
+
return { exitCode: result.exitCode, stdout: result.stdout.toString() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `{ sec = 1785305532, usec = 377197 } Wed Jul 29 ...` -> `1785305532.377197`. */
|
|
53
|
+
export function parseDarwinBootTime(raw: string): string | null {
|
|
54
|
+
const match = /sec\s*=\s*(\d+)\s*,\s*usec\s*=\s*(\d+)/u.exec(raw);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
const sec = match[1];
|
|
57
|
+
const usec = match[2];
|
|
58
|
+
if (!sec || !usec) return null;
|
|
59
|
+
// A microsecond field at or above 1e6 is not a time this kernel produced;
|
|
60
|
+
// accepting it would let a malformed reading masquerade as a distinct boot.
|
|
61
|
+
if (Number.parseInt(usec, 10) >= 1_000_000) return null;
|
|
62
|
+
return `${sec}.${usec.padStart(6, "0")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A boot id is a UUID that changes on every boot; anything else is not usable. */
|
|
66
|
+
export function parseLinuxBootId(raw: string): string | null {
|
|
67
|
+
const value = raw.trim();
|
|
68
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value) ? value : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `/proc/stat` carries `btime <seconds>` once, as the kernel boot wall-clock. */
|
|
72
|
+
export function parseLinuxProcBtime(raw: string): string | null {
|
|
73
|
+
const values: string[] = [];
|
|
74
|
+
for (const line of raw.split("\n")) {
|
|
75
|
+
const match = /^btime\s+(\d+)\s*$/u.exec(line);
|
|
76
|
+
if (match?.[1]) values.push(match[1]);
|
|
77
|
+
}
|
|
78
|
+
// The kernel reports btime exactly once. More than one means the input is not
|
|
79
|
+
// a `/proc/stat` we can reason about, so refuse rather than pick one.
|
|
80
|
+
return values.length === 1 ? (values[0] ?? null) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reads the strongest boot evidence this platform offers.
|
|
85
|
+
*
|
|
86
|
+
* Windows returns `unavailable`: restore is unsupported there (no immutable
|
|
87
|
+
* native tmux session identity), so there is nothing to gate.
|
|
88
|
+
*/
|
|
89
|
+
export function readBootGeneration(deps: BootGenerationProbeDeps = {}): BootGeneration {
|
|
90
|
+
const platform = deps.platform ?? process.platform;
|
|
91
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
92
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
93
|
+
|
|
94
|
+
if (platform === "darwin") {
|
|
95
|
+
try {
|
|
96
|
+
const probed = runCommand("sysctl", ["-n", "kern.boottime"]);
|
|
97
|
+
if (probed.exitCode !== 0) return { source: "unavailable", value: null };
|
|
98
|
+
const value = parseDarwinBootTime(probed.stdout);
|
|
99
|
+
return value ? { source: "darwin-kern-boottime", value } : { source: "unavailable", value: null };
|
|
100
|
+
} catch {
|
|
101
|
+
return { source: "unavailable", value: null };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (platform === "linux") {
|
|
106
|
+
try {
|
|
107
|
+
const value = parseLinuxBootId(readFile("/proc/sys/kernel/random/boot_id"));
|
|
108
|
+
if (value) return { source: "linux-boot-id", value };
|
|
109
|
+
} catch {
|
|
110
|
+
// Fall through: some hardened kernels hide boot_id.
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const value = parseLinuxProcBtime(readFile("/proc/stat"));
|
|
114
|
+
if (value) return { source: "linux-proc-btime", value };
|
|
115
|
+
} catch {
|
|
116
|
+
// Fall through to unavailable.
|
|
117
|
+
}
|
|
118
|
+
return { source: "unavailable", value: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { source: "unavailable", value: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Each source produces one exact shape. Validating it is what stops a value that
|
|
126
|
+
* merely differs textually — trailing space, a zero-width character — from being
|
|
127
|
+
* read as a different boot.
|
|
128
|
+
*/
|
|
129
|
+
const BOOT_VALUE_SHAPE: Record<string, RegExp> = {
|
|
130
|
+
"darwin-kern-boottime": /^\d+\.\d{6}$/u,
|
|
131
|
+
"linux-boot-id": /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u,
|
|
132
|
+
"linux-proc-btime": /^\d+$/u,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function isBootValueWellFormed(source: string, value: string): boolean {
|
|
136
|
+
return BOOT_VALUE_SHAPE[source]?.test(value) ?? false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isRecordedBootGeneration(value: unknown): value is RecordedBootGeneration {
|
|
140
|
+
if (typeof value !== "object" || value === null) return false;
|
|
141
|
+
const record = value as Record<string, unknown>;
|
|
142
|
+
return (
|
|
143
|
+
record.schema_version === BOOT_GENERATION_SCHEMA_VERSION &&
|
|
144
|
+
typeof record.source === "string" &&
|
|
145
|
+
record.source.length > 0 &&
|
|
146
|
+
record.source !== "unavailable" &&
|
|
147
|
+
typeof record.value === "string" &&
|
|
148
|
+
record.value.length > 0 &&
|
|
149
|
+
isBootValueWellFormed(record.source, record.value)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function recordBootGeneration(current: BootGeneration): RecordedBootGeneration | null {
|
|
154
|
+
if (current.source === "unavailable" || !current.value) return null;
|
|
155
|
+
if (!isBootValueWellFormed(current.source, current.value)) return null;
|
|
156
|
+
return { schema_version: BOOT_GENERATION_SCHEMA_VERSION, source: current.source, value: current.value };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Decides whether the machine rebooted since the session was recorded.
|
|
161
|
+
*
|
|
162
|
+
* `changed` is the ONLY executable answer. It requires a well-formed record, a
|
|
163
|
+
* readable current probe, an identical source, and different values. Source
|
|
164
|
+
* mismatch is deliberately `boot_unknown` rather than `changed`.
|
|
165
|
+
*/
|
|
166
|
+
export function compareBootGeneration(recorded: unknown, current: BootGeneration): BootComparison {
|
|
167
|
+
if (!isRecordedBootGeneration(recorded)) return "boot_unknown";
|
|
168
|
+
if (current.source === "unavailable" || !current.value) return "boot_unknown";
|
|
169
|
+
if (!isBootValueWellFormed(current.source, current.value)) return "boot_unknown";
|
|
170
|
+
if (recorded.source !== current.source) return "boot_unknown";
|
|
171
|
+
return recorded.value === current.value ? "same_boot" : "changed";
|
|
172
|
+
}
|