ctx-switch 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +509 -56
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  // src/types.ts
15
15
  var SUPPORTED_COMMANDS = ["continue", "doctor", "sessions"];
16
16
  var SUPPORTED_PROVIDERS = ["openrouter"];
17
- var SUPPORTED_TARGETS = ["generic", "codex", "cursor", "chatgpt"];
17
+ var SUPPORTED_TARGETS = ["generic", "claude", "codex", "cursor", "chatgpt"];
18
18
  var SUPPORTED_SOURCES = ["claude", "codex", "opencode"];
19
19
 
20
20
  // src/args.ts
@@ -142,7 +142,7 @@ function getHelpText({ name, version }) {
142
142
  " -o, --output <file> Write the final prompt to a file",
143
143
  " --source <name> Session source: claude, codex, opencode (interactive if omitted)",
144
144
  " --session <id|path> Use a specific session file or session id",
145
- " --target <name> Prompt target: generic, codex, cursor, chatgpt",
145
+ " --target <name> Prompt target: generic, claude, codex, cursor, chatgpt",
146
146
  " -n, --limit <count> Limit rows for the sessions command (default: 10)",
147
147
  "",
148
148
  "Refinement (optional)",
@@ -160,6 +160,7 @@ function getHelpText({ name, version }) {
160
160
  "Examples",
161
161
  ` ${name} # interactive source picker`,
162
162
  ` ${name} --source claude # use Claude Code sessions`,
163
+ ` ${name} --source codex --target claude`,
163
164
  ` ${name} --source codex --target codex`,
164
165
  ` ${name} --source opencode`,
165
166
  ` ${name} --refine --model openrouter/free`,
@@ -600,6 +601,60 @@ import path5 from "node:path";
600
601
  var CODEX_DIR = path5.join(os3.homedir(), ".codex");
601
602
  var CODEX_SESSIONS_DIR = path5.join(CODEX_DIR, "sessions");
602
603
  var CODEX_INDEX_PATH = path5.join(CODEX_DIR, "session_index.jsonl");
604
+ function parseJsonRecord(line) {
605
+ try {
606
+ return JSON.parse(line);
607
+ } catch {
608
+ return null;
609
+ }
610
+ }
611
+ function parseFunctionArguments(raw) {
612
+ if (!raw) return {};
613
+ try {
614
+ const parsed = JSON.parse(raw);
615
+ return parsed && typeof parsed === "object" ? parsed : {};
616
+ } catch {
617
+ return {};
618
+ }
619
+ }
620
+ function parseCustomToolInput(toolName, raw) {
621
+ if (!raw) return {};
622
+ if (typeof raw !== "string") return raw;
623
+ const trimmed = raw.trim();
624
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
625
+ try {
626
+ const parsed = JSON.parse(trimmed);
627
+ if (parsed && typeof parsed === "object") {
628
+ return parsed;
629
+ }
630
+ } catch {
631
+ }
632
+ }
633
+ if (toolName === "apply_patch") {
634
+ return { patch: raw };
635
+ }
636
+ return { input: raw };
637
+ }
638
+ function parseToolOutput(raw) {
639
+ if (!raw) return { text: "" };
640
+ let text = raw;
641
+ let exitCode;
642
+ try {
643
+ const parsed = JSON.parse(raw);
644
+ if (typeof parsed.output === "string") {
645
+ text = parsed.output;
646
+ }
647
+ if (typeof parsed.metadata?.exit_code === "number") {
648
+ exitCode = parsed.metadata.exit_code;
649
+ }
650
+ } catch {
651
+ }
652
+ const isError = exitCode !== void 0 ? exitCode !== 0 : /Process exited with code [^0]/.test(text) || /error|Error|ERROR/.test(text.slice(0, 200));
653
+ return {
654
+ text: text.slice(0, 1500),
655
+ isError: isError || void 0
656
+ };
657
+ }
603
658
  function listSessionsForProject2(cwd) {
604
659
  if (!fs4.existsSync(CODEX_SESSIONS_DIR)) {
605
660
  return [];
@@ -680,12 +735,8 @@ function parseSession2(sessionPath) {
680
735
  }
681
736
  }
682
737
  for (const line of lines) {
683
- let record;
684
- try {
685
- record = JSON.parse(line);
686
- } catch {
687
- continue;
688
- }
738
+ const record = parseJsonRecord(line);
739
+ if (!record) continue;
689
740
  if (record.type === "session_meta") {
690
741
  const payload2 = record.payload;
691
742
  if (payload2.cwd) meta.cwd = payload2.cwd;
@@ -693,6 +744,21 @@ function parseSession2(sessionPath) {
693
744
  if (payload2.id) meta.sessionId = payload2.id;
694
745
  continue;
695
746
  }
747
+ if (record.type === "event_msg" && record.payload.type === "exec_command_end" && record.payload.call_id) {
748
+ const tc = pendingCalls.get(record.payload.call_id);
749
+ if (tc) {
750
+ if (Array.isArray(record.payload.parsed_cmd)) {
751
+ tc.input.parsed_cmd = record.payload.parsed_cmd;
752
+ }
753
+ if (typeof record.payload.aggregated_output === "string" && !tc.result) {
754
+ tc.result = record.payload.aggregated_output.slice(0, 1500);
755
+ }
756
+ if (typeof record.payload.exit_code === "number") {
757
+ tc.isError = record.payload.exit_code !== 0;
758
+ }
759
+ }
760
+ continue;
761
+ }
696
762
  if (record.type !== "response_item") continue;
697
763
  const payload = record.payload;
698
764
  const payloadType = payload.type;
@@ -718,11 +784,7 @@ function parseSession2(sessionPath) {
718
784
  continue;
719
785
  }
720
786
  if (payloadType === "function_call" && payload.name) {
721
- let input = {};
722
- try {
723
- input = JSON.parse(payload.arguments || "{}");
724
- } catch {
725
- }
787
+ const input = parseFunctionArguments(payload.arguments);
726
788
  const tc = {
727
789
  id: payload.call_id || null,
728
790
  tool: payload.name,
@@ -734,12 +796,26 @@ function parseSession2(sessionPath) {
734
796
  }
735
797
  continue;
736
798
  }
737
- if (payloadType === "function_call_output" && payload.call_id) {
799
+ if (payloadType === "custom_tool_call" && payload.name) {
800
+ const tc = {
801
+ id: payload.call_id || null,
802
+ tool: payload.name,
803
+ input: parseCustomToolInput(payload.name, payload.input)
804
+ };
805
+ currentToolCalls.push(tc);
806
+ if (payload.call_id) {
807
+ pendingCalls.set(payload.call_id, tc);
808
+ }
809
+ continue;
810
+ }
811
+ if ((payloadType === "function_call_output" || payloadType === "custom_tool_call_output") && payload.call_id) {
738
812
  const tc = pendingCalls.get(payload.call_id);
739
813
  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));
814
+ const parsed = parseToolOutput(payload.output);
815
+ tc.result = parsed.text;
816
+ if (parsed.isError !== void 0) {
817
+ tc.isError = parsed.isError;
818
+ }
743
819
  }
744
820
  continue;
745
821
  }
@@ -753,7 +829,10 @@ import { execFileSync as execFileSync2 } from "node:child_process";
753
829
  import os4 from "node:os";
754
830
  import path6 from "node:path";
755
831
  var OPENCODE_DB_PATH = path6.join(os4.homedir(), ".local", "share", "opencode", "opencode.db");
756
- function runSqlite(query, dbPath = OPENCODE_DB_PATH) {
832
+ function getOpenCodeDbPath() {
833
+ return process.env.CTX_SWITCH_OPENCODE_DB_PATH || OPENCODE_DB_PATH;
834
+ }
835
+ function runSqlite(query, dbPath = getOpenCodeDbPath()) {
757
836
  try {
758
837
  const stdout = execFileSync2("sqlite3", ["-json", dbPath, query], {
759
838
  encoding: "utf8",
@@ -806,7 +885,31 @@ function resolveSessionPath3(selection, cwd) {
806
885
  const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
807
886
  return match ? match.id : null;
808
887
  }
888
+ function detectToolError(tool, status, output) {
889
+ if (status === "error") return true;
890
+ if (!output) return void 0;
891
+ if (/Process exited with code [1-9]\d*/.test(output)) return true;
892
+ if (/^npm ERR!/m.test(output)) return true;
893
+ if (/^(Error|TypeError|ReferenceError|SyntaxError):/m.test(output)) return true;
894
+ if (/^(bash|sh|zsh):/m.test(output)) return true;
895
+ if (tool === "read" || tool === "glob" || tool === "grep") return void 0;
896
+ return void 0;
897
+ }
898
+ function extractDelegatedSessionId(part) {
899
+ const metadataSessionId = part.state?.metadata?.sessionId;
900
+ if (typeof metadataSessionId === "string" && metadataSessionId.trim()) {
901
+ return metadataSessionId.trim();
902
+ }
903
+ const output = part.state?.output;
904
+ if (typeof output !== "string") return null;
905
+ const match = output.match(/\btask_id:\s*(ses_[^\s)]+)/);
906
+ return match?.[1] || null;
907
+ }
809
908
  function parseSession3(sessionId) {
909
+ return parseSessionInternal(sessionId, /* @__PURE__ */ new Set());
910
+ }
911
+ function parseSessionInternal(sessionId, seenSessions) {
912
+ seenSessions.add(sessionId);
810
913
  const messages = [];
811
914
  const meta = {
812
915
  cwd: null,
@@ -842,6 +945,7 @@ function parseSession3(sessionId) {
842
945
  if (!role || role !== "user" && role !== "assistant") continue;
843
946
  const textParts = [];
844
947
  const toolCalls = [];
948
+ const delegatedAssistantMessages = [];
845
949
  for (const partRow of partRows) {
846
950
  let part;
847
951
  try {
@@ -854,9 +958,17 @@ function parseSession3(sessionId) {
854
958
  } else if (part.type === "tool" && part.tool) {
855
959
  const input = part.state?.input || {};
856
960
  const output = part.state?.output || "";
857
- const isError = part.state?.status === "error" || /error|Error|ERROR/.test(output.slice(0, 200));
961
+ const isError = detectToolError(part.tool, part.state?.status, output);
858
962
  const toolName = normalizeToolName(part.tool);
859
963
  const normalizedInput = normalizeInput(part.tool, input);
964
+ if (toolName === "task") {
965
+ const delegatedSessionId = extractDelegatedSessionId(part);
966
+ if (delegatedSessionId && !seenSessions.has(delegatedSessionId)) {
967
+ normalizedInput.delegated_session_id = delegatedSessionId;
968
+ const delegated = parseSessionInternal(delegatedSessionId, seenSessions);
969
+ delegatedAssistantMessages.push(...delegated.messages.filter((message) => message.role === "assistant"));
970
+ }
971
+ }
860
972
  toolCalls.push({
861
973
  id: part.callID || null,
862
974
  tool: toolName,
@@ -874,11 +986,15 @@ function parseSession3(sessionId) {
874
986
  toolCalls,
875
987
  timestamp: null
876
988
  });
989
+ if (role === "assistant" && delegatedAssistantMessages.length > 0) {
990
+ messages.push(...delegatedAssistantMessages);
991
+ }
877
992
  }
878
993
  return { messages, meta };
879
994
  }
880
995
  function normalizeToolName(tool) {
881
996
  const mapping = {
997
+ apply_patch: "apply_patch",
882
998
  read: "read",
883
999
  edit: "edit",
884
1000
  write: "write",
@@ -894,10 +1010,14 @@ function normalizeToolName(tool) {
894
1010
  return mapping[tool.toLowerCase()] || tool;
895
1011
  }
896
1012
  function normalizeInput(tool, input) {
897
- if (input.filePath && !input.file_path) {
898
- return { ...input, file_path: input.filePath };
1013
+ const normalized = { ...input };
1014
+ if (normalized.filePath && !normalized.file_path) {
1015
+ normalized.file_path = normalized.filePath;
1016
+ }
1017
+ if (tool === "apply_patch" && typeof normalized.patchText === "string" && !normalized.patch) {
1018
+ normalized.patch = normalized.patchText;
899
1019
  }
900
- return input;
1020
+ return normalized;
901
1021
  }
902
1022
 
903
1023
  // src/session.ts
@@ -950,6 +1070,37 @@ function extractCommand(input) {
950
1070
  const value = input.command || input.cmd;
951
1071
  return typeof value === "string" ? value : null;
952
1072
  }
1073
+ function resolveSessionFilePath(filePath, cwd) {
1074
+ return path7.isAbsolute(filePath) ? filePath : path7.resolve(cwd, filePath);
1075
+ }
1076
+ function extractPatchFilePaths(input, cwd) {
1077
+ const patch = input.patch;
1078
+ if (typeof patch !== "string") return [];
1079
+ const matches = [...patch.matchAll(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm)];
1080
+ return matches.map((match) => resolveSessionFilePath(match[1].trim(), cwd));
1081
+ }
1082
+ function extractParsedCommands(input) {
1083
+ const parsed = input.parsed_cmd;
1084
+ if (!Array.isArray(parsed)) return [];
1085
+ return parsed.filter((entry) => Boolean(entry) && typeof entry === "object");
1086
+ }
1087
+ function tokenizeShellCommand(command) {
1088
+ const matches = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
1089
+ return matches.map((token) => token.replace(/^['"]|['"]$/g, ""));
1090
+ }
1091
+ function looksLikeFilePath(token) {
1092
+ if (!token || token.startsWith("-")) return false;
1093
+ if (/[|<>;$()]/.test(token)) return false;
1094
+ return token.includes("/") || token.includes(".");
1095
+ }
1096
+ function extractReadPathsFromCommand(command, cwd) {
1097
+ const tokens = tokenizeShellCommand(command.trim());
1098
+ if (tokens.length === 0) return [];
1099
+ const base = path7.basename(tokens[0]);
1100
+ const readCommands = /* @__PURE__ */ new Set(["cat", "head", "tail", "sed", "nl", "wc"]);
1101
+ if (!readCommands.has(base)) return [];
1102
+ return tokens.slice(1).filter((token) => looksLikeFilePath(token)).filter((token) => !/^\d+(,\d+)?p?$/.test(token)).map((token) => resolveSessionFilePath(token, cwd));
1103
+ }
953
1104
  function buildSessionContext({
954
1105
  messages,
955
1106
  meta,
@@ -967,10 +1118,23 @@ function buildSessionContext({
967
1118
  const toolName = String(toolCall.tool || "").toLowerCase();
968
1119
  const filePath = extractFilePath(toolCall.input);
969
1120
  const command = extractCommand(toolCall.input);
1121
+ const patchPaths = extractPatchFilePaths(toolCall.input, cwd);
1122
+ const parsedCommands = extractParsedCommands(toolCall.input);
1123
+ const commandReadPaths = command ? extractReadPathsFromCommand(command, cwd) : [];
970
1124
  if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
971
- filesModified.add(filePath);
1125
+ filesModified.add(resolveSessionFilePath(filePath, cwd));
972
1126
  } else if (filePath && /(read|grep|glob|search)/.test(toolName)) {
973
- filesRead.add(filePath);
1127
+ filesRead.add(resolveSessionFilePath(filePath, cwd));
1128
+ }
1129
+ for (const patchPath of patchPaths) {
1130
+ filesModified.add(patchPath);
1131
+ }
1132
+ for (const parsedCommand of parsedCommands) {
1133
+ if (parsedCommand.type !== "read" || typeof parsedCommand.path !== "string") continue;
1134
+ filesRead.add(resolveSessionFilePath(parsedCommand.path, cwd));
1135
+ }
1136
+ for (const readPath of commandReadPaths) {
1137
+ filesRead.add(readPath);
974
1138
  }
975
1139
  if (command && /(bash|command|run|exec_command)/.test(toolName)) {
976
1140
  commands.push(command);
@@ -985,8 +1149,10 @@ function buildSessionContext({
985
1149
  const toolSummary = message.toolCalls.map((toolCall) => {
986
1150
  const filePath = extractFilePath(toolCall.input);
987
1151
  const command = extractCommand(toolCall.input);
1152
+ const patchPaths = extractPatchFilePaths(toolCall.input, cwd);
988
1153
  let summary = "";
989
- if (filePath) summary = `${toolCall.tool} ${filePath}`;
1154
+ if (filePath) summary = `${toolCall.tool} ${resolveSessionFilePath(filePath, cwd)}`;
1155
+ else if (patchPaths.length > 0) summary = `${toolCall.tool} ${patchPaths.join(", ")}`;
990
1156
  else if (command) summary = `${toolCall.tool}: ${command}`;
991
1157
  else summary = toolCall.tool;
992
1158
  if (toolCall.isError && toolCall.result) {
@@ -1156,8 +1322,18 @@ function compactText(text, maxChars = 800) {
1156
1322
  function unique(list) {
1157
1323
  return [...new Set(list.filter(Boolean))];
1158
1324
  }
1325
+ function extractFilePath2(input) {
1326
+ const value = input.file_path || input.path || input.target_file || input.filePath;
1327
+ return typeof value === "string" ? value : null;
1328
+ }
1329
+ function extractCommand2(input) {
1330
+ const value = input.command || input.cmd;
1331
+ return typeof value === "string" ? value : null;
1332
+ }
1159
1333
  function buildTargetGuidance(target) {
1160
1334
  switch (target) {
1335
+ case "claude":
1336
+ 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.";
1161
1337
  case "codex":
1162
1338
  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
1339
  case "cursor":
@@ -1165,19 +1341,20 @@ function buildTargetGuidance(target) {
1165
1341
  case "chatgpt":
1166
1342
  return "The next agent is ChatGPT. It should reason from the current workspace state, explain what remains, and provide explicit next actions.";
1167
1343
  default:
1168
- return "The next agent should continue the interrupted work from the current workspace state without redoing completed steps.";
1344
+ 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.";
1169
1345
  }
1170
1346
  }
1171
1347
  function isNoiseMessage(text) {
1172
1348
  const trimmed = text.trim().toLowerCase();
1349
+ const normalized = trimmed.replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1173
1350
  if (trimmed.length < 5) return true;
1174
1351
  const noise = [
1175
1352
  "yes",
1353
+ "yes please",
1176
1354
  "no",
1177
1355
  "ok",
1178
1356
  "okay",
1179
1357
  "try",
1180
- "try?",
1181
1358
  "sure",
1182
1359
  "do it",
1183
1360
  "go ahead",
@@ -1194,11 +1371,20 @@ function isNoiseMessage(text) {
1194
1371
  "try turn off thinking",
1195
1372
  "try without timeout"
1196
1373
  ];
1197
- if (noise.includes(trimmed)) return true;
1198
- if (/^(try|yes|ok|sure|test|run)\s/i.test(trimmed) && trimmed.length < 40) return true;
1374
+ if (noise.includes(normalized)) return true;
1375
+ if (/^(try|yes|ok|sure|test|run)\s/i.test(normalized) && normalized.length < 40) return true;
1199
1376
  if (trimmed.startsWith("[request interrupted")) return true;
1200
1377
  return false;
1201
1378
  }
1379
+ function isReferentialMessage(text) {
1380
+ const normalized = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1381
+ if (!normalized || normalized.length > 220) return false;
1382
+ return /^(ok|okay|alright|now|so)\b/.test(normalized) || /\b(it|that|again|better|same|continue|still|also|another|more)\b/.test(normalized);
1383
+ }
1384
+ function isMetaQualityAssistantMessage(text) {
1385
+ const lower = text.toLowerCase();
1386
+ return /\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower);
1387
+ }
1202
1388
  function filterUserMessages(messages) {
1203
1389
  const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim());
1204
1390
  if (all.length <= 2) return all;
@@ -1227,31 +1413,267 @@ function extractKeyDecisions(messages) {
1227
1413
  const decisions = [];
1228
1414
  for (const msg of messages) {
1229
1415
  if (msg.role !== "assistant" || !msg.content) continue;
1416
+ if (isMetaQualityAssistantMessage(msg.content)) {
1417
+ continue;
1418
+ }
1230
1419
  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")) {
1420
+ 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)) {
1232
1421
  decisions.push(compactText(msg.content, 300));
1233
1422
  }
1234
1423
  }
1235
1424
  return decisions.slice(-5);
1236
1425
  }
1426
+ function findFocusedWindow(messages) {
1427
+ if (messages.length === 0) {
1428
+ return { messages, sessionAppearsComplete: false };
1429
+ }
1430
+ const substantiveUserIndexes = messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "user" && message.content && !isNoiseMessage(message.content)).map(({ index }) => index);
1431
+ if (substantiveUserIndexes.length === 0) {
1432
+ return { messages, sessionAppearsComplete: false };
1433
+ }
1434
+ const lastToolIndex = messages.reduce(
1435
+ (last, message, index) => message.role === "assistant" && message.toolCalls.length > 0 ? index : last,
1436
+ -1
1437
+ );
1438
+ const postToolUsers = substantiveUserIndexes.filter((index) => index > lastToolIndex);
1439
+ let startIndex = 0;
1440
+ if (postToolUsers.length > 0) {
1441
+ startIndex = postToolUsers[0];
1442
+ } else if (lastToolIndex >= 0) {
1443
+ startIndex = substantiveUserIndexes.filter((index) => index <= lastToolIndex).at(-1) ?? 0;
1444
+ } else {
1445
+ startIndex = substantiveUserIndexes.at(-1) ?? 0;
1446
+ }
1447
+ const startMessage = messages[startIndex];
1448
+ if (startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
1449
+ const previousSubstantive = substantiveUserIndexes.filter((index) => index < startIndex).at(-1);
1450
+ if (typeof previousSubstantive === "number") {
1451
+ startIndex = previousSubstantive;
1452
+ }
1453
+ }
1454
+ const focused = messages.slice(startIndex);
1455
+ const hasToolActivity = focused.some((message) => message.role === "assistant" && message.toolCalls.length > 0);
1456
+ const lastMessage = focused.at(-1);
1457
+ const sessionAppearsComplete = Boolean(lastMessage) && lastMessage.role === "assistant" && lastMessage.toolCalls.length === 0 && !hasToolActivity;
1458
+ return { messages: focused, sessionAppearsComplete };
1459
+ }
1460
+ function extractWorkSummary(messages) {
1461
+ const filesModified = /* @__PURE__ */ new Set();
1462
+ const commands = [];
1463
+ for (const message of messages) {
1464
+ if (message.role !== "assistant" || message.toolCalls.length === 0) continue;
1465
+ for (const toolCall of message.toolCalls) {
1466
+ const toolName = String(toolCall.tool || "").toLowerCase();
1467
+ const filePath = extractFilePath2(toolCall.input);
1468
+ const command = extractCommand2(toolCall.input);
1469
+ if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
1470
+ filesModified.add(filePath);
1471
+ }
1472
+ if (command && /(bash|command|run|exec_command)/.test(toolName)) {
1473
+ commands.push(command);
1474
+ }
1475
+ }
1476
+ }
1477
+ return {
1478
+ filesModified: [...filesModified],
1479
+ commands
1480
+ };
1481
+ }
1482
+ function extractLastAssistantAnswer(messages) {
1483
+ for (let i = messages.length - 1; i >= 0; i--) {
1484
+ const message = messages[i];
1485
+ if (message.role === "assistant" && message.content.trim()) {
1486
+ return compactText(message.content, 500);
1487
+ }
1488
+ }
1489
+ return null;
1490
+ }
1491
+ function summarizeToolCall(toolCall) {
1492
+ const filePath = extractFilePath2(toolCall.input);
1493
+ const command = extractCommand2(toolCall.input);
1494
+ if (filePath) return `${toolCall.tool} ${filePath}`;
1495
+ if (command) return `${toolCall.tool}: ${summarizeCommand(command)}`;
1496
+ return toolCall.tool;
1497
+ }
1498
+ function findLastActiveAssistant(messages) {
1499
+ for (let i = messages.length - 1; i >= 0; i--) {
1500
+ const message = messages[i];
1501
+ if (message.role !== "assistant") continue;
1502
+ if (message.content.trim() || message.toolCalls.length > 0) {
1503
+ return message;
1504
+ }
1505
+ }
1506
+ return null;
1507
+ }
1508
+ function buildCurrentStatus(messages, errors, sessionAppearsComplete) {
1509
+ const lastAssistant = findLastActiveAssistant(messages);
1510
+ const lastStep = lastAssistant?.content?.trim() ? compactText(lastAssistant.content, 400) : null;
1511
+ const lastToolActions = lastAssistant ? lastAssistant.toolCalls.slice(-4).map(summarizeToolCall) : [];
1512
+ let status = "In progress";
1513
+ if (sessionAppearsComplete) {
1514
+ status = "Latest exchange complete";
1515
+ } else if (errors.length > 0) {
1516
+ status = "Blocked by unresolved errors";
1517
+ } else if (lastAssistant?.toolCalls.length) {
1518
+ status = "Mid-task after recent tool activity";
1519
+ } else if (lastAssistant?.content.trim()) {
1520
+ status = "Awaiting the next concrete action";
1521
+ }
1522
+ return { status, lastStep, lastToolActions };
1523
+ }
1524
+ function buildRemainingWorkHints({
1525
+ sessionAppearsComplete,
1526
+ errors,
1527
+ work,
1528
+ focusFiles,
1529
+ recentCommands
1530
+ }) {
1531
+ if (sessionAppearsComplete) return [];
1532
+ const hints = [];
1533
+ if (errors.length > 0) {
1534
+ hints.push("Resolve the unresolved errors above before extending the implementation.");
1535
+ }
1536
+ if (work.filesModified.length > 0) {
1537
+ hints.push(`Inspect the in-progress changes in ${work.filesModified.map((filePath) => `\`${filePath}\``).join(", ")} and decide what still needs to be finished or verified.`);
1538
+ } else if (focusFiles.length > 0) {
1539
+ hints.push(`Start by reading ${focusFiles.map((filePath) => `\`${filePath}\``).join(", ")} to reconstruct the current working set.`);
1540
+ }
1541
+ if (recentCommands.length > 0) {
1542
+ hints.push("Rerun or extend the recent checks to confirm the current state before making further changes.");
1543
+ }
1544
+ if (focusFiles.length > 0) {
1545
+ hints.push("Run `git diff --` on the active files to see the exact in-progress changes before editing further.");
1546
+ }
1547
+ if (hints.length === 0) {
1548
+ hints.push("Inspect the active files and run `git diff` to determine the next concrete implementation step.");
1549
+ }
1550
+ return hints;
1551
+ }
1552
+ function selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete) {
1553
+ if (sessionAppearsComplete) return focusedMessages;
1554
+ const hasAssistantActivity = focusedMessages.some(
1555
+ (message) => message.role === "assistant" && (message.content.trim() || message.toolCalls.length > 0)
1556
+ );
1557
+ if (focusedMessages.length >= 3 && hasAssistantActivity) return focusedMessages;
1558
+ const filtered = allMessages.filter((message) => {
1559
+ if (message.role === "assistant" && message.content.trim() && isMetaQualityAssistantMessage(message.content)) {
1560
+ return false;
1561
+ }
1562
+ if (message.role === "user" && message.content && isNoiseMessage(message.content)) {
1563
+ return false;
1564
+ }
1565
+ return Boolean(message.content.trim()) || message.toolCalls.length > 0;
1566
+ });
1567
+ return filtered.slice(-8);
1568
+ }
1569
+ function buildSessionHistory(focusedMessages, allMessages, sessionAppearsComplete) {
1570
+ const historyMessages = selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete);
1571
+ const entries = historyMessages.map((message) => {
1572
+ const parts = [];
1573
+ if (message.content.trim()) {
1574
+ if (message.role === "assistant" && isMetaQualityAssistantMessage(message.content)) {
1575
+ return null;
1576
+ }
1577
+ parts.push(compactText(message.content, 220));
1578
+ }
1579
+ if (message.role === "assistant" && message.toolCalls.length > 0) {
1580
+ parts.push(`[tools] ${message.toolCalls.slice(-4).map(summarizeToolCall).join(", ")}`);
1581
+ }
1582
+ if (parts.length === 0) return null;
1583
+ return `${message.role.toUpperCase()}: ${parts.join(" | ")}`;
1584
+ }).filter((entry) => Boolean(entry));
1585
+ if (entries.length <= 8) return entries;
1586
+ return [entries[0], "...", ...entries.slice(-6)];
1587
+ }
1588
+ function summarizeCommand(command) {
1589
+ return compactText(command.replace(/\s+/g, " ").trim(), 140);
1590
+ }
1591
+ function extractRecentCommands(commands) {
1592
+ return unique(commands.map(summarizeCommand)).slice(-6);
1593
+ }
1594
+ function extractStatusPaths(status) {
1595
+ if (!status) return [];
1596
+ return status.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
1597
+ const renamed = line.includes(" -> ") ? line.split(" -> ").at(-1)?.trim() || "" : "";
1598
+ if (renamed) {
1599
+ return renamed.replace(/^(?:\?\?|[A-Z?!]{1,2})\s+/, "");
1600
+ }
1601
+ const match = line.match(/^(?:\?\?|[A-Z?!]{1,2})\s+(.*)$/);
1602
+ return match?.[1]?.trim() || line;
1603
+ }).filter(Boolean);
1604
+ }
1605
+ function extractFocusFiles(ctx, work) {
1606
+ return unique([
1607
+ ...work.filesModified,
1608
+ ...extractStatusPaths(ctx.gitContext.status),
1609
+ ...ctx.gitContext.untracked.map((file) => file.path)
1610
+ ]).slice(0, 6);
1611
+ }
1237
1612
  function buildRawPrompt(ctx, options = {}) {
1238
- const userMessages = filterUserMessages(ctx.messages);
1239
- const errors = extractUnresolvedErrors(ctx.messages);
1240
- const decisions = extractKeyDecisions(ctx.messages);
1613
+ const focused = findFocusedWindow(ctx.messages);
1614
+ const userMessages = filterUserMessages(focused.messages);
1615
+ const errors = extractUnresolvedErrors(focused.messages);
1616
+ const decisions = extractKeyDecisions(focused.messages);
1617
+ const work = extractWorkSummary(focused.messages);
1618
+ const focusFiles = extractFocusFiles(ctx, work);
1619
+ const recentCommands = extractRecentCommands(work.commands);
1620
+ const lastAssistantAnswer = extractLastAssistantAnswer(focused.messages);
1621
+ const currentStatus = buildCurrentStatus(focused.messages, errors, focused.sessionAppearsComplete);
1622
+ const remainingWorkHints = buildRemainingWorkHints({
1623
+ sessionAppearsComplete: focused.sessionAppearsComplete,
1624
+ errors,
1625
+ work,
1626
+ focusFiles,
1627
+ recentCommands
1628
+ });
1629
+ const sessionHistory = buildSessionHistory(focused.messages, ctx.messages, focused.sessionAppearsComplete);
1241
1630
  let prompt = "";
1242
1631
  prompt += "# Task\n\n";
1243
1632
  prompt += `Project: \`${ctx.sessionCwd}\`
1244
1633
  `;
1245
1634
  if (ctx.branch) prompt += `Branch: \`${ctx.branch}\`
1246
1635
  `;
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";
1636
+ if (focused.sessionAppearsComplete) {
1637
+ prompt += "\nThe latest exchange in this session appears complete. ";
1638
+ prompt += "Use the focused context below only if the user wants to continue from that point.\n\n";
1639
+ } else {
1640
+ prompt += "\nThis is a continuation of an interrupted AI coding session. ";
1641
+ prompt += "The previous agent was working on the task below. Pick up where it left off.\n\n";
1642
+ }
1643
+ prompt += `## What The User Asked (${focused.sessionAppearsComplete ? "recent focus" : "chronological"})
1644
+
1645
+ `;
1250
1646
  for (const msg of userMessages) {
1251
1647
  prompt += `- ${compactText(msg, 500)}
1252
1648
  `;
1253
1649
  }
1254
1650
  prompt += "\n";
1651
+ if (focused.sessionAppearsComplete && lastAssistantAnswer) {
1652
+ prompt += "## Last Answer Already Given\n\n";
1653
+ prompt += `- ${lastAssistantAnswer}
1654
+
1655
+ `;
1656
+ }
1657
+ prompt += "## Current Status\n\n";
1658
+ prompt += `- Status: ${currentStatus.status}
1659
+ `;
1660
+ if (currentStatus.lastStep) {
1661
+ prompt += `- Last active step: ${currentStatus.lastStep}
1662
+ `;
1663
+ }
1664
+ if (currentStatus.lastToolActions.length > 0) {
1665
+ prompt += `- Last tool actions: ${currentStatus.lastToolActions.join(", ")}
1666
+ `;
1667
+ }
1668
+ prompt += "\n";
1669
+ if (sessionHistory.length > 0) {
1670
+ prompt += "## Session History\n\n";
1671
+ for (const entry of sessionHistory) {
1672
+ prompt += `- ${entry}
1673
+ `;
1674
+ }
1675
+ prompt += "\n";
1676
+ }
1255
1677
  if (errors.length > 0) {
1256
1678
  prompt += "## DO NOT REPEAT \u2014 Unresolved Errors\n\n";
1257
1679
  prompt += "These errors occurred and were NOT fixed. Avoid the same approaches.\n\n";
@@ -1269,39 +1691,59 @@ function buildRawPrompt(ctx, options = {}) {
1269
1691
  }
1270
1692
  prompt += "\n";
1271
1693
  }
1272
- prompt += "## Work Already Completed\n\n";
1273
- if (unique(ctx.filesModified).length > 0) {
1694
+ if (work.filesModified.length > 0) {
1695
+ prompt += "## Work Already Completed\n\n";
1696
+ }
1697
+ if (work.filesModified.length > 0) {
1274
1698
  prompt += "**Files modified:**\n";
1275
- for (const filePath of unique(ctx.filesModified)) {
1699
+ for (const filePath of unique(work.filesModified)) {
1276
1700
  prompt += `- \`${filePath}\`
1277
1701
  `;
1278
1702
  }
1279
1703
  prompt += "\n";
1280
1704
  }
1281
- if (ctx.gitContext.recentCommits) {
1705
+ if (!focused.sessionAppearsComplete && work.filesModified.length > 0 && ctx.gitContext.recentCommits) {
1282
1706
  prompt += "**Recent commits:**\n```\n";
1283
1707
  prompt += `${ctx.gitContext.recentCommits}
1284
1708
  `;
1285
1709
  prompt += "```\n\n";
1286
1710
  }
1287
- if (ctx.gitContext.committedDiff) {
1711
+ if (!focused.sessionAppearsComplete && work.filesModified.length > 0 && ctx.gitContext.committedDiff) {
1288
1712
  prompt += "**Files changed in recent commits:**\n```\n";
1289
1713
  prompt += `${ctx.gitContext.committedDiff}
1290
1714
  `;
1291
1715
  prompt += "```\n\n";
1292
1716
  }
1717
+ if (recentCommands.length > 0) {
1718
+ prompt += "## Recent Commands / Checks\n\n";
1719
+ for (const command of recentCommands) {
1720
+ prompt += `- \`${command}\`
1721
+ `;
1722
+ }
1723
+ prompt += "\n";
1724
+ }
1725
+ if (focusFiles.length > 0) {
1726
+ prompt += "## Read These Files First\n\n";
1727
+ for (const filePath of focusFiles) {
1728
+ prompt += `- \`${filePath}\`
1729
+ `;
1730
+ }
1731
+ prompt += "\n";
1732
+ }
1733
+ if (remainingWorkHints.length > 0) {
1734
+ prompt += "## Likely Remaining Work\n\n";
1735
+ for (const hint of remainingWorkHints) {
1736
+ prompt += `- ${hint}
1737
+ `;
1738
+ }
1739
+ prompt += "\n";
1740
+ }
1293
1741
  const git = ctx.gitContext;
1294
1742
  if (git.isGitRepo && git.hasChanges) {
1295
1743
  prompt += "## Uncommitted Changes\n\n";
1296
1744
  if (git.status) {
1297
1745
  prompt += "```\n" + git.status + "\n```\n\n";
1298
1746
  }
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
1747
  if (git.untracked.length > 0) {
1306
1748
  const shown = git.untracked.slice(0, 6);
1307
1749
  prompt += "**Untracked files:**\n";
@@ -1317,19 +1759,30 @@ function buildRawPrompt(ctx, options = {}) {
1317
1759
  }
1318
1760
  }
1319
1761
  prompt += "## Your Instructions\n\n";
1320
- prompt += `${buildTargetGuidance(options.target)}
1762
+ if (focused.sessionAppearsComplete) {
1763
+ prompt += "The latest thread appears finished. Do not resume older tasks unless the user explicitly asks for them.\n\n";
1764
+ prompt += "1. **Start from the recent focus above** \u2014 ignore stale history unless the user points back to it.\n";
1765
+ prompt += "2. **Use the last answer as prior context** \u2014 avoid restating or redoing already completed work.\n";
1766
+ prompt += `3. **Inspect the workspace only as needed** \u2014 respond to follow-up questions or new work from the current repo state${focusFiles.length > 0 ? `, starting with ${focusFiles.map((filePath) => `\`${filePath}\``).join(", ")}` : ""}.
1767
+ `;
1768
+ } else {
1769
+ prompt += `${buildTargetGuidance(options.target)}
1321
1770
 
1322
1771
  `;
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.
1772
+ prompt += `1. **Read the active files first** \u2014 verify their current state before changing anything${focusFiles.length > 0 ? `: ${focusFiles.map((filePath) => `\`${filePath}\``).join(", ")}` : ""}.
1773
+ `;
1774
+ if (errors.length > 0) {
1775
+ prompt += "2. **Check the errors above** \u2014 do NOT repeat failed approaches. Try a different strategy.\n";
1776
+ }
1777
+ 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.
1328
1778
  `;
1329
- prompt += `${errors.length > 0 ? "4" : "3"}. **Do the remaining work** \u2014 pick up exactly where the previous agent stopped.
1779
+ 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.
1330
1780
  `;
1331
- prompt += `${errors.length > 0 ? "5" : "4"}. **Verify** \u2014 run tests/builds to confirm everything works.
1781
+ 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.
1332
1782
  `;
1783
+ prompt += `${errors.length > 0 ? "6" : "5"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
1784
+ `;
1785
+ }
1333
1786
  return prompt;
1334
1787
  }
1335
1788
  function buildRefinementDump(ctx, options = {}) {
@@ -1828,7 +2281,7 @@ async function promptForSource() {
1828
2281
  }
1829
2282
  async function main(argv = process.argv.slice(2)) {
1830
2283
  const options = parseArgs(argv);
1831
- const pkgInfo = { name: "ctx-switch", version: "2.0.4" };
2284
+ const pkgInfo = { name: "ctx-switch", version: "2.0.6" };
1832
2285
  const ui = createTheme(process.stderr);
1833
2286
  if (options.help) {
1834
2287
  process.stdout.write(`${getHelpText(pkgInfo)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx-switch",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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",