ctx-switch 2.0.0
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/CONTRIBUTING.md +76 -0
- package/LICENSE +21 -0
- package/README.cc-continue.md +131 -0
- package/README.ctx-switch.md +142 -0
- package/README.md +193 -0
- package/dist/index.mjs +2021 -0
- package/package.json +59 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2021 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import path9 from "node:path";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import fs6 from "node:fs";
|
|
8
|
+
import path8 from "node:path";
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
|
|
11
|
+
// src/args.ts
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/types.ts
|
|
15
|
+
var SUPPORTED_COMMANDS = ["continue", "doctor", "sessions"];
|
|
16
|
+
var SUPPORTED_PROVIDERS = ["openrouter"];
|
|
17
|
+
var SUPPORTED_TARGETS = ["generic", "codex", "cursor", "chatgpt"];
|
|
18
|
+
var SUPPORTED_SOURCES = ["claude", "codex", "opencode"];
|
|
19
|
+
|
|
20
|
+
// src/args.ts
|
|
21
|
+
function requireValue(flag, args) {
|
|
22
|
+
const value = args.shift();
|
|
23
|
+
if (!value || value.startsWith("-")) {
|
|
24
|
+
throw new Error(`Missing value for ${flag}`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const args = [...argv];
|
|
30
|
+
const options = {
|
|
31
|
+
command: "continue",
|
|
32
|
+
refine: false,
|
|
33
|
+
help: false,
|
|
34
|
+
version: false,
|
|
35
|
+
session: null,
|
|
36
|
+
model: null,
|
|
37
|
+
provider: "openrouter",
|
|
38
|
+
output: null,
|
|
39
|
+
apiKey: null,
|
|
40
|
+
target: "generic",
|
|
41
|
+
source: null,
|
|
42
|
+
limit: 10
|
|
43
|
+
};
|
|
44
|
+
if (args[0] === "doctor" || args[0] === "sessions") {
|
|
45
|
+
options.command = args[0];
|
|
46
|
+
args.shift();
|
|
47
|
+
}
|
|
48
|
+
while (args.length > 0) {
|
|
49
|
+
const arg = args.shift();
|
|
50
|
+
switch (arg) {
|
|
51
|
+
case "--help":
|
|
52
|
+
case "-h":
|
|
53
|
+
options.help = true;
|
|
54
|
+
break;
|
|
55
|
+
case "--version":
|
|
56
|
+
case "-v":
|
|
57
|
+
options.version = true;
|
|
58
|
+
break;
|
|
59
|
+
case "--refine":
|
|
60
|
+
options.refine = true;
|
|
61
|
+
break;
|
|
62
|
+
case "--session":
|
|
63
|
+
options.session = requireValue(arg, args);
|
|
64
|
+
break;
|
|
65
|
+
case "--model":
|
|
66
|
+
options.model = requireValue(arg, args);
|
|
67
|
+
break;
|
|
68
|
+
case "--provider":
|
|
69
|
+
options.provider = requireValue(arg, args);
|
|
70
|
+
break;
|
|
71
|
+
case "--output":
|
|
72
|
+
case "-o":
|
|
73
|
+
options.output = path.resolve(requireValue(arg, args));
|
|
74
|
+
break;
|
|
75
|
+
case "--api-key":
|
|
76
|
+
options.apiKey = requireValue(arg, args);
|
|
77
|
+
break;
|
|
78
|
+
case "--target":
|
|
79
|
+
options.target = requireValue(arg, args);
|
|
80
|
+
break;
|
|
81
|
+
case "--source":
|
|
82
|
+
options.source = requireValue(arg, args);
|
|
83
|
+
break;
|
|
84
|
+
case "--limit":
|
|
85
|
+
case "-n":
|
|
86
|
+
options.limit = Number(requireValue(arg, args));
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
if (!arg) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (arg.startsWith("-")) {
|
|
93
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
94
|
+
}
|
|
95
|
+
if (!options.session) {
|
|
96
|
+
options.session = arg;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!SUPPORTED_PROVIDERS.includes(options.provider)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Unsupported provider "${options.provider}". Supported providers: ${SUPPORTED_PROVIDERS.join(", ")}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (!SUPPORTED_TARGETS.includes(options.target)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Unsupported target "${options.target}". Supported targets: ${SUPPORTED_TARGETS.join(", ")}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (options.source && !SUPPORTED_SOURCES.includes(options.source)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Unsupported source "${options.source}". Supported sources: ${SUPPORTED_SOURCES.join(", ")}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (!SUPPORTED_COMMANDS.includes(options.command)) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Unsupported command "${options.command}". Supported commands: ${SUPPORTED_COMMANDS.join(", ")}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
123
|
+
throw new Error(`Invalid limit "${options.limit}". Expected a positive integer.`);
|
|
124
|
+
}
|
|
125
|
+
return options;
|
|
126
|
+
}
|
|
127
|
+
function getHelpText({ name, version }) {
|
|
128
|
+
return [
|
|
129
|
+
`${name} ${version}`,
|
|
130
|
+
"",
|
|
131
|
+
"Turn AI coding agent sessions into high-quality continuation prompts for Codex, Cursor, ChatGPT, or any other agent.",
|
|
132
|
+
"Supports Claude Code, Codex, and OpenCode as session sources.",
|
|
133
|
+
"",
|
|
134
|
+
"Usage",
|
|
135
|
+
` ${name} [options]`,
|
|
136
|
+
` ${name} doctor [options]`,
|
|
137
|
+
` ${name} sessions [options]`,
|
|
138
|
+
"",
|
|
139
|
+
"Core Options",
|
|
140
|
+
" -h, --help Show help",
|
|
141
|
+
" -v, --version Show version",
|
|
142
|
+
" -o, --output <file> Write the final prompt to a file",
|
|
143
|
+
" --source <name> Session source: claude, codex, opencode (interactive if omitted)",
|
|
144
|
+
" --session <id|path> Use a specific session file or session id",
|
|
145
|
+
" --target <name> Prompt target: generic, codex, cursor, chatgpt",
|
|
146
|
+
" -n, --limit <count> Limit rows for the sessions command (default: 10)",
|
|
147
|
+
"",
|
|
148
|
+
"Refinement (optional)",
|
|
149
|
+
" --refine Refine the prompt via an LLM provider (default: raw mode)",
|
|
150
|
+
" --provider <name> Refinement provider (default: openrouter)",
|
|
151
|
+
" --model <name> Provider model override (default: openrouter/free)",
|
|
152
|
+
" --api-key <key> Provider API key override",
|
|
153
|
+
"",
|
|
154
|
+
"Doctor",
|
|
155
|
+
" Verifies session discovery, git context, clipboard support, and API key availability.",
|
|
156
|
+
"",
|
|
157
|
+
"Sessions",
|
|
158
|
+
" Lists recent session files for the current project from the selected source.",
|
|
159
|
+
"",
|
|
160
|
+
"Examples",
|
|
161
|
+
` ${name} # interactive source picker`,
|
|
162
|
+
` ${name} --source claude # use Claude Code sessions`,
|
|
163
|
+
` ${name} --source codex --target codex`,
|
|
164
|
+
` ${name} --source opencode`,
|
|
165
|
+
` ${name} --refine --model openrouter/free`,
|
|
166
|
+
` ${name} --output ./handoff.md`,
|
|
167
|
+
` ${name} doctor`,
|
|
168
|
+
` ${name} sessions --source claude --limit 5`
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/clipboard.ts
|
|
173
|
+
import { spawnSync } from "node:child_process";
|
|
174
|
+
function hasCommand(command) {
|
|
175
|
+
const probe = spawnSync("which", [command], { stdio: "ignore" });
|
|
176
|
+
return probe.status === 0;
|
|
177
|
+
}
|
|
178
|
+
function getClipboardStrategy() {
|
|
179
|
+
if (process.platform === "darwin") {
|
|
180
|
+
return { command: "pbcopy", args: [] };
|
|
181
|
+
}
|
|
182
|
+
if (process.platform === "win32") {
|
|
183
|
+
return { command: "cmd", args: ["/c", "clip"] };
|
|
184
|
+
}
|
|
185
|
+
if (hasCommand("wl-copy")) {
|
|
186
|
+
return { command: "wl-copy", args: [] };
|
|
187
|
+
}
|
|
188
|
+
if (hasCommand("xclip")) {
|
|
189
|
+
return { command: "xclip", args: ["-selection", "clipboard"] };
|
|
190
|
+
}
|
|
191
|
+
if (hasCommand("xsel")) {
|
|
192
|
+
return { command: "xsel", args: ["--clipboard", "--input"] };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
function copyToClipboard(text) {
|
|
197
|
+
const strategy = getClipboardStrategy();
|
|
198
|
+
if (!strategy) {
|
|
199
|
+
return { ok: false, error: "No supported clipboard utility found" };
|
|
200
|
+
}
|
|
201
|
+
const result = spawnSync(strategy.command, strategy.args, {
|
|
202
|
+
input: text,
|
|
203
|
+
encoding: "utf8"
|
|
204
|
+
});
|
|
205
|
+
if (result.status !== 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: (result.stderr || "Clipboard command failed").trim()
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { ok: true, command: strategy.command };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/config.ts
|
|
215
|
+
import fs from "node:fs";
|
|
216
|
+
import os from "node:os";
|
|
217
|
+
import path2 from "node:path";
|
|
218
|
+
var CONFIG_PATH = path2.join(os.homedir(), ".ctx-switch.json");
|
|
219
|
+
var LEGACY_CONFIG_PATH = path2.join(os.homedir(), ".cc-continue.json");
|
|
220
|
+
function loadConfig(configPath = CONFIG_PATH) {
|
|
221
|
+
for (const candidate of [configPath, LEGACY_CONFIG_PATH]) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {};
|
|
228
|
+
}
|
|
229
|
+
function saveConfig(config, configPath = CONFIG_PATH) {
|
|
230
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
|
|
231
|
+
`, { mode: 384 });
|
|
232
|
+
try {
|
|
233
|
+
fs.chmodSync(configPath, 384);
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function getProviderConfig(config, provider) {
|
|
238
|
+
return config.providers?.[provider] || {};
|
|
239
|
+
}
|
|
240
|
+
function getApiKey({
|
|
241
|
+
provider,
|
|
242
|
+
cliValue,
|
|
243
|
+
env = process.env,
|
|
244
|
+
config = loadConfig()
|
|
245
|
+
}) {
|
|
246
|
+
if (cliValue) return cliValue;
|
|
247
|
+
if (provider === "openrouter" && env.OPENROUTER_API_KEY) {
|
|
248
|
+
return env.OPENROUTER_API_KEY;
|
|
249
|
+
}
|
|
250
|
+
const providerConfig = getProviderConfig(config, provider);
|
|
251
|
+
return providerConfig.apiKey || config.openrouter_api_key || null;
|
|
252
|
+
}
|
|
253
|
+
function getDefaultModel({
|
|
254
|
+
provider,
|
|
255
|
+
cliValue,
|
|
256
|
+
config = loadConfig()
|
|
257
|
+
}) {
|
|
258
|
+
if (cliValue) return cliValue;
|
|
259
|
+
const providerConfig = getProviderConfig(config, provider);
|
|
260
|
+
return providerConfig.model || "openrouter/free";
|
|
261
|
+
}
|
|
262
|
+
function storeApiKey({
|
|
263
|
+
provider,
|
|
264
|
+
apiKey,
|
|
265
|
+
configPath = CONFIG_PATH
|
|
266
|
+
}) {
|
|
267
|
+
const config = loadConfig(configPath);
|
|
268
|
+
config.providers = config.providers || {};
|
|
269
|
+
config.providers[provider] = config.providers[provider] || {};
|
|
270
|
+
config.providers[provider].apiKey = apiKey;
|
|
271
|
+
if (provider === "openrouter") {
|
|
272
|
+
delete config.openrouter_api_key;
|
|
273
|
+
}
|
|
274
|
+
saveConfig(config, configPath);
|
|
275
|
+
}
|
|
276
|
+
function promptForApiKey({
|
|
277
|
+
provider,
|
|
278
|
+
input = process.stdin,
|
|
279
|
+
output = process.stderr
|
|
280
|
+
}) {
|
|
281
|
+
if (!input.isTTY || !output.isTTY) {
|
|
282
|
+
return Promise.resolve(null);
|
|
283
|
+
}
|
|
284
|
+
const label = provider === "openrouter" ? "OpenRouter" : provider;
|
|
285
|
+
const prompt = `Enter your ${label} API key: `;
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
let buffer = "";
|
|
288
|
+
const previousRawMode = input.isRaw;
|
|
289
|
+
function cleanup() {
|
|
290
|
+
input.removeListener("data", onData);
|
|
291
|
+
if (input.isTTY) {
|
|
292
|
+
input.setRawMode(Boolean(previousRawMode));
|
|
293
|
+
}
|
|
294
|
+
input.pause();
|
|
295
|
+
}
|
|
296
|
+
function finish(value) {
|
|
297
|
+
cleanup();
|
|
298
|
+
output.write("\n");
|
|
299
|
+
resolve(value.trim() || null);
|
|
300
|
+
}
|
|
301
|
+
function onData(chunk) {
|
|
302
|
+
const text = chunk.toString("utf8");
|
|
303
|
+
if (text === "") {
|
|
304
|
+
cleanup();
|
|
305
|
+
output.write("\n");
|
|
306
|
+
const error = new Error("Prompt cancelled");
|
|
307
|
+
error.exitCode = 130;
|
|
308
|
+
reject(error);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (text === "\r" || text === "\n") {
|
|
312
|
+
finish(buffer);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (text === "\x7F") {
|
|
316
|
+
buffer = buffer.slice(0, -1);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (text.startsWith("\x1B")) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
buffer += text;
|
|
323
|
+
}
|
|
324
|
+
output.write(prompt);
|
|
325
|
+
input.setRawMode(true);
|
|
326
|
+
input.resume();
|
|
327
|
+
input.on("data", onData);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/doctor.ts
|
|
332
|
+
import fs5 from "node:fs";
|
|
333
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
334
|
+
|
|
335
|
+
// src/git.ts
|
|
336
|
+
import { execFileSync } from "node:child_process";
|
|
337
|
+
import fs2 from "node:fs";
|
|
338
|
+
import path3 from "node:path";
|
|
339
|
+
function runGit(args, cwd, timeout = 5e3) {
|
|
340
|
+
try {
|
|
341
|
+
const stdout = execFileSync("git", args, {
|
|
342
|
+
cwd,
|
|
343
|
+
encoding: "utf8",
|
|
344
|
+
timeout,
|
|
345
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
346
|
+
});
|
|
347
|
+
return { ok: true, stdout: stdout.trim() };
|
|
348
|
+
} catch (error) {
|
|
349
|
+
const gitError = error;
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
stdout: "",
|
|
353
|
+
stderr: gitError.stderr ? String(gitError.stderr).trim() : "",
|
|
354
|
+
code: typeof gitError.status === "number" ? gitError.status : 1
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function truncate(text, maxChars) {
|
|
359
|
+
if (!text) return "";
|
|
360
|
+
if (text.length <= maxChars) return text;
|
|
361
|
+
return `${text.slice(0, maxChars)}
|
|
362
|
+
... [truncated ${text.length - maxChars} chars]`;
|
|
363
|
+
}
|
|
364
|
+
function readUntrackedPreview(cwd, relativePath, maxChars) {
|
|
365
|
+
try {
|
|
366
|
+
const fullPath = path3.join(cwd, relativePath);
|
|
367
|
+
const buffer = fs2.readFileSync(fullPath);
|
|
368
|
+
if (buffer.includes(0)) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
return truncate(buffer.toString("utf8"), maxChars);
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function getGitContext(cwd, options = {}) {
|
|
377
|
+
const maxDiffChars = options.maxDiffChars || 12e3;
|
|
378
|
+
const maxUntrackedPreviewChars = options.maxUntrackedPreviewChars || 1500;
|
|
379
|
+
const repoCheck = runGit(["rev-parse", "--is-inside-work-tree"], cwd, 3e3);
|
|
380
|
+
if (!repoCheck.ok || repoCheck.stdout !== "true") {
|
|
381
|
+
return {
|
|
382
|
+
isGitRepo: false,
|
|
383
|
+
branch: null,
|
|
384
|
+
status: "",
|
|
385
|
+
staged: { stat: "", diff: "" },
|
|
386
|
+
unstaged: { stat: "", diff: "" },
|
|
387
|
+
untracked: [],
|
|
388
|
+
hasChanges: false,
|
|
389
|
+
recentCommits: "",
|
|
390
|
+
committedDiff: ""
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const branchResult = runGit(["branch", "--show-current"], cwd, 3e3);
|
|
394
|
+
const detachedResult = runGit(["rev-parse", "--short", "HEAD"], cwd, 3e3);
|
|
395
|
+
const branch = branchResult.ok && branchResult.stdout ? branchResult.stdout : detachedResult.ok && detachedResult.stdout ? `detached@${detachedResult.stdout}` : null;
|
|
396
|
+
const statusResult = runGit(["status", "--short", "--untracked-files=all"], cwd, 5e3);
|
|
397
|
+
const stagedStatResult = runGit(["diff", "--cached", "--stat"], cwd, 5e3);
|
|
398
|
+
const stagedDiffResult = runGit(["diff", "--cached"], cwd, 5e3);
|
|
399
|
+
const unstagedStatResult = runGit(["diff", "--stat"], cwd, 5e3);
|
|
400
|
+
const unstagedDiffResult = runGit(["diff"], cwd, 5e3);
|
|
401
|
+
const untrackedResult = runGit(["ls-files", "--others", "--exclude-standard"], cwd, 5e3);
|
|
402
|
+
const untracked = (untrackedResult.ok ? untrackedResult.stdout.split("\n") : []).map((item) => item.trim()).filter(Boolean).map((relativePath) => ({
|
|
403
|
+
path: relativePath,
|
|
404
|
+
preview: readUntrackedPreview(cwd, relativePath, maxUntrackedPreviewChars)
|
|
405
|
+
}));
|
|
406
|
+
const stagedDiff = truncate(stagedDiffResult.ok ? stagedDiffResult.stdout : "", maxDiffChars / 2);
|
|
407
|
+
const unstagedDiff = truncate(unstagedDiffResult.ok ? unstagedDiffResult.stdout : "", maxDiffChars / 2);
|
|
408
|
+
const recentCommitsResult = runGit(["log", "--oneline", "-15"], cwd, 5e3);
|
|
409
|
+
const recentCommits = recentCommitsResult.ok ? recentCommitsResult.stdout : "";
|
|
410
|
+
let committedDiff = "";
|
|
411
|
+
if (recentCommits) {
|
|
412
|
+
const commitCount = recentCommits.split("\n").filter(Boolean).length;
|
|
413
|
+
const diffDepth = Math.min(commitCount, 8);
|
|
414
|
+
const committedDiffResult = runGit(["diff", `HEAD~${diffDepth}..HEAD`, "--stat"], cwd, 5e3);
|
|
415
|
+
committedDiff = committedDiffResult.ok ? truncate(committedDiffResult.stdout, maxDiffChars / 2) : "";
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
isGitRepo: true,
|
|
419
|
+
branch,
|
|
420
|
+
status: statusResult.ok ? statusResult.stdout : "",
|
|
421
|
+
staged: {
|
|
422
|
+
stat: stagedStatResult.ok ? stagedStatResult.stdout : "",
|
|
423
|
+
diff: stagedDiff
|
|
424
|
+
},
|
|
425
|
+
unstaged: {
|
|
426
|
+
stat: unstagedStatResult.ok ? unstagedStatResult.stdout : "",
|
|
427
|
+
diff: unstagedDiff
|
|
428
|
+
},
|
|
429
|
+
untracked,
|
|
430
|
+
hasChanges: Boolean(statusResult.ok ? statusResult.stdout : "") || Boolean(stagedDiff) || Boolean(unstagedDiff) || untracked.length > 0,
|
|
431
|
+
recentCommits,
|
|
432
|
+
committedDiff
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/session-claude.ts
|
|
437
|
+
import fs3 from "node:fs";
|
|
438
|
+
import os2 from "node:os";
|
|
439
|
+
import path4 from "node:path";
|
|
440
|
+
var CLAUDE_DIR = path4.join(os2.homedir(), ".claude");
|
|
441
|
+
var PROJECTS_DIR = path4.join(CLAUDE_DIR, "projects");
|
|
442
|
+
function cwdToProjectDir(cwd) {
|
|
443
|
+
const resolved = path4.resolve(cwd);
|
|
444
|
+
const projectKey = resolved.replace(/[:\\/]+/g, "-");
|
|
445
|
+
return projectKey.startsWith("-") ? projectKey : `-${projectKey}`;
|
|
446
|
+
}
|
|
447
|
+
function listSessionsForProject(cwd, projectsDir = PROJECTS_DIR) {
|
|
448
|
+
const projectPath = path4.join(projectsDir, cwdToProjectDir(cwd));
|
|
449
|
+
if (!fs3.existsSync(projectPath)) {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
return fs3.readdirSync(projectPath, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")).map((entry) => {
|
|
453
|
+
const sessionPath = path4.join(projectPath, entry.name);
|
|
454
|
+
return {
|
|
455
|
+
id: entry.name.replace(/\.jsonl$/, ""),
|
|
456
|
+
name: entry.name,
|
|
457
|
+
path: sessionPath,
|
|
458
|
+
mtimeMs: fs3.statSync(sessionPath).mtimeMs
|
|
459
|
+
};
|
|
460
|
+
}).sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
461
|
+
}
|
|
462
|
+
function findLatestSession(cwd, projectsDir = PROJECTS_DIR) {
|
|
463
|
+
const sessions = listSessionsForProject(cwd, projectsDir);
|
|
464
|
+
return sessions[0] || null;
|
|
465
|
+
}
|
|
466
|
+
function resolveSessionPath(selection, cwd, projectsDir = PROJECTS_DIR) {
|
|
467
|
+
if (!selection) {
|
|
468
|
+
const latest = findLatestSession(cwd, projectsDir);
|
|
469
|
+
if (!latest) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return latest.path;
|
|
473
|
+
}
|
|
474
|
+
const looksLikePath = path4.isAbsolute(selection) || selection.includes(path4.sep) || selection.endsWith(".jsonl");
|
|
475
|
+
if (looksLikePath) {
|
|
476
|
+
const resolved = path4.resolve(selection);
|
|
477
|
+
return fs3.existsSync(resolved) ? resolved : null;
|
|
478
|
+
}
|
|
479
|
+
const projectPath = path4.join(projectsDir, cwdToProjectDir(cwd));
|
|
480
|
+
const candidates = [selection, `${selection}.jsonl`];
|
|
481
|
+
for (const candidate of candidates) {
|
|
482
|
+
const candidatePath = path4.join(projectPath, candidate);
|
|
483
|
+
if (fs3.existsSync(candidatePath)) {
|
|
484
|
+
return candidatePath;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
function extractTextFromContent(content) {
|
|
490
|
+
if (typeof content === "string") return content;
|
|
491
|
+
if (!Array.isArray(content)) return "";
|
|
492
|
+
return content.filter((item) => item && (item.type === "text" || item.type === "input_text")).map((item) => item.text || "").join("\n").trim();
|
|
493
|
+
}
|
|
494
|
+
function extractToolCalls(content) {
|
|
495
|
+
if (!Array.isArray(content)) return [];
|
|
496
|
+
return content.filter((item) => item && item.type === "tool_use").map((item) => ({
|
|
497
|
+
id: item.id || null,
|
|
498
|
+
tool: item.name || "unknown",
|
|
499
|
+
input: item.input || {}
|
|
500
|
+
}));
|
|
501
|
+
}
|
|
502
|
+
function extractToolResults(content) {
|
|
503
|
+
const results = /* @__PURE__ */ new Map();
|
|
504
|
+
if (!Array.isArray(content)) return results;
|
|
505
|
+
for (const item of content) {
|
|
506
|
+
if (item && item.type === "tool_result" && item.tool_use_id) {
|
|
507
|
+
const text = typeof item.content === "string" ? item.content : Array.isArray(item.content) ? item.content.filter((c) => c.type === "text").map((c) => c.text || "").join("\n") : "";
|
|
508
|
+
results.set(item.tool_use_id, {
|
|
509
|
+
result: text.slice(0, 1500),
|
|
510
|
+
isError: Boolean(item.is_error)
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return results;
|
|
515
|
+
}
|
|
516
|
+
function collectMessageCandidates(record) {
|
|
517
|
+
const candidates = [];
|
|
518
|
+
if ((record.type === "user" || record.type === "assistant") && record.message) {
|
|
519
|
+
candidates.push({
|
|
520
|
+
role: record.type,
|
|
521
|
+
message: record.message,
|
|
522
|
+
timestamp: record.timestamp
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const nestedMessage = record.data?.message?.message;
|
|
526
|
+
const nestedRole = nestedMessage?.role || record.data?.message?.type;
|
|
527
|
+
const nestedTimestamp = record.data?.message?.timestamp || record.timestamp;
|
|
528
|
+
if ((nestedRole === "user" || nestedRole === "assistant") && nestedMessage) {
|
|
529
|
+
candidates.push({
|
|
530
|
+
role: nestedRole,
|
|
531
|
+
message: nestedMessage,
|
|
532
|
+
timestamp: nestedTimestamp
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
return candidates;
|
|
536
|
+
}
|
|
537
|
+
function parseSession(sessionPath) {
|
|
538
|
+
const raw = fs3.readFileSync(sessionPath, "utf8");
|
|
539
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
540
|
+
const messages = [];
|
|
541
|
+
const meta = {
|
|
542
|
+
cwd: null,
|
|
543
|
+
gitBranch: null,
|
|
544
|
+
sessionId: path4.basename(sessionPath, ".jsonl")
|
|
545
|
+
};
|
|
546
|
+
for (const line of lines) {
|
|
547
|
+
let record;
|
|
548
|
+
try {
|
|
549
|
+
record = JSON.parse(line);
|
|
550
|
+
} catch {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (record.cwd) meta.cwd = record.cwd;
|
|
554
|
+
if (record.gitBranch) meta.gitBranch = record.gitBranch;
|
|
555
|
+
if (record.sessionId) meta.sessionId = record.sessionId;
|
|
556
|
+
const candidates = collectMessageCandidates(record);
|
|
557
|
+
for (const candidate of candidates) {
|
|
558
|
+
const content = candidate.message?.content;
|
|
559
|
+
const text = extractTextFromContent(content);
|
|
560
|
+
const toolCalls = extractToolCalls(content);
|
|
561
|
+
if (candidate.role === "user") {
|
|
562
|
+
const toolResults = extractToolResults(content);
|
|
563
|
+
if (toolResults.size > 0 && messages.length > 0) {
|
|
564
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
565
|
+
if (messages[i].role === "assistant") {
|
|
566
|
+
for (const tc of messages[i].toolCalls) {
|
|
567
|
+
if (tc.id && toolResults.has(tc.id)) {
|
|
568
|
+
const res = toolResults.get(tc.id);
|
|
569
|
+
tc.result = res.result;
|
|
570
|
+
tc.isError = res.isError;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const hasToolResultOnly = Array.isArray(content) && content.some((item) => item.type === "tool_result") && !content.some((item) => item.type === "text" || item.type === "input_text");
|
|
578
|
+
if (hasToolResultOnly) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!text && toolCalls.length === 0) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
messages.push({
|
|
586
|
+
role: candidate.role,
|
|
587
|
+
content: text,
|
|
588
|
+
toolCalls,
|
|
589
|
+
timestamp: candidate.timestamp || null
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { messages, meta };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/session-codex.ts
|
|
597
|
+
import fs4 from "node:fs";
|
|
598
|
+
import os3 from "node:os";
|
|
599
|
+
import path5 from "node:path";
|
|
600
|
+
var CODEX_DIR = path5.join(os3.homedir(), ".codex");
|
|
601
|
+
var CODEX_SESSIONS_DIR = path5.join(CODEX_DIR, "sessions");
|
|
602
|
+
var CODEX_INDEX_PATH = path5.join(CODEX_DIR, "session_index.jsonl");
|
|
603
|
+
function listSessionsForProject2(cwd) {
|
|
604
|
+
if (!fs4.existsSync(CODEX_SESSIONS_DIR)) {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
const resolvedCwd = path5.resolve(cwd);
|
|
608
|
+
const allSessions = [];
|
|
609
|
+
function walkDir(dir) {
|
|
610
|
+
if (!fs4.existsSync(dir)) return;
|
|
611
|
+
const entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
612
|
+
for (const entry of entries) {
|
|
613
|
+
const fullPath = path5.join(dir, entry.name);
|
|
614
|
+
if (entry.isDirectory()) {
|
|
615
|
+
walkDir(fullPath);
|
|
616
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
617
|
+
allSessions.push({
|
|
618
|
+
id: entry.name.replace(/\.jsonl$/, ""),
|
|
619
|
+
name: entry.name,
|
|
620
|
+
path: fullPath,
|
|
621
|
+
mtimeMs: fs4.statSync(fullPath).mtimeMs
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
walkDir(CODEX_SESSIONS_DIR);
|
|
627
|
+
const filtered = allSessions.filter((session) => {
|
|
628
|
+
try {
|
|
629
|
+
const firstLine = fs4.readFileSync(session.path, "utf8").split("\n")[0];
|
|
630
|
+
const record = JSON.parse(firstLine);
|
|
631
|
+
if (record.type === "session_meta" && record.payload?.cwd) {
|
|
632
|
+
return path5.resolve(record.payload.cwd) === resolvedCwd;
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
}
|
|
636
|
+
return false;
|
|
637
|
+
});
|
|
638
|
+
return filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
639
|
+
}
|
|
640
|
+
function findLatestSession2(cwd) {
|
|
641
|
+
const sessions = listSessionsForProject2(cwd);
|
|
642
|
+
return sessions[0] || null;
|
|
643
|
+
}
|
|
644
|
+
function resolveSessionPath2(selection, cwd) {
|
|
645
|
+
if (!selection) {
|
|
646
|
+
const latest = findLatestSession2(cwd);
|
|
647
|
+
return latest ? latest.path : null;
|
|
648
|
+
}
|
|
649
|
+
const looksLikePath = path5.isAbsolute(selection) || selection.includes(path5.sep) || selection.endsWith(".jsonl");
|
|
650
|
+
if (looksLikePath) {
|
|
651
|
+
const resolved = path5.resolve(selection);
|
|
652
|
+
return fs4.existsSync(resolved) ? resolved : null;
|
|
653
|
+
}
|
|
654
|
+
const sessions = listSessionsForProject2(cwd);
|
|
655
|
+
const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
|
|
656
|
+
return match ? match.path : null;
|
|
657
|
+
}
|
|
658
|
+
function parseSession2(sessionPath) {
|
|
659
|
+
const raw = fs4.readFileSync(sessionPath, "utf8");
|
|
660
|
+
const lines = raw.split(/\r?\n/).filter(Boolean);
|
|
661
|
+
const messages = [];
|
|
662
|
+
const meta = {
|
|
663
|
+
cwd: null,
|
|
664
|
+
gitBranch: null,
|
|
665
|
+
sessionId: path5.basename(sessionPath, ".jsonl")
|
|
666
|
+
};
|
|
667
|
+
const pendingCalls = /* @__PURE__ */ new Map();
|
|
668
|
+
let currentAssistantText = "";
|
|
669
|
+
let currentToolCalls = [];
|
|
670
|
+
function flushAssistant() {
|
|
671
|
+
if (currentAssistantText || currentToolCalls.length > 0) {
|
|
672
|
+
messages.push({
|
|
673
|
+
role: "assistant",
|
|
674
|
+
content: currentAssistantText.trim(),
|
|
675
|
+
toolCalls: [...currentToolCalls],
|
|
676
|
+
timestamp: null
|
|
677
|
+
});
|
|
678
|
+
currentAssistantText = "";
|
|
679
|
+
currentToolCalls = [];
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const line of lines) {
|
|
683
|
+
let record;
|
|
684
|
+
try {
|
|
685
|
+
record = JSON.parse(line);
|
|
686
|
+
} catch {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (record.type === "session_meta") {
|
|
690
|
+
const payload2 = record.payload;
|
|
691
|
+
if (payload2.cwd) meta.cwd = payload2.cwd;
|
|
692
|
+
if (payload2.git?.branch) meta.gitBranch = payload2.git.branch;
|
|
693
|
+
if (payload2.id) meta.sessionId = payload2.id;
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
if (record.type !== "response_item") continue;
|
|
697
|
+
const payload = record.payload;
|
|
698
|
+
const payloadType = payload.type;
|
|
699
|
+
if (payload.role === "user" && payloadType === "message") {
|
|
700
|
+
flushAssistant();
|
|
701
|
+
const text = (payload.content || []).filter((c) => c.type === "input_text").map((c) => c.text || "").join("\n").trim();
|
|
702
|
+
if (text) {
|
|
703
|
+
messages.push({
|
|
704
|
+
role: "user",
|
|
705
|
+
content: text,
|
|
706
|
+
toolCalls: [],
|
|
707
|
+
timestamp: null
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (payload.role === "assistant" && payloadType === "message") {
|
|
713
|
+
const text = (payload.content || []).filter((c) => c.type === "output_text").map((c) => c.text || "").join("\n").trim();
|
|
714
|
+
if (text) {
|
|
715
|
+
flushAssistant();
|
|
716
|
+
currentAssistantText = text;
|
|
717
|
+
}
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (payloadType === "function_call" && payload.name) {
|
|
721
|
+
let input = {};
|
|
722
|
+
try {
|
|
723
|
+
input = JSON.parse(payload.arguments || "{}");
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
726
|
+
const tc = {
|
|
727
|
+
id: payload.call_id || null,
|
|
728
|
+
tool: payload.name,
|
|
729
|
+
input
|
|
730
|
+
};
|
|
731
|
+
currentToolCalls.push(tc);
|
|
732
|
+
if (payload.call_id) {
|
|
733
|
+
pendingCalls.set(payload.call_id, tc);
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (payloadType === "function_call_output" && payload.call_id) {
|
|
738
|
+
const tc = pendingCalls.get(payload.call_id);
|
|
739
|
+
if (tc) {
|
|
740
|
+
const output = payload.output || "";
|
|
741
|
+
tc.result = output.slice(0, 1500);
|
|
742
|
+
tc.isError = /Process exited with code [^0]/.test(output) || /error|Error|ERROR/.test(output.slice(0, 200));
|
|
743
|
+
}
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
flushAssistant();
|
|
748
|
+
return { messages, meta };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/session-opencode.ts
|
|
752
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
753
|
+
import os4 from "node:os";
|
|
754
|
+
import path6 from "node:path";
|
|
755
|
+
var OPENCODE_DB_PATH = path6.join(os4.homedir(), ".local", "share", "opencode", "opencode.db");
|
|
756
|
+
function runSqlite(query, dbPath = OPENCODE_DB_PATH) {
|
|
757
|
+
try {
|
|
758
|
+
const stdout = execFileSync2("sqlite3", ["-json", dbPath, query], {
|
|
759
|
+
encoding: "utf8",
|
|
760
|
+
timeout: 1e4,
|
|
761
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
762
|
+
});
|
|
763
|
+
return { ok: true, stdout: stdout.trim() };
|
|
764
|
+
} catch {
|
|
765
|
+
return { ok: false, stdout: "" };
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
function parseSqliteJson(result) {
|
|
769
|
+
if (!result.ok || !result.stdout) return [];
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(result.stdout);
|
|
772
|
+
} catch {
|
|
773
|
+
return [];
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function listSessionsForProject3(cwd) {
|
|
777
|
+
const resolvedCwd = path6.resolve(cwd);
|
|
778
|
+
const projectResult = runSqlite(
|
|
779
|
+
`SELECT id FROM project WHERE worktree = '${resolvedCwd.replace(/'/g, "''")}';`
|
|
780
|
+
);
|
|
781
|
+
const projects = parseSqliteJson(projectResult);
|
|
782
|
+
if (projects.length === 0) return [];
|
|
783
|
+
const projectId = projects[0].id;
|
|
784
|
+
const sessionsResult = runSqlite(
|
|
785
|
+
`SELECT id, title, directory, time_created, time_updated FROM session WHERE project_id = '${projectId}' ORDER BY time_updated DESC;`
|
|
786
|
+
);
|
|
787
|
+
const sessions = parseSqliteJson(sessionsResult);
|
|
788
|
+
return sessions.map((s) => ({
|
|
789
|
+
id: s.id,
|
|
790
|
+
name: s.title || s.id,
|
|
791
|
+
path: s.id,
|
|
792
|
+
// OpenCode uses DB IDs, not file paths
|
|
793
|
+
mtimeMs: s.time_updated
|
|
794
|
+
}));
|
|
795
|
+
}
|
|
796
|
+
function findLatestSession3(cwd) {
|
|
797
|
+
const sessions = listSessionsForProject3(cwd);
|
|
798
|
+
return sessions[0] || null;
|
|
799
|
+
}
|
|
800
|
+
function resolveSessionPath3(selection, cwd) {
|
|
801
|
+
if (!selection) {
|
|
802
|
+
const latest = findLatestSession3(cwd);
|
|
803
|
+
return latest ? latest.id : null;
|
|
804
|
+
}
|
|
805
|
+
const sessions = listSessionsForProject3(cwd);
|
|
806
|
+
const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
|
|
807
|
+
return match ? match.id : null;
|
|
808
|
+
}
|
|
809
|
+
function parseSession3(sessionId) {
|
|
810
|
+
const messages = [];
|
|
811
|
+
const meta = {
|
|
812
|
+
cwd: null,
|
|
813
|
+
gitBranch: null,
|
|
814
|
+
sessionId
|
|
815
|
+
};
|
|
816
|
+
const sessionResult = runSqlite(
|
|
817
|
+
`SELECT title, directory FROM session WHERE id = '${sessionId.replace(/'/g, "''")}';`
|
|
818
|
+
);
|
|
819
|
+
const sessionRows = parseSqliteJson(sessionResult);
|
|
820
|
+
if (sessionRows.length > 0) {
|
|
821
|
+
meta.cwd = sessionRows[0].directory;
|
|
822
|
+
}
|
|
823
|
+
const msgResult = runSqlite(
|
|
824
|
+
`SELECT id, data FROM message WHERE session_id = '${sessionId.replace(/'/g, "''")}' ORDER BY time_created;`
|
|
825
|
+
);
|
|
826
|
+
const msgRows = parseSqliteJson(msgResult);
|
|
827
|
+
for (const row of msgRows) {
|
|
828
|
+
let msgData;
|
|
829
|
+
try {
|
|
830
|
+
msgData = JSON.parse(row.data);
|
|
831
|
+
} catch {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
if (msgData.path?.cwd && !meta.cwd) {
|
|
835
|
+
meta.cwd = msgData.path.cwd;
|
|
836
|
+
}
|
|
837
|
+
const partsResult = runSqlite(
|
|
838
|
+
`SELECT data FROM part WHERE message_id = '${row.id.replace(/'/g, "''")}' ORDER BY time_created;`
|
|
839
|
+
);
|
|
840
|
+
const partRows = parseSqliteJson(partsResult);
|
|
841
|
+
const role = msgData.role;
|
|
842
|
+
if (!role || role !== "user" && role !== "assistant") continue;
|
|
843
|
+
const textParts = [];
|
|
844
|
+
const toolCalls = [];
|
|
845
|
+
for (const partRow of partRows) {
|
|
846
|
+
let part;
|
|
847
|
+
try {
|
|
848
|
+
part = JSON.parse(partRow.data);
|
|
849
|
+
} catch {
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (part.type === "text" && part.text) {
|
|
853
|
+
textParts.push(part.text);
|
|
854
|
+
} else if (part.type === "tool" && part.tool) {
|
|
855
|
+
const input = part.state?.input || {};
|
|
856
|
+
const output = part.state?.output || "";
|
|
857
|
+
const isError = part.state?.status === "error" || /error|Error|ERROR/.test(output.slice(0, 200));
|
|
858
|
+
const toolName = normalizeToolName(part.tool);
|
|
859
|
+
const normalizedInput = normalizeInput(part.tool, input);
|
|
860
|
+
toolCalls.push({
|
|
861
|
+
id: part.callID || null,
|
|
862
|
+
tool: toolName,
|
|
863
|
+
input: normalizedInput,
|
|
864
|
+
result: output.slice(0, 1500) || void 0,
|
|
865
|
+
isError: isError || void 0
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const content = textParts.join("\n").trim();
|
|
870
|
+
if (!content && toolCalls.length === 0) continue;
|
|
871
|
+
messages.push({
|
|
872
|
+
role,
|
|
873
|
+
content,
|
|
874
|
+
toolCalls,
|
|
875
|
+
timestamp: null
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
return { messages, meta };
|
|
879
|
+
}
|
|
880
|
+
function normalizeToolName(tool) {
|
|
881
|
+
const mapping = {
|
|
882
|
+
read: "read",
|
|
883
|
+
edit: "edit",
|
|
884
|
+
write: "write",
|
|
885
|
+
grep: "grep",
|
|
886
|
+
glob: "glob",
|
|
887
|
+
bash: "bash",
|
|
888
|
+
search: "search",
|
|
889
|
+
task: "task",
|
|
890
|
+
skill: "skill",
|
|
891
|
+
todowrite: "todowrite",
|
|
892
|
+
question: "question"
|
|
893
|
+
};
|
|
894
|
+
return mapping[tool.toLowerCase()] || tool;
|
|
895
|
+
}
|
|
896
|
+
function normalizeInput(tool, input) {
|
|
897
|
+
if (input.filePath && !input.file_path) {
|
|
898
|
+
return { ...input, file_path: input.filePath };
|
|
899
|
+
}
|
|
900
|
+
return input;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/session.ts
|
|
904
|
+
import path7 from "node:path";
|
|
905
|
+
function listSessionsForProject4(cwd, source) {
|
|
906
|
+
switch (source) {
|
|
907
|
+
case "claude":
|
|
908
|
+
return listSessionsForProject(cwd);
|
|
909
|
+
case "codex":
|
|
910
|
+
return listSessionsForProject2(cwd);
|
|
911
|
+
case "opencode":
|
|
912
|
+
return listSessionsForProject3(cwd);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function findLatestSession4(cwd, source) {
|
|
916
|
+
switch (source) {
|
|
917
|
+
case "claude":
|
|
918
|
+
return findLatestSession(cwd);
|
|
919
|
+
case "codex":
|
|
920
|
+
return findLatestSession2(cwd);
|
|
921
|
+
case "opencode":
|
|
922
|
+
return findLatestSession3(cwd);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
function resolveSessionPath4(selection, cwd, source) {
|
|
926
|
+
switch (source) {
|
|
927
|
+
case "claude":
|
|
928
|
+
return resolveSessionPath(selection, cwd);
|
|
929
|
+
case "codex":
|
|
930
|
+
return resolveSessionPath2(selection, cwd);
|
|
931
|
+
case "opencode":
|
|
932
|
+
return resolveSessionPath3(selection, cwd);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
function parseSession4(sessionPathOrId, source) {
|
|
936
|
+
switch (source) {
|
|
937
|
+
case "claude":
|
|
938
|
+
return parseSession(sessionPathOrId);
|
|
939
|
+
case "codex":
|
|
940
|
+
return parseSession2(sessionPathOrId);
|
|
941
|
+
case "opencode":
|
|
942
|
+
return parseSession3(sessionPathOrId);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function extractFilePath(input) {
|
|
946
|
+
const value = input.file_path || input.path || input.target_file || input.filePath;
|
|
947
|
+
return typeof value === "string" ? value : null;
|
|
948
|
+
}
|
|
949
|
+
function extractCommand(input) {
|
|
950
|
+
const value = input.command || input.cmd;
|
|
951
|
+
return typeof value === "string" ? value : null;
|
|
952
|
+
}
|
|
953
|
+
function buildSessionContext({
|
|
954
|
+
messages,
|
|
955
|
+
meta,
|
|
956
|
+
cwd,
|
|
957
|
+
sessionPath,
|
|
958
|
+
gitContext
|
|
959
|
+
}) {
|
|
960
|
+
const filesModified = /* @__PURE__ */ new Set();
|
|
961
|
+
const filesRead = /* @__PURE__ */ new Set();
|
|
962
|
+
const commands = [];
|
|
963
|
+
const transcript = [];
|
|
964
|
+
for (const message of messages) {
|
|
965
|
+
if (message.role === "assistant" && message.toolCalls.length > 0) {
|
|
966
|
+
for (const toolCall of message.toolCalls) {
|
|
967
|
+
const toolName = String(toolCall.tool || "").toLowerCase();
|
|
968
|
+
const filePath = extractFilePath(toolCall.input);
|
|
969
|
+
const command = extractCommand(toolCall.input);
|
|
970
|
+
if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
|
|
971
|
+
filesModified.add(filePath);
|
|
972
|
+
} else if (filePath && /(read|grep|glob|search)/.test(toolName)) {
|
|
973
|
+
filesRead.add(filePath);
|
|
974
|
+
}
|
|
975
|
+
if (command && /(bash|command|run|exec_command)/.test(toolName)) {
|
|
976
|
+
commands.push(command);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const summaryParts = [];
|
|
981
|
+
if (message.content) {
|
|
982
|
+
summaryParts.push(message.content);
|
|
983
|
+
}
|
|
984
|
+
if (message.role === "assistant" && message.toolCalls.length > 0) {
|
|
985
|
+
const toolSummary = message.toolCalls.map((toolCall) => {
|
|
986
|
+
const filePath = extractFilePath(toolCall.input);
|
|
987
|
+
const command = extractCommand(toolCall.input);
|
|
988
|
+
let summary = "";
|
|
989
|
+
if (filePath) summary = `${toolCall.tool} ${filePath}`;
|
|
990
|
+
else if (command) summary = `${toolCall.tool}: ${command}`;
|
|
991
|
+
else summary = toolCall.tool;
|
|
992
|
+
if (toolCall.isError && toolCall.result) {
|
|
993
|
+
summary += ` [ERROR: ${toolCall.result.slice(0, 200)}]`;
|
|
994
|
+
}
|
|
995
|
+
return summary;
|
|
996
|
+
}).join(", ");
|
|
997
|
+
if (toolSummary) {
|
|
998
|
+
summaryParts.push(`[tools] ${toolSummary}`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (summaryParts.length > 0) {
|
|
1002
|
+
transcript.push({
|
|
1003
|
+
role: message.role,
|
|
1004
|
+
text: summaryParts.join(" | "),
|
|
1005
|
+
timestamp: message.timestamp || null
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
cwd,
|
|
1011
|
+
sessionCwd: meta.cwd || cwd,
|
|
1012
|
+
sessionPath,
|
|
1013
|
+
sessionId: meta.sessionId || path7.basename(sessionPath, ".jsonl"),
|
|
1014
|
+
branch: gitContext.branch || meta.gitBranch || null,
|
|
1015
|
+
transcript,
|
|
1016
|
+
filesModified: [...filesModified],
|
|
1017
|
+
filesRead: [...filesRead],
|
|
1018
|
+
commands,
|
|
1019
|
+
messages,
|
|
1020
|
+
gitContext
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/doctor.ts
|
|
1025
|
+
function checkSqliteAvailable() {
|
|
1026
|
+
try {
|
|
1027
|
+
execFileSync3("sqlite3", ["--version"], { encoding: "utf8", timeout: 3e3, stdio: ["ignore", "pipe", "pipe"] });
|
|
1028
|
+
return true;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return false;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function runDoctor({
|
|
1034
|
+
cwd,
|
|
1035
|
+
source,
|
|
1036
|
+
provider,
|
|
1037
|
+
cliApiKey,
|
|
1038
|
+
env,
|
|
1039
|
+
config
|
|
1040
|
+
}) {
|
|
1041
|
+
const checks = [];
|
|
1042
|
+
const notes = [];
|
|
1043
|
+
const nextSteps = [];
|
|
1044
|
+
if (source === "claude") {
|
|
1045
|
+
const claudeDirExists = fs5.existsSync(CLAUDE_DIR);
|
|
1046
|
+
checks.push({
|
|
1047
|
+
status: claudeDirExists ? "OK" : "WARN",
|
|
1048
|
+
label: "Claude dir",
|
|
1049
|
+
detail: claudeDirExists ? CLAUDE_DIR : `Missing: ${CLAUDE_DIR}`
|
|
1050
|
+
});
|
|
1051
|
+
if (!claudeDirExists) {
|
|
1052
|
+
nextSteps.push("Install or run Claude Code so ~/.claude/projects is created.");
|
|
1053
|
+
}
|
|
1054
|
+
} else if (source === "codex") {
|
|
1055
|
+
const codexDirExists = fs5.existsSync(CODEX_DIR);
|
|
1056
|
+
checks.push({
|
|
1057
|
+
status: codexDirExists ? "OK" : "WARN",
|
|
1058
|
+
label: "Codex dir",
|
|
1059
|
+
detail: codexDirExists ? CODEX_DIR : `Missing: ${CODEX_DIR}`
|
|
1060
|
+
});
|
|
1061
|
+
if (!codexDirExists) {
|
|
1062
|
+
nextSteps.push("Install or run Codex so ~/.codex/sessions is created.");
|
|
1063
|
+
}
|
|
1064
|
+
} else if (source === "opencode") {
|
|
1065
|
+
const dbExists = fs5.existsSync(OPENCODE_DB_PATH);
|
|
1066
|
+
checks.push({
|
|
1067
|
+
status: dbExists ? "OK" : "WARN",
|
|
1068
|
+
label: "OpenCode DB",
|
|
1069
|
+
detail: dbExists ? OPENCODE_DB_PATH : `Missing: ${OPENCODE_DB_PATH}`
|
|
1070
|
+
});
|
|
1071
|
+
const sqliteOk = checkSqliteAvailable();
|
|
1072
|
+
checks.push({
|
|
1073
|
+
status: sqliteOk ? "OK" : "WARN",
|
|
1074
|
+
label: "sqlite3",
|
|
1075
|
+
detail: sqliteOk ? "Available" : "sqlite3 command not found (required for OpenCode)"
|
|
1076
|
+
});
|
|
1077
|
+
if (!dbExists) {
|
|
1078
|
+
nextSteps.push("Install or run OpenCode so the database is created.");
|
|
1079
|
+
}
|
|
1080
|
+
if (!sqliteOk) {
|
|
1081
|
+
nextSteps.push("Install sqlite3 (required for reading OpenCode sessions).");
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const sessions = listSessionsForProject4(cwd, source);
|
|
1085
|
+
const sourceLabel = source === "claude" ? "Claude" : source === "codex" ? "Codex" : "OpenCode";
|
|
1086
|
+
if (sessions.length > 0) {
|
|
1087
|
+
checks.push({
|
|
1088
|
+
status: "OK",
|
|
1089
|
+
label: "Project sessions",
|
|
1090
|
+
detail: `${sessions.length} ${sourceLabel} session(s) found, latest: ${sessions[0].name}`
|
|
1091
|
+
});
|
|
1092
|
+
} else {
|
|
1093
|
+
checks.push({
|
|
1094
|
+
status: "WARN",
|
|
1095
|
+
label: "Project sessions",
|
|
1096
|
+
detail: `No ${sourceLabel} session files found for the current directory`
|
|
1097
|
+
});
|
|
1098
|
+
nextSteps.push(`Run ${sourceLabel} in this project once so a session file exists.`);
|
|
1099
|
+
}
|
|
1100
|
+
const latest = findLatestSession4(cwd, source);
|
|
1101
|
+
if (latest) {
|
|
1102
|
+
checks.push({
|
|
1103
|
+
status: "OK",
|
|
1104
|
+
label: "Latest session",
|
|
1105
|
+
detail: latest.path
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
const gitContext = getGitContext(cwd);
|
|
1109
|
+
if (gitContext.isGitRepo) {
|
|
1110
|
+
checks.push({
|
|
1111
|
+
status: "OK",
|
|
1112
|
+
label: "Git",
|
|
1113
|
+
detail: gitContext.branch ? `Repo detected on ${gitContext.branch}` : "Repo detected"
|
|
1114
|
+
});
|
|
1115
|
+
} else {
|
|
1116
|
+
checks.push({
|
|
1117
|
+
status: "WARN",
|
|
1118
|
+
label: "Git",
|
|
1119
|
+
detail: "Current directory is not a git repository"
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
const apiKey = getApiKey({
|
|
1123
|
+
provider,
|
|
1124
|
+
cliValue: cliApiKey,
|
|
1125
|
+
env,
|
|
1126
|
+
config
|
|
1127
|
+
});
|
|
1128
|
+
checks.push({
|
|
1129
|
+
status: apiKey ? "OK" : "WARN",
|
|
1130
|
+
label: "API key",
|
|
1131
|
+
detail: apiKey ? `Available for provider ${provider}` : `Missing for provider ${provider}`
|
|
1132
|
+
});
|
|
1133
|
+
const clipboard = getClipboardStrategy();
|
|
1134
|
+
checks.push({
|
|
1135
|
+
status: clipboard ? "OK" : "WARN",
|
|
1136
|
+
label: "Clipboard",
|
|
1137
|
+
detail: clipboard ? `Using ${clipboard.command}` : "No supported clipboard utility found"
|
|
1138
|
+
});
|
|
1139
|
+
if (!apiKey) {
|
|
1140
|
+
notes.push("Refined mode will prompt for an API key when running interactively.");
|
|
1141
|
+
nextSteps.push("Set OPENROUTER_API_KEY or run ctx-switch once interactively to save it.");
|
|
1142
|
+
}
|
|
1143
|
+
if (!gitContext.isGitRepo) {
|
|
1144
|
+
notes.push("Raw continuation prompts still work outside git, but code-state fidelity is lower.");
|
|
1145
|
+
nextSteps.push("Initialize git in this project if you want better code-state summaries.");
|
|
1146
|
+
}
|
|
1147
|
+
return { checks, notes, nextSteps };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/prompt.ts
|
|
1151
|
+
function compactText(text, maxChars = 800) {
|
|
1152
|
+
let compacted = String(text || "").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
1153
|
+
if (compacted.length <= maxChars) return compacted;
|
|
1154
|
+
return `${compacted.slice(0, maxChars)}...`;
|
|
1155
|
+
}
|
|
1156
|
+
function unique(list) {
|
|
1157
|
+
return [...new Set(list.filter(Boolean))];
|
|
1158
|
+
}
|
|
1159
|
+
function buildTargetGuidance(target) {
|
|
1160
|
+
switch (target) {
|
|
1161
|
+
case "codex":
|
|
1162
|
+
return "The next agent is Codex. It should inspect the current files first, avoid redoing completed work, and finish any remaining implementation or verification.";
|
|
1163
|
+
case "cursor":
|
|
1164
|
+
return "The next agent is Cursor. It should continue the implementation directly from the current workspace state and verify behavior in-editor.";
|
|
1165
|
+
case "chatgpt":
|
|
1166
|
+
return "The next agent is ChatGPT. It should reason from the current workspace state, explain what remains, and provide explicit next actions.";
|
|
1167
|
+
default:
|
|
1168
|
+
return "The next agent should continue the interrupted work from the current workspace state without redoing completed steps.";
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function isNoiseMessage(text) {
|
|
1172
|
+
const trimmed = text.trim().toLowerCase();
|
|
1173
|
+
if (trimmed.length < 5) return true;
|
|
1174
|
+
const noise = [
|
|
1175
|
+
"yes",
|
|
1176
|
+
"no",
|
|
1177
|
+
"ok",
|
|
1178
|
+
"okay",
|
|
1179
|
+
"try",
|
|
1180
|
+
"try?",
|
|
1181
|
+
"sure",
|
|
1182
|
+
"do it",
|
|
1183
|
+
"go ahead",
|
|
1184
|
+
"works",
|
|
1185
|
+
"nice",
|
|
1186
|
+
"cool",
|
|
1187
|
+
"thanks",
|
|
1188
|
+
"thank you",
|
|
1189
|
+
"lgtm",
|
|
1190
|
+
"ship it",
|
|
1191
|
+
"push it",
|
|
1192
|
+
"works push it",
|
|
1193
|
+
"try low effort",
|
|
1194
|
+
"try turn off thinking",
|
|
1195
|
+
"try without timeout"
|
|
1196
|
+
];
|
|
1197
|
+
if (noise.includes(trimmed)) return true;
|
|
1198
|
+
if (/^(try|yes|ok|sure|test|run)\s/i.test(trimmed) && trimmed.length < 40) return true;
|
|
1199
|
+
if (trimmed.startsWith("[request interrupted")) return true;
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
function filterUserMessages(messages) {
|
|
1203
|
+
const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim());
|
|
1204
|
+
if (all.length <= 2) return all;
|
|
1205
|
+
const first = all[0];
|
|
1206
|
+
const last = all[all.length - 1];
|
|
1207
|
+
const middle = all.slice(1, -1).filter((msg) => !isNoiseMessage(msg));
|
|
1208
|
+
return [first, ...middle, last];
|
|
1209
|
+
}
|
|
1210
|
+
function extractUnresolvedErrors(messages) {
|
|
1211
|
+
const errorEntries = [];
|
|
1212
|
+
const successes = /* @__PURE__ */ new Set();
|
|
1213
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1214
|
+
for (const tc of messages[i].toolCalls) {
|
|
1215
|
+
const target = String(tc.input.file_path || tc.input.path || tc.input.command || "").slice(0, 100);
|
|
1216
|
+
const key = `${tc.tool}:${target}`;
|
|
1217
|
+
if (tc.isError && tc.result) {
|
|
1218
|
+
errorEntries.push({ tool: tc.tool, target, error: tc.result.slice(0, 300), index: i });
|
|
1219
|
+
} else if (!tc.isError) {
|
|
1220
|
+
successes.add(key);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return errorEntries.filter((e) => !successes.has(`${e.tool}:${e.target}`)).map((e) => `${e.tool}(${e.target}): ${e.error}`);
|
|
1225
|
+
}
|
|
1226
|
+
function extractKeyDecisions(messages) {
|
|
1227
|
+
const decisions = [];
|
|
1228
|
+
for (const msg of messages) {
|
|
1229
|
+
if (msg.role !== "assistant" || !msg.content) continue;
|
|
1230
|
+
const lower = msg.content.toLowerCase();
|
|
1231
|
+
if (lower.includes("instead") || lower.includes("let me try") || lower.includes("switching to") || lower.includes("the issue is") || lower.includes("the problem is") || lower.includes("root cause")) {
|
|
1232
|
+
decisions.push(compactText(msg.content, 300));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return decisions.slice(-5);
|
|
1236
|
+
}
|
|
1237
|
+
function buildRawPrompt(ctx, options = {}) {
|
|
1238
|
+
const userMessages = filterUserMessages(ctx.messages);
|
|
1239
|
+
const errors = extractUnresolvedErrors(ctx.messages);
|
|
1240
|
+
const decisions = extractKeyDecisions(ctx.messages);
|
|
1241
|
+
let prompt = "";
|
|
1242
|
+
prompt += "# Task\n\n";
|
|
1243
|
+
prompt += `Project: \`${ctx.sessionCwd}\`
|
|
1244
|
+
`;
|
|
1245
|
+
if (ctx.branch) prompt += `Branch: \`${ctx.branch}\`
|
|
1246
|
+
`;
|
|
1247
|
+
prompt += "\nThis is a continuation of an interrupted AI coding session. ";
|
|
1248
|
+
prompt += "The previous agent was working on the task below. Pick up where it left off.\n\n";
|
|
1249
|
+
prompt += "## What The User Asked (chronological)\n\n";
|
|
1250
|
+
for (const msg of userMessages) {
|
|
1251
|
+
prompt += `- ${compactText(msg, 500)}
|
|
1252
|
+
`;
|
|
1253
|
+
}
|
|
1254
|
+
prompt += "\n";
|
|
1255
|
+
if (errors.length > 0) {
|
|
1256
|
+
prompt += "## DO NOT REPEAT \u2014 Unresolved Errors\n\n";
|
|
1257
|
+
prompt += "These errors occurred and were NOT fixed. Avoid the same approaches.\n\n";
|
|
1258
|
+
for (const error of errors.slice(-6)) {
|
|
1259
|
+
prompt += `- ${error}
|
|
1260
|
+
`;
|
|
1261
|
+
}
|
|
1262
|
+
prompt += "\n";
|
|
1263
|
+
}
|
|
1264
|
+
if (decisions.length > 0) {
|
|
1265
|
+
prompt += "## Key Discoveries From Previous Agent\n\n";
|
|
1266
|
+
for (const decision of decisions) {
|
|
1267
|
+
prompt += `- ${decision}
|
|
1268
|
+
`;
|
|
1269
|
+
}
|
|
1270
|
+
prompt += "\n";
|
|
1271
|
+
}
|
|
1272
|
+
prompt += "## Work Already Completed\n\n";
|
|
1273
|
+
if (unique(ctx.filesModified).length > 0) {
|
|
1274
|
+
prompt += "**Files modified:**\n";
|
|
1275
|
+
for (const filePath of unique(ctx.filesModified)) {
|
|
1276
|
+
prompt += `- \`${filePath}\`
|
|
1277
|
+
`;
|
|
1278
|
+
}
|
|
1279
|
+
prompt += "\n";
|
|
1280
|
+
}
|
|
1281
|
+
if (ctx.gitContext.recentCommits) {
|
|
1282
|
+
prompt += "**Recent commits:**\n```\n";
|
|
1283
|
+
prompt += `${ctx.gitContext.recentCommits}
|
|
1284
|
+
`;
|
|
1285
|
+
prompt += "```\n\n";
|
|
1286
|
+
}
|
|
1287
|
+
if (ctx.gitContext.committedDiff) {
|
|
1288
|
+
prompt += "**Files changed in recent commits:**\n```\n";
|
|
1289
|
+
prompt += `${ctx.gitContext.committedDiff}
|
|
1290
|
+
`;
|
|
1291
|
+
prompt += "```\n\n";
|
|
1292
|
+
}
|
|
1293
|
+
const git = ctx.gitContext;
|
|
1294
|
+
if (git.isGitRepo && git.hasChanges) {
|
|
1295
|
+
prompt += "## Uncommitted Changes\n\n";
|
|
1296
|
+
if (git.status) {
|
|
1297
|
+
prompt += "```\n" + git.status + "\n```\n\n";
|
|
1298
|
+
}
|
|
1299
|
+
if (git.staged.diff) {
|
|
1300
|
+
prompt += "**Staged diff:**\n```diff\n" + git.staged.diff + "\n```\n\n";
|
|
1301
|
+
}
|
|
1302
|
+
if (git.unstaged.diff) {
|
|
1303
|
+
prompt += "**Unstaged diff:**\n```diff\n" + git.unstaged.diff + "\n```\n\n";
|
|
1304
|
+
}
|
|
1305
|
+
if (git.untracked.length > 0) {
|
|
1306
|
+
const shown = git.untracked.slice(0, 6);
|
|
1307
|
+
prompt += "**Untracked files:**\n";
|
|
1308
|
+
for (const file of shown) {
|
|
1309
|
+
prompt += `- \`${file.path}\`
|
|
1310
|
+
`;
|
|
1311
|
+
}
|
|
1312
|
+
if (git.untracked.length > shown.length) {
|
|
1313
|
+
prompt += `- ... and ${git.untracked.length - shown.length} more
|
|
1314
|
+
`;
|
|
1315
|
+
}
|
|
1316
|
+
prompt += "\n";
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
prompt += "## Your Instructions\n\n";
|
|
1320
|
+
prompt += `${buildTargetGuidance(options.target)}
|
|
1321
|
+
|
|
1322
|
+
`;
|
|
1323
|
+
prompt += "1. **Read modified files first** \u2014 verify their current state before changing anything.\n";
|
|
1324
|
+
if (errors.length > 0) {
|
|
1325
|
+
prompt += "2. **Check the errors above** \u2014 do NOT repeat failed approaches. Try a different strategy.\n";
|
|
1326
|
+
}
|
|
1327
|
+
prompt += `${errors.length > 0 ? "3" : "2"}. **Identify what's done vs what remains** \u2014 the commits and modified files above show completed work.
|
|
1328
|
+
`;
|
|
1329
|
+
prompt += `${errors.length > 0 ? "4" : "3"}. **Do the remaining work** \u2014 pick up exactly where the previous agent stopped.
|
|
1330
|
+
`;
|
|
1331
|
+
prompt += `${errors.length > 0 ? "5" : "4"}. **Verify** \u2014 run tests/builds to confirm everything works.
|
|
1332
|
+
`;
|
|
1333
|
+
return prompt;
|
|
1334
|
+
}
|
|
1335
|
+
function buildRefinementDump(ctx, options = {}) {
|
|
1336
|
+
return buildRawPrompt(ctx, options);
|
|
1337
|
+
}
|
|
1338
|
+
function buildRefinementSystemPrompt(target) {
|
|
1339
|
+
return `You are an expert at creating continuation prompts for AI coding agents. You receive a structured dump of an interrupted AI coding session and must produce a handoff prompt that lets the next agent pick up exactly where the previous one left off.
|
|
1340
|
+
|
|
1341
|
+
Your output must be a single, actionable prompt with these sections:
|
|
1342
|
+
|
|
1343
|
+
## Goal
|
|
1344
|
+
State what the user wants in 1-2 sentences. Use the user's own words where possible.
|
|
1345
|
+
|
|
1346
|
+
## Completed Work
|
|
1347
|
+
List what was already done \u2014 files created/modified, features implemented, tests passing. Be specific with file paths.
|
|
1348
|
+
|
|
1349
|
+
## Errors & Failed Approaches
|
|
1350
|
+
If any errors or failures occurred, list them clearly so the next agent does NOT waste time repeating them. Include what was tried and why it failed.
|
|
1351
|
+
|
|
1352
|
+
## Remaining Work
|
|
1353
|
+
List exactly what still needs to be done. Be specific \u2014 "implement the validation logic in src/validator.ts" not "finish the feature".
|
|
1354
|
+
|
|
1355
|
+
## Current Code State
|
|
1356
|
+
Summarize the git state: branch, what's committed, what's staged, what's modified but uncommitted. Mention specific files.
|
|
1357
|
+
|
|
1358
|
+
## Key Files
|
|
1359
|
+
List the most important files the next agent should read first to understand the current state.
|
|
1360
|
+
|
|
1361
|
+
## Action Plan
|
|
1362
|
+
Give the next agent a concrete numbered list of steps to follow, starting with "Read [specific files] to verify current state" and ending with verification steps.
|
|
1363
|
+
|
|
1364
|
+
Rules:
|
|
1365
|
+
- Output ONLY the handoff prompt. No preamble, no meta-commentary.
|
|
1366
|
+
- Be specific and concrete. File paths, function names, error messages \u2014 not vague descriptions.
|
|
1367
|
+
- If the data is incomplete or ambiguous, say so explicitly rather than guessing.
|
|
1368
|
+
- Prioritize information density. Every sentence should help the next agent.
|
|
1369
|
+
- If errors/failures were found in the session, make them prominent \u2014 avoiding repeated mistakes is critical.
|
|
1370
|
+
- Target agent: ${target}. ${buildTargetGuidance(target)}`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// src/openrouter.ts
|
|
1374
|
+
import https from "node:https";
|
|
1375
|
+
function buildOpenRouterPayload({
|
|
1376
|
+
model,
|
|
1377
|
+
systemPrompt,
|
|
1378
|
+
userPrompt
|
|
1379
|
+
}) {
|
|
1380
|
+
return {
|
|
1381
|
+
model,
|
|
1382
|
+
messages: [
|
|
1383
|
+
{ role: "system", content: systemPrompt },
|
|
1384
|
+
{ role: "user", content: userPrompt }
|
|
1385
|
+
]
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
function parseJsonSafely(value) {
|
|
1389
|
+
try {
|
|
1390
|
+
return JSON.parse(value);
|
|
1391
|
+
} catch {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
function classifyOpenRouterError({
|
|
1396
|
+
statusCode,
|
|
1397
|
+
body = "",
|
|
1398
|
+
requestError
|
|
1399
|
+
}) {
|
|
1400
|
+
if (requestError) {
|
|
1401
|
+
return {
|
|
1402
|
+
category: "network",
|
|
1403
|
+
message: requestError,
|
|
1404
|
+
suggestions: [
|
|
1405
|
+
"Check your network connection and try again.",
|
|
1406
|
+
"Run `ctx-switch --raw` if you want to skip refinement."
|
|
1407
|
+
]
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const parsed = parseJsonSafely(body);
|
|
1411
|
+
const providerMessage = parsed?.error?.message || parsed?.message || (body ? String(body).slice(0, 500) : "Unknown provider error");
|
|
1412
|
+
const lower = providerMessage.toLowerCase();
|
|
1413
|
+
if (statusCode === 404 && lower.includes("guardrail restrictions") && lower.includes("data policy")) {
|
|
1414
|
+
return {
|
|
1415
|
+
category: "privacy-policy",
|
|
1416
|
+
message: "OpenRouter blocked this model because your privacy settings do not allow any available endpoint for it.",
|
|
1417
|
+
suggestions: [
|
|
1418
|
+
"Open https://openrouter.ai/settings/privacy and relax the privacy restriction for this model.",
|
|
1419
|
+
"Retry with `ctx-switch --raw` if you want to skip provider refinement.",
|
|
1420
|
+
"Retry with `ctx-switch --model <another-openrouter-model>` if you have another allowed model."
|
|
1421
|
+
],
|
|
1422
|
+
raw: providerMessage
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
1426
|
+
return {
|
|
1427
|
+
category: "auth",
|
|
1428
|
+
message: "OpenRouter rejected the request. Check that your API key is valid and allowed to use this model.",
|
|
1429
|
+
suggestions: [
|
|
1430
|
+
"Verify `OPENROUTER_API_KEY` or rerun interactively to save a fresh key.",
|
|
1431
|
+
"Retry with `ctx-switch --raw` if you want to skip refinement."
|
|
1432
|
+
],
|
|
1433
|
+
raw: providerMessage
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
if (statusCode === 404) {
|
|
1437
|
+
return {
|
|
1438
|
+
category: "not-found",
|
|
1439
|
+
message: "OpenRouter could not find a compatible endpoint for this request.",
|
|
1440
|
+
suggestions: [
|
|
1441
|
+
"Retry with `ctx-switch --model <another-openrouter-model>`.",
|
|
1442
|
+
"Run `ctx-switch --raw` if you want to skip provider refinement."
|
|
1443
|
+
],
|
|
1444
|
+
raw: providerMessage
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (statusCode === 429) {
|
|
1448
|
+
return {
|
|
1449
|
+
category: "rate-limit",
|
|
1450
|
+
message: "OpenRouter rate limited this request.",
|
|
1451
|
+
suggestions: [
|
|
1452
|
+
"Wait a bit and retry.",
|
|
1453
|
+
"Run `ctx-switch --raw` if you want to skip refinement."
|
|
1454
|
+
],
|
|
1455
|
+
raw: providerMessage
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
category: "provider",
|
|
1460
|
+
message: statusCode ? `OpenRouter error ${statusCode}: ${providerMessage}` : providerMessage,
|
|
1461
|
+
suggestions: ["Retry with `ctx-switch --raw` if you want to skip refinement."],
|
|
1462
|
+
raw: providerMessage
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function parseSSEChunk(line) {
|
|
1466
|
+
if (!line.startsWith("data: ")) return null;
|
|
1467
|
+
const data = line.slice(6).trim();
|
|
1468
|
+
if (data === "[DONE]") return null;
|
|
1469
|
+
try {
|
|
1470
|
+
const parsed = JSON.parse(data);
|
|
1471
|
+
if (parsed.error) return null;
|
|
1472
|
+
return parsed.choices?.[0]?.delta?.content || null;
|
|
1473
|
+
} catch {
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
function doStreamRequest({
|
|
1478
|
+
apiKey,
|
|
1479
|
+
body,
|
|
1480
|
+
timeoutMs,
|
|
1481
|
+
onStatus,
|
|
1482
|
+
onToken
|
|
1483
|
+
}) {
|
|
1484
|
+
return new Promise((resolve) => {
|
|
1485
|
+
let receivedFirstToken = false;
|
|
1486
|
+
let fullText = "";
|
|
1487
|
+
let sseBuffer = "";
|
|
1488
|
+
const req = https.request(
|
|
1489
|
+
{
|
|
1490
|
+
hostname: "openrouter.ai",
|
|
1491
|
+
path: "/api/v1/chat/completions",
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
headers: {
|
|
1494
|
+
"Content-Type": "application/json",
|
|
1495
|
+
"Content-Length": Buffer.byteLength(body),
|
|
1496
|
+
Authorization: `Bearer ${apiKey}`
|
|
1497
|
+
}
|
|
1498
|
+
},
|
|
1499
|
+
(res) => {
|
|
1500
|
+
onStatus?.(`provider responded (${res.statusCode || "unknown"})`);
|
|
1501
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
1502
|
+
let errorData = "";
|
|
1503
|
+
res.on("data", (chunk) => {
|
|
1504
|
+
errorData += chunk;
|
|
1505
|
+
});
|
|
1506
|
+
res.on("end", () => {
|
|
1507
|
+
const classified = classifyOpenRouterError({ statusCode: res.statusCode, body: errorData });
|
|
1508
|
+
resolve({
|
|
1509
|
+
ok: false,
|
|
1510
|
+
error: classified.message,
|
|
1511
|
+
category: classified.category,
|
|
1512
|
+
suggestions: classified.suggestions,
|
|
1513
|
+
rawError: classified.raw || errorData.slice(0, 500)
|
|
1514
|
+
});
|
|
1515
|
+
});
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
res.on("data", (chunk) => {
|
|
1519
|
+
sseBuffer += chunk;
|
|
1520
|
+
const lines = sseBuffer.split("\n");
|
|
1521
|
+
sseBuffer = lines.pop() || "";
|
|
1522
|
+
for (const line of lines) {
|
|
1523
|
+
const trimmed = line.trim();
|
|
1524
|
+
if (!trimmed) continue;
|
|
1525
|
+
const token = parseSSEChunk(trimmed);
|
|
1526
|
+
if (token) {
|
|
1527
|
+
if (!receivedFirstToken) {
|
|
1528
|
+
receivedFirstToken = true;
|
|
1529
|
+
onStatus?.("streaming");
|
|
1530
|
+
}
|
|
1531
|
+
fullText += token;
|
|
1532
|
+
onToken?.(token);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
res.on("end", () => {
|
|
1537
|
+
if (sseBuffer.trim()) {
|
|
1538
|
+
const token = parseSSEChunk(sseBuffer.trim());
|
|
1539
|
+
if (token) {
|
|
1540
|
+
fullText += token;
|
|
1541
|
+
onToken?.(token);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (fullText) {
|
|
1545
|
+
resolve({ ok: true, text: fullText });
|
|
1546
|
+
} else {
|
|
1547
|
+
resolve({ ok: false, error: "Empty response from provider" });
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
);
|
|
1552
|
+
onStatus?.("sending request");
|
|
1553
|
+
req.on("socket", () => {
|
|
1554
|
+
onStatus?.("waiting for provider");
|
|
1555
|
+
});
|
|
1556
|
+
if (timeoutMs > 0) {
|
|
1557
|
+
req.setTimeout(timeoutMs, () => {
|
|
1558
|
+
onStatus?.("request timed out");
|
|
1559
|
+
req.destroy(new Error("Provider request timed out"));
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
req.on("error", (error) => {
|
|
1563
|
+
const classified = classifyOpenRouterError({ requestError: error.message });
|
|
1564
|
+
resolve({
|
|
1565
|
+
ok: false,
|
|
1566
|
+
error: classified.message,
|
|
1567
|
+
category: classified.category,
|
|
1568
|
+
suggestions: classified.suggestions,
|
|
1569
|
+
rawError: error.message
|
|
1570
|
+
});
|
|
1571
|
+
});
|
|
1572
|
+
req.write(body);
|
|
1573
|
+
req.end();
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
async function refineWithOpenRouter({
|
|
1577
|
+
apiKey,
|
|
1578
|
+
model,
|
|
1579
|
+
systemPrompt,
|
|
1580
|
+
userPrompt,
|
|
1581
|
+
timeoutMs = 0,
|
|
1582
|
+
onStatus,
|
|
1583
|
+
onToken
|
|
1584
|
+
}) {
|
|
1585
|
+
const payload = buildOpenRouterPayload({ model, systemPrompt, userPrompt });
|
|
1586
|
+
const fastBody = JSON.stringify({ ...payload, stream: true, reasoning: { effort: "low" } });
|
|
1587
|
+
const firstResult = await doStreamRequest({ apiKey, body: fastBody, timeoutMs, onStatus, onToken });
|
|
1588
|
+
if (!firstResult.ok && firstResult.rawError && /reasoning/i.test(firstResult.rawError)) {
|
|
1589
|
+
onStatus?.("retrying without reasoning flag");
|
|
1590
|
+
const fallbackBody = JSON.stringify({ ...payload, stream: true });
|
|
1591
|
+
return doStreamRequest({ apiKey, body: fallbackBody, timeoutMs, onStatus, onToken });
|
|
1592
|
+
}
|
|
1593
|
+
return firstResult;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// src/ui.ts
|
|
1597
|
+
function createColors(enabled) {
|
|
1598
|
+
const wrap = (code) => (text) => enabled ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
1599
|
+
return {
|
|
1600
|
+
bold: wrap("1"),
|
|
1601
|
+
dim: wrap("2"),
|
|
1602
|
+
cyan: wrap("36"),
|
|
1603
|
+
green: wrap("32"),
|
|
1604
|
+
yellow: wrap("33"),
|
|
1605
|
+
red: wrap("31")
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
function createTheme(stream = process.stderr) {
|
|
1609
|
+
const enabled = Boolean(stream.isTTY) && !process.env.NO_COLOR;
|
|
1610
|
+
const colors = createColors(enabled);
|
|
1611
|
+
function write(line = "") {
|
|
1612
|
+
stream.write(`${line}
|
|
1613
|
+
`);
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
step(message) {
|
|
1617
|
+
write(`${colors.cyan("[..]")} ${message}`);
|
|
1618
|
+
},
|
|
1619
|
+
success(message) {
|
|
1620
|
+
write(`${colors.green("[ok]")} ${message}`);
|
|
1621
|
+
},
|
|
1622
|
+
warn(message) {
|
|
1623
|
+
write(`${colors.yellow("[!!]")} ${message}`);
|
|
1624
|
+
},
|
|
1625
|
+
note(message) {
|
|
1626
|
+
write(`${colors.dim(" -> ")} ${message}`);
|
|
1627
|
+
},
|
|
1628
|
+
section(title) {
|
|
1629
|
+
write(colors.bold(title));
|
|
1630
|
+
},
|
|
1631
|
+
plain(message = "") {
|
|
1632
|
+
write(message);
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
function pad(value, width) {
|
|
1637
|
+
return String(value).padEnd(width, " ");
|
|
1638
|
+
}
|
|
1639
|
+
function formatRelativeTime(timestamp) {
|
|
1640
|
+
if (!timestamp) return "unknown";
|
|
1641
|
+
const deltaSeconds = Math.max(1, Math.round((Date.now() - timestamp) / 1e3));
|
|
1642
|
+
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
|
|
1643
|
+
const minutes = Math.round(deltaSeconds / 60);
|
|
1644
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1645
|
+
const hours = Math.round(minutes / 60);
|
|
1646
|
+
if (hours < 48) return `${hours}h ago`;
|
|
1647
|
+
const days = Math.round(hours / 24);
|
|
1648
|
+
return `${days}d ago`;
|
|
1649
|
+
}
|
|
1650
|
+
function summarizeGitContext(gitContext) {
|
|
1651
|
+
if (!gitContext.isGitRepo) {
|
|
1652
|
+
return "not a git repo";
|
|
1653
|
+
}
|
|
1654
|
+
const changed = gitContext.status ? gitContext.status.split("\n").filter(Boolean).length : 0;
|
|
1655
|
+
const untracked = gitContext.untracked.length;
|
|
1656
|
+
if (!changed && !untracked) {
|
|
1657
|
+
return gitContext.branch ? `${gitContext.branch}, clean` : "clean";
|
|
1658
|
+
}
|
|
1659
|
+
const parts = [];
|
|
1660
|
+
if (gitContext.branch) parts.push(gitContext.branch);
|
|
1661
|
+
if (changed) parts.push(`${changed} changed`);
|
|
1662
|
+
if (untracked) parts.push(`${untracked} untracked`);
|
|
1663
|
+
return parts.join(", ");
|
|
1664
|
+
}
|
|
1665
|
+
function formatRunSummary({
|
|
1666
|
+
ctx,
|
|
1667
|
+
options,
|
|
1668
|
+
mode,
|
|
1669
|
+
model
|
|
1670
|
+
}) {
|
|
1671
|
+
const lines = [];
|
|
1672
|
+
lines.push("Run Summary");
|
|
1673
|
+
lines.push(` Session: ${ctx.sessionId}`);
|
|
1674
|
+
lines.push(` Source: ${ctx.sessionPath}`);
|
|
1675
|
+
lines.push(` Messages: ${ctx.messages.length}`);
|
|
1676
|
+
lines.push(` Files: ${ctx.filesModified.length} modified, ${ctx.filesRead.length} read`);
|
|
1677
|
+
lines.push(` Git: ${summarizeGitContext(ctx.gitContext)}`);
|
|
1678
|
+
lines.push(` Target: ${options.target}`);
|
|
1679
|
+
lines.push(` Mode: ${mode === "raw" ? "raw" : `refined via ${options.provider}${model ? ` (${model})` : ""}`}`);
|
|
1680
|
+
return lines.join("\n");
|
|
1681
|
+
}
|
|
1682
|
+
function formatDoctorReport(report) {
|
|
1683
|
+
const lines = ["ctx-switch doctor", ""];
|
|
1684
|
+
for (const check of report.checks) {
|
|
1685
|
+
lines.push(`${pad(check.status, 4)} ${pad(check.label, 16)} ${check.detail}`);
|
|
1686
|
+
}
|
|
1687
|
+
if (report.notes.length > 0) {
|
|
1688
|
+
lines.push("");
|
|
1689
|
+
lines.push("Notes");
|
|
1690
|
+
for (const note of report.notes) {
|
|
1691
|
+
lines.push(`- ${note}`);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
if (report.nextSteps.length > 0) {
|
|
1695
|
+
lines.push("");
|
|
1696
|
+
lines.push("Next Steps");
|
|
1697
|
+
for (const step of report.nextSteps) {
|
|
1698
|
+
lines.push(`- ${step}`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return lines.join("\n");
|
|
1702
|
+
}
|
|
1703
|
+
function formatSessionsReport({
|
|
1704
|
+
cwd,
|
|
1705
|
+
sessions,
|
|
1706
|
+
limit,
|
|
1707
|
+
source
|
|
1708
|
+
}) {
|
|
1709
|
+
const sourceLabel = source === "codex" ? "Codex" : source === "opencode" ? "OpenCode" : "Claude";
|
|
1710
|
+
const lines = ["ctx-switch sessions", "", `Project: ${cwd}`, `Source: ${sourceLabel}`, ""];
|
|
1711
|
+
if (sessions.length === 0) {
|
|
1712
|
+
lines.push(`No ${sourceLabel} session files were found for this project.`);
|
|
1713
|
+
lines.push("");
|
|
1714
|
+
lines.push("Next Steps");
|
|
1715
|
+
lines.push(`- Run ${sourceLabel} in this project at least once.`);
|
|
1716
|
+
lines.push("- Run `ctx-switch doctor` to verify the expected session directory.");
|
|
1717
|
+
return lines.join("\n");
|
|
1718
|
+
}
|
|
1719
|
+
const visible = sessions.slice(0, limit);
|
|
1720
|
+
const idWidth = Math.max(7, ...visible.map((session) => session.id.length));
|
|
1721
|
+
const ageWidth = 10;
|
|
1722
|
+
lines.push(`${pad("Session", idWidth)} ${pad("Updated", ageWidth)} File`);
|
|
1723
|
+
lines.push(`${"-".repeat(idWidth)} ${"-".repeat(ageWidth)} ${"-".repeat(40)}`);
|
|
1724
|
+
for (const session of visible) {
|
|
1725
|
+
lines.push(`${pad(session.id, idWidth)} ${pad(formatRelativeTime(session.mtimeMs), ageWidth)} ${session.path}`);
|
|
1726
|
+
}
|
|
1727
|
+
if (sessions.length > visible.length) {
|
|
1728
|
+
lines.push("");
|
|
1729
|
+
lines.push(`Showing ${visible.length} of ${sessions.length} session(s). Use --limit to see more.`);
|
|
1730
|
+
}
|
|
1731
|
+
return lines.join("\n");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/cli.ts
|
|
1735
|
+
var SOURCE_LABELS = {
|
|
1736
|
+
claude: "Claude Code",
|
|
1737
|
+
codex: "Codex",
|
|
1738
|
+
opencode: "OpenCode"
|
|
1739
|
+
};
|
|
1740
|
+
function fail(message, { exitCode = 1, suggestions = [] } = {}) {
|
|
1741
|
+
const error = new Error(message);
|
|
1742
|
+
error.exitCode = exitCode;
|
|
1743
|
+
error.suggestions = suggestions;
|
|
1744
|
+
throw error;
|
|
1745
|
+
}
|
|
1746
|
+
function createActivityReporter(label) {
|
|
1747
|
+
const stream = process.stderr;
|
|
1748
|
+
const start = Date.now();
|
|
1749
|
+
let status = "starting";
|
|
1750
|
+
let timer = null;
|
|
1751
|
+
let lastRendered = "";
|
|
1752
|
+
const frames = ["|", "/", "-", "\\"];
|
|
1753
|
+
let frameIndex = 0;
|
|
1754
|
+
function elapsedSeconds() {
|
|
1755
|
+
return Math.max(1, Math.round((Date.now() - start) / 1e3));
|
|
1756
|
+
}
|
|
1757
|
+
function render() {
|
|
1758
|
+
const line = `${frames[frameIndex]} ${label}: ${status} (${elapsedSeconds()}s)`;
|
|
1759
|
+
frameIndex = (frameIndex + 1) % frames.length;
|
|
1760
|
+
if (stream.isTTY) {
|
|
1761
|
+
const padded = line.padEnd(Math.max(lastRendered.length, line.length), " ");
|
|
1762
|
+
stream.write(`\r${padded}`);
|
|
1763
|
+
lastRendered = padded;
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (elapsedSeconds() === 1 || elapsedSeconds() % 5 === 0) {
|
|
1767
|
+
stream.write(`${line}
|
|
1768
|
+
`);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
function startTimer() {
|
|
1772
|
+
render();
|
|
1773
|
+
timer = setInterval(render, 1e3);
|
|
1774
|
+
timer.unref();
|
|
1775
|
+
}
|
|
1776
|
+
function update(nextStatus) {
|
|
1777
|
+
status = nextStatus;
|
|
1778
|
+
if (stream.isTTY) {
|
|
1779
|
+
render();
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
function stop(finalStatus) {
|
|
1783
|
+
if (timer) {
|
|
1784
|
+
clearInterval(timer);
|
|
1785
|
+
}
|
|
1786
|
+
const message = `${label}: ${finalStatus} (${elapsedSeconds()}s)`;
|
|
1787
|
+
if (stream.isTTY) {
|
|
1788
|
+
const padded = message.padEnd(Math.max(lastRendered.length, message.length), " ");
|
|
1789
|
+
stream.write(`\r${padded}
|
|
1790
|
+
`);
|
|
1791
|
+
} else {
|
|
1792
|
+
stream.write(`${message}
|
|
1793
|
+
`);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
startTimer();
|
|
1797
|
+
return { update, stop };
|
|
1798
|
+
}
|
|
1799
|
+
function writeOutputFile(outputPath, text) {
|
|
1800
|
+
fs6.mkdirSync(path8.dirname(outputPath), { recursive: true });
|
|
1801
|
+
fs6.writeFileSync(outputPath, `${text}
|
|
1802
|
+
`);
|
|
1803
|
+
}
|
|
1804
|
+
async function promptForSource() {
|
|
1805
|
+
const sources = ["claude", "codex", "opencode"];
|
|
1806
|
+
const rl = readline.createInterface({
|
|
1807
|
+
input: process.stdin,
|
|
1808
|
+
output: process.stderr
|
|
1809
|
+
});
|
|
1810
|
+
process.stderr.write("\nSelect the AI agent to extract context from:\n\n");
|
|
1811
|
+
for (let i = 0; i < sources.length; i++) {
|
|
1812
|
+
process.stderr.write(` ${i + 1}) ${SOURCE_LABELS[sources[i]]}
|
|
1813
|
+
`);
|
|
1814
|
+
}
|
|
1815
|
+
process.stderr.write("\n");
|
|
1816
|
+
return new Promise((resolve) => {
|
|
1817
|
+
rl.question("Enter choice (1-3): ", (answer) => {
|
|
1818
|
+
rl.close();
|
|
1819
|
+
const idx = Number.parseInt(answer.trim(), 10) - 1;
|
|
1820
|
+
if (idx >= 0 && idx < sources.length) {
|
|
1821
|
+
resolve(sources[idx]);
|
|
1822
|
+
} else {
|
|
1823
|
+
process.stderr.write("Invalid choice, defaulting to Claude Code.\n");
|
|
1824
|
+
resolve("claude");
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1830
|
+
const options = parseArgs(argv);
|
|
1831
|
+
const pkgInfo = { name: "ctx-switch", version: "2.0.0" };
|
|
1832
|
+
const ui = createTheme(process.stderr);
|
|
1833
|
+
if (options.help) {
|
|
1834
|
+
process.stdout.write(`${getHelpText(pkgInfo)}
|
|
1835
|
+
`);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (options.version) {
|
|
1839
|
+
process.stdout.write(`${pkgInfo.version}
|
|
1840
|
+
`);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const cwd = process.cwd();
|
|
1844
|
+
const config = loadConfig();
|
|
1845
|
+
let source;
|
|
1846
|
+
if (options.source) {
|
|
1847
|
+
source = options.source;
|
|
1848
|
+
} else if (process.stdin.isTTY) {
|
|
1849
|
+
source = await promptForSource();
|
|
1850
|
+
} else {
|
|
1851
|
+
source = "claude";
|
|
1852
|
+
}
|
|
1853
|
+
const sourceLabel = SOURCE_LABELS[source];
|
|
1854
|
+
if (options.command === "doctor") {
|
|
1855
|
+
const report = runDoctor({
|
|
1856
|
+
cwd,
|
|
1857
|
+
source,
|
|
1858
|
+
provider: options.provider,
|
|
1859
|
+
cliApiKey: options.apiKey,
|
|
1860
|
+
env: process.env,
|
|
1861
|
+
config
|
|
1862
|
+
});
|
|
1863
|
+
process.stdout.write(`${formatDoctorReport(report)}
|
|
1864
|
+
`);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
if (options.command === "sessions") {
|
|
1868
|
+
const sessions = listSessionsForProject4(cwd, source);
|
|
1869
|
+
process.stdout.write(`${formatSessionsReport({ cwd, sessions, limit: options.limit, source })}
|
|
1870
|
+
`);
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
ui.step(`Finding ${sourceLabel} session`);
|
|
1874
|
+
const sessionPath = resolveSessionPath4(options.session, cwd, source);
|
|
1875
|
+
if (!sessionPath) {
|
|
1876
|
+
fail(
|
|
1877
|
+
options.session ? `Unable to find session "${options.session}" for ${cwd} (source: ${sourceLabel})` : `No ${sourceLabel} session files found for ${cwd}.`,
|
|
1878
|
+
{
|
|
1879
|
+
suggestions: [
|
|
1880
|
+
`Run \`ctx-switch sessions --source ${source}\` to inspect project sessions.`,
|
|
1881
|
+
"Run `ctx-switch doctor` for diagnostics.",
|
|
1882
|
+
`Run ${sourceLabel} in this project at least once if no sessions exist yet.`
|
|
1883
|
+
]
|
|
1884
|
+
}
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
const sessionDisplay = source === "opencode" ? sessionPath : path8.basename(sessionPath);
|
|
1888
|
+
ui.success(`Using session ${sessionDisplay}`);
|
|
1889
|
+
ui.step("Parsing session");
|
|
1890
|
+
const { messages, meta } = parseSession4(sessionPath, source);
|
|
1891
|
+
if (messages.length === 0) {
|
|
1892
|
+
fail(`Parsed zero usable messages from ${sessionPath}`);
|
|
1893
|
+
}
|
|
1894
|
+
ui.step("Capturing git context");
|
|
1895
|
+
const gitContext = getGitContext(cwd);
|
|
1896
|
+
const ctx = buildSessionContext({
|
|
1897
|
+
messages,
|
|
1898
|
+
meta,
|
|
1899
|
+
cwd,
|
|
1900
|
+
sessionPath,
|
|
1901
|
+
gitContext
|
|
1902
|
+
});
|
|
1903
|
+
let finalPrompt = "";
|
|
1904
|
+
let mode = "raw";
|
|
1905
|
+
let activeModel = null;
|
|
1906
|
+
if (!options.refine) {
|
|
1907
|
+
ui.step("Building continuation prompt");
|
|
1908
|
+
finalPrompt = buildRawPrompt(ctx, { target: options.target });
|
|
1909
|
+
} else {
|
|
1910
|
+
const provider = options.provider;
|
|
1911
|
+
let apiKey = getApiKey({
|
|
1912
|
+
provider,
|
|
1913
|
+
cliValue: options.apiKey,
|
|
1914
|
+
env: process.env,
|
|
1915
|
+
config
|
|
1916
|
+
});
|
|
1917
|
+
const model = getDefaultModel({
|
|
1918
|
+
provider,
|
|
1919
|
+
cliValue: options.model,
|
|
1920
|
+
config
|
|
1921
|
+
});
|
|
1922
|
+
activeModel = model;
|
|
1923
|
+
if (!apiKey) {
|
|
1924
|
+
ui.warn(`No ${provider} API key found. A key can be saved to ${CONFIG_PATH}.`);
|
|
1925
|
+
apiKey = await promptForApiKey({ provider });
|
|
1926
|
+
if (apiKey) {
|
|
1927
|
+
storeApiKey({ provider, apiKey });
|
|
1928
|
+
ui.success(`Saved ${provider} API key to ${CONFIG_PATH}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if (!apiKey) {
|
|
1932
|
+
ui.warn("No API key available. Falling back to raw continuation prompt.");
|
|
1933
|
+
finalPrompt = buildRawPrompt(ctx, { target: options.target });
|
|
1934
|
+
} else {
|
|
1935
|
+
mode = "refined";
|
|
1936
|
+
ui.section(formatRunSummary({ ctx, options, mode, model }));
|
|
1937
|
+
ui.plain();
|
|
1938
|
+
ui.step(`Refining prompt with ${provider} (${model})`);
|
|
1939
|
+
ui.note("Streaming response below:");
|
|
1940
|
+
const reporter = createActivityReporter("Refining prompt");
|
|
1941
|
+
let streamStarted = false;
|
|
1942
|
+
const refined = await refineWithOpenRouter({
|
|
1943
|
+
apiKey,
|
|
1944
|
+
model,
|
|
1945
|
+
systemPrompt: buildRefinementSystemPrompt(options.target),
|
|
1946
|
+
userPrompt: buildRefinementDump(ctx, { target: options.target }),
|
|
1947
|
+
timeoutMs: 0,
|
|
1948
|
+
onStatus: (status) => {
|
|
1949
|
+
if (!streamStarted) reporter.update(status);
|
|
1950
|
+
},
|
|
1951
|
+
onToken: (token) => {
|
|
1952
|
+
if (!streamStarted) {
|
|
1953
|
+
streamStarted = true;
|
|
1954
|
+
reporter.stop("streaming");
|
|
1955
|
+
process.stderr.write("\n");
|
|
1956
|
+
}
|
|
1957
|
+
process.stderr.write(token);
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
if (streamStarted) {
|
|
1961
|
+
process.stderr.write("\n\n");
|
|
1962
|
+
} else {
|
|
1963
|
+
reporter.stop(refined.ok ? "done" : "failed");
|
|
1964
|
+
}
|
|
1965
|
+
if (refined.ok) {
|
|
1966
|
+
finalPrompt = refined.text;
|
|
1967
|
+
} else {
|
|
1968
|
+
mode = "raw";
|
|
1969
|
+
ui.warn(`Provider refinement failed: ${refined.error}`);
|
|
1970
|
+
if (Array.isArray(refined.suggestions)) {
|
|
1971
|
+
for (const suggestion of refined.suggestions) {
|
|
1972
|
+
ui.note(suggestion);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (refined.rawError && refined.rawError !== refined.error) {
|
|
1976
|
+
ui.note(`Provider detail: ${refined.rawError}`);
|
|
1977
|
+
}
|
|
1978
|
+
ui.note("Falling back to the raw structured prompt.");
|
|
1979
|
+
finalPrompt = buildRawPrompt(ctx, { target: options.target });
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
if (mode === "raw") {
|
|
1984
|
+
ui.section(formatRunSummary({ ctx, options, mode, model: activeModel }));
|
|
1985
|
+
ui.plain();
|
|
1986
|
+
}
|
|
1987
|
+
if (options.output) {
|
|
1988
|
+
writeOutputFile(options.output, finalPrompt);
|
|
1989
|
+
ui.success(`Wrote prompt to ${options.output}`);
|
|
1990
|
+
}
|
|
1991
|
+
const clipboard = copyToClipboard(finalPrompt);
|
|
1992
|
+
if (clipboard.ok) {
|
|
1993
|
+
ui.success(`Copied to clipboard (${finalPrompt.length} chars)`);
|
|
1994
|
+
} else {
|
|
1995
|
+
ui.warn(`Clipboard copy failed: ${clipboard.error}`);
|
|
1996
|
+
ui.success(`Prompt ready (${finalPrompt.length} chars)`);
|
|
1997
|
+
}
|
|
1998
|
+
process.stdout.write(`${finalPrompt}
|
|
1999
|
+
`);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// src/index.ts
|
|
2003
|
+
var binName = path9.basename(process.argv[1] || "");
|
|
2004
|
+
if (binName === "cc-continue" || binName === "cc-continue.mjs") {
|
|
2005
|
+
process.stderr.write(
|
|
2006
|
+
"\x1B[33mNote:\x1B[0m cc-continue has been renamed to \x1B[1mctx-switch\x1B[0m. Please use \x1B[36mctx-switch\x1B[0m going forward.\n\n"
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
void main().catch((error) => {
|
|
2010
|
+
const appError = error;
|
|
2011
|
+
const message = appError?.message ? appError.message : String(error);
|
|
2012
|
+
console.error(`Error: ${message}`);
|
|
2013
|
+
if (Array.isArray(appError?.suggestions) && appError.suggestions.length > 0) {
|
|
2014
|
+
console.error("");
|
|
2015
|
+
console.error("Next Steps");
|
|
2016
|
+
for (const suggestion of appError.suggestions) {
|
|
2017
|
+
console.error(`- ${suggestion}`);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
process.exit(typeof appError?.exitCode === "number" ? appError.exitCode : 1);
|
|
2021
|
+
});
|