@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
@@ -19,6 +19,9 @@ export interface SkcLaunchWorktreePlan {
19
19
  detached: boolean;
20
20
  baseRef: string;
21
21
  branchName: string | null;
22
+ sourceCheckoutRoot: string;
23
+ relativeCwd: string;
24
+ relativeDependencyRoot: string | null;
22
25
  }
23
26
 
24
27
  export interface SkcLaunchWorktreeResult extends SkcLaunchWorktreePlan {
@@ -207,11 +210,49 @@ export function parseLaunchWorktreeMode(args: string[]): ParsedLaunchWorktreeMod
207
210
  return { mode, remainingArgs };
208
211
  }
209
212
 
213
+ function repoRelativePath(checkoutRoot: string, targetPath: string): string {
214
+ const relative = path.relative(checkoutRoot, targetPath);
215
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
216
+ throw new Error(`worktree_cwd_outside_checkout:${targetPath}`);
217
+ }
218
+ return relative;
219
+ }
220
+
221
+ function hasPackageLockMetadata(directory: string): boolean {
222
+ return (
223
+ fs.existsSync(path.join(directory, "bun.lock")) ||
224
+ fs.existsSync(path.join(directory, "bun.lockb")) ||
225
+ fs.existsSync(path.join(directory, "pnpm-lock.yaml")) ||
226
+ fs.existsSync(path.join(directory, "pnpm-workspace.yaml")) ||
227
+ fs.existsSync(path.join(directory, "package-lock.json")) ||
228
+ fs.existsSync(path.join(directory, "npm-shrinkwrap.json"))
229
+ );
230
+ }
231
+
232
+ function resolveSourceDependencyRoot(checkoutRoot: string, cwd: string): string | null {
233
+ let current = path.resolve(cwd);
234
+ let nearestPackageRoot: string | null = null;
235
+ while (true) {
236
+ if (!nearestPackageRoot && fs.existsSync(path.join(current, "package.json"))) nearestPackageRoot = current;
237
+ if (hasPackageLockMetadata(current)) return current;
238
+ if (current === checkoutRoot) return nearestPackageRoot;
239
+ const parent = path.dirname(current);
240
+ if (parent === current) return nearestPackageRoot;
241
+ current = parent;
242
+ }
243
+ }
244
+
210
245
  export function planLaunchWorktree(
211
246
  cwd: string,
212
247
  mode: SkcLaunchWorktreeMode,
213
248
  ): SkcLaunchWorktreePlan | { enabled: false } {
214
249
  if (!mode.enabled) return { enabled: false };
250
+ const sourceCheckoutRoot = fs.realpathSync.native(path.resolve(runGit(cwd, ["rev-parse", "--show-toplevel"])));
251
+ const sourceCwd = fs.realpathSync.native(path.resolve(cwd));
252
+ const relativeCwd = repoRelativePath(sourceCheckoutRoot, sourceCwd);
253
+ const sourceDependencyRoot = resolveSourceDependencyRoot(sourceCheckoutRoot, sourceCwd);
254
+ const relativeDependencyRoot =
255
+ sourceDependencyRoot === null ? null : repoRelativePath(sourceCheckoutRoot, sourceDependencyRoot);
215
256
  const repoRoot = resolveCanonicalRepoRoot(cwd);
216
257
  const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
217
258
  const branchName = mode.detached ? null : mode.name;
@@ -219,7 +260,17 @@ export function planLaunchWorktree(
219
260
  const bucket = `${path.basename(repoRoot)}.sayknow-cli-worktrees`;
220
261
  const worktreeSlug = mode.detached ? resolveSourceBranchSlug(repoRoot, baseRef) : sanitizePathToken(mode.name);
221
262
  const worktreePath = path.join(path.dirname(repoRoot), bucket, worktreeSlug);
222
- return { enabled: true, repoRoot, worktreePath, detached: mode.detached, baseRef, branchName };
263
+ return {
264
+ enabled: true,
265
+ repoRoot,
266
+ worktreePath,
267
+ detached: mode.detached,
268
+ baseRef,
269
+ branchName,
270
+ sourceCheckoutRoot,
271
+ relativeCwd,
272
+ relativeDependencyRoot,
273
+ };
223
274
  }
224
275
 
225
276
  export function ensureLaunchWorktree(
@@ -290,8 +341,216 @@ export function ensureLaunchWorktree(
290
341
  };
291
342
  }
292
343
 
293
- export function ensureReusableNodeModules(sourceRoot: string, worktreePath: string): "symlink" | "present" | "missing" {
344
+ interface WorktreePackageManifest {
345
+ packageManager?: unknown;
346
+ }
347
+
348
+ type WorktreePackageManager = "bun" | "npm" | "pnpm";
349
+
350
+ interface ResolvedWorktreePackageManager {
351
+ name: WorktreePackageManager;
352
+ version: string | null;
353
+ }
354
+
355
+ interface WorktreeDependencyProbes {
356
+ isExecutableAvailable?: (name: Exclude<WorktreePackageManager, "bun">) => boolean;
357
+ version?: (name: WorktreePackageManager) => string;
358
+ spawnInstall?: (command: readonly string[], cwd: string) => { exitCode: number; stderr: string };
359
+ }
360
+
361
+ function fileSystemErrorCode(error: unknown): string | undefined {
362
+ return error && typeof error === "object" && "code" in error && typeof error.code === "string"
363
+ ? error.code
364
+ : undefined;
365
+ }
366
+
367
+ function readWorktreePackageManifest(worktreePath: string): WorktreePackageManifest | null {
368
+ try {
369
+ return JSON.parse(fs.readFileSync(path.join(worktreePath, "package.json"), "utf8")) as WorktreePackageManifest;
370
+ } catch (error) {
371
+ if (fileSystemErrorCode(error) === "ENOENT") return null;
372
+ throw error;
373
+ }
374
+ }
375
+
376
+ function hasWorktreePackageMetadata(worktreePath: string, manifest: WorktreePackageManifest | null): boolean {
377
+ return (
378
+ manifest !== null ||
379
+ fs.existsSync(path.join(worktreePath, "bun.lock")) ||
380
+ fs.existsSync(path.join(worktreePath, "bun.lockb")) ||
381
+ fs.existsSync(path.join(worktreePath, "pnpm-lock.yaml")) ||
382
+ fs.existsSync(path.join(worktreePath, "pnpm-workspace.yaml")) ||
383
+ fs.existsSync(path.join(worktreePath, "package-lock.json")) ||
384
+ fs.existsSync(path.join(worktreePath, "npm-shrinkwrap.json"))
385
+ );
386
+ }
387
+
388
+ function declaredPackageManager(
389
+ manifest: WorktreePackageManifest | null,
390
+ ): { name: string; version: string | null } | null {
391
+ if (typeof manifest?.packageManager !== "string") return null;
392
+ const separator = manifest.packageManager.indexOf("@");
393
+ const name = (separator < 0 ? manifest.packageManager : manifest.packageManager.slice(0, separator)).trim();
394
+ if (!name) return null;
395
+ const version = separator < 0 ? null : manifest.packageManager.slice(separator + 1).trim() || null;
396
+ return { name, version };
397
+ }
398
+
399
+ function worktreeLockfiles(worktreePath: string): Array<{ manager: WorktreePackageManager; file: string }> {
400
+ const candidates: Array<{ manager: WorktreePackageManager; file: string }> = [
401
+ { manager: "bun", file: "bun.lock" },
402
+ { manager: "bun", file: "bun.lockb" },
403
+ { manager: "pnpm", file: "pnpm-lock.yaml" },
404
+ { manager: "npm", file: "package-lock.json" },
405
+ { manager: "npm", file: "npm-shrinkwrap.json" },
406
+ ];
407
+ return candidates.filter(candidate => fs.existsSync(path.join(worktreePath, candidate.file)));
408
+ }
409
+
410
+ function isWorktreePackageManager(value: string): value is WorktreePackageManager {
411
+ return value === "bun" || value === "npm" || value === "pnpm";
412
+ }
413
+
414
+ function resolveWorktreePackageManager(
415
+ worktreePath: string,
416
+ manifest: WorktreePackageManifest | null,
417
+ ): ResolvedWorktreePackageManager {
418
+ const declared = declaredPackageManager(manifest);
419
+ const declaredManager = declared && isWorktreePackageManager(declared.name) ? declared.name : null;
420
+ if (declared && !declaredManager) {
421
+ throw new Error(`worktree_dependency_manager_unsupported:${declared.name}`);
422
+ }
423
+
424
+ const lockfiles = worktreeLockfiles(worktreePath);
425
+ if (lockfiles.length === 0) throw new Error("worktree_dependency_lockfile_missing");
426
+ if (lockfiles.length > 1) {
427
+ throw new Error(`worktree_dependency_lockfile_ambiguous:${lockfiles.map(lockfile => lockfile.file).join(",")}`);
428
+ }
429
+ const lockfileManager = lockfiles[0]!.manager;
430
+ if (declaredManager && declaredManager !== lockfileManager) {
431
+ throw new Error(`worktree_dependency_manager_mismatch:${declaredManager}:${lockfileManager}`);
432
+ }
433
+ return { name: declaredManager ?? lockfileManager, version: declared?.version ?? null };
434
+ }
435
+
436
+ function removeLegacySourceNodeModulesLink(sourceRoot: string, worktreePath: string): void {
437
+ const target = path.join(worktreePath, "node_modules");
438
+ let targetStat: fs.Stats;
439
+ try {
440
+ targetStat = fs.lstatSync(target);
441
+ } catch (error) {
442
+ if (fileSystemErrorCode(error) === "ENOENT") return;
443
+ throw error;
444
+ }
445
+ if (!targetStat.isSymbolicLink()) return;
446
+
447
+ const sourceModules = path.join(sourceRoot, "node_modules");
448
+ const linkTarget = path.resolve(path.dirname(target), fs.readlinkSync(target));
449
+ let linksToSource = linkTarget === path.resolve(sourceModules);
450
+ try {
451
+ linksToSource = linksToSource || fs.realpathSync(target) === fs.realpathSync(sourceModules);
452
+ } catch (error) {
453
+ if (fileSystemErrorCode(error) !== "ENOENT") throw error;
454
+ }
455
+ if (!linksToSource) throw new Error(`worktree_node_modules_not_local:${target}`);
456
+ fs.unlinkSync(target);
457
+ }
458
+
459
+ function installedPackageManagerVersion(
460
+ name: WorktreePackageManager,
461
+ cwd: string,
462
+ probes: WorktreeDependencyProbes,
463
+ ): string {
464
+ const testVersion = probes.version?.(name);
465
+ if (testVersion !== undefined) return testVersion;
466
+ if (name === "bun") return Bun.version;
467
+ const result = Bun.spawnSync([name, "--version"], { cwd, stdout: "pipe", stderr: "ignore", timeout: 10_000 });
468
+ if (result.exitCode !== 0) throw new Error(`worktree_dependency_manager_unavailable:${name}`);
469
+ return result.stdout?.toString().trim() ?? "";
470
+ }
471
+
472
+ function worktreeInstallCommand(
473
+ manager: ResolvedWorktreePackageManager,
474
+ cwd: string,
475
+ probes: WorktreeDependencyProbes,
476
+ ): string[] {
477
+ const args = manager.name === "npm" ? ["ci"] : ["install", "--frozen-lockfile"];
478
+ if (manager.name !== "bun" && !(probes.isExecutableAvailable?.(manager.name) ?? Bun.which(manager.name) !== null)) {
479
+ throw new Error(`worktree_dependency_manager_unavailable:${manager.name}`);
480
+ }
481
+ if (manager.version) {
482
+ const installedVersion = installedPackageManagerVersion(manager.name, cwd, probes);
483
+ if (installedVersion !== manager.version) {
484
+ throw new Error(
485
+ `worktree_dependency_manager_version_mismatch:${manager.name}:${manager.version}:${installedVersion || "unknown"}`,
486
+ );
487
+ }
488
+ }
489
+ return [manager.name, ...args];
490
+ }
491
+
492
+ function boundedInstallFailure(stderr: string, exitCode: number): string {
493
+ const detail = stderr.trim().replace(/\s+/g, " ");
494
+ if (!detail) return `exit_${exitCode}`;
495
+ return detail.length <= 2048 ? detail : `${detail.slice(0, 2045)}...`;
496
+ }
497
+
498
+ function installWorktreeDependencies(
499
+ sourceRoot: string,
500
+ worktreePath: string,
501
+ manifest: WorktreePackageManifest | null,
502
+ probes: WorktreeDependencyProbes,
503
+ ): void {
504
+ removeLegacySourceNodeModulesLink(sourceRoot, worktreePath);
505
+ const manager = resolveWorktreePackageManager(worktreePath, manifest);
506
+ const command = worktreeInstallCommand(manager, worktreePath, probes);
507
+ let result: { exitCode: number; stderr: string };
508
+ try {
509
+ if (probes.spawnInstall) {
510
+ result = probes.spawnInstall(command, worktreePath);
511
+ } else {
512
+ const spawned = Bun.spawnSync(command, {
513
+ cwd: worktreePath,
514
+ stdout: "ignore",
515
+ stderr: "pipe",
516
+ timeout: 120_000,
517
+ maxBuffer: 64 * 1024,
518
+ });
519
+ result = { exitCode: spawned.exitCode, stderr: spawned.stderr?.toString() ?? "" };
520
+ }
521
+ } catch (error) {
522
+ const detail = error instanceof Error && error.message ? error.message : "spawn_failed";
523
+ throw new Error(`worktree_dependency_install_failed:${manager.name}:${detail.slice(0, 2048)}`);
524
+ }
525
+ if (result.exitCode !== 0) {
526
+ throw new Error(
527
+ `worktree_dependency_install_failed:${manager.name}:${boundedInstallFailure(result.stderr, result.exitCode)}`,
528
+ );
529
+ }
530
+
531
+ try {
532
+ const installed = fs.lstatSync(path.join(worktreePath, "node_modules"));
533
+ if (!installed.isDirectory() || installed.isSymbolicLink()) {
534
+ throw new Error("worktree_dependency_install_not_local");
535
+ }
536
+ } catch (error) {
537
+ if (fileSystemErrorCode(error) !== "ENOENT") throw error;
538
+ }
539
+ }
540
+
541
+ /** Package worktrees own their complete lockfile-resolved dependency graph. */
542
+
543
+ export function ensureReusableNodeModules(
544
+ sourceRoot: string,
545
+ worktreePath: string,
546
+ probes: WorktreeDependencyProbes = {},
547
+ ): "symlink" | "present" | "missing" {
294
548
  const target = path.join(worktreePath, "node_modules");
549
+ const manifest = readWorktreePackageManifest(worktreePath);
550
+ if (hasWorktreePackageMetadata(worktreePath, manifest)) {
551
+ installWorktreeDependencies(sourceRoot, worktreePath, manifest, probes);
552
+ return "present";
553
+ }
295
554
  if (fs.existsSync(target)) return "present";
296
555
  const source = path.join(sourceRoot, "node_modules");
297
556
  if (!fs.existsSync(source)) return "missing";
@@ -299,6 +558,20 @@ export function ensureReusableNodeModules(sourceRoot: string, worktreePath: stri
299
558
  return "symlink";
300
559
  }
301
560
 
561
+ export function launchWorktreeCwd(plan: SkcLaunchWorktreePlan): string {
562
+ return path.resolve(plan.worktreePath, plan.relativeCwd);
563
+ }
564
+
565
+ export function ensureLaunchWorktreeDependencies(plan: SkcLaunchWorktreePlan): "symlink" | "present" | "missing" {
566
+ if (plan.relativeDependencyRoot === null) {
567
+ return ensureReusableNodeModules(plan.repoRoot, plan.worktreePath);
568
+ }
569
+ return ensureReusableNodeModules(
570
+ path.resolve(plan.sourceCheckoutRoot, plan.relativeDependencyRoot),
571
+ path.resolve(plan.worktreePath, plan.relativeDependencyRoot),
572
+ );
573
+ }
574
+
302
575
  /** Result of {@link prepareLaunchWorktree}: the effective working directory, remaining args, and resolved worktree plan. */
303
576
  export interface PreparedLaunchWorktree {
304
577
  cwd: string;
@@ -311,6 +584,6 @@ export function prepareLaunchWorktree(cwd: string, args: string[]): PreparedLaun
311
584
  const planned = planLaunchWorktree(cwd, parsed.mode);
312
585
  const ensured = ensureLaunchWorktree(planned);
313
586
  if (!ensured.enabled) return { cwd, args: parsed.remainingArgs, worktree: ensured };
314
- ensureReusableNodeModules(ensured.repoRoot, ensured.worktreePath);
315
- return { cwd: ensured.worktreePath, args: parsed.remainingArgs, worktree: ensured };
587
+ ensureLaunchWorktreeDependencies(ensured);
588
+ return { cwd: launchWorktreeCwd(ensured), args: parsed.remainingArgs, worktree: ensured };
316
589
  }
@@ -0,0 +1,120 @@
1
+ // Live-system bindings for restore eligibility.
2
+ //
3
+ // `session-restore.ts` stays pure and injectable so its fail-closed decision
4
+ // table can be tested without a filesystem or a tmux server. This module is the
5
+ // thin adapter that supplies the real readings.
6
+
7
+ import * as fsSync from "node:fs";
8
+ import type { BootGeneration } from "./boot-generation";
9
+ import {
10
+ type RestoreCandidateDeps,
11
+ type RestorePointer,
12
+ type RestoreSidecarFacts,
13
+ readTranscriptSessionId,
14
+ } from "./session-restore";
15
+ import { SKC_COORDINATOR_SESSION_ID_ENV, SKC_COORDINATOR_SESSION_STATE_FILE_ENV } from "./session-state-sidecar";
16
+ import { resolveSkcTmuxBinary } from "./tmux-common";
17
+ import { createSkcTmuxSession, listSkcTmuxSessions } from "./tmux-sessions";
18
+
19
+ const TERMINAL_SIDECAR_STATES = new Set(["completed", "errored"]);
20
+
21
+ /**
22
+ * Strict re-read of the sidecar a pointer names.
23
+ *
24
+ * Returns null for missing, unreadable, or malformed content: restore must never
25
+ * infer a session's identity from the pointer alone.
26
+ */
27
+ export function readSidecarFacts(pointer: RestorePointer): RestoreSidecarFacts | null {
28
+ let parsed: Record<string, unknown>;
29
+ try {
30
+ parsed = JSON.parse(fsSync.readFileSync(pointer.state_file, "utf8")) as Record<string, unknown>;
31
+ } catch {
32
+ return null;
33
+ }
34
+ if (typeof parsed !== "object" || parsed === null) return null;
35
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : null;
36
+ if (!sessionId) return null;
37
+ return {
38
+ sessionId,
39
+ stateFile: pointer.state_file,
40
+ sessionFile: typeof parsed.session_file === "string" ? parsed.session_file : null,
41
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : null,
42
+ terminal: typeof parsed.state === "string" && TERMINAL_SIDECAR_STATES.has(parsed.state),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * True when a tmux session already carries this exact coordinator identity.
48
+ *
49
+ * An unreadable tmux is reported as a collision on purpose: not being able to
50
+ * see the server is not evidence that the identity is free.
51
+ */
52
+ export function hasLiveIdentity(pointer: RestorePointer, env: NodeJS.ProcessEnv = process.env): boolean {
53
+ try {
54
+ return listSkcTmuxSessions(env).some(
55
+ session =>
56
+ session.sessionId === pointer.coordinator_session_id && session.sessionStateFile === pointer.state_file,
57
+ );
58
+ } catch {
59
+ return true;
60
+ }
61
+ }
62
+
63
+ /** psmux exposes no immutable native session identity, so restore cannot prove ownership there. */
64
+ export function ownerProofAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
65
+ try {
66
+ return !resolveSkcTmuxBinary({ env }).isPsmux;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function buildRestoreCandidateDeps(
73
+ currentBoot: BootGeneration,
74
+ env: NodeJS.ProcessEnv = process.env,
75
+ ): RestoreCandidateDeps {
76
+ return {
77
+ currentBoot,
78
+ readSidecar: readSidecarFacts,
79
+ pathExists: target => fsSync.existsSync(target),
80
+ hasLiveIdentity: pointer => hasLiveIdentity(pointer, env),
81
+ readTranscriptSessionId: pointer => readTranscriptSessionId(pointer.session_file),
82
+ ownerProofAvailable: () => ownerProofAvailable(env),
83
+ };
84
+ }
85
+
86
+ export type RestoreOutcome =
87
+ | { ok: true; pointer: RestorePointer; tmuxSession: string }
88
+ | { ok: false; pointer: RestorePointer; code: string; detail: string };
89
+
90
+ /**
91
+ * Restores exactly one eligible candidate.
92
+ *
93
+ * Deliberately goes through the ordinary fenced creator rather than spawning
94
+ * tmux directly: restore must inherit the same identity fence, owner-isolation
95
+ * proof, and exact cleanup as every other producer. The only differences are the
96
+ * working directory and the `--resume` argv handed to the child.
97
+ *
98
+ * Never cleans up another owner's session. A fence refusal is reported and the
99
+ * candidate is skipped.
100
+ */
101
+ export function restoreSession(pointer: RestorePointer, env: NodeJS.ProcessEnv = process.env): RestoreOutcome {
102
+ // The child must adopt the recorded identity, not this process's.
103
+ const childEnv: NodeJS.ProcessEnv = {
104
+ ...env,
105
+ [SKC_COORDINATOR_SESSION_ID_ENV]: pointer.coordinator_session_id,
106
+ [SKC_COORDINATOR_SESSION_STATE_FILE_ENV]: pointer.state_file,
107
+ SKC_SESSION_ID: pointer.skc_session_id,
108
+ };
109
+ try {
110
+ const session = createSkcTmuxSession(childEnv, {
111
+ cwd: pointer.cwd,
112
+ childArgs: ["--resume", pointer.skc_session_id],
113
+ });
114
+ return { ok: true, pointer, tmuxSession: session.name };
115
+ } catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ const [code = "restore_failed"] = message.split(":");
118
+ return { ok: false, pointer, code, detail: message };
119
+ }
120
+ }