@golba98/codexa 1.0.1

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 (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,143 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { afterEach, beforeEach, describe } from "node:test";
3
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+ import { resolvePlanDir, savePlan, readPlan } from "./planStorage.js";
7
+
8
+ describe("resolvePlanDir", () => {
9
+ const savedEnv: Record<string, string | undefined> = {};
10
+ const envKeys = ["CODEXA_PLAN_DIR", "LOCALAPPDATA", "APPDATA", "XDG_DATA_HOME"];
11
+
12
+ beforeEach(() => {
13
+ for (const key of envKeys) {
14
+ savedEnv[key] = process.env[key];
15
+ }
16
+ });
17
+
18
+ afterEach(() => {
19
+ for (const key of envKeys) {
20
+ if (savedEnv[key] === undefined) {
21
+ delete process.env[key];
22
+ } else {
23
+ process.env[key] = savedEnv[key];
24
+ }
25
+ }
26
+ });
27
+
28
+ test("CODEXA_PLAN_DIR override takes priority", () => {
29
+ process.env["CODEXA_PLAN_DIR"] = "/custom/plan/dir";
30
+ assert.equal(resolvePlanDir(), "/custom/plan/dir");
31
+ });
32
+
33
+ test("Windows LOCALAPPDATA path resolution", () => {
34
+ delete process.env["CODEXA_PLAN_DIR"];
35
+ process.env["LOCALAPPDATA"] = "C:\\Users\\test\\AppData\\Local";
36
+ const result = resolvePlanDir("win32");
37
+ assert.ok(result.includes("Codexa"));
38
+ assert.ok(result.includes("plans"));
39
+ assert.ok(result.includes("AppData"));
40
+ });
41
+
42
+ test("Windows APPDATA fallback", () => {
43
+ delete process.env["CODEXA_PLAN_DIR"];
44
+ delete process.env["LOCALAPPDATA"];
45
+ process.env["APPDATA"] = "C:\\Users\\test\\AppData\\Roaming";
46
+ const result = resolvePlanDir("win32");
47
+ assert.ok(result.includes("Codexa"));
48
+ assert.ok(result.includes("plans"));
49
+ assert.ok(result.includes("Roaming"));
50
+ });
51
+
52
+ test("macOS path", () => {
53
+ delete process.env["CODEXA_PLAN_DIR"];
54
+ const result = resolvePlanDir("darwin");
55
+ assert.ok(result.includes("Library"));
56
+ assert.ok(result.includes("Codexa"));
57
+ assert.ok(result.includes("plans"));
58
+ });
59
+
60
+ test("Linux default path", () => {
61
+ delete process.env["CODEXA_PLAN_DIR"];
62
+ delete process.env["XDG_DATA_HOME"];
63
+ const result = resolvePlanDir("linux");
64
+ assert.ok(result.includes(".local"));
65
+ assert.ok(result.includes("share"));
66
+ assert.ok(result.includes("codexa"));
67
+ assert.ok(result.includes("plans"));
68
+ });
69
+
70
+ test("Linux XDG_DATA_HOME override", () => {
71
+ delete process.env["CODEXA_PLAN_DIR"];
72
+ process.env["XDG_DATA_HOME"] = "/custom/xdg/data";
73
+ const result = resolvePlanDir("linux");
74
+ assert.ok(result.includes("custom"));
75
+ assert.ok(result.includes("xdg"));
76
+ assert.ok(result.includes("codexa"));
77
+ assert.ok(result.includes("plans"));
78
+ });
79
+ });
80
+
81
+ describe("savePlan", () => {
82
+ let tempDir: string;
83
+
84
+ beforeEach(() => {
85
+ tempDir = join(tmpdir(), `planStorage-test-${Date.now()}`);
86
+ mkdirSync(tempDir, { recursive: true });
87
+ process.env["CODEXA_PLAN_DIR"] = tempDir;
88
+ });
89
+
90
+ afterEach(() => {
91
+ delete process.env["CODEXA_PLAN_DIR"];
92
+ rmSync(tempDir, { recursive: true, force: true });
93
+ });
94
+
95
+ test("safe filename format with timestamp and 8-hex-char hash", () => {
96
+ const result = savePlan("# My Plan", "/some/workspace");
97
+ assert.notEqual(result, null);
98
+ const filename = result!.split(/[/\\]/).pop()!;
99
+ assert.match(filename, /^plan-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}.*-[0-9a-f]{8}\.md$/);
100
+ });
101
+
102
+ test("written content can be read back", () => {
103
+ const content = "# Test Plan\n\n- Step 1\n- Step 2\n";
104
+ const result = savePlan(content, "/workspace");
105
+ assert.notEqual(result, null);
106
+ assert.ok(existsSync(result!));
107
+ assert.equal(readPlan(result!), content);
108
+ });
109
+
110
+ test("write failure returns null", () => {
111
+ process.env["CODEXA_PLAN_DIR"] = "/nonexistent\x00/bad/path";
112
+ const result = savePlan("content", "/workspace");
113
+ assert.equal(result, null);
114
+ });
115
+
116
+ test("does not write to process.cwd()", () => {
117
+ const cwdPlanDir = join(process.cwd(), ".codexa");
118
+ const beforeMarkdown = existsSync(cwdPlanDir)
119
+ ? readdirSync(cwdPlanDir).filter((name) => name.endsWith(".md")).sort()
120
+ : [];
121
+ savePlan("# Plan", process.cwd());
122
+ const afterMarkdown = existsSync(cwdPlanDir)
123
+ ? readdirSync(cwdPlanDir).filter((name) => name.endsWith(".md")).sort()
124
+ : [];
125
+ assert.deepEqual(afterMarkdown, beforeMarkdown);
126
+ });
127
+ });
128
+
129
+ describe("readPlan", () => {
130
+ test("returns null for missing file", () => {
131
+ assert.equal(readPlan("/nonexistent/file.md"), null);
132
+ });
133
+
134
+ test("reads existing file content", () => {
135
+ const tempFile = join(tmpdir(), `plan-read-test-${Date.now()}.md`);
136
+ writeFileSync(tempFile, "hello plan", "utf-8");
137
+ try {
138
+ assert.equal(readPlan(tempFile), "hello plan");
139
+ } finally {
140
+ rmSync(tempFile, { force: true });
141
+ }
142
+ });
143
+ });
@@ -0,0 +1,141 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import { isNoiseLine } from "./providers/codexTranscript.js";
6
+ import { sanitizeTerminalOutput } from "./terminal/terminalSanitize.js";
7
+
8
+ type Platform = "win32" | "darwin" | "linux" | string;
9
+
10
+ const SECTION_LINE_RE = /^\s*(?:#{1,3}\s+)?(?:\*\*)?([A-Za-z][A-Za-z0-9 /&-]{0,48})(?:\*\*)?:?\s*$/;
11
+ const ABSOLUTE_WINDOWS_PATH_RE = /[A-Za-z]:[\\/][^\s`),;\]]+/g;
12
+
13
+ function normalizePathSeparators(value: string): string {
14
+ return value.replace(/\\/g, "/");
15
+ }
16
+
17
+ function replaceAllLiteral(value: string, search: string, replacement: string): string {
18
+ if (!search) return value;
19
+ return value.split(search).join(replacement);
20
+ }
21
+
22
+ /**
23
+ * Strips absolute filesystem paths from plan text, replacing them with
24
+ * relative paths or truncated versions to protect user privacy.
25
+ */
26
+ export function hidePlanReviewFilesystemDetails(planText: string, workspaceRoot?: string | null): string {
27
+ let output = planText;
28
+ const normalizedRoot = workspaceRoot?.trim() ? normalizePathSeparators(workspaceRoot.trim()).replace(/\/+$/, "") : "";
29
+
30
+ if (normalizedRoot) {
31
+ output = replaceAllLiteral(output, workspaceRoot!.replace(/\\+$/, ""), "");
32
+ output = replaceAllLiteral(normalizePathSeparators(output), normalizedRoot, "");
33
+ output = output.replace(/(^|[\s(`])\/+([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)/g, "$1$2");
34
+ }
35
+
36
+ return output.replace(ABSOLUTE_WINDOWS_PATH_RE, (match) => {
37
+ const normalized = normalizePathSeparators(match);
38
+ const srcIndex = normalized.search(/(?:^|\/)(src|test|tests|docs|scripts|bin)\//);
39
+ if (srcIndex >= 0) {
40
+ return normalized.slice(normalized[srcIndex] === "/" ? srcIndex + 1 : srcIndex);
41
+ }
42
+ const parts = normalized.split("/").filter(Boolean);
43
+ return parts.slice(-2).join("/") || match;
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Normalizes plan markdown for consistent rendering, converting bold labels
49
+ * into proper headings and hiding filesystem details.
50
+ */
51
+ export function normalizePlanReviewMarkdown(planText: string, workspaceRoot?: string | null): string {
52
+ const sanitized = sanitizeTerminalOutput(hidePlanReviewFilesystemDetails(planText, workspaceRoot), {
53
+ preserveTabs: false,
54
+ tabSize: 2,
55
+ })
56
+ .replace(/\r\n/g, "\n")
57
+ .replace(/\r/g, "\n")
58
+ .replace(/\n{4,}/g, "\n\n\n")
59
+ .split("\n")
60
+ .filter((line) => !isNoiseLine(line))
61
+ .join("\n");
62
+
63
+ return sanitized
64
+ .split("\n")
65
+ .map((line) => {
66
+ const trimmed = line.trim();
67
+ const sectionMatch = SECTION_LINE_RE.exec(trimmed);
68
+ if (sectionMatch && !/^[-*]\s+/.test(trimmed) && !/^\d+\.\s+/.test(trimmed)) {
69
+ return `## ${sectionMatch[1]!.trim()}`;
70
+ }
71
+ return line;
72
+ })
73
+ .join("\n");
74
+ }
75
+
76
+ /**
77
+ * Resolve the directory where plan files are stored.
78
+ * Uses platform-appropriate app-data locations instead of the workspace.
79
+ */
80
+ export function resolvePlanDir(platformOverride?: Platform): string {
81
+ const envDir = process.env["CODEXA_PLAN_DIR"];
82
+ if (envDir) return envDir;
83
+
84
+ const platform = platformOverride ?? process.platform;
85
+
86
+ if (platform === "win32") {
87
+ const localAppData = process.env["LOCALAPPDATA"];
88
+ if (localAppData) return join(localAppData, "Codexa", "plans");
89
+ const appData = process.env["APPDATA"];
90
+ if (appData) return join(appData, "Codexa", "plans");
91
+ return join(homedir(), "AppData", "Local", "Codexa", "plans");
92
+ }
93
+
94
+ if (platform === "darwin") {
95
+ return join(homedir(), "Library", "Application Support", "Codexa", "plans");
96
+ }
97
+
98
+ // Linux and other Unix-like
99
+ const xdgDataHome = process.env["XDG_DATA_HOME"];
100
+ if (xdgDataHome) return join(xdgDataHome, "codexa", "plans");
101
+ return join(homedir(), ".local", "share", "codexa", "plans");
102
+ }
103
+
104
+ // SHA-256 of the workspace path ensures filename uniqueness across projects with the same name.
105
+ function workspaceHash(workspaceRoot: string): string {
106
+ return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 8);
107
+ }
108
+
109
+ function safeTimestamp(): string {
110
+ return new Date().toISOString().replace(/[:.]/g, "-");
111
+ }
112
+
113
+ /**
114
+ * Save plan content to the app-data plan directory.
115
+ * Returns the written file path, or null on failure.
116
+ */
117
+ export function savePlan(content: string, workspaceRoot: string): string | null {
118
+ try {
119
+ const dir = resolvePlanDir();
120
+ mkdirSync(dir, { recursive: true });
121
+ const filename = `plan-${safeTimestamp()}-${workspaceHash(workspaceRoot)}.md`;
122
+ const filePath = join(dir, filename);
123
+ writeFileSync(filePath, content, "utf-8");
124
+ return filePath;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Read plan content from a file path.
132
+ * Returns the content string, or null on failure.
133
+ */
134
+ export function readPlan(filePath: string): string | null {
135
+ try {
136
+ if (!existsSync(filePath)) return null;
137
+ return readFileSync(filePath, "utf-8");
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+ import { runCommand, runShellCommand, summarizeCommandResult, type CommandResult } from "./CommandRunner.js";
6
+
7
+ function makeResult(overrides: Partial<CommandResult> = {}): CommandResult {
8
+ return {
9
+ status: "completed",
10
+ exitCode: 0,
11
+ signal: null,
12
+ stdout: "",
13
+ stderr: "",
14
+ startedAt: 1,
15
+ endedAt: 2,
16
+ durationMs: 1,
17
+ userMessage: "Command completed.",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ test("summarizes ripgrep file listings without flooding the UI", () => {
23
+ const result = makeResult({
24
+ stdout: "src/app.tsx\nsrc/ui/BottomComposer.tsx\n",
25
+ });
26
+
27
+ assert.equal(summarizeCommandResult("rg --files", result), "Found 2 files.");
28
+ });
29
+
30
+ test("keeps a concise fallback summary for generic successful commands", () => {
31
+ const result = makeResult({
32
+ stdout: "alpha\nbeta\ngamma\n",
33
+ });
34
+
35
+ assert.equal(summarizeCommandResult("node script.js", result), "Produced 3 lines of output.");
36
+ });
37
+
38
+ test("preserves the failure message for unsuccessful commands", () => {
39
+ const result = makeResult({
40
+ status: "failed",
41
+ exitCode: 1,
42
+ userMessage: "git exited with code 1.",
43
+ stderr: "fatal: not a git repository",
44
+ });
45
+
46
+ assert.equal(summarizeCommandResult("git status", result), "git exited with code 1.");
47
+ });
48
+
49
+ test("runCommand executes a direct executable with argument array", async () => {
50
+ const runner = runCommand({
51
+ executable: process.execPath,
52
+ args: ["-e", "console.log(process.argv[1])", "direct-ok"],
53
+ cwd: process.cwd(),
54
+ });
55
+
56
+ const result = await runner.result;
57
+ assert.equal(result.status, "completed");
58
+ assert.equal(result.exitCode, 0);
59
+ assert.equal(result.stdout.trim(), "direct-ok");
60
+ });
61
+
62
+ test("runCommand rejects obvious executable injection", () => {
63
+ assert.throws(
64
+ () => runCommand({
65
+ executable: "node & echo injected",
66
+ args: ["--version"],
67
+ cwd: process.cwd(),
68
+ }),
69
+ /single executable name|shell metacharacters/i,
70
+ );
71
+ });
72
+
73
+ test("runShellCommand is the explicit shell execution path", async () => {
74
+ const runner = runShellCommand("echo shell-ok", { cwd: process.cwd() });
75
+ const result = await runner.result;
76
+
77
+ assert.equal(result.status, "completed");
78
+ assert.equal(result.exitCode, 0);
79
+ assert.match(result.stdout, /shell-ok/);
80
+ });
81
+
82
+ test("command runner reports lifecycle boundaries for terminal title reassertion", () => {
83
+ const source = readFileSync(fileURLToPath(new URL("./CommandRunner.ts", import.meta.url)), "utf8");
84
+ const beforeSpawnIndex = source.indexOf('handlers.onProcessLifecycle?.("before-spawn")');
85
+ const spawnIndex = source.indexOf("child = spawn(");
86
+ const spawnedIndex = source.indexOf('handlers.onProcessLifecycle?.("spawned")', spawnIndex);
87
+ const errorIndex = source.indexOf('child.once("error"', spawnIndex);
88
+ const lifecycleErrorIndex = source.indexOf('handlers.onProcessLifecycle?.("error")', errorIndex);
89
+ const closeIndex = source.indexOf('child.once("close"', spawnIndex);
90
+ const exitIndex = source.indexOf('handlers.onProcessLifecycle?.("exit")', closeIndex);
91
+ const cancelIndex = source.indexOf("cancel: () =>");
92
+ const lifecycleCancelIndex = source.indexOf('handlers.onProcessLifecycle?.("cancel")', cancelIndex);
93
+
94
+ assert.ok(beforeSpawnIndex >= 0 && beforeSpawnIndex < spawnIndex);
95
+ assert.ok(spawnedIndex > spawnIndex);
96
+ assert.ok(lifecycleErrorIndex > errorIndex);
97
+ assert.ok(exitIndex > closeIndex);
98
+ assert.ok(lifecycleCancelIndex > cancelIndex);
99
+ });
100
+
101
+ test("generic command runner does not expose shell mode", () => {
102
+ const source = readFileSync(fileURLToPath(new URL("./CommandRunner.ts", import.meta.url)), "utf8");
103
+ assert.equal(source.includes("shell?: boolean"), false);
104
+ assert.equal(source.includes("spec.shell"), false);
105
+ });
@@ -0,0 +1,269 @@
1
+ import { spawn, type ChildProcess } from "child_process";
2
+ import { sanitizeTerminalOutput } from "../terminal/terminalSanitize.js";
3
+ import { createTerminalTitleSequenceStripper } from "../terminal/terminalTitle.js";
4
+ import { validateExecutableForSpawn } from "./processValidation.js";
5
+
6
+ export interface CommandSpec {
7
+ executable: string;
8
+ args: string[];
9
+ cwd: string;
10
+ env?: NodeJS.ProcessEnv;
11
+ timeoutMs?: number;
12
+ }
13
+
14
+ export interface CommandResult {
15
+ status: "completed" | "failed" | "spawn_error" | "timeout" | "canceled";
16
+ exitCode: number | null;
17
+ signal: NodeJS.Signals | null;
18
+ stdout: string;
19
+ stderr: string;
20
+ startedAt: number;
21
+ endedAt: number;
22
+ durationMs: number;
23
+ errorCode?: string;
24
+ userMessage: string;
25
+ debugMessage?: string;
26
+ }
27
+
28
+ export interface CommandStreamHandlers {
29
+ onStdout?: (text: string) => void;
30
+ onStderr?: (text: string) => void;
31
+ onProcessLifecycle?: (event: "before-spawn" | "spawned" | "exit" | "error" | "cancel") => void;
32
+ }
33
+
34
+ interface InternalCommandSpec extends CommandSpec {
35
+ displayExecutable?: string;
36
+ }
37
+
38
+ // Sanitize before splitting: title sequences can span newlines and must not corrupt the line output.
39
+ function splitOutputLines(text: string): string[] {
40
+ return sanitizeTerminalOutput(text)
41
+ .replace(/\r\n/g, "\n")
42
+ .replace(/\r/g, "\n")
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .filter(Boolean);
46
+ }
47
+
48
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
49
+ return `${count} ${count === 1 ? singular : plural}`;
50
+ }
51
+
52
+ function looksLikePath(line: string): boolean {
53
+ return /[\\/]/.test(line) || /\.[a-z0-9_-]+$/i.test(line);
54
+ }
55
+
56
+ function buildUserMessage(result: {
57
+ executable: string;
58
+ code?: string;
59
+ exitCode: number | null;
60
+ stderr: string;
61
+ signal: NodeJS.Signals | null;
62
+ status: CommandResult["status"];
63
+ }): string {
64
+ const stderrLine = sanitizeTerminalOutput(result.stderr).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
65
+ if (result.status === "spawn_error" && result.code === "ENOENT") {
66
+ return `\`${result.executable}\` is not installed or not available on PATH.`;
67
+ }
68
+ if (result.status === "spawn_error" && result.code === "EACCES") {
69
+ return `\`${result.executable}\` could not be executed because permission was denied.`;
70
+ }
71
+ if (result.status === "timeout") {
72
+ return `Command timed out before it could finish.`;
73
+ }
74
+ if (result.status === "canceled") {
75
+ return "Command was canceled.";
76
+ }
77
+ if (result.signal) {
78
+ return `Command exited after receiving signal ${result.signal}.`;
79
+ }
80
+ if (result.exitCode === 1 && stderrLine?.match(/not recognized|not found|No such file/i)) {
81
+ return stderrLine;
82
+ }
83
+ if (result.exitCode === 1 && result.executable === "rg" && !stderrLine) {
84
+ return "ripgrep returned no matches.";
85
+ }
86
+ if (result.exitCode && result.exitCode !== 0) {
87
+ return stderrLine ?? `Command exited with code ${result.exitCode}.`;
88
+ }
89
+ return "Command completed.";
90
+ }
91
+
92
+ export function summarizeCommandResult(command: string, result: Pick<CommandResult, "status" | "exitCode" | "signal" | "stdout" | "stderr" | "userMessage">): string {
93
+ if (result.status !== "completed" || result.exitCode !== 0 || result.signal) {
94
+ return result.userMessage;
95
+ }
96
+
97
+ const stdoutLines = splitOutputLines(result.stdout);
98
+ if (stdoutLines.length === 0) {
99
+ return "Completed with no output.";
100
+ }
101
+
102
+ const lowerCommand = command.toLowerCase();
103
+ if (/\brg\b/.test(lowerCommand) && /--files\b/.test(lowerCommand)) {
104
+ return `Found ${pluralize(stdoutLines.length, "file")}.`;
105
+ }
106
+
107
+ if (/\b(get-childitem|ls|dir)\b/.test(lowerCommand)) {
108
+ return `Listed ${pluralize(stdoutLines.length, "item")}.`;
109
+ }
110
+
111
+ if (/\b(rg|grep|select-string|findstr)\b/.test(lowerCommand)) {
112
+ return `Found ${pluralize(stdoutLines.length, "match", "matches")}.`;
113
+ }
114
+
115
+ if (stdoutLines.length === 1) {
116
+ return stdoutLines[0]!;
117
+ }
118
+
119
+ if (stdoutLines.every(looksLikePath)) {
120
+ return `Returned ${pluralize(stdoutLines.length, "path")}.`;
121
+ }
122
+
123
+ return `Produced ${pluralize(stdoutLines.length, "line")} of output.`;
124
+ }
125
+
126
+ export function runCommand(
127
+ spec: CommandSpec,
128
+ handlers: CommandStreamHandlers = {},
129
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
130
+ return runProcess(spec, handlers);
131
+ }
132
+
133
+ export function runShellCommand(
134
+ command: string,
135
+ options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
136
+ handlers: CommandStreamHandlers = {},
137
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
138
+ const shellSpec = process.platform === "win32"
139
+ ? { executable: "cmd.exe", args: ["/d", "/s", "/c", command] }
140
+ : { executable: "/bin/sh", args: ["-c", command] };
141
+
142
+ return runProcess({
143
+ ...options,
144
+ executable: shellSpec.executable,
145
+ args: shellSpec.args,
146
+ displayExecutable: command,
147
+ }, handlers);
148
+ }
149
+
150
+ function runProcess(
151
+ spec: InternalCommandSpec,
152
+ handlers: CommandStreamHandlers,
153
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
154
+ const startedAt = Date.now();
155
+ const executable = validateExecutableForSpawn(spec.executable, {
156
+ label: "Command executable",
157
+ cwd: spec.cwd,
158
+ });
159
+ const displayExecutable = spec.displayExecutable ?? executable;
160
+ let stdout = "";
161
+ let stderr = "";
162
+ let canceled = false;
163
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
164
+ const stdoutTitleStripper = createTerminalTitleSequenceStripper({
165
+ source: "src/core/process/CommandRunner.ts:shell.stdout",
166
+ stream: "stdout",
167
+ origin: "shell",
168
+ });
169
+ const stderrTitleStripper = createTerminalTitleSequenceStripper({
170
+ source: "src/core/process/CommandRunner.ts:shell.stderr",
171
+ stream: "stderr",
172
+ origin: "shell",
173
+ });
174
+
175
+ handlers.onProcessLifecycle?.("before-spawn");
176
+ const child = spawn(executable, spec.args, {
177
+ cwd: spec.cwd,
178
+ env: spec.env,
179
+ shell: false,
180
+ stdio: ["ignore", "pipe", "pipe"],
181
+ });
182
+ handlers.onProcessLifecycle?.("spawned");
183
+
184
+ const result = new Promise<CommandResult>((resolve) => {
185
+ const finish = (partial: Omit<CommandResult, "stdout" | "stderr" | "startedAt" | "endedAt" | "durationMs" | "userMessage"> & { endedAt?: number }) => {
186
+ if (timeoutHandle) clearTimeout(timeoutHandle);
187
+ stdout += stdoutTitleStripper.flush();
188
+ stderr += stderrTitleStripper.flush();
189
+ const endedAt = partial.endedAt ?? Date.now();
190
+ resolve({
191
+ ...partial,
192
+ stdout: sanitizeTerminalOutput(stdout),
193
+ stderr: sanitizeTerminalOutput(stderr),
194
+ startedAt,
195
+ endedAt,
196
+ durationMs: endedAt - startedAt,
197
+ userMessage: buildUserMessage({
198
+ executable: displayExecutable,
199
+ code: partial.errorCode,
200
+ exitCode: partial.exitCode,
201
+ stderr,
202
+ signal: partial.signal,
203
+ status: partial.status,
204
+ }),
205
+ });
206
+ };
207
+
208
+ child.stdout?.on("data", (buffer: Buffer) => {
209
+ const text = stdoutTitleStripper.process(buffer);
210
+ stdout += text;
211
+ handlers.onStdout?.(sanitizeTerminalOutput(text));
212
+ });
213
+
214
+ child.stderr?.on("data", (buffer: Buffer) => {
215
+ const text = stderrTitleStripper.process(buffer);
216
+ stderr += text;
217
+ handlers.onStderr?.(sanitizeTerminalOutput(text));
218
+ });
219
+
220
+ child.once("error", (error: NodeJS.ErrnoException) => {
221
+ handlers.onProcessLifecycle?.("error");
222
+ finish({
223
+ status: canceled ? "canceled" : "spawn_error",
224
+ exitCode: null,
225
+ signal: null,
226
+ errorCode: error.code,
227
+ debugMessage: error.message,
228
+ });
229
+ });
230
+
231
+ child.once("close", (code, signal) => {
232
+ handlers.onProcessLifecycle?.("exit");
233
+ finish({
234
+ status: canceled ? "canceled" : code === 0 ? "completed" : "failed",
235
+ exitCode: code,
236
+ signal,
237
+ });
238
+ });
239
+
240
+ if (spec.timeoutMs && spec.timeoutMs > 0) {
241
+ timeoutHandle = setTimeout(() => {
242
+ if (child.killed) return;
243
+ child.kill();
244
+ finish({
245
+ status: "timeout",
246
+ exitCode: null,
247
+ signal: null,
248
+ debugMessage: `Timed out after ${spec.timeoutMs}ms`,
249
+ });
250
+ }, spec.timeoutMs);
251
+ }
252
+ });
253
+
254
+ return {
255
+ child,
256
+ result,
257
+ cancel: () => {
258
+ canceled = true;
259
+ handlers.onProcessLifecycle?.("cancel");
260
+ if (!child.killed) {
261
+ try {
262
+ child.kill();
263
+ } catch {
264
+ // ignore cancellation failures
265
+ }
266
+ }
267
+ },
268
+ };
269
+ }