ctx-switch 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +350 -31
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -32,6 +32,8 @@ function parseArgs(argv) {
32
32
  refine: false,
33
33
  help: false,
34
34
  version: false,
35
+ pickUser: false,
36
+ fromUser: null,
35
37
  session: null,
36
38
  model: null,
37
39
  provider: "openrouter",
@@ -59,6 +61,12 @@ function parseArgs(argv) {
59
61
  case "--refine":
60
62
  options.refine = true;
61
63
  break;
64
+ case "--pick-user":
65
+ options.pickUser = true;
66
+ break;
67
+ case "--from-user":
68
+ options.fromUser = Number(requireValue(arg, args));
69
+ break;
62
70
  case "--session":
63
71
  options.session = requireValue(arg, args);
64
72
  break;
@@ -122,6 +130,9 @@ function parseArgs(argv) {
122
130
  if (!Number.isInteger(options.limit) || options.limit <= 0) {
123
131
  throw new Error(`Invalid limit "${options.limit}". Expected a positive integer.`);
124
132
  }
133
+ if (options.fromUser !== null && (!Number.isInteger(options.fromUser) || options.fromUser <= 0)) {
134
+ throw new Error(`Invalid --from-user value "${options.fromUser}". Expected a positive integer.`);
135
+ }
125
136
  return options;
126
137
  }
127
138
  function getHelpText({ name, version }) {
@@ -142,6 +153,8 @@ function getHelpText({ name, version }) {
142
153
  " -o, --output <file> Write the final prompt to a file",
143
154
  " --source <name> Session source: claude, codex, opencode (interactive if omitted)",
144
155
  " --session <id|path> Use a specific session file or session id",
156
+ " --pick-user Interactively choose the starting user prompt for preserved context",
157
+ " --from-user <n> Start preserved context from the nth substantive user prompt",
145
158
  " --target <name> Prompt target: generic, claude, codex, cursor, chatgpt",
146
159
  " -n, --limit <count> Limit rows for the sessions command (default: 10)",
147
160
  "",
@@ -163,6 +176,8 @@ function getHelpText({ name, version }) {
163
176
  ` ${name} --source codex --target claude`,
164
177
  ` ${name} --source codex --target codex`,
165
178
  ` ${name} --source opencode`,
179
+ ` ${name} --pick-user`,
180
+ ` ${name} --from-user 2`,
166
181
  ` ${name} --refine --model openrouter/free`,
167
182
  ` ${name} --output ./handoff.md`,
168
183
  ` ${name} doctor`,
@@ -829,7 +844,10 @@ import { execFileSync as execFileSync2 } from "node:child_process";
829
844
  import os4 from "node:os";
830
845
  import path6 from "node:path";
831
846
  var OPENCODE_DB_PATH = path6.join(os4.homedir(), ".local", "share", "opencode", "opencode.db");
832
- function runSqlite(query, dbPath = OPENCODE_DB_PATH) {
847
+ function getOpenCodeDbPath() {
848
+ return process.env.CTX_SWITCH_OPENCODE_DB_PATH || OPENCODE_DB_PATH;
849
+ }
850
+ function runSqlite(query, dbPath = getOpenCodeDbPath()) {
833
851
  try {
834
852
  const stdout = execFileSync2("sqlite3", ["-json", dbPath, query], {
835
853
  encoding: "utf8",
@@ -882,7 +900,31 @@ function resolveSessionPath3(selection, cwd) {
882
900
  const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
883
901
  return match ? match.id : null;
884
902
  }
903
+ function detectToolError(tool, status, output) {
904
+ if (status === "error") return true;
905
+ if (!output) return void 0;
906
+ if (/Process exited with code [1-9]\d*/.test(output)) return true;
907
+ if (/^npm ERR!/m.test(output)) return true;
908
+ if (/^(Error|TypeError|ReferenceError|SyntaxError):/m.test(output)) return true;
909
+ if (/^(bash|sh|zsh):/m.test(output)) return true;
910
+ if (tool === "read" || tool === "glob" || tool === "grep") return void 0;
911
+ return void 0;
912
+ }
913
+ function extractDelegatedSessionId(part) {
914
+ const metadataSessionId = part.state?.metadata?.sessionId;
915
+ if (typeof metadataSessionId === "string" && metadataSessionId.trim()) {
916
+ return metadataSessionId.trim();
917
+ }
918
+ const output = part.state?.output;
919
+ if (typeof output !== "string") return null;
920
+ const match = output.match(/\btask_id:\s*(ses_[^\s)]+)/);
921
+ return match?.[1] || null;
922
+ }
885
923
  function parseSession3(sessionId) {
924
+ return parseSessionInternal(sessionId, /* @__PURE__ */ new Set());
925
+ }
926
+ function parseSessionInternal(sessionId, seenSessions) {
927
+ seenSessions.add(sessionId);
886
928
  const messages = [];
887
929
  const meta = {
888
930
  cwd: null,
@@ -918,6 +960,7 @@ function parseSession3(sessionId) {
918
960
  if (!role || role !== "user" && role !== "assistant") continue;
919
961
  const textParts = [];
920
962
  const toolCalls = [];
963
+ const delegatedAssistantMessages = [];
921
964
  for (const partRow of partRows) {
922
965
  let part;
923
966
  try {
@@ -930,9 +973,17 @@ function parseSession3(sessionId) {
930
973
  } else if (part.type === "tool" && part.tool) {
931
974
  const input = part.state?.input || {};
932
975
  const output = part.state?.output || "";
933
- const isError = part.state?.status === "error" || /error|Error|ERROR/.test(output.slice(0, 200));
976
+ const isError = detectToolError(part.tool, part.state?.status, output);
934
977
  const toolName = normalizeToolName(part.tool);
935
978
  const normalizedInput = normalizeInput(part.tool, input);
979
+ if (toolName === "task") {
980
+ const delegatedSessionId = extractDelegatedSessionId(part);
981
+ if (delegatedSessionId && !seenSessions.has(delegatedSessionId)) {
982
+ normalizedInput.delegated_session_id = delegatedSessionId;
983
+ const delegated = parseSessionInternal(delegatedSessionId, seenSessions);
984
+ delegatedAssistantMessages.push(...delegated.messages.filter((message) => message.role === "assistant"));
985
+ }
986
+ }
936
987
  toolCalls.push({
937
988
  id: part.callID || null,
938
989
  tool: toolName,
@@ -950,11 +1001,15 @@ function parseSession3(sessionId) {
950
1001
  toolCalls,
951
1002
  timestamp: null
952
1003
  });
1004
+ if (role === "assistant" && delegatedAssistantMessages.length > 0) {
1005
+ messages.push(...delegatedAssistantMessages);
1006
+ }
953
1007
  }
954
1008
  return { messages, meta };
955
1009
  }
956
1010
  function normalizeToolName(tool) {
957
1011
  const mapping = {
1012
+ apply_patch: "apply_patch",
958
1013
  read: "read",
959
1014
  edit: "edit",
960
1015
  write: "write",
@@ -970,10 +1025,14 @@ function normalizeToolName(tool) {
970
1025
  return mapping[tool.toLowerCase()] || tool;
971
1026
  }
972
1027
  function normalizeInput(tool, input) {
973
- if (input.filePath && !input.file_path) {
974
- return { ...input, file_path: input.filePath };
1028
+ const normalized = { ...input };
1029
+ if (normalized.filePath && !normalized.file_path) {
1030
+ normalized.file_path = normalized.filePath;
1031
+ }
1032
+ if (tool === "apply_patch" && typeof normalized.patchText === "string" && !normalized.patch) {
1033
+ normalized.patch = normalized.patchText;
975
1034
  }
976
- return input;
1035
+ return normalized;
977
1036
  }
978
1037
 
979
1038
  // src/session.ts
@@ -1040,6 +1099,23 @@ function extractParsedCommands(input) {
1040
1099
  if (!Array.isArray(parsed)) return [];
1041
1100
  return parsed.filter((entry) => Boolean(entry) && typeof entry === "object");
1042
1101
  }
1102
+ function tokenizeShellCommand(command) {
1103
+ const matches = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
1104
+ return matches.map((token) => token.replace(/^['"]|['"]$/g, ""));
1105
+ }
1106
+ function looksLikeFilePath(token) {
1107
+ if (!token || token.startsWith("-")) return false;
1108
+ if (/[|<>;$()]/.test(token)) return false;
1109
+ return token.includes("/") || token.includes(".");
1110
+ }
1111
+ function extractReadPathsFromCommand(command, cwd) {
1112
+ const tokens = tokenizeShellCommand(command.trim());
1113
+ if (tokens.length === 0) return [];
1114
+ const base = path7.basename(tokens[0]);
1115
+ const readCommands = /* @__PURE__ */ new Set(["cat", "head", "tail", "sed", "nl", "wc"]);
1116
+ if (!readCommands.has(base)) return [];
1117
+ return tokens.slice(1).filter((token) => looksLikeFilePath(token)).filter((token) => !/^\d+(,\d+)?p?$/.test(token)).map((token) => resolveSessionFilePath(token, cwd));
1118
+ }
1043
1119
  function buildSessionContext({
1044
1120
  messages,
1045
1121
  meta,
@@ -1059,6 +1135,7 @@ function buildSessionContext({
1059
1135
  const command = extractCommand(toolCall.input);
1060
1136
  const patchPaths = extractPatchFilePaths(toolCall.input, cwd);
1061
1137
  const parsedCommands = extractParsedCommands(toolCall.input);
1138
+ const commandReadPaths = command ? extractReadPathsFromCommand(command, cwd) : [];
1062
1139
  if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
1063
1140
  filesModified.add(resolveSessionFilePath(filePath, cwd));
1064
1141
  } else if (filePath && /(read|grep|glob|search)/.test(toolName)) {
@@ -1071,6 +1148,9 @@ function buildSessionContext({
1071
1148
  if (parsedCommand.type !== "read" || typeof parsedCommand.path !== "string") continue;
1072
1149
  filesRead.add(resolveSessionFilePath(parsedCommand.path, cwd));
1073
1150
  }
1151
+ for (const readPath of commandReadPaths) {
1152
+ filesRead.add(readPath);
1153
+ }
1074
1154
  if (command && /(bash|command|run|exec_command)/.test(toolName)) {
1075
1155
  commands.push(command);
1076
1156
  }
@@ -1257,6 +1337,10 @@ function compactText(text, maxChars = 800) {
1257
1337
  function unique(list) {
1258
1338
  return [...new Set(list.filter(Boolean))];
1259
1339
  }
1340
+ function isLocalCommandMarkup(text) {
1341
+ const trimmed = text.trim().toLowerCase();
1342
+ return trimmed.includes("<local-command-caveat>") || trimmed.includes("<command-name>") || trimmed.includes("<command-message>") || trimmed.includes("<command-args>") || trimmed.includes("<local-command-stdout>");
1343
+ }
1260
1344
  function extractFilePath2(input) {
1261
1345
  const value = input.file_path || input.path || input.target_file || input.filePath;
1262
1346
  return typeof value === "string" ? value : null;
@@ -1265,10 +1349,13 @@ function extractCommand2(input) {
1265
1349
  const value = input.command || input.cmd;
1266
1350
  return typeof value === "string" ? value : null;
1267
1351
  }
1352
+ function getSubstantiveUserIndexes(messages) {
1353
+ return messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "user" && message.content && !isNoiseMessage(message.content)).map(({ index }) => index);
1354
+ }
1268
1355
  function buildTargetGuidance(target) {
1269
1356
  switch (target) {
1270
1357
  case "claude":
1271
- return "The next agent is Claude Code. It should read the active files first, trust the current workspace and git diff over stale transcript details, and continue the implementation or debugging directly.";
1358
+ return "The next agent is Claude Code. It should read the active files first, inspect the current workspace with git commands, and continue the implementation or debugging directly.";
1272
1359
  case "codex":
1273
1360
  return "The next agent is Codex. It should inspect the current files first, avoid redoing completed work, and finish any remaining implementation or verification.";
1274
1361
  case "cursor":
@@ -1276,11 +1363,12 @@ function buildTargetGuidance(target) {
1276
1363
  case "chatgpt":
1277
1364
  return "The next agent is ChatGPT. It should reason from the current workspace state, explain what remains, and provide explicit next actions.";
1278
1365
  default:
1279
- return "The next agent should read the active files first, trust the current workspace and git diff over stale transcript details, continue the interrupted work directly, and avoid redoing completed steps.";
1366
+ return "The next agent should read the active files first, inspect the current workspace with git commands, continue the interrupted work directly, and avoid redoing completed steps.";
1280
1367
  }
1281
1368
  }
1282
1369
  function isNoiseMessage(text) {
1283
1370
  const trimmed = text.trim().toLowerCase();
1371
+ if (isLocalCommandMarkup(trimmed)) return true;
1284
1372
  const normalized = trimmed.replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1285
1373
  if (trimmed.length < 5) return true;
1286
1374
  const noise = [
@@ -1311,17 +1399,27 @@ function isNoiseMessage(text) {
1311
1399
  if (trimmed.startsWith("[request interrupted")) return true;
1312
1400
  return false;
1313
1401
  }
1402
+ function isAssistantNoiseMessage(text) {
1403
+ const trimmed = text.trim().toLowerCase();
1404
+ if (!trimmed) return true;
1405
+ if (isLocalCommandMarkup(trimmed)) return true;
1406
+ return trimmed.includes("you're out of extra usage") || trimmed.includes("resets 3:30pm") || trimmed.includes("rate limit") || trimmed.includes("login interrupted");
1407
+ }
1314
1408
  function isReferentialMessage(text) {
1315
1409
  const normalized = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1316
1410
  if (!normalized || normalized.length > 220) return false;
1317
1411
  return /^(ok|okay|alright|now|so)\b/.test(normalized) || /\b(it|that|again|better|same|continue|still|also|another|more)\b/.test(normalized);
1318
1412
  }
1413
+ function isMetaQualityAssistantMessage(text) {
1414
+ const lower = text.toLowerCase();
1415
+ return /\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower);
1416
+ }
1319
1417
  function filterUserMessages(messages) {
1320
- const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim());
1418
+ const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim()).filter((msg) => !isNoiseMessage(msg));
1321
1419
  if (all.length <= 2) return all;
1322
1420
  const first = all[0];
1323
1421
  const last = all[all.length - 1];
1324
- const middle = all.slice(1, -1).filter((msg) => !isNoiseMessage(msg));
1422
+ const middle = all.slice(1, -1);
1325
1423
  return [first, ...middle, last];
1326
1424
  }
1327
1425
  function extractUnresolvedErrors(messages) {
@@ -1344,21 +1442,21 @@ function extractKeyDecisions(messages) {
1344
1442
  const decisions = [];
1345
1443
  for (const msg of messages) {
1346
1444
  if (msg.role !== "assistant" || !msg.content) continue;
1347
- const lower = msg.content.toLowerCase();
1348
- if (/\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower)) {
1445
+ if (isMetaQualityAssistantMessage(msg.content)) {
1349
1446
  continue;
1350
1447
  }
1448
+ const lower = msg.content.toLowerCase();
1351
1449
  if (/\b(root cause|the issue is|the problem is|caused by|failed because|failing because|need to)\b/.test(lower) || /\b(exposed|revealed|showed)\b.*\b(gap|issue|problem|bug)\b/.test(lower) || /\bmissing\b/.test(lower)) {
1352
1450
  decisions.push(compactText(msg.content, 300));
1353
1451
  }
1354
1452
  }
1355
1453
  return decisions.slice(-5);
1356
1454
  }
1357
- function findFocusedWindow(messages) {
1455
+ function findFocusedWindowFrom(messages, fromUserMessage) {
1358
1456
  if (messages.length === 0) {
1359
1457
  return { messages, sessionAppearsComplete: false };
1360
1458
  }
1361
- const substantiveUserIndexes = messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "user" && message.content && !isNoiseMessage(message.content)).map(({ index }) => index);
1459
+ const substantiveUserIndexes = getSubstantiveUserIndexes(messages);
1362
1460
  if (substantiveUserIndexes.length === 0) {
1363
1461
  return { messages, sessionAppearsComplete: false };
1364
1462
  }
@@ -1368,7 +1466,9 @@ function findFocusedWindow(messages) {
1368
1466
  );
1369
1467
  const postToolUsers = substantiveUserIndexes.filter((index) => index > lastToolIndex);
1370
1468
  let startIndex = 0;
1371
- if (postToolUsers.length > 0) {
1469
+ if (typeof fromUserMessage === "number" && fromUserMessage > 0) {
1470
+ startIndex = substantiveUserIndexes[Math.min(fromUserMessage - 1, substantiveUserIndexes.length - 1)] ?? 0;
1471
+ } else if (postToolUsers.length > 0) {
1372
1472
  startIndex = postToolUsers[0];
1373
1473
  } else if (lastToolIndex >= 0) {
1374
1474
  startIndex = substantiveUserIndexes.filter((index) => index <= lastToolIndex).at(-1) ?? 0;
@@ -1376,7 +1476,7 @@ function findFocusedWindow(messages) {
1376
1476
  startIndex = substantiveUserIndexes.at(-1) ?? 0;
1377
1477
  }
1378
1478
  const startMessage = messages[startIndex];
1379
- if (startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
1479
+ if ((fromUserMessage === null || typeof fromUserMessage === "undefined") && startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
1380
1480
  const previousSubstantive = substantiveUserIndexes.filter((index) => index < startIndex).at(-1);
1381
1481
  if (typeof previousSubstantive === "number") {
1382
1482
  startIndex = previousSubstantive;
@@ -1384,10 +1484,25 @@ function findFocusedWindow(messages) {
1384
1484
  }
1385
1485
  const focused = messages.slice(startIndex);
1386
1486
  const hasToolActivity = focused.some((message) => message.role === "assistant" && message.toolCalls.length > 0);
1387
- const lastMessage = focused.at(-1);
1487
+ const lastMessage = [...focused].reverse().find((message) => {
1488
+ if (!message.content.trim() && message.toolCalls.length === 0) return false;
1489
+ if (message.role === "assistant" && message.content.trim() && isAssistantNoiseMessage(message.content)) {
1490
+ return false;
1491
+ }
1492
+ if (message.role === "user" && message.content.trim() && isNoiseMessage(message.content)) {
1493
+ return false;
1494
+ }
1495
+ return true;
1496
+ });
1388
1497
  const sessionAppearsComplete = Boolean(lastMessage) && lastMessage.role === "assistant" && lastMessage.toolCalls.length === 0 && !hasToolActivity;
1389
1498
  return { messages: focused, sessionAppearsComplete };
1390
1499
  }
1500
+ function listSubstantiveUserMessages(messages) {
1501
+ return getSubstantiveUserIndexes(messages).map((messageIndex, index) => ({
1502
+ index: index + 1,
1503
+ text: messages[messageIndex]?.content.trim() || ""
1504
+ }));
1505
+ }
1391
1506
  function extractWorkSummary(messages) {
1392
1507
  const filesModified = /* @__PURE__ */ new Set();
1393
1508
  const commands = [];
@@ -1413,12 +1528,124 @@ function extractWorkSummary(messages) {
1413
1528
  function extractLastAssistantAnswer(messages) {
1414
1529
  for (let i = messages.length - 1; i >= 0; i--) {
1415
1530
  const message = messages[i];
1416
- if (message.role === "assistant" && message.content.trim()) {
1531
+ if (message.role === "assistant" && message.content.trim() && !isAssistantNoiseMessage(message.content)) {
1417
1532
  return compactText(message.content, 500);
1418
1533
  }
1419
1534
  }
1420
1535
  return null;
1421
1536
  }
1537
+ function summarizeToolCall(toolCall) {
1538
+ const filePath = extractFilePath2(toolCall.input);
1539
+ const command = extractCommand2(toolCall.input);
1540
+ const url = typeof toolCall.input.url === "string" ? toolCall.input.url : null;
1541
+ const query = typeof toolCall.input.query === "string" ? toolCall.input.query : null;
1542
+ const description = typeof toolCall.input.description === "string" ? toolCall.input.description : typeof toolCall.input.prompt === "string" ? toolCall.input.prompt : null;
1543
+ if (filePath) return `${toolCall.tool} ${filePath}`;
1544
+ if (command) return `${toolCall.tool}: ${summarizeCommand(command)}`;
1545
+ if (url) return `${toolCall.tool}: ${compactText(url, 120)}`;
1546
+ if (description) return `${toolCall.tool}: ${compactText(description, 120)}`;
1547
+ if (query) return `${toolCall.tool}: ${compactText(query, 120)}`;
1548
+ return toolCall.tool;
1549
+ }
1550
+ function findLastActiveAssistant(messages) {
1551
+ for (let i = messages.length - 1; i >= 0; i--) {
1552
+ const message = messages[i];
1553
+ if (message.role !== "assistant") continue;
1554
+ if (message.content.trim() && isAssistantNoiseMessage(message.content)) continue;
1555
+ if (message.content.trim() || message.toolCalls.length > 0) {
1556
+ return message;
1557
+ }
1558
+ }
1559
+ return null;
1560
+ }
1561
+ function buildCurrentStatus(messages, errors, sessionAppearsComplete) {
1562
+ const lastAssistant = findLastActiveAssistant(messages);
1563
+ const lastStep = lastAssistant?.content?.trim() ? compactText(lastAssistant.content, 400) : null;
1564
+ const lastToolActions = lastAssistant ? lastAssistant.toolCalls.slice(-4).map(summarizeToolCall) : [];
1565
+ let status = "In progress";
1566
+ if (sessionAppearsComplete) {
1567
+ status = "Latest exchange complete";
1568
+ } else if (errors.length > 0) {
1569
+ status = "Blocked by unresolved errors";
1570
+ } else if (lastAssistant?.toolCalls.length) {
1571
+ status = "Mid-task after recent tool activity";
1572
+ } else if (lastAssistant?.content.trim()) {
1573
+ status = "Awaiting the next concrete action";
1574
+ }
1575
+ return { status, lastStep, lastToolActions };
1576
+ }
1577
+ function buildRemainingWorkHints({
1578
+ sessionAppearsComplete,
1579
+ errors,
1580
+ work,
1581
+ focusFiles,
1582
+ recentCommands,
1583
+ lastAssistantAnswer
1584
+ }) {
1585
+ if (sessionAppearsComplete) return [];
1586
+ const hints = [];
1587
+ if (errors.length > 0) {
1588
+ hints.push("Resolve the unresolved errors above before extending the implementation.");
1589
+ }
1590
+ if (work.filesModified.length > 0) {
1591
+ hints.push(`Inspect the in-progress changes in ${work.filesModified.map((filePath) => `\`${filePath}\``).join(", ")} and decide what still needs to be finished or verified.`);
1592
+ } else if (focusFiles.length > 0) {
1593
+ hints.push(`Start by reading ${focusFiles.map((filePath) => `\`${filePath}\``).join(", ")} to reconstruct the current working set.`);
1594
+ }
1595
+ if (recentCommands.length > 0) {
1596
+ hints.push("Rerun or extend the recent checks to confirm the current state before making further changes.");
1597
+ }
1598
+ if (focusFiles.length > 0) {
1599
+ hints.push("Run `git diff --` on the active files to see the exact in-progress changes before editing further.");
1600
+ }
1601
+ if (hints.length === 0 && lastAssistantAnswer) {
1602
+ hints.push("Continue from the last meaningful assistant answer above. This session appears to have stalled after planning or approval, not after code changes.");
1603
+ }
1604
+ if (hints.length === 0) {
1605
+ hints.push("Inspect the active files and run `git diff` to determine the next concrete implementation step.");
1606
+ }
1607
+ return hints;
1608
+ }
1609
+ function selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete) {
1610
+ const sanitizeHistoryMessages = (messages) => messages.filter((message) => {
1611
+ if (message.role === "assistant" && message.content.trim() && isMetaQualityAssistantMessage(message.content)) {
1612
+ return false;
1613
+ }
1614
+ if (message.role === "assistant" && message.content.trim() && isAssistantNoiseMessage(message.content)) {
1615
+ return false;
1616
+ }
1617
+ if (message.role === "user" && message.content && isNoiseMessage(message.content)) {
1618
+ return false;
1619
+ }
1620
+ return Boolean(message.content.trim()) || message.toolCalls.length > 0;
1621
+ });
1622
+ if (sessionAppearsComplete) return sanitizeHistoryMessages(focusedMessages);
1623
+ const hasAssistantActivity = focusedMessages.some(
1624
+ (message) => message.role === "assistant" && (message.content.trim() || message.toolCalls.length > 0)
1625
+ );
1626
+ if (focusedMessages.length >= 3 && hasAssistantActivity) return sanitizeHistoryMessages(focusedMessages);
1627
+ const filtered = sanitizeHistoryMessages(allMessages);
1628
+ return filtered.slice(-8);
1629
+ }
1630
+ function buildSessionHistory(focusedMessages, allMessages, sessionAppearsComplete) {
1631
+ const historyMessages = selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete);
1632
+ const entries = historyMessages.map((message) => {
1633
+ const parts = [];
1634
+ if (message.content.trim()) {
1635
+ if (message.role === "assistant" && (isMetaQualityAssistantMessage(message.content) || isAssistantNoiseMessage(message.content))) {
1636
+ return null;
1637
+ }
1638
+ parts.push(compactText(message.content, 220));
1639
+ }
1640
+ if (message.role === "assistant" && message.toolCalls.length > 0) {
1641
+ parts.push(`[tools] ${message.toolCalls.slice(-4).map(summarizeToolCall).join(", ")}`);
1642
+ }
1643
+ if (parts.length === 0) return null;
1644
+ return `${message.role.toUpperCase()}: ${parts.join(" | ")}`;
1645
+ }).filter((entry) => Boolean(entry));
1646
+ if (entries.length <= 8) return entries;
1647
+ return [entries[0], "...", ...entries.slice(-6)];
1648
+ }
1422
1649
  function summarizeCommand(command) {
1423
1650
  return compactText(command.replace(/\s+/g, " ").trim(), 140);
1424
1651
  }
@@ -1444,7 +1671,7 @@ function extractFocusFiles(ctx, work) {
1444
1671
  ]).slice(0, 6);
1445
1672
  }
1446
1673
  function buildRawPrompt(ctx, options = {}) {
1447
- const focused = findFocusedWindow(ctx.messages);
1674
+ const focused = findFocusedWindowFrom(ctx.messages, options.fromUserMessage);
1448
1675
  const userMessages = filterUserMessages(focused.messages);
1449
1676
  const errors = extractUnresolvedErrors(focused.messages);
1450
1677
  const decisions = extractKeyDecisions(focused.messages);
@@ -1452,6 +1679,16 @@ function buildRawPrompt(ctx, options = {}) {
1452
1679
  const focusFiles = extractFocusFiles(ctx, work);
1453
1680
  const recentCommands = extractRecentCommands(work.commands);
1454
1681
  const lastAssistantAnswer = extractLastAssistantAnswer(focused.messages);
1682
+ const currentStatus = buildCurrentStatus(focused.messages, errors, focused.sessionAppearsComplete);
1683
+ const remainingWorkHints = buildRemainingWorkHints({
1684
+ sessionAppearsComplete: focused.sessionAppearsComplete,
1685
+ errors,
1686
+ work,
1687
+ focusFiles,
1688
+ recentCommands,
1689
+ lastAssistantAnswer
1690
+ });
1691
+ const sessionHistory = buildSessionHistory(focused.messages, ctx.messages, focused.sessionAppearsComplete);
1455
1692
  let prompt = "";
1456
1693
  prompt += "# Task\n\n";
1457
1694
  prompt += `Project: \`${ctx.sessionCwd}\`
@@ -1479,6 +1716,26 @@ function buildRawPrompt(ctx, options = {}) {
1479
1716
 
1480
1717
  `;
1481
1718
  }
1719
+ prompt += "## Current Status\n\n";
1720
+ prompt += `- Status: ${currentStatus.status}
1721
+ `;
1722
+ if (currentStatus.lastStep) {
1723
+ prompt += `- Last active step: ${currentStatus.lastStep}
1724
+ `;
1725
+ }
1726
+ if (currentStatus.lastToolActions.length > 0) {
1727
+ prompt += `- Last tool actions: ${currentStatus.lastToolActions.join(", ")}
1728
+ `;
1729
+ }
1730
+ prompt += "\n";
1731
+ if (sessionHistory.length > 0) {
1732
+ prompt += "## Session History\n\n";
1733
+ for (const entry of sessionHistory) {
1734
+ prompt += `- ${entry}
1735
+ `;
1736
+ }
1737
+ prompt += "\n";
1738
+ }
1482
1739
  if (errors.length > 0) {
1483
1740
  prompt += "## DO NOT REPEAT \u2014 Unresolved Errors\n\n";
1484
1741
  prompt += "These errors occurred and were NOT fixed. Avoid the same approaches.\n\n";
@@ -1531,6 +1788,14 @@ function buildRawPrompt(ctx, options = {}) {
1531
1788
  prompt += "## Read These Files First\n\n";
1532
1789
  for (const filePath of focusFiles) {
1533
1790
  prompt += `- \`${filePath}\`
1791
+ `;
1792
+ }
1793
+ prompt += "\n";
1794
+ }
1795
+ if (remainingWorkHints.length > 0) {
1796
+ prompt += "## Likely Remaining Work\n\n";
1797
+ for (const hint of remainingWorkHints) {
1798
+ prompt += `- ${hint}
1534
1799
  `;
1535
1800
  }
1536
1801
  prompt += "\n";
@@ -1541,12 +1806,6 @@ function buildRawPrompt(ctx, options = {}) {
1541
1806
  if (git.status) {
1542
1807
  prompt += "```\n" + git.status + "\n```\n\n";
1543
1808
  }
1544
- if (git.staged.diff) {
1545
- prompt += "**Staged diff:**\n```diff\n" + git.staged.diff + "\n```\n\n";
1546
- }
1547
- if (git.unstaged.diff) {
1548
- prompt += "**Unstaged diff:**\n```diff\n" + git.unstaged.diff + "\n```\n\n";
1549
- }
1550
1809
  if (git.untracked.length > 0) {
1551
1810
  const shown = git.untracked.slice(0, 6);
1552
1811
  prompt += "**Untracked files:**\n";
@@ -1577,11 +1836,13 @@ function buildRawPrompt(ctx, options = {}) {
1577
1836
  if (errors.length > 0) {
1578
1837
  prompt += "2. **Check the errors above** \u2014 do NOT repeat failed approaches. Try a different strategy.\n";
1579
1838
  }
1580
- prompt += `${errors.length > 0 ? "3" : "2"}. **Identify what's done vs what remains** \u2014 use the recent commands, active files, and git state above as the source of truth for the current thread.
1839
+ prompt += `${errors.length > 0 ? "3" : "2"}. **Inspect the workspace state explicitly** \u2014 run \`git status --short\`, \`git diff --stat\`, and \`git diff -- ${focusFiles.length > 0 ? focusFiles.slice(0, 4).join(" ") : "."}\` before changing code.
1581
1840
  `;
1582
- prompt += `${errors.length > 0 ? "4" : "3"}. **Do the remaining work** \u2014 pick up exactly where the previous agent stopped.
1841
+ prompt += `${errors.length > 0 ? "4" : "3"}. **Identify what's done vs what remains** \u2014 use the Current Status, Session History, Likely Remaining Work, recent commands, active files, and git state above as the source of truth for the current thread.
1583
1842
  `;
1584
- prompt += `${errors.length > 0 ? "5" : "4"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
1843
+ prompt += `${errors.length > 0 ? "5" : "4"}. **Continue from the last active step** \u2014 if the stop point is still ambiguous, inspect the read-first files and rerun the recent commands before changing code.
1844
+ `;
1845
+ prompt += `${errors.length > 0 ? "6" : "5"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
1585
1846
  `;
1586
1847
  }
1587
1848
  return prompt;
@@ -1929,6 +2190,9 @@ function formatRunSummary({
1929
2190
  lines.push(` Messages: ${ctx.messages.length}`);
1930
2191
  lines.push(` Files: ${ctx.filesModified.length} modified, ${ctx.filesRead.length} read`);
1931
2192
  lines.push(` Git: ${summarizeGitContext(ctx.gitContext)}`);
2193
+ if (options.fromUser) {
2194
+ lines.push(` Focus: user prompt #${options.fromUser}`);
2195
+ }
1932
2196
  lines.push(` Target: ${options.target}`);
1933
2197
  lines.push(` Mode: ${mode === "raw" ? "raw" : `refined via ${options.provider}${model ? ` (${model})` : ""}`}`);
1934
2198
  return lines.join("\n");
@@ -2080,9 +2344,50 @@ async function promptForSource() {
2080
2344
  });
2081
2345
  });
2082
2346
  }
2347
+ function summarizePromptChoice(text) {
2348
+ return text.replace(/\s+/g, " ").trim().slice(0, 100);
2349
+ }
2350
+ async function promptForUserStart(messages) {
2351
+ const userPrompts = listSubstantiveUserMessages(messages);
2352
+ if (userPrompts.length === 0) {
2353
+ return null;
2354
+ }
2355
+ if (userPrompts.length === 1) {
2356
+ process.stderr.write("\nOnly one substantive user prompt was found. Using it automatically.\n");
2357
+ return 1;
2358
+ }
2359
+ const rl = readline.createInterface({
2360
+ input: process.stdin,
2361
+ output: process.stderr
2362
+ });
2363
+ process.stderr.write("\nSelect the user prompt to preserve context from:\n\n");
2364
+ for (const prompt of userPrompts) {
2365
+ process.stderr.write(` ${prompt.index}) ${summarizePromptChoice(prompt.text)}
2366
+ `);
2367
+ }
2368
+ process.stderr.write("\n");
2369
+ return new Promise((resolve) => {
2370
+ rl.question(`Enter choice (1-${userPrompts.length}): `, (answer) => {
2371
+ rl.close();
2372
+ const trimmed = answer.trim();
2373
+ if (!trimmed) {
2374
+ process.stderr.write("Invalid choice, using automatic focus.\n");
2375
+ resolve(null);
2376
+ return;
2377
+ }
2378
+ const idx = Number.parseInt(trimmed, 10);
2379
+ if (idx >= 1 && idx <= userPrompts.length) {
2380
+ resolve(idx);
2381
+ } else {
2382
+ process.stderr.write("Invalid choice, using automatic focus.\n");
2383
+ resolve(null);
2384
+ }
2385
+ });
2386
+ });
2387
+ }
2083
2388
  async function main(argv = process.argv.slice(2)) {
2084
2389
  const options = parseArgs(argv);
2085
- const pkgInfo = { name: "ctx-switch", version: "2.0.5" };
2390
+ const pkgInfo = { name: "ctx-switch", version: "2.0.7" };
2086
2391
  const ui = createTheme(process.stderr);
2087
2392
  if (options.help) {
2088
2393
  process.stdout.write(`${getHelpText(pkgInfo)}
@@ -2145,6 +2450,20 @@ async function main(argv = process.argv.slice(2)) {
2145
2450
  if (messages.length === 0) {
2146
2451
  fail(`Parsed zero usable messages from ${sessionPath}`);
2147
2452
  }
2453
+ let fromUserMessage = options.fromUser;
2454
+ const userPrompts = listSubstantiveUserMessages(messages);
2455
+ if (fromUserMessage !== null && fromUserMessage > userPrompts.length) {
2456
+ fail(
2457
+ `Requested --from-user ${fromUserMessage}, but only ${userPrompts.length} substantive user prompt(s) were found.`,
2458
+ {
2459
+ suggestions: ["Run `ctx-switch --pick-user` to choose interactively.", "Omit `--from-user` to use automatic focus."]
2460
+ }
2461
+ );
2462
+ }
2463
+ if (options.pickUser && process.stdin.isTTY) {
2464
+ ui.step("Choosing preserved context start");
2465
+ fromUserMessage = await promptForUserStart(messages);
2466
+ }
2148
2467
  ui.step("Capturing git context");
2149
2468
  const gitContext = getGitContext(cwd);
2150
2469
  const ctx = buildSessionContext({
@@ -2159,7 +2478,7 @@ async function main(argv = process.argv.slice(2)) {
2159
2478
  let activeModel = null;
2160
2479
  if (!options.refine) {
2161
2480
  ui.step("Building continuation prompt");
2162
- finalPrompt = buildRawPrompt(ctx, { target: options.target });
2481
+ finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
2163
2482
  } else {
2164
2483
  const provider = options.provider;
2165
2484
  let apiKey = getApiKey({
@@ -2197,7 +2516,7 @@ async function main(argv = process.argv.slice(2)) {
2197
2516
  apiKey,
2198
2517
  model,
2199
2518
  systemPrompt: buildRefinementSystemPrompt(options.target),
2200
- userPrompt: buildRefinementDump(ctx, { target: options.target }),
2519
+ userPrompt: buildRefinementDump(ctx, { target: options.target, fromUserMessage }),
2201
2520
  timeoutMs: 0,
2202
2521
  onStatus: (status) => {
2203
2522
  if (!streamStarted) reporter.update(status);
@@ -2230,7 +2549,7 @@ async function main(argv = process.argv.slice(2)) {
2230
2549
  ui.note(`Provider detail: ${refined.rawError}`);
2231
2550
  }
2232
2551
  ui.note("Falling back to the raw structured prompt.");
2233
- finalPrompt = buildRawPrompt(ctx, { target: options.target });
2552
+ finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
2234
2553
  }
2235
2554
  }
2236
2555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx-switch",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "Switch coding agents without losing context. Generate handoff prompts across Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",