@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.
- package/CHANGELOG.md +185 -14
- package/dist/types/cli/update-cli.d.ts +8 -1
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/dap/client.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +5 -0
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +13 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +9 -0
- package/dist/types/session/session-storage.d.ts +17 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +22 -0
- package/dist/types/session-import/command.d.ts +20 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +28 -0
- package/dist/types/session-import/types.d.ts +125 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
- package/dist/types/slash-commands/types.d.ts +5 -0
- package/dist/types/tools/ask.d.ts +169 -4
- package/package.json +16 -7
- package/scripts/generate-sdk-operation-inventory.ts +5 -6
- package/src/cli/config-cli.ts +23 -8
- package/src/cli/update-cli.ts +265 -21
- package/src/commands/session.ts +88 -2
- package/src/config/atomic-yaml-patch.ts +167 -30
- package/src/config/keybindings.ts +10 -0
- package/src/config/model-profiles.ts +69 -1
- package/src/config/model-registry.ts +72 -38
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +9 -0
- package/src/config/settings.ts +13 -2
- package/src/config/telegram-autostart.ts +11 -4
- package/src/dap/client.ts +78 -30
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/lsp/client.ts +17 -29
- package/src/main.ts +1 -1
- package/src/modes/action-registry.ts +2 -0
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/components/welcome.ts +5 -0
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/controllers/goal-mode-controller.ts +7 -2
- package/src/modes/controllers/input-controller.ts +71 -6
- package/src/modes/controllers/plan-mode-controller.ts +8 -1
- package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +47 -19
- package/src/modes/interactive-mode.ts +54 -4
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/modes/types.ts +5 -1
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/runtime-mcp/transports/stdio.ts +96 -33
- package/src/sdk/broker/lifecycle.ts +6 -5
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +25 -18
- package/src/session/agent-session.ts +120 -25
- package/src/session/internal/managed-session-scope.ts +41 -6
- package/src/session/internal/managed-session-storage.ts +14 -0
- package/src/session/session-manager.ts +113 -11
- package/src/session/session-storage.ts +70 -0
- package/src/session-import/claude.ts +382 -0
- package/src/session-import/codex.ts +458 -0
- package/src/session-import/command.ts +61 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +27 -0
- package/src/session-import/redact.ts +125 -0
- package/src/session-import/service.ts +749 -0
- package/src/session-import/types.ts +163 -0
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/launch-worktree.ts +277 -4
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/acp-builtins.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +109 -5
- package/src/slash-commands/types.ts +5 -0
- package/src/tools/ask.ts +188 -10
- package/src/tools/eval.ts +2 -2
- package/src/export/html/template.css +0 -1060
- package/src/export/html/template.html +0 -47
- package/src/export/html/template.js +0 -2348
- package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
- package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
- package/vendor/insane-search/engine/tests/test_u1.py +0 -200
- package/vendor/insane-search/engine/tests/test_u4.py +0 -131
- package/vendor/insane-search/engine/tests/test_u5.py +0 -163
- package/vendor/insane-search/engine/tests/test_u7.py +0 -124
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
return { cwd: 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
|
+
}
|