@sayknow-cli/coding-agent 0.5.1 → 0.5.6

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 (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -88,6 +88,10 @@ function redactConfigValue(path: string, value: unknown, showSecrets?: boolean):
88
88
  }
89
89
  return REDACTED_SECRET_VALUE;
90
90
  }
91
+ function reportConfigPersistenceFailure(): never {
92
+ console.error(chalk.red("Failed to persist configuration."));
93
+ process.exit(1);
94
+ }
91
95
 
92
96
  /** Find setting definition by path */
93
97
  function findSettingDef(path: string): CliSettingDef | undefined {
@@ -223,25 +227,25 @@ function parseAndSetValue(path: SettingPath, rawValue: string): void {
223
227
  const lower = trimmed.toLowerCase();
224
228
  if (["true", "1", "yes", "on"].includes(lower)) parsedValue = true;
225
229
  else if (["false", "0", "no", "off"].includes(lower)) parsedValue = false;
226
- else throw new Error(`Invalid boolean value: ${rawValue}. Use true/false, yes/no, on/off, or 1/0`);
230
+ else throw new Error("Invalid boolean value. Use true/false, yes/no, on/off, or 1/0");
227
231
  break;
228
232
  }
229
233
  case "number": {
230
234
  parsedValue = Number(trimmed);
231
- if (!Number.isFinite(parsedValue)) throw new Error(`Invalid number: ${rawValue}`);
235
+ if (!Number.isFinite(parsedValue)) throw new Error("Invalid number");
232
236
  const validate =
233
237
  "validate" in SETTINGS_SCHEMA[path]
234
238
  ? (SETTINGS_SCHEMA[path].validate as ((value: number) => boolean) | undefined)
235
239
  : undefined;
236
240
  if (validate?.(parsedValue as number) === false) {
237
- throw new Error(`Invalid number for ${path}: ${rawValue}`);
241
+ throw new Error(`Invalid number for ${path}`);
238
242
  }
239
243
  break;
240
244
  }
241
245
  case "enum": {
242
246
  const valid = getEnumValues(path);
243
247
  if (valid && !valid.includes(trimmed)) {
244
- throw new Error(`Invalid value: ${rawValue}. Valid values: ${valid.join(", ")}`);
248
+ throw new Error(`Invalid value. Valid values: ${valid.join(", ")}`);
245
249
  }
246
250
  parsedValue = trimmed;
247
251
  break;
@@ -251,10 +255,10 @@ function parseAndSetValue(path: SettingPath, rawValue: string): void {
251
255
  try {
252
256
  parsed = JSON.parse(trimmed);
253
257
  } catch {
254
- throw new Error(`Invalid array JSON: ${rawValue}`);
258
+ throw new Error("Invalid array JSON");
255
259
  }
256
260
  if (!Array.isArray(parsed)) {
257
- throw new Error(`Invalid array JSON: ${rawValue}`);
261
+ throw new Error("Invalid array JSON");
258
262
  }
259
263
  parsedValue = parsed;
260
264
  break;
@@ -264,10 +268,10 @@ function parseAndSetValue(path: SettingPath, rawValue: string): void {
264
268
  try {
265
269
  parsed = JSON.parse(trimmed);
266
270
  } catch {
267
- throw new Error(`Invalid record JSON: ${rawValue}`);
271
+ throw new Error("Invalid record JSON");
268
272
  }
269
273
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
270
- throw new Error(`Invalid record JSON: ${rawValue}`);
274
+ throw new Error("Invalid record JSON");
271
275
  }
272
276
  parsedValue = parsed;
273
277
  break;
@@ -408,6 +412,11 @@ async function handleSet(
408
412
  console.error(chalk.red(String(err)));
409
413
  process.exit(1);
410
414
  }
415
+ try {
416
+ await settings.flushOrThrow();
417
+ } catch {
418
+ reportConfigPersistenceFailure();
419
+ }
411
420
 
412
421
  const newValue = settings.get(def.path);
413
422
  const displayValue = redactConfigValue(def.path, newValue, flags.showSecrets);
@@ -438,6 +447,12 @@ async function handleReset(key: string | undefined, flags: { json?: boolean }):
438
447
  if (defaultValue === undefined) settings.unset(path);
439
448
  else settings.set(path, defaultValue as SettingValue<typeof path>);
440
449
 
450
+ try {
451
+ await settings.flushOrThrow();
452
+ } catch {
453
+ reportConfigPersistenceFailure();
454
+ }
455
+
441
456
  if (flags.json) {
442
457
  console.log(JSON.stringify({ key: def.path, value: defaultValue }));
443
458
  } else {
@@ -4,6 +4,7 @@
4
4
  * Handles `skc update` to check for and install updates.
5
5
  * Uses bun if available, otherwise downloads binary from GitHub releases.
6
6
  */
7
+ import { createHash, randomUUID } from "node:crypto";
7
8
  import * as fs from "node:fs";
8
9
  import * as path from "node:path";
9
10
  import { pipeline } from "node:stream/promises";
@@ -17,6 +18,8 @@ const RELEASE_REPO = "jaybeyond/Sayknow_CLI";
17
18
  const PACKAGE = "@sayknow-cli/coding-agent";
18
19
  const NPM_WRAPPER_PACKAGE = "sayknow-cli";
19
20
  const NPM_MANAGED_PACKAGES = [NPM_WRAPPER_PACKAGE, PACKAGE] as const;
21
+ const BINARY_MANIFEST_ASSET = "sayknow-release-binaries-v1.json";
22
+ const BINARY_SHA256_ASSET = "sayknow-release-binaries.sha256";
20
23
 
21
24
  interface ReleaseInfo {
22
25
  tag: string;
@@ -272,11 +275,21 @@ function getBinaryName(platform: NodeJS.Platform = process.platform, arch: strin
272
275
  }
273
276
 
274
277
  if (os === "windows") {
278
+ if (archName !== "x64") {
279
+ throw new Error(formatUnsupportedTargetMessage(`Unsupported architecture for Windows: ${arch}`));
280
+ }
275
281
  return `${APP_NAME}-${os}-${archName}.exe`;
276
282
  }
277
283
  return `${APP_NAME}-${os}-${archName}`;
278
284
  }
279
285
 
286
+ export function getBinaryNameForTest(
287
+ platform: NodeJS.Platform = process.platform,
288
+ arch: string = process.arch,
289
+ ): string {
290
+ return getBinaryName(platform, arch);
291
+ }
292
+
280
293
  /**
281
294
  * Resolve the path that `skc` maps to in the user's PATH.
282
295
  */
@@ -367,10 +380,89 @@ function buildReleaseBinaryUrl(
367
380
  arch: string = process.arch,
368
381
  ): string {
369
382
  const binaryName = getBinaryName(platform, arch);
370
- const tag = `v${version}`;
383
+ const tag = `sayknow-v${version}`;
371
384
  return `https://github.com/${RELEASE_REPO}/releases/download/${tag}/${binaryName}`;
372
385
  }
373
386
 
387
+ function buildReleaseAssetUrl(version: string, assetName: string): string {
388
+ return `https://github.com/${RELEASE_REPO}/releases/download/sayknow-v${version}/${assetName}`;
389
+ }
390
+
391
+ function parseExpectedDigestFromSums(text: string, assetName: string): string | undefined {
392
+ const matches: string[] = [];
393
+ for (const line of text.split(/\r?\n/)) {
394
+ const match = line.trim().match(/^([a-fA-F0-9]{64}) {2}[ *]?([^/\\]+)$/);
395
+ if (match?.[2] === assetName) matches.push(match[1]!.toLowerCase());
396
+ }
397
+ if (matches.length > 1) throw new Error(`Release checksum file lists ${assetName} more than once`);
398
+ return matches[0];
399
+ }
400
+
401
+ function expectedDigestFromManifest(value: unknown, version: string, assetName: string): string {
402
+ if (!value || typeof value !== "object") throw new Error("Release binary manifest is not an object");
403
+ const manifest = value as Record<string, unknown>;
404
+ if (
405
+ manifest.schema !== "sayknow-release-binaries-v1" ||
406
+ manifest.schema_version !== 1 ||
407
+ manifest.release_version !== version ||
408
+ manifest.tag !== `sayknow-v${version}` ||
409
+ !Array.isArray(manifest.binaries)
410
+ ) {
411
+ throw new Error("Release binary manifest metadata does not match the requested release");
412
+ }
413
+ const matches = manifest.binaries.filter(
414
+ entry => entry && typeof entry === "object" && (entry as Record<string, unknown>).name === assetName,
415
+ ) as Array<Record<string, unknown>>;
416
+ if (matches.length !== 1 || typeof matches[0]?.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(matches[0].sha256)) {
417
+ throw new Error(`Release binary manifest does not contain one valid SHA-256 for ${assetName}`);
418
+ }
419
+ return matches[0].sha256;
420
+ }
421
+
422
+ export async function fetchExpectedBinaryDigest(
423
+ version: string,
424
+ assetName: string,
425
+ fetchImpl: (url: string) => Promise<Response> = fetch,
426
+ ): Promise<string> {
427
+ const sumsResponse = await fetchImpl(buildReleaseAssetUrl(version, BINARY_SHA256_ASSET));
428
+ if (sumsResponse.ok) {
429
+ const digest = parseExpectedDigestFromSums(await sumsResponse.text(), assetName);
430
+ if (!digest) throw new Error(`Release checksum file does not list ${assetName}`);
431
+ return digest;
432
+ }
433
+ if (sumsResponse.status !== 404) {
434
+ throw new Error(`Release checksum file could not be fetched: HTTP ${sumsResponse.status}`);
435
+ }
436
+
437
+ const manifestResponse = await fetchImpl(buildReleaseAssetUrl(version, BINARY_MANIFEST_ASSET));
438
+ if (!manifestResponse.ok) {
439
+ if (manifestResponse.status === 404) {
440
+ throw new Error(`Release ${version} has no Sayknow binary integrity manifest`);
441
+ }
442
+ throw new Error(`Release binary manifest could not be fetched: HTTP ${manifestResponse.status}`);
443
+ }
444
+ return expectedDigestFromManifest(await manifestResponse.json(), version, assetName);
445
+ }
446
+
447
+ async function sha256File(filePath: string): Promise<string> {
448
+ const hash = createHash("sha256");
449
+ for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
450
+ return hash.digest("hex");
451
+ }
452
+
453
+ export async function verifyReleaseBinaryIntegrity(
454
+ filePath: string,
455
+ version: string,
456
+ assetName: string,
457
+ fetchImpl: (url: string) => Promise<Response> = fetch,
458
+ ): Promise<void> {
459
+ const expected = await fetchExpectedBinaryDigest(version, assetName, fetchImpl);
460
+ const actual = await sha256File(filePath);
461
+ if (actual !== expected) {
462
+ throw new Error(`SHA-256 mismatch for ${assetName}: expected ${expected}, got ${actual}`);
463
+ }
464
+ }
465
+
374
466
  function formatBinaryDownloadFailureMessage(
375
467
  binaryName: string,
376
468
  url: string,
@@ -439,42 +531,186 @@ function formatBackupCleanupWarning(backupPath: string, err: unknown): string {
439
531
 
440
532
  async function cleanupVerifiedBackup(backupPath: string): Promise<string | undefined> {
441
533
  try {
442
- await unlinkIfExists(backupPath);
534
+ await unlinkDurably(backupPath);
443
535
  return undefined;
444
536
  } catch (err) {
445
537
  return formatBackupCleanupWarning(backupPath, err);
446
538
  }
447
539
  }
448
540
 
541
+ async function pathExists(filePath: string): Promise<boolean> {
542
+ try {
543
+ await fs.promises.lstat(filePath);
544
+ return true;
545
+ } catch (err) {
546
+ if (isEnoent(err)) return false;
547
+ throw err;
548
+ }
549
+ }
550
+
551
+ async function syncParentDirectory(filePath: string): Promise<void> {
552
+ // Node cannot portably fsync directory handles on Windows. NTFS journals the
553
+ // rename operations used below; POSIX platforms require an explicit parent sync.
554
+ if (process.platform === "win32") return;
555
+ const handle = await fs.promises.open(path.dirname(filePath), "r");
556
+ try {
557
+ await handle.sync();
558
+ } finally {
559
+ await handle.close();
560
+ }
561
+ }
562
+
563
+ async function unlinkDurably(filePath: string): Promise<void> {
564
+ await unlinkIfExists(filePath);
565
+ await syncParentDirectory(filePath);
566
+ }
567
+
568
+ async function renameDurably(sourcePath: string, targetPath: string): Promise<void> {
569
+ await fs.promises.rename(sourcePath, targetPath);
570
+ await syncParentDirectory(targetPath);
571
+ if (path.dirname(sourcePath) !== path.dirname(targetPath)) await syncParentDirectory(sourcePath);
572
+ }
573
+
574
+ export async function withBinaryUpdateLock<T>(targetPath: string, operation: () => Promise<T>): Promise<T> {
575
+ const lockPath = path.join(path.dirname(targetPath), ".skc-install.lock");
576
+ try {
577
+ await fs.promises.mkdir(lockPath);
578
+ } catch (err) {
579
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
580
+ throw new Error(`Another SKC installer or update is already running (lock: ${lockPath}).`);
581
+ }
582
+ throw err;
583
+ }
584
+ try {
585
+ await fs.promises.writeFile(
586
+ path.join(lockPath, "owner.json"),
587
+ JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }),
588
+ { flag: "wx" },
589
+ );
590
+ return await operation();
591
+ } finally {
592
+ await fs.promises.rm(lockPath, { recursive: true, force: true });
593
+ }
594
+ }
595
+
596
+ type BinaryReplacementPhase = "replacing" | "committed";
597
+
598
+ interface BinaryReplacementState {
599
+ phase: BinaryReplacementPhase;
600
+ hadTarget: boolean;
601
+ }
602
+
603
+ function binaryReplacementStatePath(backupPath: string): string {
604
+ return `${backupPath}.state.json`;
605
+ }
606
+
607
+ async function readBinaryReplacementState(statePath: string): Promise<BinaryReplacementState | undefined> {
608
+ try {
609
+ const value = JSON.parse(await fs.promises.readFile(statePath, "utf8")) as Partial<BinaryReplacementState>;
610
+ if ((value.phase === "replacing" || value.phase === "committed") && typeof value.hadTarget === "boolean") {
611
+ return value as BinaryReplacementState;
612
+ }
613
+ return { phase: "replacing", hadTarget: true };
614
+ } catch (err) {
615
+ if (isEnoent(err)) return undefined;
616
+ return { phase: "replacing", hadTarget: true };
617
+ }
618
+ }
619
+
620
+ async function writeBinaryReplacementState(statePath: string, state: BinaryReplacementState): Promise<void> {
621
+ const handle = await fs.promises.open(statePath, "w", 0o600);
622
+ try {
623
+ await handle.writeFile(JSON.stringify(state));
624
+ await handle.sync();
625
+ } finally {
626
+ await handle.close();
627
+ }
628
+ await syncParentDirectory(statePath);
629
+ }
630
+
631
+ async function recoverInterruptedBinaryReplacement(targetPath: string, backupPath: string): Promise<void> {
632
+ const statePath = binaryReplacementStatePath(backupPath);
633
+ const state = await readBinaryReplacementState(statePath);
634
+ if (!(await pathExists(backupPath))) {
635
+ if (state?.phase === "replacing" && state.hadTarget === false) {
636
+ await unlinkDurably(targetPath);
637
+ }
638
+ await unlinkDurably(statePath);
639
+ return;
640
+ }
641
+ if (state?.phase === "committed" && (await pathExists(targetPath))) {
642
+ await unlinkDurably(backupPath);
643
+ await unlinkDurably(statePath);
644
+ return;
645
+ }
646
+ await unlinkDurably(targetPath);
647
+ await renameDurably(backupPath, targetPath);
648
+ await unlinkDurably(statePath);
649
+ }
650
+
651
+ async function cleanupCommittedReplacementState(statePath: string): Promise<string | undefined> {
652
+ try {
653
+ await unlinkDurably(statePath);
654
+ return undefined;
655
+ } catch (err) {
656
+ return `Installed update, but could not remove transaction state file ${statePath}: ${err}. The verified binary remains installed.`;
657
+ }
658
+ }
659
+
449
660
  /**
450
661
  * Atomically replace the installed binary and roll back if version verification fails.
451
662
  */
452
663
  export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
664
+ const statePath = binaryReplacementStatePath(options.backupPath);
665
+ await recoverInterruptedBinaryReplacement(options.targetPath, options.backupPath);
666
+ await unlinkDurably(options.backupPath);
667
+ const hadTarget = await pathExists(options.targetPath);
453
668
  let backupReady = false;
669
+ let publishedNew = false;
670
+ let verification: InstalledVersionVerification;
454
671
  try {
455
- await unlinkIfExists(options.backupPath);
456
- await fs.promises.rename(options.targetPath, options.backupPath);
457
- backupReady = true;
672
+ await writeBinaryReplacementState(statePath, {
673
+ phase: "replacing",
674
+ hadTarget,
675
+ });
676
+ try {
677
+ await fs.promises.rename(options.targetPath, options.backupPath);
678
+ backupReady = true;
679
+ await syncParentDirectory(options.backupPath);
680
+ } catch (err) {
681
+ if (!isEnoent(err)) throw err;
682
+ }
458
683
  await fs.promises.rename(options.tempPath, options.targetPath);
684
+ publishedNew = true;
685
+ await syncParentDirectory(options.targetPath);
459
686
 
460
- const verification = await options.verifyInstalledVersion(options.expectedVersion);
687
+ verification = await options.verifyInstalledVersion(options.expectedVersion);
461
688
  if (!verification.ok) {
462
689
  throw new Error(
463
690
  `${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`,
464
691
  );
465
692
  }
466
693
 
694
+ await writeBinaryReplacementState(statePath, {
695
+ phase: "committed",
696
+ hadTarget,
697
+ });
467
698
  backupReady = false;
468
- const cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
469
- return cleanupWarning ? { ...verification, cleanupWarning } : verification;
470
699
  } catch (err) {
471
700
  if (backupReady) {
472
- await unlinkIfExists(options.targetPath);
473
- await fs.promises.rename(options.backupPath, options.targetPath);
701
+ await unlinkDurably(options.targetPath);
702
+ await renameDurably(options.backupPath, options.targetPath);
703
+ } else if (publishedNew) {
704
+ await unlinkDurably(options.targetPath);
474
705
  }
475
- await unlinkIfExists(options.tempPath);
706
+ await unlinkDurably(options.tempPath);
707
+ await unlinkDurably(statePath);
476
708
  throw err;
477
709
  }
710
+
711
+ let cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
712
+ if (!cleanupWarning) cleanupWarning = await cleanupCommittedReplacementState(statePath);
713
+ return cleanupWarning ? { ...verification, cleanupWarning } : verification;
478
714
  }
479
715
 
480
716
  function formatPackageManagerInstallFailure(
@@ -594,17 +830,20 @@ async function downloadBinaryTo(url: string, tempPath: string, binaryName: strin
594
830
  /** Injectable steps of the binary update flow (seams for testing ordering). */
595
831
  export interface BinaryUpdateFlow {
596
832
  download(url: string, tempPath: string): Promise<void>;
833
+ verifyIntegrity(filePath: string): Promise<void>;
597
834
  fsync(filePath: string): Promise<void>;
598
835
  replace(options: BinaryReplacementOptions): Promise<InstalledVersionVerification>;
599
836
  verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification>;
600
837
  /** Best-effort cleanup of the temp file when the flow aborts before replace. */
601
838
  removeTemp?(filePath: string): Promise<void>;
839
+ /** Stable suffix for focused tests; production uses a process-unique staging path. */
840
+ transactionId?: string;
602
841
  /** Called once fsync has succeeded, right before replacement begins. */
603
842
  beforeReplace?(): void;
604
843
  }
605
844
 
606
845
  /**
607
- * Orchestrate download → fsync → replace → verify with a strict ordering
846
+ * Orchestrate download → integrity verification → fsync → replace → version/smoke verification.
608
847
  * contract: the downloaded temp binary MUST be flushed to stable storage
609
848
  * before it is published (renamed into place) or exec'd for verification.
610
849
  *
@@ -618,11 +857,13 @@ export async function runBinaryUpdateFlow(
618
857
  expectedVersion: string,
619
858
  flow: BinaryUpdateFlow,
620
859
  ): Promise<InstalledVersionVerification> {
621
- const tempPath = `${targetPath}.new`;
860
+ const transactionId = flow.transactionId ?? `${process.pid}.${randomUUID()}`;
861
+ const tempPath = `${targetPath}.new.${transactionId}`;
622
862
  const backupPath = `${targetPath}.bak`;
623
863
 
624
864
  await flow.download(url, tempPath);
625
865
  try {
866
+ await flow.verifyIntegrity(tempPath);
626
867
  await flow.fsync(tempPath);
627
868
  } catch (err) {
628
869
  if (flow.removeTemp) await flow.removeTemp(tempPath);
@@ -647,14 +888,17 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
647
888
  const url = buildReleaseBinaryUrl(expectedVersion);
648
889
  console.log(chalk.dim(`Downloading ${binaryName}…`));
649
890
 
650
- const verification = await runBinaryUpdateFlow(targetPath, url, expectedVersion, {
651
- download: (downloadUrl, tempPath) => downloadBinaryTo(downloadUrl, tempPath, binaryName),
652
- fsync: fsyncFile,
653
- replace: replaceBinaryForUpdate,
654
- verifyInstalledVersion: verifyInstalledRuntime,
655
- removeTemp: unlinkIfExists,
656
- beforeReplace: () => console.log(chalk.dim("Installing update...")),
657
- });
891
+ const verification = await withBinaryUpdateLock(targetPath, () =>
892
+ runBinaryUpdateFlow(targetPath, url, expectedVersion, {
893
+ download: (downloadUrl, tempPath) => downloadBinaryTo(downloadUrl, tempPath, binaryName),
894
+ verifyIntegrity: tempPath => verifyReleaseBinaryIntegrity(tempPath, expectedVersion, binaryName),
895
+ fsync: fsyncFile,
896
+ replace: replaceBinaryForUpdate,
897
+ verifyInstalledVersion: verifyInstalledRuntime,
898
+ removeTemp: unlinkIfExists,
899
+ beforeReplace: () => console.log(chalk.dim("Installing update...")),
900
+ }),
901
+ );
658
902
 
659
903
  printVerifiedVersion(expectedVersion);
660
904
  if (verification.cleanupWarning) console.warn(chalk.yellow(verification.cleanupWarning));
@@ -1,4 +1,13 @@
1
1
  import { Args, Command, Flags } from "@sayknow-cli/utils/cli";
2
+ import { readBootGeneration } from "../skc-runtime/boot-generation";
3
+ import {
4
+ decodeRestoreReference,
5
+ encodeRestoreReference,
6
+ evaluateRestoreCandidates,
7
+ listRestorePointers,
8
+ type RestoreCandidateVerdict,
9
+ } from "../skc-runtime/session-restore";
10
+ import { buildRestoreCandidateDeps, restoreSession } from "../skc-runtime/session-restore-runtime";
2
11
  import {
3
12
  attachSkcTmuxSession,
4
13
  createSkcTmuxSession,
@@ -45,12 +54,13 @@ function sessionJson(session: SessionJsonDto): SessionJsonDto {
45
54
  }
46
55
 
47
56
  export default class Session extends Command {
48
- static description = "List, inspect, attach, and remove tagged SKC-managed tmux sessions";
57
+ static description =
58
+ "List, inspect, attach, and remove tagged SKC-managed tmux sessions. `restore` is a manual, opt-in recovery step that only ever restores sessions this machine can prove died in a REBOOT: nothing starts automatically at login, a session that is still live is never duplicated, and running tools or background work are not recovered — only the tmux session, its directory, and its conversation.";
49
59
  static strict = false;
50
60
 
51
61
  static args = {
52
62
  action: Args.string({
53
- description: "list (default), status, create, attach, or remove",
63
+ description: "list (default), status, create, attach, remove, or restore",
54
64
  required: false,
55
65
  }),
56
66
  session: Args.string({
@@ -67,6 +77,15 @@ export default class Session extends Command {
67
77
  "state-file": Flags.string({
68
78
  description: "Expected @skc-session-state-file tag for force-close (defense-in-depth match)",
69
79
  }),
80
+ "dry-run": Flags.boolean({
81
+ description:
82
+ "restore: list candidates and their verdicts. Changes nothing at all (no tmux command, no lock, no database, no pointer write). This is the default when no --reference is given.",
83
+ default: false,
84
+ }),
85
+ reference: Flags.string({
86
+ description:
87
+ "restore: restore exactly one candidate by the opaque reference shown in --dry-run output. Refused unless that candidate is still reboot-eligible at this moment.",
88
+ }),
70
89
  };
71
90
 
72
91
  static examples = [
@@ -76,6 +95,8 @@ export default class Session extends Command {
76
95
  "skc session attach <session>",
77
96
  "skc session remove <session>",
78
97
  "skc session force-close <session> --session-id <id>",
98
+ "skc session restore --dry-run # list what a reboot left behind",
99
+ "skc session restore --reference <ref> # restore exactly that one",
79
100
  ];
80
101
 
81
102
  async run(): Promise<void> {
@@ -105,6 +126,71 @@ export default class Session extends Command {
105
126
  return;
106
127
  }
107
128
 
129
+ if (action === "restore") {
130
+ // Manual, reboot-proven, opt-in. Nothing here starts automatically at
131
+ // login, and a session is only ever restored when this host can prove
132
+ // the machine rebooted since that session was recorded.
133
+ const reference = flags.reference;
134
+ const decoded = reference ? decodeRestoreReference(reference) : null;
135
+ if (reference && !decoded) throw new Error("invalid_restore_reference");
136
+ const pointers = listRestorePointers().filter(
137
+ pointer =>
138
+ !decoded ||
139
+ (pointer.coordinator_session_id === decoded.coordinatorSessionId &&
140
+ pointer.state_file === decoded.stateFile),
141
+ );
142
+ if (reference && pointers.length === 0) throw new Error("restore_reference_not_found");
143
+ const verdicts = evaluateRestoreCandidates(pointers, buildRestoreCandidateDeps(readBootGeneration()));
144
+ const describe = (verdict: RestoreCandidateVerdict) => ({
145
+ reference: encodeRestoreReference(verdict.pointer.coordinator_session_id, verdict.pointer.state_file),
146
+ sessionId: verdict.pointer.skc_session_id,
147
+ cwd: verdict.pointer.cwd,
148
+ branch: verdict.pointer.branch,
149
+ eligible: verdict.eligible,
150
+ ...(verdict.eligible ? {} : { reason: verdict.reason }),
151
+ });
152
+ // Dry run is the default reporting surface and performs zero mutation:
153
+ // no pointer write, no lock, no SQLite, no tmux command.
154
+ if (flags["dry-run"] || !reference) {
155
+ const rows = verdicts.map(describe);
156
+ if (json) {
157
+ writeJson({ ok: true, dryRun: true, candidates: rows });
158
+ return;
159
+ }
160
+ writeText(
161
+ rows.length === 0
162
+ ? ["no restore candidates"]
163
+ : rows.map(row =>
164
+ [
165
+ row.eligible ? "restorable" : `skipped(${row.reason})`,
166
+ row.sessionId,
167
+ row.cwd,
168
+ row.reference,
169
+ ].join("\t"),
170
+ ),
171
+ );
172
+ return;
173
+ }
174
+ const verdict = verdicts[0];
175
+ if (!verdict) throw new Error("restore_reference_not_found");
176
+ if (!verdict.eligible) throw new Error(`restore_ineligible_${verdict.reason}`);
177
+ const outcome = restoreSession(verdict.pointer);
178
+ if (!outcome.ok) throw new Error(outcome.detail);
179
+ if (json) {
180
+ writeJson({
181
+ ok: true,
182
+ restored: {
183
+ sessionId: outcome.pointer.skc_session_id,
184
+ cwd: outcome.pointer.cwd,
185
+ tmuxSession: outcome.tmuxSession,
186
+ },
187
+ });
188
+ return;
189
+ }
190
+ writeText([`restored: ${outcome.tmuxSession} (${outcome.pointer.skc_session_id})`]);
191
+ return;
192
+ }
193
+
108
194
  if (action === "create") {
109
195
  const session = createSkcTmuxSession();
110
196
  if (json) {