@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
package/src/cli/config-cli.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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}
|
|
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
|
|
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(
|
|
258
|
+
throw new Error("Invalid array JSON");
|
|
255
259
|
}
|
|
256
260
|
if (!Array.isArray(parsed)) {
|
|
257
|
-
throw new Error(
|
|
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(
|
|
271
|
+
throw new Error("Invalid record JSON");
|
|
268
272
|
}
|
|
269
273
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
270
|
-
throw new Error(
|
|
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 {
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
473
|
-
await
|
|
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
|
|
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 →
|
|
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
|
|
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
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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));
|
package/src/commands/session.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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) {
|