@sayknow-cli/coding-agent 0.5.1 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -0,0 +1,749 @@
1
+ /** Provider-neutral, explicit-file session import service. */
2
+ import { createHash } from "node:crypto";
3
+ import type * as fs from "node:fs";
4
+ import * as nodeFs from "node:fs";
5
+ import * as fsp from "node:fs/promises";
6
+ import * as path from "node:path";
7
+ import { canContinuePersistedHistory } from "@sayknow-cli/agent-core";
8
+ import { getAgentDir } from "@sayknow-cli/utils";
9
+ import { withFileLock } from "../config/file-lock";
10
+ import { type SessionDestinationInput, SessionManager } from "../session/session-manager";
11
+ import { FileSessionStorage, type SessionStorageSnapshot } from "../session/session-storage";
12
+ import { parseClaudeCodeTranscript, parseClaudeExport } from "./claude";
13
+ import { parseCodexRollout } from "./codex";
14
+ import { detectSessionImportFormat } from "./detect";
15
+ import { IMPORT_SANITIZER_VERSION, redactImportedText } from "./redact";
16
+ import {
17
+ type ImportedConversation,
18
+ type ImportQuarantineRecord,
19
+ type PreparedSessionImport,
20
+ SESSION_IMPORT_QUARANTINE_MAX_RECORDS,
21
+ type SessionImportCompleted,
22
+ type SessionImportCounts,
23
+ SessionImportError,
24
+ type SessionImportProvenance,
25
+ type SessionImportProviderId,
26
+ } from "./types";
27
+
28
+ export const IMPORT_CONVERTER_VERSION = 1;
29
+ /** Durable metadata only; it is not added to model context. */
30
+ export const SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE = "session-import";
31
+ export const SESSION_IMPORT_QUARANTINE_CUSTOM_TYPE = "session-import-quarantine";
32
+ export const SESSION_IMPORT_COMPLETION_CUSTOM_TYPE = "session-import-complete";
33
+ /** Displayed custom-message context reconstructed from the imported transcript. */
34
+ export const SESSION_IMPORT_CONTEXT_CUSTOM_TYPE = "session-import";
35
+ export const SESSION_IMPORT_SOURCE_MAX_BYTES = 64 * 1024 * 1024;
36
+ const MAX_SOURCE_MESSAGES = 5000;
37
+ const MAX_CONTEXT_CHARS = 120_000;
38
+ const HEAD_CONTEXT_CHARS = 20_000;
39
+ const MAX_MESSAGE_CHARS = 16_000;
40
+ const MAX_TITLE_CHARS = 200;
41
+ const MAX_SOURCE_SESSION_ID_CHARS = 512;
42
+ const MAX_SOURCE_CWD_CHARS = 4096;
43
+ const SOURCE_READ_CHUNK_BYTES = 1024 * 1024;
44
+ const MAX_NATIVE_IMPORT_CANDIDATES = 512;
45
+ const MAX_NATIVE_IMPORT_SCAN_BYTES = 128 * 1024 * 1024;
46
+
47
+ export interface SessionImportRequest {
48
+ sourcePath: string;
49
+ provider?: SessionImportProviderId;
50
+ cwd: string;
51
+ destination?: SessionDestinationInput;
52
+ now?: () => Date;
53
+ }
54
+
55
+ /** Invocation-scoped mutation seam used only by TOCTOU regression tests. */
56
+ export interface SessionImportTestProbe {
57
+ afterSourceOpen?: (resolvedSourcePath: string) => void | Promise<void>;
58
+ afterSourceIdentityCheck?: (resolvedSourcePath: string) => void | Promise<void>;
59
+ }
60
+
61
+ interface ParsedSource {
62
+ conversation: ImportedConversation;
63
+ quarantine: { present: boolean; truncated: boolean };
64
+ quarantineRecords: ImportQuarantineRecord[];
65
+ counts: SessionImportCounts;
66
+ redactionKinds: string[];
67
+ }
68
+
69
+ /** Reads only the selected regular file, with a stable identity check. */
70
+ async function readImportSource(
71
+ sourcePath: string,
72
+ testProbe?: SessionImportTestProbe,
73
+ ): Promise<{ text: string; bytes: number; sha256: string }> {
74
+ const resolved = path.resolve(sourcePath);
75
+ const sourceLabel = redactImportedText(path.basename(resolved)).value || "selected transcript";
76
+ let before: fs.Stats;
77
+ try {
78
+ before = await fsp.lstat(resolved);
79
+ } catch (error) {
80
+ const code = (error as NodeJS.ErrnoException).code;
81
+ if (code === "ENOENT")
82
+ throw new SessionImportError("source_not_found", "read", `Transcript file does not exist: ${sourceLabel}`);
83
+ throw new SessionImportError(
84
+ "source_unreadable",
85
+ "read",
86
+ `Transcript file cannot be read (${code ?? "unknown error"}): ${sourceLabel}`,
87
+ { retryable: true },
88
+ );
89
+ }
90
+ if (!before.isFile() || before.isSymbolicLink())
91
+ throw new SessionImportError("invalid_request", "read", `Import source must be a regular file: ${sourceLabel}`);
92
+ if (before.size === 0)
93
+ throw new SessionImportError("malformed_input", "parse", `Transcript file is empty: ${sourceLabel}`);
94
+ if (before.size > SESSION_IMPORT_SOURCE_MAX_BYTES)
95
+ throw new SessionImportError(
96
+ "content_too_large",
97
+ "read",
98
+ `Transcript exceeds the ${SESSION_IMPORT_SOURCE_MAX_BYTES}-byte import limit.`,
99
+ { limitBytes: SESSION_IMPORT_SOURCE_MAX_BYTES, observedBytes: before.size },
100
+ );
101
+ let handle: fsp.FileHandle;
102
+ try {
103
+ handle = await fsp.open(resolved, nodeFs.constants.O_RDONLY | nodeFs.constants.O_NOFOLLOW);
104
+ } catch (error) {
105
+ const code = (error as NodeJS.ErrnoException).code;
106
+ throw new SessionImportError(
107
+ "source_unreadable",
108
+ "read",
109
+ `Transcript file cannot be read (${code ?? "unknown error"}): ${sourceLabel}`,
110
+ { retryable: true },
111
+ );
112
+ }
113
+ let sourceBytes: Buffer;
114
+ try {
115
+ await testProbe?.afterSourceOpen?.(resolved);
116
+ const opened = await handle.stat();
117
+ if (
118
+ !opened.isFile() ||
119
+ opened.size !== before.size ||
120
+ opened.mtimeMs !== before.mtimeMs ||
121
+ opened.ctimeMs !== before.ctimeMs ||
122
+ opened.nlink !== before.nlink ||
123
+ opened.dev !== before.dev ||
124
+ opened.ino !== before.ino
125
+ ) {
126
+ throw new SessionImportError(
127
+ "source_changed",
128
+ "read",
129
+ "The transcript file changed while it was being read.",
130
+ {
131
+ retryable: true,
132
+ },
133
+ );
134
+ }
135
+ await testProbe?.afterSourceIdentityCheck?.(resolved);
136
+ const chunks: Buffer[] = [];
137
+ let bytes = 0;
138
+ for (;;) {
139
+ const capacity = Math.min(SOURCE_READ_CHUNK_BYTES, SESSION_IMPORT_SOURCE_MAX_BYTES + 1 - bytes);
140
+ const chunk = Buffer.allocUnsafe(capacity);
141
+ const { bytesRead } = await handle.read(chunk, 0, capacity, bytes);
142
+ if (bytesRead === 0) break;
143
+ chunks.push(chunk.subarray(0, bytesRead));
144
+ bytes += bytesRead;
145
+ if (bytes > SESSION_IMPORT_SOURCE_MAX_BYTES) {
146
+ throw new SessionImportError(
147
+ "content_too_large",
148
+ "read",
149
+ `Transcript exceeds the ${SESSION_IMPORT_SOURCE_MAX_BYTES}-byte import limit.`,
150
+ { limitBytes: SESSION_IMPORT_SOURCE_MAX_BYTES, observedBytes: bytes },
151
+ );
152
+ }
153
+ }
154
+ const terminal = await handle.stat();
155
+ if (
156
+ terminal.size !== opened.size ||
157
+ terminal.mtimeMs !== opened.mtimeMs ||
158
+ terminal.ctimeMs !== opened.ctimeMs ||
159
+ terminal.nlink !== opened.nlink ||
160
+ terminal.dev !== opened.dev ||
161
+ terminal.ino !== opened.ino ||
162
+ terminal.size !== bytes
163
+ ) {
164
+ throw new SessionImportError(
165
+ "source_changed",
166
+ "read",
167
+ "The transcript file changed while it was being read.",
168
+ { retryable: true },
169
+ );
170
+ }
171
+ sourceBytes = Buffer.concat(chunks, bytes);
172
+ } finally {
173
+ await handle.close();
174
+ }
175
+ const bytes = sourceBytes.byteLength;
176
+ const after = await fsp.lstat(resolved).catch(() => undefined);
177
+ if (
178
+ !after ||
179
+ after.size !== before.size ||
180
+ after.mtimeMs !== before.mtimeMs ||
181
+ after.ctimeMs !== before.ctimeMs ||
182
+ after.nlink !== before.nlink ||
183
+ after.dev !== before.dev ||
184
+ after.ino !== before.ino
185
+ ) {
186
+ throw new SessionImportError("source_changed", "read", "The transcript file changed while it was being read.", {
187
+ retryable: true,
188
+ });
189
+ }
190
+ let text: string;
191
+ try {
192
+ text = new TextDecoder("utf-8", { fatal: true }).decode(sourceBytes);
193
+ } catch {
194
+ throw new SessionImportError("malformed_input", "parse", "Transcript is not valid UTF-8.");
195
+ }
196
+ return { text, bytes, sha256: createHash("sha256").update(sourceBytes).digest("hex") };
197
+ }
198
+
199
+ function normalizeAdapterResult(result: {
200
+ conversation: ImportedConversation;
201
+ quarantine: ImportQuarantineRecord[];
202
+ counts: SessionImportCounts;
203
+ redactionKinds: string[];
204
+ }): ParsedSource {
205
+ return {
206
+ conversation: result.conversation,
207
+ quarantine: {
208
+ present: result.counts.quarantined > 0,
209
+ truncated:
210
+ result.counts.quarantined > result.quarantine.length ||
211
+ result.quarantine.length > SESSION_IMPORT_QUARANTINE_MAX_RECORDS,
212
+ },
213
+ quarantineRecords: result.quarantine.slice(0, SESSION_IMPORT_QUARANTINE_MAX_RECORDS),
214
+ counts: result.counts,
215
+ redactionKinds: result.redactionKinds,
216
+ };
217
+ }
218
+ function parseDetectedFormat(detection: ReturnType<typeof detectSessionImportFormat>, text: string): ParsedSource {
219
+ switch (detection.format) {
220
+ case "codex-rollout-jsonl":
221
+ return normalizeAdapterResult(parseCodexRollout(text));
222
+ case "claude-code-jsonl":
223
+ return normalizeAdapterResult(parseClaudeCodeTranscript(text));
224
+ case "claude-export-json":
225
+ return normalizeAdapterResult(parseClaudeExport(text));
226
+ }
227
+ }
228
+ function truncateText(text: string, maxChars: number): string {
229
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
230
+ }
231
+ function sanitizeConversationAtBoundary(
232
+ conversation: ImportedConversation,
233
+ counts: SessionImportCounts,
234
+ redactionKinds: string[],
235
+ ): void {
236
+ const kinds = new Set(redactionKinds);
237
+ const sanitize = (value: string): string => {
238
+ const result = redactImportedText(value);
239
+ counts.redacted += result.redacted;
240
+ for (const kind of result.kinds) kinds.add(kind);
241
+ return result.value;
242
+ };
243
+ if (conversation.sourceSessionId !== undefined)
244
+ conversation.sourceSessionId = sanitize(conversation.sourceSessionId);
245
+ if (conversation.title !== undefined) conversation.title = sanitize(conversation.title);
246
+ if (conversation.cwd !== undefined) conversation.cwd = sanitize(conversation.cwd);
247
+ for (const message of conversation.messages) {
248
+ if (message.text !== undefined) message.text = sanitize(message.text);
249
+ if (message.toolEvidence !== undefined) message.toolEvidence = message.toolEvidence.map(sanitize);
250
+ }
251
+ redactionKinds.splice(0, redactionKinds.length, ...[...kinds].sort());
252
+ }
253
+
254
+ function canonicalizeTimestamps(conversation: ImportedConversation): void {
255
+ for (const message of conversation.messages) {
256
+ if (message.timestamp === undefined) continue;
257
+ const timestamp = Date.parse(message.timestamp);
258
+ if (!Number.isFinite(timestamp))
259
+ throw new SessionImportError(
260
+ "malformed_input",
261
+ "normalize",
262
+ "A message carries an unparseable timestamp; the transcript is not a clean export.",
263
+ );
264
+ message.timestamp = new Date(timestamp).toISOString();
265
+ }
266
+ }
267
+
268
+ /** Deterministic bounded head/tail continuation context. */
269
+ function renderImportContext(conversation: ImportedConversation): {
270
+ text: string;
271
+ omitted: number;
272
+ truncated: boolean;
273
+ } {
274
+ const title = conversation.title ? ` titled "${truncateText(conversation.title, MAX_TITLE_CHARS)}"` : "";
275
+ const prefix = [
276
+ `<imported-session provider="${conversation.provider}" format="${conversation.format}">`,
277
+ "",
278
+ `The conversation below was imported from an external ${conversation.provider === "codex" ? "Codex" : "Claude"} session transcript${title}.`,
279
+ "It is reconstructed context only: roles and text are preserved, but tool/internal provider state was not cloned, and secrets were redacted.",
280
+ "Continue this conversation naturally as its current participant.",
281
+ "",
282
+ ];
283
+ const rendered = conversation.messages.flatMap(message => {
284
+ const blocks: string[] = [];
285
+ if (message.text) blocks.push(truncateText(message.text, MAX_MESSAGE_CHARS));
286
+ if (message.toolEvidence?.length)
287
+ blocks.push(["Tool/file evidence:", ...message.toolEvidence.map(line => ` ${line}`)].join("\n"));
288
+ return blocks.length ? [`### ${message.role === "user" ? "User" : "Assistant"}\n\n${blocks.join("\n\n")}`] : [];
289
+ });
290
+ const buildText = (body: readonly string[]): string => [...prefix, ...body, "", "</imported-session>"].join("\n");
291
+ const fullText = buildText(rendered);
292
+ if (fullText.length <= MAX_CONTEXT_CHARS) {
293
+ return { text: fullText, omitted: 0, truncated: false };
294
+ }
295
+
296
+ const head: string[] = [];
297
+ const tail: string[] = [];
298
+ let headChars = 0;
299
+ const marker = (omitted: number): string =>
300
+ `[… ${omitted} earlier message${omitted === 1 ? "" : "s"} from the imported ${conversation.provider} session were elided to bound context; the continuation tail is preserved below. …]`;
301
+
302
+ while (head.length + tail.length < rendered.length - 1) {
303
+ const next = rendered[head.length]!;
304
+ if (headChars + next.length + 2 > HEAD_CONTEXT_CHARS) break;
305
+ const candidate = [...head, next];
306
+ const omitted = rendered.length - candidate.length - tail.length;
307
+ if (buildText([...candidate, marker(omitted), ...tail]).length > MAX_CONTEXT_CHARS) break;
308
+ head.push(next);
309
+ headChars += next.length + 2;
310
+ }
311
+ for (let index = rendered.length - 1; index >= head.length + 1; index--) {
312
+ const candidateTail = [rendered[index]!, ...tail];
313
+ const omitted = rendered.length - head.length - candidateTail.length;
314
+ if (buildText([...head, marker(omitted), ...candidateTail]).length > MAX_CONTEXT_CHARS) break;
315
+ tail.unshift(rendered[index]!);
316
+ }
317
+ const omitted = rendered.length - head.length - tail.length;
318
+ const text = buildText([...head, marker(omitted), ...tail]);
319
+ return { text, omitted, truncated: true };
320
+ }
321
+
322
+ /** Read, detect, parse, normalize, redact, and bound without session mutation. */
323
+ export async function prepareSessionImport(
324
+ request: Pick<SessionImportRequest, "sourcePath" | "provider">,
325
+ testProbe?: SessionImportTestProbe,
326
+ ): Promise<PreparedSessionImport> {
327
+ if (typeof request.sourcePath !== "string" || request.sourcePath.trim().length === 0)
328
+ throw new SessionImportError(
329
+ "invalid_request",
330
+ "request",
331
+ "A transcript file path is required: /import-session <file> [--provider codex|claude]",
332
+ );
333
+ if (request.provider !== undefined && request.provider !== "codex" && request.provider !== "claude")
334
+ throw new SessionImportError("invalid_request", "request", "Unsupported provider; expected codex or claude.");
335
+ const source = await readImportSource(request.sourcePath, testProbe);
336
+ const detection = detectSessionImportFormat(source.text, request.provider);
337
+ const parsed = parseDetectedFormat(detection, source.text);
338
+ sanitizeConversationAtBoundary(parsed.conversation, parsed.counts, parsed.redactionKinds);
339
+ canonicalizeTimestamps(parsed.conversation);
340
+ if (parsed.conversation.messages.length === 0)
341
+ throw new SessionImportError(
342
+ "malformed_input",
343
+ "parse",
344
+ `No importable user/assistant messages were found in the ${detection.format} transcript.`,
345
+ );
346
+ if (parsed.conversation.messages.length > MAX_SOURCE_MESSAGES)
347
+ throw new SessionImportError(
348
+ "content_too_large",
349
+ "normalize",
350
+ `The transcript has ${parsed.conversation.messages.length} messages, exceeding the ${MAX_SOURCE_MESSAGES}-message import limit.`,
351
+ { limitBytes: MAX_SOURCE_MESSAGES, observedBytes: parsed.conversation.messages.length },
352
+ );
353
+ let metadataTruncated = false;
354
+ if (
355
+ parsed.conversation.sourceSessionId?.length &&
356
+ parsed.conversation.sourceSessionId.length > MAX_SOURCE_SESSION_ID_CHARS
357
+ ) {
358
+ parsed.conversation.sourceSessionId = truncateText(
359
+ parsed.conversation.sourceSessionId,
360
+ MAX_SOURCE_SESSION_ID_CHARS,
361
+ );
362
+ metadataTruncated = true;
363
+ }
364
+ if (parsed.conversation.title?.length && parsed.conversation.title.length > MAX_TITLE_CHARS) {
365
+ parsed.conversation.title = truncateText(parsed.conversation.title, MAX_TITLE_CHARS);
366
+ metadataTruncated = true;
367
+ }
368
+ if (parsed.conversation.cwd?.length && parsed.conversation.cwd.length > MAX_SOURCE_CWD_CHARS) {
369
+ parsed.conversation.cwd = truncateText(parsed.conversation.cwd, MAX_SOURCE_CWD_CHARS);
370
+ metadataTruncated = true;
371
+ }
372
+ const rendered = renderImportContext(parsed.conversation);
373
+ const sourceFileNameRedaction = redactImportedText(path.basename(path.resolve(request.sourcePath)));
374
+ const counts: SessionImportCounts = {
375
+ ...parsed.counts,
376
+ redacted: parsed.counts.redacted + sourceFileNameRedaction.redacted,
377
+ omitted: parsed.counts.omitted + rendered.omitted,
378
+ };
379
+ const redactionKinds = [...new Set([...parsed.redactionKinds, ...sourceFileNameRedaction.kinds])].sort();
380
+ const provenance: PreparedSessionImport["provenance"] = {
381
+ schemaVersion: 1,
382
+ customType: SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE,
383
+ provider: parsed.conversation.provider,
384
+ format: parsed.conversation.format,
385
+ sourceFileName: sourceFileNameRedaction.value,
386
+ ...(parsed.conversation.sourceSessionId ? { sourceSessionId: parsed.conversation.sourceSessionId } : {}),
387
+ ...(parsed.conversation.title ? { sourceTitle: truncateText(parsed.conversation.title, MAX_TITLE_CHARS) } : {}),
388
+ sourceSha256: source.sha256,
389
+ sourceBytes: source.bytes,
390
+ converterVersion: IMPORT_CONVERTER_VERSION,
391
+ sanitizerVersion: IMPORT_SANITIZER_VERSION,
392
+ counts,
393
+ truncated: rendered.truncated || metadataTruncated || parsed.counts.omitted > 0,
394
+ quarantine: parsed.quarantine,
395
+ };
396
+ return {
397
+ conversation: parsed.conversation,
398
+ contextText: rendered.text,
399
+ provenance,
400
+ counts,
401
+ redactionKinds,
402
+ quarantineRecords: parsed.quarantineRecords,
403
+ sourceSha256: source.sha256,
404
+ sourceBytes: source.bytes,
405
+ };
406
+ }
407
+
408
+ function canonicalPublicationPath(value: string): string {
409
+ const resolved = path.resolve(value);
410
+ const suffix: string[] = [];
411
+ let cursor = resolved;
412
+ for (;;) {
413
+ try {
414
+ return path.join(nodeFs.realpathSync.native(cursor), ...suffix);
415
+ } catch {
416
+ const parent = path.dirname(cursor);
417
+ if (parent === cursor) return resolved;
418
+ suffix.unshift(path.basename(cursor));
419
+ cursor = parent;
420
+ }
421
+ }
422
+ }
423
+
424
+ function isRecord(value: unknown): value is Record<string, unknown> {
425
+ return typeof value === "object" && value !== null && !Array.isArray(value);
426
+ }
427
+
428
+ function findExistingWorkspaceImport(
429
+ prepared: PreparedSessionImport,
430
+ options: Pick<SessionImportRequest, "cwd" | "destination">,
431
+ ): SessionImportCompleted | undefined {
432
+ const destination = options.destination;
433
+ const storage = new FileSessionStorage();
434
+ const inventory = SessionManager.inventorySessionsStrict(options.cwd, {
435
+ ...(typeof destination === "string"
436
+ ? { sessionDir: path.resolve(destination) }
437
+ : destination
438
+ ? { sessionDir: destination.directory, destination }
439
+ : {}),
440
+ storage,
441
+ maxCandidates: MAX_NATIVE_IMPORT_CANDIDATES,
442
+ maxTotalBytes: MAX_NATIVE_IMPORT_SCAN_BYTES,
443
+ filterOtherWorkspaces: true,
444
+ });
445
+ if (inventory.kind === "failure") {
446
+ throw new SessionImportError(
447
+ "io_failed",
448
+ "persist",
449
+ "Existing imports could not be verified for this workspace.",
450
+ { retryable: true },
451
+ );
452
+ }
453
+ if (inventory.candidates.length > MAX_NATIVE_IMPORT_CANDIDATES) {
454
+ throw new SessionImportError(
455
+ "content_too_large",
456
+ "persist",
457
+ "Existing imports exceed the bounded workspace scan limit.",
458
+ { limitBytes: MAX_NATIVE_IMPORT_CANDIDATES, observedBytes: inventory.candidates.length },
459
+ );
460
+ }
461
+
462
+ let scannedBytes = 0;
463
+ for (const candidate of [...inventory.candidates].sort((left, right) => left.path.localeCompare(right.path))) {
464
+ let snapshot: SessionStorageSnapshot;
465
+ try {
466
+ snapshot = storage.readSnapshotBoundedSync(candidate.path, MAX_NATIVE_IMPORT_SCAN_BYTES - scannedBytes);
467
+ } catch {
468
+ throw new SessionImportError(
469
+ "io_failed",
470
+ "persist",
471
+ "An existing workspace session changed during import verification.",
472
+ { retryable: true },
473
+ );
474
+ }
475
+ scannedBytes += snapshot.bytes.byteLength;
476
+
477
+ let header: Record<string, unknown> | undefined;
478
+ let provenance: Record<string, unknown> | undefined;
479
+ let contextMatches = false;
480
+ let completionMatches = false;
481
+ try {
482
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(snapshot.bytes);
483
+ for (const line of text.split("\n")) {
484
+ if (!line.trim()) continue;
485
+ const entry: unknown = JSON.parse(line);
486
+ if (!isRecord(entry)) continue;
487
+ if (!header && entry.type === "session") header = entry;
488
+ if (
489
+ entry.type === "custom" &&
490
+ entry.customType === SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE &&
491
+ isRecord(entry.data)
492
+ ) {
493
+ provenance = entry.data;
494
+ }
495
+ if (
496
+ entry.type === "custom_message" &&
497
+ entry.customType === SESSION_IMPORT_CONTEXT_CUSTOM_TYPE &&
498
+ entry.display === true &&
499
+ typeof entry.content === "string" &&
500
+ entry.content === prepared.contextText &&
501
+ isRecord(entry.details) &&
502
+ entry.details.sourceSha256 === prepared.sourceSha256 &&
503
+ entry.details.provider === prepared.conversation.provider &&
504
+ entry.details.format === prepared.conversation.format
505
+ ) {
506
+ contextMatches = true;
507
+ }
508
+ if (
509
+ entry.type === "custom" &&
510
+ entry.customType === SESSION_IMPORT_COMPLETION_CUSTOM_TYPE &&
511
+ isRecord(entry.data) &&
512
+ entry.data.schemaVersion === 1 &&
513
+ entry.data.targetSessionId === candidate.id &&
514
+ entry.data.sourceSha256 === prepared.sourceSha256 &&
515
+ entry.data.converterVersion === IMPORT_CONVERTER_VERSION &&
516
+ entry.data.sanitizerVersion === IMPORT_SANITIZER_VERSION
517
+ ) {
518
+ completionMatches = true;
519
+ }
520
+ }
521
+ } catch {
522
+ throw new SessionImportError("io_failed", "persist", "An existing workspace session could not be verified.", {
523
+ retryable: true,
524
+ });
525
+ }
526
+ if (
527
+ !header ||
528
+ !provenance ||
529
+ header.id !== candidate.id ||
530
+ header.cwd !== candidate.cwd ||
531
+ !contextMatches ||
532
+ !completionMatches ||
533
+ provenance.schemaVersion !== 1 ||
534
+ provenance.customType !== SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE ||
535
+ provenance.targetSessionId !== candidate.id ||
536
+ provenance.sourceSha256 !== prepared.sourceSha256 ||
537
+ provenance.sourceBytes !== prepared.sourceBytes ||
538
+ provenance.provider !== prepared.conversation.provider ||
539
+ provenance.format !== prepared.conversation.format ||
540
+ provenance.converterVersion !== IMPORT_CONVERTER_VERSION ||
541
+ provenance.sanitizerVersion !== IMPORT_SANITIZER_VERSION
542
+ ) {
543
+ continue;
544
+ }
545
+ return {
546
+ targetSessionId: candidate.id,
547
+ targetPath: candidate.path,
548
+ title:
549
+ typeof header.title === "string" && header.title.trim()
550
+ ? truncateText(header.title, MAX_TITLE_CHARS)
551
+ : (prepared.conversation.title ??
552
+ `Imported ${prepared.conversation.provider === "codex" ? "Codex" : "Claude"} session ${(
553
+ prepared.conversation.sourceSessionId ?? prepared.sourceSha256
554
+ ).slice(0, 8)}`),
555
+ prepared,
556
+ reused: true,
557
+ };
558
+ }
559
+ return undefined;
560
+ }
561
+ /** Reuse the same workspace import key or materialize one new independent session. */
562
+ async function materializeSessionImportLocked(
563
+ prepared: PreparedSessionImport,
564
+ options: Pick<SessionImportRequest, "cwd" | "destination" | "now">,
565
+ ): Promise<SessionImportCompleted> {
566
+ if (typeof options.cwd !== "string" || !options.cwd)
567
+ throw new SessionImportError("invalid_request", "request", "A workspace cwd is required to host the import.");
568
+ const existing = findExistingWorkspaceImport(prepared, options);
569
+ if (existing) return existing;
570
+ const manager = SessionManager.create(options.cwd, options.destination);
571
+ const sessionFile = manager.getSessionFile();
572
+ if (!sessionFile) {
573
+ const closeFailure = await manager.close().then(
574
+ () => undefined,
575
+ closeError => closeError,
576
+ );
577
+ if (closeFailure)
578
+ throw new SessionImportError(
579
+ "io_failed",
580
+ "cleanup",
581
+ "Session persistence is unavailable and manager cleanup could not be verified.",
582
+ { retryable: false },
583
+ );
584
+ throw new SessionImportError(
585
+ "io_failed",
586
+ "persist",
587
+ "Session persistence is unavailable for this workspace; the import has nowhere to live.",
588
+ );
589
+ }
590
+ const targetSessionId = manager.getSessionId();
591
+ try {
592
+ const importedAt = (options.now?.() ?? new Date()).toISOString();
593
+ const provenance: SessionImportProvenance = { ...prepared.provenance, targetSessionId, importedAt };
594
+ manager.appendCustomEntry(SESSION_IMPORT_PROVENANCE_CUSTOM_TYPE, provenance);
595
+ if (prepared.quarantineRecords.length > 0) {
596
+ manager.appendCustomEntry(SESSION_IMPORT_QUARANTINE_CUSTOM_TYPE, {
597
+ schemaVersion: 1,
598
+ total: prepared.counts.quarantined,
599
+ truncated: prepared.provenance.quarantine.truncated,
600
+ records: prepared.quarantineRecords,
601
+ });
602
+ }
603
+ manager.appendCustomMessageEntry(
604
+ SESSION_IMPORT_CONTEXT_CUSTOM_TYPE,
605
+ prepared.contextText,
606
+ true,
607
+ {
608
+ provider: prepared.conversation.provider,
609
+ format: prepared.conversation.format,
610
+ sourceSha256: prepared.sourceSha256,
611
+ sourceBytes: prepared.sourceBytes,
612
+ counts: prepared.counts,
613
+ truncated: prepared.provenance.truncated,
614
+ importedAt,
615
+ },
616
+ "agent",
617
+ );
618
+ const title =
619
+ prepared.conversation.title ??
620
+ `Imported ${prepared.conversation.provider === "codex" ? "Codex" : "Claude"} session ${(prepared.conversation.sourceSessionId ?? prepared.sourceSha256).slice(0, 8)}`;
621
+ await manager.setSessionName(truncateText(title, MAX_TITLE_CHARS), "user");
622
+ await manager.ensureOnDisk();
623
+ await manager.flush();
624
+ await manager.close();
625
+ const reopened = await SessionManager.open(sessionFile, options.destination);
626
+ try {
627
+ if (!canContinuePersistedHistory(reopened.buildSessionContext().messages))
628
+ throw new SessionImportError(
629
+ "malformed_input",
630
+ "persist",
631
+ "The imported session did not reconstruct a continuable conversation.",
632
+ );
633
+ reopened.appendCustomEntry(SESSION_IMPORT_COMPLETION_CUSTOM_TYPE, {
634
+ schemaVersion: 1,
635
+ targetSessionId,
636
+ sourceSha256: prepared.sourceSha256,
637
+ converterVersion: IMPORT_CONVERTER_VERSION,
638
+ sanitizerVersion: IMPORT_SANITIZER_VERSION,
639
+ });
640
+ await reopened.flush();
641
+ } finally {
642
+ await reopened.close();
643
+ }
644
+ return {
645
+ targetSessionId,
646
+ targetPath: sessionFile,
647
+ title: truncateText(title, MAX_TITLE_CHARS),
648
+ prepared,
649
+ reused: false,
650
+ };
651
+ } catch (error) {
652
+ const closeFailure = await manager.close().then(
653
+ () => undefined,
654
+ closeError => closeError,
655
+ );
656
+ const deleteFailure = await new FileSessionStorage().deleteSessionWithArtifacts(sessionFile).then(
657
+ () => undefined,
658
+ deleteError => deleteError,
659
+ );
660
+ const remaining = await fsp.lstat(sessionFile).then(
661
+ () => true,
662
+ statError => (statError as NodeJS.ErrnoException).code !== "ENOENT",
663
+ );
664
+ if (closeFailure || deleteFailure || remaining) {
665
+ throw new SessionImportError(
666
+ "io_failed",
667
+ "cleanup",
668
+ "Import failed and cleanup of the unpublished session could not be verified.",
669
+ { retryable: false },
670
+ );
671
+ }
672
+ if (error instanceof SessionImportError) throw error;
673
+ throw new SessionImportError(
674
+ "io_failed",
675
+ "persist",
676
+ "Failed to persist the imported session before publication.",
677
+ { retryable: true },
678
+ );
679
+ }
680
+ }
681
+ export async function importExternalSession(request: SessionImportRequest): Promise<SessionImportCompleted> {
682
+ return materializeSessionImport(await prepareSessionImport(request), request);
683
+ }
684
+ export async function materializeSessionImport(
685
+ prepared: PreparedSessionImport,
686
+ options: Pick<SessionImportRequest, "cwd" | "destination" | "now">,
687
+ ): Promise<SessionImportCompleted> {
688
+ if (typeof options.cwd !== "string" || !options.cwd)
689
+ throw new SessionImportError("invalid_request", "request", "A workspace cwd is required to host the import.");
690
+ const destination = options.destination;
691
+ const destinationKey =
692
+ typeof destination === "string" ? destination : (destination?.directory ?? "<managed-workspace>");
693
+ const publicationKey = createHash("sha256")
694
+ .update(
695
+ JSON.stringify([
696
+ canonicalPublicationPath(options.cwd),
697
+ destinationKey === "<managed-workspace>" ? destinationKey : canonicalPublicationPath(destinationKey),
698
+ prepared.sourceSha256,
699
+ prepared.conversation.provider,
700
+ prepared.conversation.format,
701
+ IMPORT_CONVERTER_VERSION,
702
+ IMPORT_SANITIZER_VERSION,
703
+ ]),
704
+ )
705
+ .digest("hex");
706
+ const lockRoot =
707
+ typeof destination === "string" || destination?.kind === "explicit"
708
+ ? canonicalPublicationPath(destinationKey)
709
+ : path.join(getAgentDir(), "locks", "session-import");
710
+ const lockTarget = path.join(lockRoot, `publication-${publicationKey}`);
711
+ try {
712
+ return await withFileLock(lockTarget, () => materializeSessionImportLocked(prepared, options), {
713
+ staleMs: 60_000,
714
+ retries: 600,
715
+ retryDelayMs: 50,
716
+ });
717
+ } catch (error) {
718
+ if (error instanceof SessionImportError) throw error;
719
+ throw new SessionImportError("io_failed", "persist", "Import publication serialization could not be completed.", {
720
+ retryable: true,
721
+ });
722
+ }
723
+ }
724
+ export function formatSessionImportSummary(result: SessionImportCompleted): string {
725
+ const { prepared } = result,
726
+ provider = prepared.conversation.provider === "codex" ? "Codex" : "Claude";
727
+ const lines = [
728
+ `${result.reused ? "Reused existing imported" : "Imported"} ${provider} session (${prepared.conversation.format}) ${result.reused ? "in" : "into"} the current SKC workspace.`,
729
+ `Session: ${result.targetSessionId}`,
730
+ `Title: ${result.title}`,
731
+ `Messages: ${prepared.conversation.messages.length} reconstructed (${prepared.counts.mapped} records mapped, ${prepared.counts.quarantined} quarantined)`,
732
+ `Source: ${prepared.provenance.sourceFileName} (${prepared.sourceBytes} bytes, sha256 ${prepared.sourceSha256.slice(0, 12)}…)`,
733
+ ];
734
+ if (prepared.counts.omitted > 0)
735
+ lines.push(
736
+ `Bounded: ${prepared.counts.omitted} message/evidence item${prepared.counts.omitted === 1 ? "" : "s"} omitted by fixed import limits.`,
737
+ );
738
+ if (prepared.counts.redacted > 0)
739
+ lines.push(
740
+ `Redacted: ${prepared.counts.redacted} secret/credential value${prepared.counts.redacted === 1 ? "" : "s"} (${prepared.redactionKinds.join(", ")}).`,
741
+ );
742
+ lines.push("The original transcript file was not modified.");
743
+ return lines.join("\n");
744
+ }
745
+ export function formatSessionImportError(error: unknown): string {
746
+ if (error instanceof SessionImportError)
747
+ return `Import failed: ${error.code} [${error.phase}]${error.retryable ? " (retryable)" : ""} — ${error.message}`;
748
+ return "Import failed: unexpected_error — An unexpected session import error occurred.";
749
+ }