@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/dist/types/commands/session.d.ts +7 -0
  3. package/dist/types/config/telegram-autostart.d.ts +9 -1
  4. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  5. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  6. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  7. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  8. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  9. package/dist/types/session/agent-session.d.ts +1 -0
  10. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  11. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  12. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  13. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  14. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  15. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  16. package/dist/types/tools/ask.d.ts +164 -4
  17. package/package.json +10 -7
  18. package/src/commands/session.ts +88 -2
  19. package/src/config/model-registry.ts +12 -0
  20. package/src/config/telegram-autostart.ts +11 -4
  21. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  22. package/src/internal-urls/docs-index.generated.ts +1 -1
  23. package/src/main.ts +1 -1
  24. package/src/modes/components/pet-capability.ts +22 -13
  25. package/src/modes/components/pet-selector.ts +1 -1
  26. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  27. package/src/modes/controllers/event-controller.ts +1 -1
  28. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  29. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  30. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  31. package/src/prompts/system/eager-todo.md +2 -0
  32. package/src/prompts/system/plan-mode-approved.md +1 -1
  33. package/src/prompts/system/system-prompt.md +4 -2
  34. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  35. package/src/session/agent-session.ts +31 -11
  36. package/src/skc-runtime/boot-generation.ts +172 -0
  37. package/src/skc-runtime/launch-tmux.ts +219 -41
  38. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  39. package/src/skc-runtime/session-restore.ts +296 -0
  40. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  41. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  42. package/src/skc-runtime/tmux-sessions.ts +284 -108
  43. package/src/slash-commands/builtin-registry.ts +9 -4
  44. package/src/tools/ask.ts +183 -10
  45. 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
- const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
877
- const predecessor = resolveManagedOwnerPredecessorSync(stateDir, ids.intendedSessionId, previousBaseline);
878
- const generation = crypto.randomUUID();
879
- const runId = crypto.randomUUID();
880
- const incarnation = crypto.randomUUID();
881
- // Detached: no interactive TTY needed (daemon-safe). These values contain
882
- // only opaque ids and paths needed by the resident sidecar to publish its
883
- // exact-owner terminal verdict.
884
- const childEnv: Record<string, string> = {
885
- SKC_TMUX_LAUNCHED: "1",
886
- SKC_NOTIFICATIONS: "1",
887
- SKC_SESSION_ID: ids.intendedSessionId,
888
- SKC_LIFECYCLE_REQUEST_ID: ids.lifecycleRequestId,
889
- [SKC_COORDINATOR_SESSION_ID_ENV]: ids.intendedSessionId,
890
- [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
891
- [SKC_TMUX_OWNER_GENERATION_ENV]: generation,
892
- [SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
893
- [SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
894
- SKC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["skc", ...args]),
895
- [MANAGED_OWNER_RUN_ID_ENV]: runId,
896
- [MANAGED_OWNER_INCARNATION_ENV]: incarnation,
897
- ...(predecessor
898
- ? {
899
- [MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
900
- [MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
901
- [MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
902
- [MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
903
- [MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.SKC_SESSION_FILE ?? "",
904
- }
905
- : {}),
906
- };
907
- if (ids.startupPromptRef) childEnv.SKC_STARTUP_PROMPT_REF = ids.startupPromptRef;
908
- const envPairs = Object.entries(childEnv)
909
- .map(([key, value]) => `${key}=${shellQuote(value)}`)
910
- .join(" ");
911
- const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} skc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
912
- await completeLifecycleSpawnTransaction({
913
- tmux,
914
- env,
915
- sessionId: ids.intendedSessionId,
916
- generation,
917
- stateDir,
918
- cwd,
919
- sessionName: name,
920
- sessionStateFile,
921
- argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
922
- ownerIsolationProbe: opts.ownerIsolationProbe,
923
- prepareSpawn: () => {
924
- if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
925
- },
926
- previousBaseline,
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
- return {
930
- sessionId: ids.intendedSessionId,
931
- tmuxSession: name,
932
- sessionStateFile,
933
- endpointUrl: "",
934
- topicThreadId: "",
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
- const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
1090
- const predecessor = resolveManagedOwnerPredecessorSync(stateDir, resumeId, previousBaseline);
1091
- const generation = crypto.randomUUID();
1092
- const runId = crypto.randomUUID();
1093
- const incarnation = crypto.randomUUID();
1094
- const childEnv: Record<string, string> = {
1095
- SKC_TMUX_LAUNCHED: "1",
1096
- SKC_NOTIFICATIONS: "1",
1097
- [SKC_COORDINATOR_SESSION_ID_ENV]: resumeId,
1098
- [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: sessionStateFile,
1099
- [SKC_TMUX_OWNER_GENERATION_ENV]: generation,
1100
- [SKC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
1101
- [SKC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
1102
- SKC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["skc", "--resume", resumeId]),
1103
- [MANAGED_OWNER_RUN_ID_ENV]: runId,
1104
- [MANAGED_OWNER_INCARNATION_ENV]: incarnation,
1105
- ...(predecessor
1106
- ? {
1107
- [MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
1108
- [MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
1109
- [MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
1110
- [MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
1111
- [MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.SKC_SESSION_FILE ?? "",
1112
- }
1113
- : {}),
1114
- };
1115
- const envPairs = Object.entries(childEnv)
1116
- .map(([key, value]) => `${key}=${shellQuote(value)}`)
1117
- .join(" ");
1118
- const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} skc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
1119
- await completeLifecycleSpawnTransaction({
1120
- tmux,
1121
- env,
1122
- sessionId: resumeId,
1123
- generation,
1124
- stateDir,
1125
- cwd: resolvedResumeCwd,
1126
- sessionName: name,
1127
- sessionStateFile,
1128
- argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
1129
- ownerIsolationProbe: opts.ownerIsolationProbe,
1130
- previousBaseline,
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
- return {
1134
- sessionId: resumeId,
1135
- tmuxSession: name,
1136
- sessionStateFile,
1137
- endpointUrl: "",
1138
- topicThreadId: "",
1139
- mode: "cold_restarted",
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 && Array.isArray(details?.phases)) {
3798
- this.setTodoPhases(details.phases);
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: reminderText,
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
+ }