@sayknow-cli/coding-agent 0.5.2 → 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 +55 -16
- package/dist/types/cli/update-cli.d.ts +8 -1
- 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/dap/client.d.ts +1 -0
- package/dist/types/lsp/client.d.ts +5 -0
- 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/types.d.ts +7 -1
- package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +8 -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/launch-worktree.d.ts +17 -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 +5 -0
- package/package.json +13 -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/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 +60 -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/dap/client.ts +78 -30
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/lsp/client.ts +17 -29
- package/src/modes/action-registry.ts +2 -0
- package/src/modes/components/welcome.ts +5 -0
- 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/types.ts +5 -1
- package/src/runtime-mcp/transports/stdio.ts +96 -33
- package/src/sdk/broker/lifecycle.ts +6 -5
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +25 -18
- package/src/session/agent-session.ts +89 -14
- 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/launch-worktree.ts +277 -4
- package/src/slash-commands/acp-builtins.ts +5 -1
- package/src/slash-commands/builtin-registry.ts +100 -1
- package/src/slash-commands/types.ts +5 -0
- package/src/tools/ask.ts +5 -0
- 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
|
+
}
|
|
@@ -19,6 +19,9 @@ export interface SkcLaunchWorktreePlan {
|
|
|
19
19
|
detached: boolean;
|
|
20
20
|
baseRef: string;
|
|
21
21
|
branchName: string | null;
|
|
22
|
+
sourceCheckoutRoot: string;
|
|
23
|
+
relativeCwd: string;
|
|
24
|
+
relativeDependencyRoot: string | null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface SkcLaunchWorktreeResult extends SkcLaunchWorktreePlan {
|
|
@@ -207,11 +210,49 @@ export function parseLaunchWorktreeMode(args: string[]): ParsedLaunchWorktreeMod
|
|
|
207
210
|
return { mode, remainingArgs };
|
|
208
211
|
}
|
|
209
212
|
|
|
213
|
+
function repoRelativePath(checkoutRoot: string, targetPath: string): string {
|
|
214
|
+
const relative = path.relative(checkoutRoot, targetPath);
|
|
215
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
216
|
+
throw new Error(`worktree_cwd_outside_checkout:${targetPath}`);
|
|
217
|
+
}
|
|
218
|
+
return relative;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function hasPackageLockMetadata(directory: string): boolean {
|
|
222
|
+
return (
|
|
223
|
+
fs.existsSync(path.join(directory, "bun.lock")) ||
|
|
224
|
+
fs.existsSync(path.join(directory, "bun.lockb")) ||
|
|
225
|
+
fs.existsSync(path.join(directory, "pnpm-lock.yaml")) ||
|
|
226
|
+
fs.existsSync(path.join(directory, "pnpm-workspace.yaml")) ||
|
|
227
|
+
fs.existsSync(path.join(directory, "package-lock.json")) ||
|
|
228
|
+
fs.existsSync(path.join(directory, "npm-shrinkwrap.json"))
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function resolveSourceDependencyRoot(checkoutRoot: string, cwd: string): string | null {
|
|
233
|
+
let current = path.resolve(cwd);
|
|
234
|
+
let nearestPackageRoot: string | null = null;
|
|
235
|
+
while (true) {
|
|
236
|
+
if (!nearestPackageRoot && fs.existsSync(path.join(current, "package.json"))) nearestPackageRoot = current;
|
|
237
|
+
if (hasPackageLockMetadata(current)) return current;
|
|
238
|
+
if (current === checkoutRoot) return nearestPackageRoot;
|
|
239
|
+
const parent = path.dirname(current);
|
|
240
|
+
if (parent === current) return nearestPackageRoot;
|
|
241
|
+
current = parent;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
export function planLaunchWorktree(
|
|
211
246
|
cwd: string,
|
|
212
247
|
mode: SkcLaunchWorktreeMode,
|
|
213
248
|
): SkcLaunchWorktreePlan | { enabled: false } {
|
|
214
249
|
if (!mode.enabled) return { enabled: false };
|
|
250
|
+
const sourceCheckoutRoot = fs.realpathSync.native(path.resolve(runGit(cwd, ["rev-parse", "--show-toplevel"])));
|
|
251
|
+
const sourceCwd = fs.realpathSync.native(path.resolve(cwd));
|
|
252
|
+
const relativeCwd = repoRelativePath(sourceCheckoutRoot, sourceCwd);
|
|
253
|
+
const sourceDependencyRoot = resolveSourceDependencyRoot(sourceCheckoutRoot, sourceCwd);
|
|
254
|
+
const relativeDependencyRoot =
|
|
255
|
+
sourceDependencyRoot === null ? null : repoRelativePath(sourceCheckoutRoot, sourceDependencyRoot);
|
|
215
256
|
const repoRoot = resolveCanonicalRepoRoot(cwd);
|
|
216
257
|
const baseRef = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
217
258
|
const branchName = mode.detached ? null : mode.name;
|
|
@@ -219,7 +260,17 @@ export function planLaunchWorktree(
|
|
|
219
260
|
const bucket = `${path.basename(repoRoot)}.sayknow-cli-worktrees`;
|
|
220
261
|
const worktreeSlug = mode.detached ? resolveSourceBranchSlug(repoRoot, baseRef) : sanitizePathToken(mode.name);
|
|
221
262
|
const worktreePath = path.join(path.dirname(repoRoot), bucket, worktreeSlug);
|
|
222
|
-
return {
|
|
263
|
+
return {
|
|
264
|
+
enabled: true,
|
|
265
|
+
repoRoot,
|
|
266
|
+
worktreePath,
|
|
267
|
+
detached: mode.detached,
|
|
268
|
+
baseRef,
|
|
269
|
+
branchName,
|
|
270
|
+
sourceCheckoutRoot,
|
|
271
|
+
relativeCwd,
|
|
272
|
+
relativeDependencyRoot,
|
|
273
|
+
};
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
export function ensureLaunchWorktree(
|
|
@@ -290,8 +341,216 @@ export function ensureLaunchWorktree(
|
|
|
290
341
|
};
|
|
291
342
|
}
|
|
292
343
|
|
|
293
|
-
|
|
344
|
+
interface WorktreePackageManifest {
|
|
345
|
+
packageManager?: unknown;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
type WorktreePackageManager = "bun" | "npm" | "pnpm";
|
|
349
|
+
|
|
350
|
+
interface ResolvedWorktreePackageManager {
|
|
351
|
+
name: WorktreePackageManager;
|
|
352
|
+
version: string | null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface WorktreeDependencyProbes {
|
|
356
|
+
isExecutableAvailable?: (name: Exclude<WorktreePackageManager, "bun">) => boolean;
|
|
357
|
+
version?: (name: WorktreePackageManager) => string;
|
|
358
|
+
spawnInstall?: (command: readonly string[], cwd: string) => { exitCode: number; stderr: string };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function fileSystemErrorCode(error: unknown): string | undefined {
|
|
362
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
363
|
+
? error.code
|
|
364
|
+
: undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function readWorktreePackageManifest(worktreePath: string): WorktreePackageManifest | null {
|
|
368
|
+
try {
|
|
369
|
+
return JSON.parse(fs.readFileSync(path.join(worktreePath, "package.json"), "utf8")) as WorktreePackageManifest;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (fileSystemErrorCode(error) === "ENOENT") return null;
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function hasWorktreePackageMetadata(worktreePath: string, manifest: WorktreePackageManifest | null): boolean {
|
|
377
|
+
return (
|
|
378
|
+
manifest !== null ||
|
|
379
|
+
fs.existsSync(path.join(worktreePath, "bun.lock")) ||
|
|
380
|
+
fs.existsSync(path.join(worktreePath, "bun.lockb")) ||
|
|
381
|
+
fs.existsSync(path.join(worktreePath, "pnpm-lock.yaml")) ||
|
|
382
|
+
fs.existsSync(path.join(worktreePath, "pnpm-workspace.yaml")) ||
|
|
383
|
+
fs.existsSync(path.join(worktreePath, "package-lock.json")) ||
|
|
384
|
+
fs.existsSync(path.join(worktreePath, "npm-shrinkwrap.json"))
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function declaredPackageManager(
|
|
389
|
+
manifest: WorktreePackageManifest | null,
|
|
390
|
+
): { name: string; version: string | null } | null {
|
|
391
|
+
if (typeof manifest?.packageManager !== "string") return null;
|
|
392
|
+
const separator = manifest.packageManager.indexOf("@");
|
|
393
|
+
const name = (separator < 0 ? manifest.packageManager : manifest.packageManager.slice(0, separator)).trim();
|
|
394
|
+
if (!name) return null;
|
|
395
|
+
const version = separator < 0 ? null : manifest.packageManager.slice(separator + 1).trim() || null;
|
|
396
|
+
return { name, version };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function worktreeLockfiles(worktreePath: string): Array<{ manager: WorktreePackageManager; file: string }> {
|
|
400
|
+
const candidates: Array<{ manager: WorktreePackageManager; file: string }> = [
|
|
401
|
+
{ manager: "bun", file: "bun.lock" },
|
|
402
|
+
{ manager: "bun", file: "bun.lockb" },
|
|
403
|
+
{ manager: "pnpm", file: "pnpm-lock.yaml" },
|
|
404
|
+
{ manager: "npm", file: "package-lock.json" },
|
|
405
|
+
{ manager: "npm", file: "npm-shrinkwrap.json" },
|
|
406
|
+
];
|
|
407
|
+
return candidates.filter(candidate => fs.existsSync(path.join(worktreePath, candidate.file)));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isWorktreePackageManager(value: string): value is WorktreePackageManager {
|
|
411
|
+
return value === "bun" || value === "npm" || value === "pnpm";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function resolveWorktreePackageManager(
|
|
415
|
+
worktreePath: string,
|
|
416
|
+
manifest: WorktreePackageManifest | null,
|
|
417
|
+
): ResolvedWorktreePackageManager {
|
|
418
|
+
const declared = declaredPackageManager(manifest);
|
|
419
|
+
const declaredManager = declared && isWorktreePackageManager(declared.name) ? declared.name : null;
|
|
420
|
+
if (declared && !declaredManager) {
|
|
421
|
+
throw new Error(`worktree_dependency_manager_unsupported:${declared.name}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const lockfiles = worktreeLockfiles(worktreePath);
|
|
425
|
+
if (lockfiles.length === 0) throw new Error("worktree_dependency_lockfile_missing");
|
|
426
|
+
if (lockfiles.length > 1) {
|
|
427
|
+
throw new Error(`worktree_dependency_lockfile_ambiguous:${lockfiles.map(lockfile => lockfile.file).join(",")}`);
|
|
428
|
+
}
|
|
429
|
+
const lockfileManager = lockfiles[0]!.manager;
|
|
430
|
+
if (declaredManager && declaredManager !== lockfileManager) {
|
|
431
|
+
throw new Error(`worktree_dependency_manager_mismatch:${declaredManager}:${lockfileManager}`);
|
|
432
|
+
}
|
|
433
|
+
return { name: declaredManager ?? lockfileManager, version: declared?.version ?? null };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function removeLegacySourceNodeModulesLink(sourceRoot: string, worktreePath: string): void {
|
|
437
|
+
const target = path.join(worktreePath, "node_modules");
|
|
438
|
+
let targetStat: fs.Stats;
|
|
439
|
+
try {
|
|
440
|
+
targetStat = fs.lstatSync(target);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (fileSystemErrorCode(error) === "ENOENT") return;
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
if (!targetStat.isSymbolicLink()) return;
|
|
446
|
+
|
|
447
|
+
const sourceModules = path.join(sourceRoot, "node_modules");
|
|
448
|
+
const linkTarget = path.resolve(path.dirname(target), fs.readlinkSync(target));
|
|
449
|
+
let linksToSource = linkTarget === path.resolve(sourceModules);
|
|
450
|
+
try {
|
|
451
|
+
linksToSource = linksToSource || fs.realpathSync(target) === fs.realpathSync(sourceModules);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (fileSystemErrorCode(error) !== "ENOENT") throw error;
|
|
454
|
+
}
|
|
455
|
+
if (!linksToSource) throw new Error(`worktree_node_modules_not_local:${target}`);
|
|
456
|
+
fs.unlinkSync(target);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function installedPackageManagerVersion(
|
|
460
|
+
name: WorktreePackageManager,
|
|
461
|
+
cwd: string,
|
|
462
|
+
probes: WorktreeDependencyProbes,
|
|
463
|
+
): string {
|
|
464
|
+
const testVersion = probes.version?.(name);
|
|
465
|
+
if (testVersion !== undefined) return testVersion;
|
|
466
|
+
if (name === "bun") return Bun.version;
|
|
467
|
+
const result = Bun.spawnSync([name, "--version"], { cwd, stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
468
|
+
if (result.exitCode !== 0) throw new Error(`worktree_dependency_manager_unavailable:${name}`);
|
|
469
|
+
return result.stdout?.toString().trim() ?? "";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function worktreeInstallCommand(
|
|
473
|
+
manager: ResolvedWorktreePackageManager,
|
|
474
|
+
cwd: string,
|
|
475
|
+
probes: WorktreeDependencyProbes,
|
|
476
|
+
): string[] {
|
|
477
|
+
const args = manager.name === "npm" ? ["ci"] : ["install", "--frozen-lockfile"];
|
|
478
|
+
if (manager.name !== "bun" && !(probes.isExecutableAvailable?.(manager.name) ?? Bun.which(manager.name) !== null)) {
|
|
479
|
+
throw new Error(`worktree_dependency_manager_unavailable:${manager.name}`);
|
|
480
|
+
}
|
|
481
|
+
if (manager.version) {
|
|
482
|
+
const installedVersion = installedPackageManagerVersion(manager.name, cwd, probes);
|
|
483
|
+
if (installedVersion !== manager.version) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`worktree_dependency_manager_version_mismatch:${manager.name}:${manager.version}:${installedVersion || "unknown"}`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return [manager.name, ...args];
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function boundedInstallFailure(stderr: string, exitCode: number): string {
|
|
493
|
+
const detail = stderr.trim().replace(/\s+/g, " ");
|
|
494
|
+
if (!detail) return `exit_${exitCode}`;
|
|
495
|
+
return detail.length <= 2048 ? detail : `${detail.slice(0, 2045)}...`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function installWorktreeDependencies(
|
|
499
|
+
sourceRoot: string,
|
|
500
|
+
worktreePath: string,
|
|
501
|
+
manifest: WorktreePackageManifest | null,
|
|
502
|
+
probes: WorktreeDependencyProbes,
|
|
503
|
+
): void {
|
|
504
|
+
removeLegacySourceNodeModulesLink(sourceRoot, worktreePath);
|
|
505
|
+
const manager = resolveWorktreePackageManager(worktreePath, manifest);
|
|
506
|
+
const command = worktreeInstallCommand(manager, worktreePath, probes);
|
|
507
|
+
let result: { exitCode: number; stderr: string };
|
|
508
|
+
try {
|
|
509
|
+
if (probes.spawnInstall) {
|
|
510
|
+
result = probes.spawnInstall(command, worktreePath);
|
|
511
|
+
} else {
|
|
512
|
+
const spawned = Bun.spawnSync(command, {
|
|
513
|
+
cwd: worktreePath,
|
|
514
|
+
stdout: "ignore",
|
|
515
|
+
stderr: "pipe",
|
|
516
|
+
timeout: 120_000,
|
|
517
|
+
maxBuffer: 64 * 1024,
|
|
518
|
+
});
|
|
519
|
+
result = { exitCode: spawned.exitCode, stderr: spawned.stderr?.toString() ?? "" };
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const detail = error instanceof Error && error.message ? error.message : "spawn_failed";
|
|
523
|
+
throw new Error(`worktree_dependency_install_failed:${manager.name}:${detail.slice(0, 2048)}`);
|
|
524
|
+
}
|
|
525
|
+
if (result.exitCode !== 0) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`worktree_dependency_install_failed:${manager.name}:${boundedInstallFailure(result.stderr, result.exitCode)}`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const installed = fs.lstatSync(path.join(worktreePath, "node_modules"));
|
|
533
|
+
if (!installed.isDirectory() || installed.isSymbolicLink()) {
|
|
534
|
+
throw new Error("worktree_dependency_install_not_local");
|
|
535
|
+
}
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (fileSystemErrorCode(error) !== "ENOENT") throw error;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Package worktrees own their complete lockfile-resolved dependency graph. */
|
|
542
|
+
|
|
543
|
+
export function ensureReusableNodeModules(
|
|
544
|
+
sourceRoot: string,
|
|
545
|
+
worktreePath: string,
|
|
546
|
+
probes: WorktreeDependencyProbes = {},
|
|
547
|
+
): "symlink" | "present" | "missing" {
|
|
294
548
|
const target = path.join(worktreePath, "node_modules");
|
|
549
|
+
const manifest = readWorktreePackageManifest(worktreePath);
|
|
550
|
+
if (hasWorktreePackageMetadata(worktreePath, manifest)) {
|
|
551
|
+
installWorktreeDependencies(sourceRoot, worktreePath, manifest, probes);
|
|
552
|
+
return "present";
|
|
553
|
+
}
|
|
295
554
|
if (fs.existsSync(target)) return "present";
|
|
296
555
|
const source = path.join(sourceRoot, "node_modules");
|
|
297
556
|
if (!fs.existsSync(source)) return "missing";
|
|
@@ -299,6 +558,20 @@ export function ensureReusableNodeModules(sourceRoot: string, worktreePath: stri
|
|
|
299
558
|
return "symlink";
|
|
300
559
|
}
|
|
301
560
|
|
|
561
|
+
export function launchWorktreeCwd(plan: SkcLaunchWorktreePlan): string {
|
|
562
|
+
return path.resolve(plan.worktreePath, plan.relativeCwd);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function ensureLaunchWorktreeDependencies(plan: SkcLaunchWorktreePlan): "symlink" | "present" | "missing" {
|
|
566
|
+
if (plan.relativeDependencyRoot === null) {
|
|
567
|
+
return ensureReusableNodeModules(plan.repoRoot, plan.worktreePath);
|
|
568
|
+
}
|
|
569
|
+
return ensureReusableNodeModules(
|
|
570
|
+
path.resolve(plan.sourceCheckoutRoot, plan.relativeDependencyRoot),
|
|
571
|
+
path.resolve(plan.worktreePath, plan.relativeDependencyRoot),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
302
575
|
/** Result of {@link prepareLaunchWorktree}: the effective working directory, remaining args, and resolved worktree plan. */
|
|
303
576
|
export interface PreparedLaunchWorktree {
|
|
304
577
|
cwd: string;
|
|
@@ -311,6 +584,6 @@ export function prepareLaunchWorktree(cwd: string, args: string[]): PreparedLaun
|
|
|
311
584
|
const planned = planLaunchWorktree(cwd, parsed.mode);
|
|
312
585
|
const ensured = ensureLaunchWorktree(planned);
|
|
313
586
|
if (!ensured.enabled) return { cwd, args: parsed.remainingArgs, worktree: ensured };
|
|
314
|
-
|
|
315
|
-
return { cwd: ensured
|
|
587
|
+
ensureLaunchWorktreeDependencies(ensured);
|
|
588
|
+
return { cwd: launchWorktreeCwd(ensured), args: parsed.remainingArgs, worktree: ensured };
|
|
316
589
|
}
|
|
@@ -15,7 +15,7 @@ export type { AcpBuiltinCommandRuntime, AcpBuiltinSlashCommandResult } from "./t
|
|
|
15
15
|
* see commands it cannot drive.
|
|
16
16
|
*/
|
|
17
17
|
export const ACP_BUILTIN_SLASH_COMMANDS: AvailableCommand[] = BUILTIN_SLASH_COMMANDS_INTERNAL.filter(
|
|
18
|
-
command => command.handle !== undefined,
|
|
18
|
+
command => command.handle !== undefined && command.acp !== false,
|
|
19
19
|
).map(command => {
|
|
20
20
|
// Honor mode-specific copy: ACP clients receive concise text-mode
|
|
21
21
|
// descriptions/hints when the spec sets `acpDescription` / `acpInputHint`,
|
|
@@ -43,6 +43,10 @@ export async function executeAcpBuiltinSlashCommand(
|
|
|
43
43
|
const parsed = parseSlashCommand(text);
|
|
44
44
|
if (!parsed) return false;
|
|
45
45
|
const command = lookupBuiltinSlashCommand(parsed.name);
|
|
46
|
+
if (command?.acp === false) {
|
|
47
|
+
await runtime.output(`/${parsed.name} is not available over ACP.`);
|
|
48
|
+
return { consumed: true };
|
|
49
|
+
}
|
|
46
50
|
if (!command?.handle) {
|
|
47
51
|
const diagnostic = formatUnknownBuiltinSlashCommandDiagnostic(parsed.name);
|
|
48
52
|
if (!diagnostic) return false;
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
sendNotificationTest,
|
|
42
42
|
} from "../sdk/bus/notification-service";
|
|
43
43
|
import { computeCacheMissCostSummary, formatCacheMissSummaryLines } from "../session/cache-economics";
|
|
44
|
+
import { formatSessionImportSummary, runSessionImportCommand } from "../session-import";
|
|
44
45
|
import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
|
|
45
46
|
import {
|
|
46
47
|
addApiCompatibleProvider,
|
|
@@ -510,6 +511,93 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
|
|
|
510
511
|
return commandConsumed();
|
|
511
512
|
};
|
|
512
513
|
|
|
514
|
+
type SessionImportRunner = typeof runSessionImportCommand;
|
|
515
|
+
|
|
516
|
+
function isBusySessionImportActivation(error: unknown): boolean {
|
|
517
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "busy";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function handleImportSessionCommand(
|
|
521
|
+
command: ParsedSlashCommand,
|
|
522
|
+
runtime: SlashCommandRuntime,
|
|
523
|
+
importer: SessionImportRunner = runSessionImportCommand,
|
|
524
|
+
): Promise<SlashCommandResult> {
|
|
525
|
+
if (runtime.session.isStreaming)
|
|
526
|
+
return usage("Cannot import a session while a response is streaming; wait for it to finish.", runtime);
|
|
527
|
+
const outcome = await importer(command.args, runtime.cwd);
|
|
528
|
+
if (outcome.kind === "error") return usage(outcome.message, runtime);
|
|
529
|
+
const importTargetLabel = outcome.result.reused ? "reused imported session" : "newly published imported session";
|
|
530
|
+
if (runtime.session.isStreaming)
|
|
531
|
+
return usage(
|
|
532
|
+
`Import completed, but the ${importTargetLabel} was not activated because a response started streaming. Resume it later from the session picker.`,
|
|
533
|
+
runtime,
|
|
534
|
+
);
|
|
535
|
+
let switched: boolean;
|
|
536
|
+
try {
|
|
537
|
+
switched = await runtime.session.switchSession(outcome.result.targetPath, { requireIdle: true });
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return usage(
|
|
540
|
+
isBusySessionImportActivation(error)
|
|
541
|
+
? `Import completed, but the ${importTargetLabel} was not activated because a response or turn started. Resume it later from the session picker.`
|
|
542
|
+
: `Import completed, but the ${importTargetLabel} could not be activated. Resume it later from the session picker.`,
|
|
543
|
+
runtime,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
if (!switched)
|
|
547
|
+
return usage(
|
|
548
|
+
`${outcome.result.reused ? "Import found an existing imported session" : "Import created a new session"}, but switching to it was cancelled. Resume it later from the session picker.`,
|
|
549
|
+
runtime,
|
|
550
|
+
);
|
|
551
|
+
await runtime.notifyTitleChanged?.();
|
|
552
|
+
await runtime.output(formatSessionImportSummary(outcome.result));
|
|
553
|
+
return commandConsumed();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function handleImportSessionTuiCommand(
|
|
557
|
+
command: ParsedSlashCommand,
|
|
558
|
+
runtime: TuiSlashCommandRuntime,
|
|
559
|
+
importer: SessionImportRunner = runSessionImportCommand,
|
|
560
|
+
): Promise<SlashCommandResult> {
|
|
561
|
+
runtime.ctx.editor.setText("");
|
|
562
|
+
if (runtime.ctx.session.isStreaming) {
|
|
563
|
+
runtime.ctx.showError("Cannot import a session while a response is streaming; wait for it to finish.");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const outcome = await importer(command.args, runtime.ctx.sessionManager.getCwd());
|
|
567
|
+
if (outcome.kind === "error") {
|
|
568
|
+
runtime.ctx.showError(outcome.message);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const importTargetLabel = outcome.result.reused ? "reused imported session" : "newly published imported session";
|
|
572
|
+
if (runtime.ctx.session.isStreaming) {
|
|
573
|
+
runtime.ctx.showError(
|
|
574
|
+
`Import completed, but the ${importTargetLabel} was not activated because a response started streaming. Resume it later from the session picker.`,
|
|
575
|
+
);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
let resumed: boolean;
|
|
579
|
+
try {
|
|
580
|
+
resumed = await runtime.ctx.handleResumeSession(outcome.result.targetPath, { requireIdle: true });
|
|
581
|
+
} catch (error) {
|
|
582
|
+
runtime.ctx.showError(
|
|
583
|
+
isBusySessionImportActivation(error)
|
|
584
|
+
? `Import completed, but the ${importTargetLabel} was not activated because a response or turn started. Resume it later from the session picker.`
|
|
585
|
+
: `Import completed, but the ${importTargetLabel} could not be activated. Resume it later from the session picker.`,
|
|
586
|
+
);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!resumed) {
|
|
590
|
+
runtime.ctx.showError(
|
|
591
|
+
`${outcome.result.reused ? "Import found an existing imported session" : "Import created a new session"}, but switching to it was cancelled. Resume it later from the session picker.`,
|
|
592
|
+
);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const { prepared } = outcome.result;
|
|
596
|
+
const providerLabel = prepared.conversation.provider === "codex" ? "Codex" : "Claude";
|
|
597
|
+
runtime.ctx.showStatus(
|
|
598
|
+
`${outcome.result.reused ? "Reused" : "Imported"} ${providerLabel} session "${outcome.result.title}" (${prepared.conversation.messages.length} messages reconstructed${prepared.counts.redacted > 0 ? `, ${prepared.counts.redacted} secret values redacted` : ""}).`,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
513
601
|
const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
514
602
|
{
|
|
515
603
|
name: "notify",
|
|
@@ -882,6 +970,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
882
970
|
name: "export",
|
|
883
971
|
priority: 50,
|
|
884
972
|
description: "Export this session to an HTML file",
|
|
973
|
+
acp: false,
|
|
885
974
|
inlineHint: "[path]",
|
|
886
975
|
allowArgs: true,
|
|
887
976
|
handle: async (command, runtime) => {
|
|
@@ -906,6 +995,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
906
995
|
runtime.ctx.editor.setText("");
|
|
907
996
|
},
|
|
908
997
|
},
|
|
998
|
+
{
|
|
999
|
+
name: "import-session",
|
|
1000
|
+
priority: 51,
|
|
1001
|
+
description: "Import or reuse a Codex or Claude session transcript",
|
|
1002
|
+
acp: false,
|
|
1003
|
+
inlineHint: "<transcript-file> [--provider codex|claude]",
|
|
1004
|
+
allowArgs: true,
|
|
1005
|
+
handle: handleImportSessionCommand,
|
|
1006
|
+
handleTui: handleImportSessionTuiCommand,
|
|
1007
|
+
},
|
|
909
1008
|
{
|
|
910
1009
|
name: "copy",
|
|
911
1010
|
priority: 55,
|
|
@@ -1654,7 +1753,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1654
1753
|
{
|
|
1655
1754
|
name: "move",
|
|
1656
1755
|
description: "Move session to a different working directory",
|
|
1657
|
-
|
|
1756
|
+
acp: false,
|
|
1658
1757
|
inlineHint: "<path>",
|
|
1659
1758
|
allowArgs: true,
|
|
1660
1759
|
handle: async (command, runtime) => {
|
|
@@ -84,6 +84,11 @@ export interface SlashCommandSpec extends BuiltinSlashCommand {
|
|
|
84
84
|
aliases?: string[];
|
|
85
85
|
/** When false, the dispatcher refuses to handle invocations that include arguments. */
|
|
86
86
|
allowArgs?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Whether ACP may advertise and execute this command. Commands that read
|
|
89
|
+
* caller-selected local paths must set this to false.
|
|
90
|
+
*/
|
|
91
|
+
acp?: boolean;
|
|
87
92
|
/**
|
|
88
93
|
* ACP-specific override for `description`. Used by `ACP_BUILTIN_SLASH_COMMANDS`
|
|
89
94
|
* when building `available_commands_update` payloads so the client receives
|
package/src/tools/ask.ts
CHANGED
|
@@ -595,6 +595,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
595
595
|
readonly description: string;
|
|
596
596
|
readonly parameters = askSchema;
|
|
597
597
|
readonly strict = true;
|
|
598
|
+
/**
|
|
599
|
+
* These fields are rendered to the user and carry no executable or durable
|
|
600
|
+
* meaning. IDs and workflow/deep-interview metadata remain fail-closed.
|
|
601
|
+
*/
|
|
602
|
+
readonly displaySafeEscapedArgFields = ["questions.question", "questions.options.label"] as const;
|
|
598
603
|
readonly loadMode = "discoverable";
|
|
599
604
|
|
|
600
605
|
constructor(private readonly session: ToolSession) {
|