ctx-switch 2.0.5 → 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 +218 -19
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -829,7 +829,10 @@ import { execFileSync as execFileSync2 } from "node:child_process";
829
829
  import os4 from "node:os";
830
830
  import path6 from "node:path";
831
831
  var OPENCODE_DB_PATH = path6.join(os4.homedir(), ".local", "share", "opencode", "opencode.db");
832
- 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()) {
833
836
  try {
834
837
  const stdout = execFileSync2("sqlite3", ["-json", dbPath, query], {
835
838
  encoding: "utf8",
@@ -882,7 +885,31 @@ function resolveSessionPath3(selection, cwd) {
882
885
  const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
883
886
  return match ? match.id : null;
884
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
+ }
885
908
  function parseSession3(sessionId) {
909
+ return parseSessionInternal(sessionId, /* @__PURE__ */ new Set());
910
+ }
911
+ function parseSessionInternal(sessionId, seenSessions) {
912
+ seenSessions.add(sessionId);
886
913
  const messages = [];
887
914
  const meta = {
888
915
  cwd: null,
@@ -918,6 +945,7 @@ function parseSession3(sessionId) {
918
945
  if (!role || role !== "user" && role !== "assistant") continue;
919
946
  const textParts = [];
920
947
  const toolCalls = [];
948
+ const delegatedAssistantMessages = [];
921
949
  for (const partRow of partRows) {
922
950
  let part;
923
951
  try {
@@ -930,9 +958,17 @@ function parseSession3(sessionId) {
930
958
  } else if (part.type === "tool" && part.tool) {
931
959
  const input = part.state?.input || {};
932
960
  const output = part.state?.output || "";
933
- const isError = part.state?.status === "error" || /error|Error|ERROR/.test(output.slice(0, 200));
961
+ const isError = detectToolError(part.tool, part.state?.status, output);
934
962
  const toolName = normalizeToolName(part.tool);
935
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
+ }
936
972
  toolCalls.push({
937
973
  id: part.callID || null,
938
974
  tool: toolName,
@@ -950,11 +986,15 @@ function parseSession3(sessionId) {
950
986
  toolCalls,
951
987
  timestamp: null
952
988
  });
989
+ if (role === "assistant" && delegatedAssistantMessages.length > 0) {
990
+ messages.push(...delegatedAssistantMessages);
991
+ }
953
992
  }
954
993
  return { messages, meta };
955
994
  }
956
995
  function normalizeToolName(tool) {
957
996
  const mapping = {
997
+ apply_patch: "apply_patch",
958
998
  read: "read",
959
999
  edit: "edit",
960
1000
  write: "write",
@@ -970,10 +1010,14 @@ function normalizeToolName(tool) {
970
1010
  return mapping[tool.toLowerCase()] || tool;
971
1011
  }
972
1012
  function normalizeInput(tool, input) {
973
- if (input.filePath && !input.file_path) {
974
- return { ...input, file_path: input.filePath };
1013
+ const normalized = { ...input };
1014
+ if (normalized.filePath && !normalized.file_path) {
1015
+ normalized.file_path = normalized.filePath;
975
1016
  }
976
- return input;
1017
+ if (tool === "apply_patch" && typeof normalized.patchText === "string" && !normalized.patch) {
1018
+ normalized.patch = normalized.patchText;
1019
+ }
1020
+ return normalized;
977
1021
  }
978
1022
 
979
1023
  // src/session.ts
@@ -1040,6 +1084,23 @@ function extractParsedCommands(input) {
1040
1084
  if (!Array.isArray(parsed)) return [];
1041
1085
  return parsed.filter((entry) => Boolean(entry) && typeof entry === "object");
1042
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
+ }
1043
1104
  function buildSessionContext({
1044
1105
  messages,
1045
1106
  meta,
@@ -1059,6 +1120,7 @@ function buildSessionContext({
1059
1120
  const command = extractCommand(toolCall.input);
1060
1121
  const patchPaths = extractPatchFilePaths(toolCall.input, cwd);
1061
1122
  const parsedCommands = extractParsedCommands(toolCall.input);
1123
+ const commandReadPaths = command ? extractReadPathsFromCommand(command, cwd) : [];
1062
1124
  if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
1063
1125
  filesModified.add(resolveSessionFilePath(filePath, cwd));
1064
1126
  } else if (filePath && /(read|grep|glob|search)/.test(toolName)) {
@@ -1071,6 +1133,9 @@ function buildSessionContext({
1071
1133
  if (parsedCommand.type !== "read" || typeof parsedCommand.path !== "string") continue;
1072
1134
  filesRead.add(resolveSessionFilePath(parsedCommand.path, cwd));
1073
1135
  }
1136
+ for (const readPath of commandReadPaths) {
1137
+ filesRead.add(readPath);
1138
+ }
1074
1139
  if (command && /(bash|command|run|exec_command)/.test(toolName)) {
1075
1140
  commands.push(command);
1076
1141
  }
@@ -1268,7 +1333,7 @@ function extractCommand2(input) {
1268
1333
  function buildTargetGuidance(target) {
1269
1334
  switch (target) {
1270
1335
  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.";
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.";
1272
1337
  case "codex":
1273
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.";
1274
1339
  case "cursor":
@@ -1276,7 +1341,7 @@ function buildTargetGuidance(target) {
1276
1341
  case "chatgpt":
1277
1342
  return "The next agent is ChatGPT. It should reason from the current workspace state, explain what remains, and provide explicit next actions.";
1278
1343
  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.";
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.";
1280
1345
  }
1281
1346
  }
1282
1347
  function isNoiseMessage(text) {
@@ -1316,6 +1381,10 @@ function isReferentialMessage(text) {
1316
1381
  if (!normalized || normalized.length > 220) return false;
1317
1382
  return /^(ok|okay|alright|now|so)\b/.test(normalized) || /\b(it|that|again|better|same|continue|still|also|another|more)\b/.test(normalized);
1318
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
+ }
1319
1388
  function filterUserMessages(messages) {
1320
1389
  const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim());
1321
1390
  if (all.length <= 2) return all;
@@ -1344,10 +1413,10 @@ function extractKeyDecisions(messages) {
1344
1413
  const decisions = [];
1345
1414
  for (const msg of messages) {
1346
1415
  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)) {
1416
+ if (isMetaQualityAssistantMessage(msg.content)) {
1349
1417
  continue;
1350
1418
  }
1419
+ const lower = msg.content.toLowerCase();
1351
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)) {
1352
1421
  decisions.push(compactText(msg.content, 300));
1353
1422
  }
@@ -1419,6 +1488,103 @@ function extractLastAssistantAnswer(messages) {
1419
1488
  }
1420
1489
  return null;
1421
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
+ }
1422
1588
  function summarizeCommand(command) {
1423
1589
  return compactText(command.replace(/\s+/g, " ").trim(), 140);
1424
1590
  }
@@ -1452,6 +1618,15 @@ function buildRawPrompt(ctx, options = {}) {
1452
1618
  const focusFiles = extractFocusFiles(ctx, work);
1453
1619
  const recentCommands = extractRecentCommands(work.commands);
1454
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);
1455
1630
  let prompt = "";
1456
1631
  prompt += "# Task\n\n";
1457
1632
  prompt += `Project: \`${ctx.sessionCwd}\`
@@ -1479,6 +1654,26 @@ function buildRawPrompt(ctx, options = {}) {
1479
1654
 
1480
1655
  `;
1481
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
+ }
1482
1677
  if (errors.length > 0) {
1483
1678
  prompt += "## DO NOT REPEAT \u2014 Unresolved Errors\n\n";
1484
1679
  prompt += "These errors occurred and were NOT fixed. Avoid the same approaches.\n\n";
@@ -1531,6 +1726,14 @@ function buildRawPrompt(ctx, options = {}) {
1531
1726
  prompt += "## Read These Files First\n\n";
1532
1727
  for (const filePath of focusFiles) {
1533
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}
1534
1737
  `;
1535
1738
  }
1536
1739
  prompt += "\n";
@@ -1541,12 +1744,6 @@ function buildRawPrompt(ctx, options = {}) {
1541
1744
  if (git.status) {
1542
1745
  prompt += "```\n" + git.status + "\n```\n\n";
1543
1746
  }
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
1747
  if (git.untracked.length > 0) {
1551
1748
  const shown = git.untracked.slice(0, 6);
1552
1749
  prompt += "**Untracked files:**\n";
@@ -1577,11 +1774,13 @@ function buildRawPrompt(ctx, options = {}) {
1577
1774
  if (errors.length > 0) {
1578
1775
  prompt += "2. **Check the errors above** \u2014 do NOT repeat failed approaches. Try a different strategy.\n";
1579
1776
  }
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.
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.
1778
+ `;
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.
1581
1780
  `;
1582
- prompt += `${errors.length > 0 ? "4" : "3"}. **Do the remaining work** \u2014 pick up exactly where the previous agent stopped.
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.
1583
1782
  `;
1584
- prompt += `${errors.length > 0 ? "5" : "4"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
1783
+ prompt += `${errors.length > 0 ? "6" : "5"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
1585
1784
  `;
1586
1785
  }
1587
1786
  return prompt;
@@ -2082,7 +2281,7 @@ async function promptForSource() {
2082
2281
  }
2083
2282
  async function main(argv = process.argv.slice(2)) {
2084
2283
  const options = parseArgs(argv);
2085
- const pkgInfo = { name: "ctx-switch", version: "2.0.5" };
2284
+ const pkgInfo = { name: "ctx-switch", version: "2.0.6" };
2086
2285
  const ui = createTheme(process.stderr);
2087
2286
  if (options.help) {
2088
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.5",
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",