@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral session-import contract (issue #3709).
|
|
3
|
+
*
|
|
4
|
+
* An import accepts ONE explicit user-selected Codex or Claude transcript/export
|
|
5
|
+
* file, normalizes it into the intermediate representation below, and rebuilds a
|
|
6
|
+
* bounded, redacted continuation context in one workspace-scoped native SKC
|
|
7
|
+
* session. A verified identical import is reused; otherwise a new session is
|
|
8
|
+
* published. The source file is only ever read; provider process state is never scraped.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Providers with a first-party observable transcript/export format. */
|
|
12
|
+
export type SessionImportProviderId = "codex" | "claude";
|
|
13
|
+
|
|
14
|
+
/** Concrete on-disk format variants the adapters understand. */
|
|
15
|
+
export type SessionImportFormatId = "codex-rollout-jsonl" | "claude-code-jsonl" | "claude-export-json";
|
|
16
|
+
|
|
17
|
+
/** Maximum quarantined-record digests retained in memory during import. */
|
|
18
|
+
export const SESSION_IMPORT_QUARANTINE_MAX_RECORDS = 512;
|
|
19
|
+
|
|
20
|
+
/** One normalized conversational unit produced by a provider adapter. */
|
|
21
|
+
export interface ImportedMessage {
|
|
22
|
+
/** Conversational role. Tool evidence is attached to assistant turns. */
|
|
23
|
+
role: "user" | "assistant";
|
|
24
|
+
/** Visible text (may be empty when only tool evidence exists). */
|
|
25
|
+
text: string;
|
|
26
|
+
/** ISO-8601 source timestamp when the record carried one. */
|
|
27
|
+
timestamp?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Bounded, already redacted tool/file evidence lines (e.g. `$ ls` → `ok`).
|
|
30
|
+
* Rendered inline after the text so completed work stays visible.
|
|
31
|
+
*/
|
|
32
|
+
toolEvidence?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Provider-neutral normalized conversation. */
|
|
36
|
+
export interface ImportedConversation {
|
|
37
|
+
provider: SessionImportProviderId;
|
|
38
|
+
format: SessionImportFormatId;
|
|
39
|
+
/** Source-side session/conversation identifier when the format carries one. */
|
|
40
|
+
sourceSessionId?: string;
|
|
41
|
+
/** Source title when the format carries one. */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Source workspace cwd when the format carries one. */
|
|
44
|
+
cwd?: string;
|
|
45
|
+
messages: ImportedMessage[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Deterministic, actionable import failure codes. */
|
|
49
|
+
export type SessionImportErrorCode =
|
|
50
|
+
| "invalid_request"
|
|
51
|
+
| "source_not_found"
|
|
52
|
+
| "source_unreadable"
|
|
53
|
+
| "source_changed"
|
|
54
|
+
| "unsupported_format"
|
|
55
|
+
| "format_mismatch"
|
|
56
|
+
| "malformed_input"
|
|
57
|
+
| "content_too_large"
|
|
58
|
+
| "destination_conflict"
|
|
59
|
+
| "io_failed";
|
|
60
|
+
|
|
61
|
+
/** Import pipeline phase a failure belongs to. */
|
|
62
|
+
export type SessionImportPhase =
|
|
63
|
+
| "request"
|
|
64
|
+
| "read"
|
|
65
|
+
| "detect"
|
|
66
|
+
| "parse"
|
|
67
|
+
| "normalize"
|
|
68
|
+
| "persist"
|
|
69
|
+
| "cleanup"
|
|
70
|
+
| "switch";
|
|
71
|
+
|
|
72
|
+
export class SessionImportError extends Error {
|
|
73
|
+
readonly code: SessionImportErrorCode;
|
|
74
|
+
readonly phase: SessionImportPhase;
|
|
75
|
+
readonly retryable: boolean;
|
|
76
|
+
readonly limitBytes?: number;
|
|
77
|
+
readonly observedBytes?: number;
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
code: SessionImportErrorCode,
|
|
81
|
+
phase: SessionImportPhase,
|
|
82
|
+
message: string,
|
|
83
|
+
options?: { retryable?: boolean; limitBytes?: number; observedBytes?: number },
|
|
84
|
+
) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "SessionImportError";
|
|
87
|
+
this.code = code;
|
|
88
|
+
this.phase = phase;
|
|
89
|
+
this.retryable = options?.retryable ?? false;
|
|
90
|
+
this.limitBytes = options?.limitBytes;
|
|
91
|
+
this.observedBytes = options?.observedBytes;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One record the adapter could not map. Never carries raw content. */
|
|
96
|
+
export interface ImportQuarantineRecord {
|
|
97
|
+
/** 1-based record/line number in the source. */
|
|
98
|
+
record: number;
|
|
99
|
+
byteLength: number;
|
|
100
|
+
sha256: string;
|
|
101
|
+
reason: "invalid_json" | "unknown_record" | "missing_fields" | "oversized_record";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Bounded tallies reported to the user and persisted in provenance. */
|
|
105
|
+
export interface SessionImportCounts {
|
|
106
|
+
/** Records successfully mapped into the normalized conversation. */
|
|
107
|
+
mapped: number;
|
|
108
|
+
/** Records quarantined (never silently dropped). */
|
|
109
|
+
quarantined: number;
|
|
110
|
+
/** Secret/credential redactions applied to imported text. */
|
|
111
|
+
redacted: number;
|
|
112
|
+
/** Messages omitted from the rendered context by head/tail bounding. */
|
|
113
|
+
omitted: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Provenance persisted as a `custom` session entry on the import target. */
|
|
117
|
+
export interface SessionImportProvenance {
|
|
118
|
+
schemaVersion: 1;
|
|
119
|
+
customType: "session-import";
|
|
120
|
+
provider: SessionImportProviderId;
|
|
121
|
+
format: SessionImportFormatId;
|
|
122
|
+
/** Basename of the explicit user-selected source file (never a full path). */
|
|
123
|
+
sourceFileName: string;
|
|
124
|
+
sourceSessionId?: string;
|
|
125
|
+
sourceTitle?: string;
|
|
126
|
+
/** SHA-256 over the exact source bytes that were imported. */
|
|
127
|
+
sourceSha256: string;
|
|
128
|
+
sourceBytes: number;
|
|
129
|
+
/** RFC 4122 UUID of the SKC session that received the import. */
|
|
130
|
+
targetSessionId: string;
|
|
131
|
+
importedAt: string;
|
|
132
|
+
converterVersion: number;
|
|
133
|
+
sanitizerVersion: number;
|
|
134
|
+
counts: SessionImportCounts;
|
|
135
|
+
/** True when head/tail bounding elided part of the conversation. */
|
|
136
|
+
truncated: boolean;
|
|
137
|
+
quarantine: { present: boolean; truncated: boolean };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fully prepared import: parsed, normalized, redacted, bounded. No session state mutated yet. */
|
|
141
|
+
export interface PreparedSessionImport {
|
|
142
|
+
conversation: ImportedConversation;
|
|
143
|
+
/** Rendered continuation context for the `custom_message` entry. */
|
|
144
|
+
contextText: string;
|
|
145
|
+
provenance: Omit<SessionImportProvenance, "targetSessionId" | "importedAt">;
|
|
146
|
+
counts: SessionImportCounts;
|
|
147
|
+
/** Distinct redaction pattern ids that fired (for the user-facing summary). */
|
|
148
|
+
redactionKinds: string[];
|
|
149
|
+
/** Bounded digest-only records for durable quarantine metadata. */
|
|
150
|
+
quarantineRecords: ImportQuarantineRecord[];
|
|
151
|
+
sourceSha256: string;
|
|
152
|
+
sourceBytes: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Result of a completed or idempotently reused imported session. */
|
|
156
|
+
export interface SessionImportCompleted {
|
|
157
|
+
targetSessionId: string;
|
|
158
|
+
targetPath: string;
|
|
159
|
+
title: string;
|
|
160
|
+
prepared: PreparedSessionImport;
|
|
161
|
+
/** True when the same source/version import already existed in this workspace. */
|
|
162
|
+
reused: boolean;
|
|
163
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-generation evidence for reboot-only session restore.
|
|
3
|
+
*
|
|
4
|
+
* Restore exists to survive a reboot, and only a reboot. The eligibility rule is
|
|
5
|
+
* therefore narrow on purpose: a session may be restored only when the recorded
|
|
6
|
+
* and current boot values come from the SAME source and differ. Anything else —
|
|
7
|
+
* equal values, a missing or malformed record, an unreadable probe, or two
|
|
8
|
+
* different sources — is inconclusive and must never spawn.
|
|
9
|
+
*
|
|
10
|
+
* The same-source requirement is not pedantry. Linux can report `boot-id` at
|
|
11
|
+
* record time and fall back to `proc-btime` later; those two values are always
|
|
12
|
+
* unequal even on one boot, so comparing across sources would read every launch
|
|
13
|
+
* as a reboot and duplicate live sessions.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fsSync from "node:fs";
|
|
17
|
+
|
|
18
|
+
/** Where a boot value came from. Values are only ever compared within one source. */
|
|
19
|
+
export type BootGenerationSource = "darwin-kern-boottime" | "linux-boot-id" | "linux-proc-btime" | "unavailable";
|
|
20
|
+
|
|
21
|
+
export interface BootGeneration {
|
|
22
|
+
source: BootGenerationSource;
|
|
23
|
+
/** Opaque; only equality within the same source is meaningful. */
|
|
24
|
+
value: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BootComparison = "changed" | "same_boot" | "boot_unknown";
|
|
28
|
+
|
|
29
|
+
const BOOT_GENERATION_SCHEMA_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
export interface RecordedBootGeneration {
|
|
32
|
+
schema_version: number;
|
|
33
|
+
source: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BootGenerationProbeDeps {
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
readFile?: (file: string) => string;
|
|
40
|
+
runCommand?: (command: string, args: string[]) => { exitCode: number | null; stdout: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultReadFile(file: string): string {
|
|
44
|
+
return fsSync.readFileSync(file, "utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultRunCommand(command: string, args: string[]): { exitCode: number | null; stdout: string } {
|
|
48
|
+
const result = Bun.spawnSync({ cmd: [command, ...args], stdout: "pipe", stderr: "ignore" });
|
|
49
|
+
return { exitCode: result.exitCode, stdout: result.stdout.toString() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `{ sec = 1785305532, usec = 377197 } Wed Jul 29 ...` -> `1785305532.377197`. */
|
|
53
|
+
export function parseDarwinBootTime(raw: string): string | null {
|
|
54
|
+
const match = /sec\s*=\s*(\d+)\s*,\s*usec\s*=\s*(\d+)/u.exec(raw);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
const sec = match[1];
|
|
57
|
+
const usec = match[2];
|
|
58
|
+
if (!sec || !usec) return null;
|
|
59
|
+
// A microsecond field at or above 1e6 is not a time this kernel produced;
|
|
60
|
+
// accepting it would let a malformed reading masquerade as a distinct boot.
|
|
61
|
+
if (Number.parseInt(usec, 10) >= 1_000_000) return null;
|
|
62
|
+
return `${sec}.${usec.padStart(6, "0")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A boot id is a UUID that changes on every boot; anything else is not usable. */
|
|
66
|
+
export function parseLinuxBootId(raw: string): string | null {
|
|
67
|
+
const value = raw.trim();
|
|
68
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value) ? value : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `/proc/stat` carries `btime <seconds>` once, as the kernel boot wall-clock. */
|
|
72
|
+
export function parseLinuxProcBtime(raw: string): string | null {
|
|
73
|
+
const values: string[] = [];
|
|
74
|
+
for (const line of raw.split("\n")) {
|
|
75
|
+
const match = /^btime\s+(\d+)\s*$/u.exec(line);
|
|
76
|
+
if (match?.[1]) values.push(match[1]);
|
|
77
|
+
}
|
|
78
|
+
// The kernel reports btime exactly once. More than one means the input is not
|
|
79
|
+
// a `/proc/stat` we can reason about, so refuse rather than pick one.
|
|
80
|
+
return values.length === 1 ? (values[0] ?? null) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reads the strongest boot evidence this platform offers.
|
|
85
|
+
*
|
|
86
|
+
* Windows returns `unavailable`: restore is unsupported there (no immutable
|
|
87
|
+
* native tmux session identity), so there is nothing to gate.
|
|
88
|
+
*/
|
|
89
|
+
export function readBootGeneration(deps: BootGenerationProbeDeps = {}): BootGeneration {
|
|
90
|
+
const platform = deps.platform ?? process.platform;
|
|
91
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
92
|
+
const runCommand = deps.runCommand ?? defaultRunCommand;
|
|
93
|
+
|
|
94
|
+
if (platform === "darwin") {
|
|
95
|
+
try {
|
|
96
|
+
const probed = runCommand("sysctl", ["-n", "kern.boottime"]);
|
|
97
|
+
if (probed.exitCode !== 0) return { source: "unavailable", value: null };
|
|
98
|
+
const value = parseDarwinBootTime(probed.stdout);
|
|
99
|
+
return value ? { source: "darwin-kern-boottime", value } : { source: "unavailable", value: null };
|
|
100
|
+
} catch {
|
|
101
|
+
return { source: "unavailable", value: null };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (platform === "linux") {
|
|
106
|
+
try {
|
|
107
|
+
const value = parseLinuxBootId(readFile("/proc/sys/kernel/random/boot_id"));
|
|
108
|
+
if (value) return { source: "linux-boot-id", value };
|
|
109
|
+
} catch {
|
|
110
|
+
// Fall through: some hardened kernels hide boot_id.
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const value = parseLinuxProcBtime(readFile("/proc/stat"));
|
|
114
|
+
if (value) return { source: "linux-proc-btime", value };
|
|
115
|
+
} catch {
|
|
116
|
+
// Fall through to unavailable.
|
|
117
|
+
}
|
|
118
|
+
return { source: "unavailable", value: null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { source: "unavailable", value: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Each source produces one exact shape. Validating it is what stops a value that
|
|
126
|
+
* merely differs textually — trailing space, a zero-width character — from being
|
|
127
|
+
* read as a different boot.
|
|
128
|
+
*/
|
|
129
|
+
const BOOT_VALUE_SHAPE: Record<string, RegExp> = {
|
|
130
|
+
"darwin-kern-boottime": /^\d+\.\d{6}$/u,
|
|
131
|
+
"linux-boot-id": /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u,
|
|
132
|
+
"linux-proc-btime": /^\d+$/u,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function isBootValueWellFormed(source: string, value: string): boolean {
|
|
136
|
+
return BOOT_VALUE_SHAPE[source]?.test(value) ?? false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isRecordedBootGeneration(value: unknown): value is RecordedBootGeneration {
|
|
140
|
+
if (typeof value !== "object" || value === null) return false;
|
|
141
|
+
const record = value as Record<string, unknown>;
|
|
142
|
+
return (
|
|
143
|
+
record.schema_version === BOOT_GENERATION_SCHEMA_VERSION &&
|
|
144
|
+
typeof record.source === "string" &&
|
|
145
|
+
record.source.length > 0 &&
|
|
146
|
+
record.source !== "unavailable" &&
|
|
147
|
+
typeof record.value === "string" &&
|
|
148
|
+
record.value.length > 0 &&
|
|
149
|
+
isBootValueWellFormed(record.source, record.value)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function recordBootGeneration(current: BootGeneration): RecordedBootGeneration | null {
|
|
154
|
+
if (current.source === "unavailable" || !current.value) return null;
|
|
155
|
+
if (!isBootValueWellFormed(current.source, current.value)) return null;
|
|
156
|
+
return { schema_version: BOOT_GENERATION_SCHEMA_VERSION, source: current.source, value: current.value };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Decides whether the machine rebooted since the session was recorded.
|
|
161
|
+
*
|
|
162
|
+
* `changed` is the ONLY executable answer. It requires a well-formed record, a
|
|
163
|
+
* readable current probe, an identical source, and different values. Source
|
|
164
|
+
* mismatch is deliberately `boot_unknown` rather than `changed`.
|
|
165
|
+
*/
|
|
166
|
+
export function compareBootGeneration(recorded: unknown, current: BootGeneration): BootComparison {
|
|
167
|
+
if (!isRecordedBootGeneration(recorded)) return "boot_unknown";
|
|
168
|
+
if (current.source === "unavailable" || !current.value) return "boot_unknown";
|
|
169
|
+
if (!isBootValueWellFormed(current.source, current.value)) return "boot_unknown";
|
|
170
|
+
if (recorded.source !== current.source) return "boot_unknown";
|
|
171
|
+
return recorded.value === current.value ? "same_boot" : "changed";
|
|
172
|
+
}
|
|
@@ -19,6 +19,13 @@ import {
|
|
|
19
19
|
SKC_TMUX_SESSION_PREFIX,
|
|
20
20
|
type SkcTmuxProfileCommand,
|
|
21
21
|
} from "./tmux-common";
|
|
22
|
+
import {
|
|
23
|
+
abandonIdentityCreate,
|
|
24
|
+
advanceIdentityCreatePhase,
|
|
25
|
+
type IdentityCreateReservation,
|
|
26
|
+
releaseIdentityCreate,
|
|
27
|
+
reserveIdentityCreate,
|
|
28
|
+
} from "./tmux-owner-isolation";
|
|
22
29
|
import { findSkcTmuxSessionByName, findSkcTmuxSessionByScope, type SkcTmuxSessionStatus } from "./tmux-sessions";
|
|
23
30
|
|
|
24
31
|
export {
|
|
@@ -65,6 +72,8 @@ export interface TmuxSpawnResult {
|
|
|
65
72
|
exitCode: number | null;
|
|
66
73
|
signalCode?: string | null;
|
|
67
74
|
stderr?: string;
|
|
75
|
+
/** Populated only when the caller asked for `stdout: "pipe"`. */
|
|
76
|
+
stdout?: string;
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
export type TmuxSpawnSync = (command: string, args: string[], options: TmuxSpawnOptions) => TmuxSpawnResult;
|
|
@@ -73,8 +82,8 @@ export interface TmuxSpawnOptions {
|
|
|
73
82
|
cwd: string;
|
|
74
83
|
env: NodeJS.ProcessEnv;
|
|
75
84
|
stdin: "inherit";
|
|
76
|
-
stdout: "inherit";
|
|
77
|
-
stderr: "inherit";
|
|
85
|
+
stdout: "inherit" | "pipe";
|
|
86
|
+
stderr: "inherit" | "pipe";
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
export interface TmuxLaunchPlan {
|
|
@@ -88,6 +97,12 @@ export interface TmuxLaunchPlan {
|
|
|
88
97
|
project?: string | null;
|
|
89
98
|
sessionId?: string | null;
|
|
90
99
|
sessionStateFile?: string | null;
|
|
100
|
+
/**
|
|
101
|
+
* Capability of the RESOLVED provider, not a platform guess. psmux has no
|
|
102
|
+
* immutable native session identity, so it stays outside the native-proof
|
|
103
|
+
* create fence and keeps its existing spawn/profile/attach behavior.
|
|
104
|
+
*/
|
|
105
|
+
isPsmux: boolean;
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
function explicitTmuxSessionName(env: NodeJS.ProcessEnv): string | undefined {
|
|
@@ -408,12 +423,106 @@ function readCurrentBranch(cwd: string): string | null {
|
|
|
408
423
|
}
|
|
409
424
|
}
|
|
410
425
|
|
|
411
|
-
|
|
426
|
+
/**
|
|
427
|
+
* Immutable identity of a session this process created, used so cleanup can
|
|
428
|
+
* never target a different session that merely reuses the name.
|
|
429
|
+
*/
|
|
430
|
+
interface CreatedSessionIdentity {
|
|
431
|
+
nativeSessionId: string;
|
|
432
|
+
serverPid: string;
|
|
433
|
+
/** Session birth time: distinguishes a reused `$id` on a restarted server. */
|
|
434
|
+
sessionCreated: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Reads `#{session_id}` and the server `#{pid}` for the session just created.
|
|
439
|
+
* Returns null when the provider cannot supply them, in which case cleanup falls
|
|
440
|
+
* back to the pre-existing name-based path.
|
|
441
|
+
*/
|
|
442
|
+
function captureCreatedSessionIdentity(
|
|
443
|
+
plan: TmuxLaunchPlan,
|
|
444
|
+
spawnSync: TmuxSpawnSync,
|
|
445
|
+
options: TmuxSpawnOptions,
|
|
446
|
+
): CreatedSessionIdentity | null {
|
|
447
|
+
const probed = spawnSync(
|
|
448
|
+
plan.tmuxCommand,
|
|
449
|
+
[
|
|
450
|
+
"display-message",
|
|
451
|
+
"-p",
|
|
452
|
+
"-t",
|
|
453
|
+
buildSkcTmuxExactSessionTarget(plan.sessionName, { env: options.env }),
|
|
454
|
+
"#{session_id}\t#{pid}\t#{session_created}",
|
|
455
|
+
],
|
|
456
|
+
{ ...options, stdout: "pipe" },
|
|
457
|
+
);
|
|
458
|
+
if (probed.exitCode !== 0 || typeof probed.stdout !== "string") return null;
|
|
459
|
+
const [nativeSessionId, serverPid, sessionCreated] = probed.stdout.trim().split("\t");
|
|
460
|
+
if (!nativeSessionId?.startsWith("$") || !serverPid || !sessionCreated) return null;
|
|
461
|
+
return { nativeSessionId, serverPid, sessionCreated };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Returns false when the created session was NOT provably removed, so the caller
|
|
466
|
+
* can keep the reservation as evidence for authority-first recovery.
|
|
467
|
+
*/
|
|
468
|
+
function cleanupCreatedTmuxSession(
|
|
469
|
+
plan: TmuxLaunchPlan,
|
|
470
|
+
spawnSync: TmuxSpawnSync,
|
|
471
|
+
options: TmuxSpawnOptions,
|
|
472
|
+
identity?: CreatedSessionIdentity | null,
|
|
473
|
+
): boolean {
|
|
474
|
+
if (plan.isPsmux) {
|
|
475
|
+
// Capability carveout: psmux exposes no immutable session identity, so the
|
|
476
|
+
// pre-existing name-based cleanup is all that is available there.
|
|
477
|
+
const killed = spawnSync(
|
|
478
|
+
plan.tmuxCommand,
|
|
479
|
+
["kill-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env: options.env })],
|
|
480
|
+
options,
|
|
481
|
+
);
|
|
482
|
+
return killed.exitCode === 0;
|
|
483
|
+
}
|
|
484
|
+
// Fail closed: without the immutable identity we cannot prove what we would be
|
|
485
|
+
// killing, and a session NAME may belong to somebody else by now. Leaking a
|
|
486
|
+
// session is recoverable; killing an unrelated one is not.
|
|
487
|
+
if (!identity) return false;
|
|
488
|
+
// One tmux invocation proves AND kills, so no other process can swap the
|
|
489
|
+
// target between the check and the kill. The predicate pins the server
|
|
490
|
+
// incarnation, the exact session id, and that session's birth time.
|
|
491
|
+
const predicate = [
|
|
492
|
+
`#{&&:#{==:#{pid},${identity.serverPid}}`,
|
|
493
|
+
`#{&&:#{==:#{session_id},${identity.nativeSessionId}}`,
|
|
494
|
+
`#{==:#{session_created},${identity.sessionCreated}}}}`,
|
|
495
|
+
].join(",");
|
|
412
496
|
spawnSync(
|
|
413
497
|
plan.tmuxCommand,
|
|
414
|
-
["
|
|
498
|
+
["if-shell", "-t", identity.nativeSessionId, "-F", predicate, `kill-session -t ${identity.nativeSessionId}`],
|
|
415
499
|
options,
|
|
416
500
|
);
|
|
501
|
+
// `if-shell` exits 0 even when the predicate is false and nothing ran, so its
|
|
502
|
+
// exit code is not proof. Ask whether that exact session is still there.
|
|
503
|
+
const survivor = spawnSync(plan.tmuxCommand, ["has-session", "-t", identity.nativeSessionId], {
|
|
504
|
+
...options,
|
|
505
|
+
stdout: "pipe",
|
|
506
|
+
stderr: "pipe",
|
|
507
|
+
});
|
|
508
|
+
// A signalled or otherwise unfinished probe answers nothing. Treating that as
|
|
509
|
+
// removal would delete the only evidence that a child may survive, so an
|
|
510
|
+
// indeterminate result fails closed.
|
|
511
|
+
if (typeof survivor.exitCode !== "number") return false;
|
|
512
|
+
// Zero means the session is still there.
|
|
513
|
+
if (survivor.exitCode === 0) return false;
|
|
514
|
+
// Non-zero is ambiguous on its own: tmux exits non-zero both for a session it
|
|
515
|
+
// cannot find and for an operational failure such as a refused socket. Only an
|
|
516
|
+
// explicit absence — including the whole server being gone — proves our child
|
|
517
|
+
// is no longer on a server we own. Anything else fails closed.
|
|
518
|
+
const diagnostic = (survivor.stderr ?? "").toLowerCase();
|
|
519
|
+
return (
|
|
520
|
+
diagnostic.includes("can't find session") ||
|
|
521
|
+
diagnostic.includes("cannot find session") ||
|
|
522
|
+
diagnostic.includes("session not found") ||
|
|
523
|
+
diagnostic.includes("no server running") ||
|
|
524
|
+
diagnostic.includes("no such file or directory")
|
|
525
|
+
);
|
|
417
526
|
}
|
|
418
527
|
function isTmuxAttachDisconnectError(result: TmuxSpawnResult): boolean {
|
|
419
528
|
if (result.signalCode === "SIGHUP") return true;
|
|
@@ -486,6 +595,7 @@ export function buildDefaultTmuxLaunchPlan(context: TmuxLaunchContext): TmuxLaun
|
|
|
486
595
|
sessionId,
|
|
487
596
|
sessionStateFile,
|
|
488
597
|
attachSessionName: existingSessionName,
|
|
598
|
+
isPsmux: resolvedBinary.isPsmux,
|
|
489
599
|
};
|
|
490
600
|
}
|
|
491
601
|
|
|
@@ -498,7 +608,12 @@ function defaultSpawnSync(command: string, args: string[], options: TmuxSpawnOpt
|
|
|
498
608
|
stdout: options.stdout,
|
|
499
609
|
stderr: options.stderr,
|
|
500
610
|
});
|
|
501
|
-
return {
|
|
611
|
+
return {
|
|
612
|
+
exitCode: result.exitCode,
|
|
613
|
+
signalCode: result.signalCode,
|
|
614
|
+
...(options.stdout === "pipe" ? { stdout: result.stdout?.toString() } : {}),
|
|
615
|
+
...(options.stderr === "pipe" ? { stderr: result.stderr?.toString() } : {}),
|
|
616
|
+
};
|
|
502
617
|
}
|
|
503
618
|
|
|
504
619
|
export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
|
|
@@ -525,49 +640,112 @@ export function launchDefaultTmuxIfNeeded(context: TmuxLaunchContext): boolean {
|
|
|
525
640
|
if (attached.exitCode === 0) return true;
|
|
526
641
|
}
|
|
527
642
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
643
|
+
// Shared identity create fence for the CREATED branch only. The attach
|
|
644
|
+
// fallback above is not a creator and stays unfenced.
|
|
645
|
+
//
|
|
646
|
+
// Capability, not platform: psmux exposes no immutable native session
|
|
647
|
+
// identity, so it cannot participate in the native-proof fence and keeps its
|
|
648
|
+
// existing behavior. That carveout is safe because restore already ends in
|
|
649
|
+
// `unsupported_owner_proof` before it can create on psmux, so no restore
|
|
650
|
+
// child can race this producer.
|
|
651
|
+
const fenceIdentity =
|
|
652
|
+
plan.isPsmux || !plan.sessionId || !plan.sessionStateFile
|
|
653
|
+
? null
|
|
654
|
+
: {
|
|
655
|
+
stateDir: path.dirname(plan.sessionStateFile),
|
|
656
|
+
sessionId: plan.sessionId,
|
|
657
|
+
stateFile: plan.sessionStateFile,
|
|
658
|
+
};
|
|
659
|
+
let reservation: IdentityCreateReservation | null = null;
|
|
660
|
+
let cleanupUncertain = false;
|
|
661
|
+
if (fenceIdentity) {
|
|
662
|
+
const reserved = reserveIdentityCreate(fenceIdentity);
|
|
663
|
+
if (!reserved.ok) {
|
|
664
|
+
// A PROVEN competing owner blocks the launch: perform no tmux mutation
|
|
665
|
+
// (no spawn, no attach, no delete). Falling through to a direct
|
|
666
|
+
// in-process launch is refused too, because it would put a second
|
|
667
|
+
// writer on the same coordinator transcript.
|
|
668
|
+
// Fail closed for every non-ok result. The fence database lives under
|
|
669
|
+
// SKC's own config root, so an unreachable fence is not a routine
|
|
670
|
+
// environment difference: it means the one-create guarantee cannot be
|
|
671
|
+
// honored, and launching anyway risks a second writer on this identity.
|
|
672
|
+
(context.diagnosticWriter ?? safeStderrWrite)(
|
|
673
|
+
formatTmuxLaunchDiagnostic("identity create fence", `${reserved.code}: ${reserved.diagnostic}`),
|
|
674
|
+
);
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
reservation = reserved.reservation;
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
// Record the attempt name BEFORE the first spawn-capable call, exactly like
|
|
681
|
+
// the other producers. Without it a crash between `new-session` and the
|
|
682
|
+
// release leaves the row at `reserved`, which a successor reclaims WITHOUT a
|
|
683
|
+
// census — and that is how a second owner reaches one transcript.
|
|
684
|
+
if (reservation) {
|
|
685
|
+
reservation =
|
|
686
|
+
advanceIdentityCreatePhase(reservation, "helper_invoked", { attemptSessionName: plan.sessionName }) ??
|
|
687
|
+
(() => {
|
|
688
|
+
throw new Error("skc_tmux_identity_create_fence_lost");
|
|
689
|
+
})();
|
|
690
|
+
}
|
|
691
|
+
const created = spawnSync(plan.tmuxCommand, plan.newSessionArgs, options);
|
|
692
|
+
// Capture the immutable native identity while we still know the session we
|
|
693
|
+
// just made is the one behind this name. psmux cannot supply it, which is
|
|
694
|
+
// exactly why it stays outside the native-proof guarantee.
|
|
695
|
+
const createdIdentity =
|
|
696
|
+
created.exitCode === 0 && !plan.isPsmux ? captureCreatedSessionIdentity(plan, spawnSync, options) : null;
|
|
697
|
+
if (created.exitCode === 0) {
|
|
698
|
+
renameTmuxWindow(
|
|
699
|
+
plan.tmuxCommand,
|
|
700
|
+
buildSkcTmuxWindowTitle(plan.project ?? plan.cwd, plan.branch),
|
|
701
|
+
spawnSync,
|
|
702
|
+
options,
|
|
703
|
+
buildSkcTmuxExactSessionTarget(plan.sessionName, { env }),
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
const profile = applySkcTmuxProfile({
|
|
707
|
+
tmuxCommand: plan.tmuxCommand,
|
|
708
|
+
target: plan.sessionName,
|
|
709
|
+
cwd: plan.cwd,
|
|
710
|
+
env,
|
|
711
|
+
spawnSync,
|
|
712
|
+
branch: plan.branch,
|
|
713
|
+
project: plan.project,
|
|
714
|
+
sessionId: plan.sessionId ?? null,
|
|
715
|
+
sessionStateFile: plan.sessionStateFile ?? null,
|
|
716
|
+
version: VERSION,
|
|
717
|
+
});
|
|
718
|
+
const ownershipFailure = profile.failures.find(item => item.command.args.includes("@skc-profile"));
|
|
719
|
+
if (ownershipFailure) {
|
|
720
|
+
if (!cleanupCreatedTmuxSession(plan, spawnSync, options, createdIdentity)) cleanupUncertain = true;
|
|
721
|
+
(context.diagnosticWriter ?? safeStderrWrite)(
|
|
722
|
+
formatTmuxLaunchDiagnostic("profile tagging failed", ownershipFailure.stderr),
|
|
723
|
+
);
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (created.exitCode !== 0) return false;
|
|
728
|
+
const attached = spawnSync(
|
|
531
729
|
plan.tmuxCommand,
|
|
532
|
-
|
|
533
|
-
spawnSync,
|
|
730
|
+
["attach-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env })],
|
|
534
731
|
options,
|
|
535
|
-
buildSkcTmuxExactSessionTarget(plan.sessionName, { env }),
|
|
536
732
|
);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
tmuxCommand: plan.tmuxCommand,
|
|
540
|
-
target: plan.sessionName,
|
|
541
|
-
cwd: plan.cwd,
|
|
542
|
-
env,
|
|
543
|
-
spawnSync,
|
|
544
|
-
branch: plan.branch,
|
|
545
|
-
project: plan.project,
|
|
546
|
-
sessionId: plan.sessionId ?? null,
|
|
547
|
-
sessionStateFile: plan.sessionStateFile ?? null,
|
|
548
|
-
version: VERSION,
|
|
549
|
-
});
|
|
550
|
-
const ownershipFailure = profile.failures.find(item => item.command.args.includes("@skc-profile"));
|
|
551
|
-
if (ownershipFailure) {
|
|
552
|
-
cleanupCreatedTmuxSession(plan, spawnSync, options);
|
|
733
|
+
if (attached.exitCode === 0) return true;
|
|
734
|
+
if (isTmuxAttachDisconnectError(attached)) {
|
|
553
735
|
(context.diagnosticWriter ?? safeStderrWrite)(
|
|
554
|
-
formatTmuxLaunchDiagnostic("
|
|
736
|
+
formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr),
|
|
555
737
|
);
|
|
556
738
|
return true;
|
|
557
739
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const attached = spawnSync(
|
|
561
|
-
plan.tmuxCommand,
|
|
562
|
-
["attach-session", "-t", buildSkcTmuxExactSessionTarget(plan.sessionName, { env })],
|
|
563
|
-
options,
|
|
564
|
-
);
|
|
565
|
-
if (attached.exitCode === 0) return true;
|
|
566
|
-
if (isTmuxAttachDisconnectError(attached)) {
|
|
567
|
-
(context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach disconnected", attached.stderr));
|
|
740
|
+
if (!cleanupCreatedTmuxSession(plan, spawnSync, options, createdIdentity)) cleanupUncertain = true;
|
|
741
|
+
(context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
|
|
568
742
|
return true;
|
|
743
|
+
} finally {
|
|
744
|
+
if (reservation) {
|
|
745
|
+
// An unproven cleanup may have left a child; its row is the only evidence
|
|
746
|
+
// a successor gets, so end the attempt without deleting it.
|
|
747
|
+
if (cleanupUncertain) abandonIdentityCreate(reservation);
|
|
748
|
+
else releaseIdentityCreate(reservation);
|
|
749
|
+
}
|
|
569
750
|
}
|
|
570
|
-
cleanupCreatedTmuxSession(plan, spawnSync, options);
|
|
571
|
-
(context.diagnosticWriter ?? safeStderrWrite)(formatTmuxLaunchDiagnostic("attach failed", attached.stderr));
|
|
572
|
-
return true;
|
|
573
751
|
}
|