@sayknow-cli/coding-agent 0.5.2 → 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 (86) hide show
  1. package/CHANGELOG.md +55 -16
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  4. package/dist/types/config/keybindings.d.ts +10 -0
  5. package/dist/types/config/model-registry.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +9 -0
  8. package/dist/types/dap/client.d.ts +1 -0
  9. package/dist/types/lsp/client.d.ts +5 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  12. package/dist/types/modes/interactive-mode.d.ts +13 -1
  13. package/dist/types/modes/types.d.ts +7 -1
  14. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  15. package/dist/types/session/agent-session.d.ts +8 -0
  16. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  17. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  18. package/dist/types/session/session-manager.d.ts +9 -0
  19. package/dist/types/session/session-storage.d.ts +17 -0
  20. package/dist/types/session-import/claude.d.ts +26 -0
  21. package/dist/types/session-import/codex.d.ts +22 -0
  22. package/dist/types/session-import/command.d.ts +20 -0
  23. package/dist/types/session-import/detect.d.ts +22 -0
  24. package/dist/types/session-import/index.d.ts +7 -0
  25. package/dist/types/session-import/redact.d.ts +23 -0
  26. package/dist/types/session-import/service.d.ts +28 -0
  27. package/dist/types/session-import/types.d.ts +125 -0
  28. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  30. package/dist/types/slash-commands/types.d.ts +5 -0
  31. package/dist/types/tools/ask.d.ts +5 -0
  32. package/package.json +13 -7
  33. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  34. package/src/cli/config-cli.ts +23 -8
  35. package/src/cli/update-cli.ts +265 -21
  36. package/src/config/atomic-yaml-patch.ts +167 -30
  37. package/src/config/keybindings.ts +10 -0
  38. package/src/config/model-profiles.ts +69 -1
  39. package/src/config/model-registry.ts +60 -38
  40. package/src/config/models-config-schema.ts +1 -1
  41. package/src/config/settings-schema.ts +9 -0
  42. package/src/config/settings.ts +13 -2
  43. package/src/dap/client.ts +78 -30
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/lsp/client.ts +17 -29
  46. package/src/modes/action-registry.ts +2 -0
  47. package/src/modes/components/welcome.ts +5 -0
  48. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  49. package/src/modes/controllers/input-controller.ts +71 -6
  50. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  51. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  52. package/src/modes/controllers/selector-controller.ts +47 -19
  53. package/src/modes/interactive-mode.ts +54 -4
  54. package/src/modes/types.ts +5 -1
  55. package/src/runtime-mcp/transports/stdio.ts +96 -33
  56. package/src/sdk/broker/lifecycle.ts +6 -5
  57. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  58. package/src/sdk/session.ts +25 -18
  59. package/src/session/agent-session.ts +89 -14
  60. package/src/session/internal/managed-session-scope.ts +41 -6
  61. package/src/session/internal/managed-session-storage.ts +14 -0
  62. package/src/session/session-manager.ts +113 -11
  63. package/src/session/session-storage.ts +70 -0
  64. package/src/session-import/claude.ts +382 -0
  65. package/src/session-import/codex.ts +458 -0
  66. package/src/session-import/command.ts +61 -0
  67. package/src/session-import/detect.ts +133 -0
  68. package/src/session-import/index.ts +27 -0
  69. package/src/session-import/redact.ts +125 -0
  70. package/src/session-import/service.ts +749 -0
  71. package/src/session-import/types.ts +163 -0
  72. package/src/skc-runtime/launch-worktree.ts +277 -4
  73. package/src/slash-commands/acp-builtins.ts +5 -1
  74. package/src/slash-commands/builtin-registry.ts +100 -1
  75. package/src/slash-commands/types.ts +5 -0
  76. package/src/tools/ask.ts +5 -0
  77. package/src/export/html/template.css +0 -1060
  78. package/src/export/html/template.html +0 -47
  79. package/src/export/html/template.js +0 -2348
  80. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  81. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  82. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  83. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  84. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  85. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  86. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -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));