ubume 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,398 @@
1
+ import { appendFileSync, mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ import { useEffect, useRef } from "react";
4
+ import { resolveUbumeDebugLogPath } from "../workspace/appData.js";
5
+
6
+ type DebugEnv = Record<string, string | undefined>;
7
+
8
+ // Global debug state — populated lazily on first check, or eagerly via configureRenderDebug().
9
+ let configured = false;
10
+ let enabled = false;
11
+ let renderTraceEnabled = false;
12
+ let lifecycleEnabled = false;
13
+ let flickerEnabled = false;
14
+ let plainActionsEnabled = false;
15
+ let logPath = resolveUbumeDebugLogPath();
16
+ let sessionId = `${Date.now()}-${process.pid}`;
17
+ const counters = new Map<string, number>();
18
+
19
+ function configureFromEnv(env: DebugEnv = process.env): void {
20
+ renderTraceEnabled = env["UBUME_DEBUG_RENDER_TRACE"] === "1" || env["CODEXA_DEBUG_RENDER_TRACE"] === "1";
21
+ // Both UBUME_RENDER_DEBUG and UBUME_DEBUG_RENDER activate render debugging —
22
+ // two names exist for historical reasons; either one is sufficient.
23
+ // UBUME_TERMINAL_TRACE is a focused alias for diagnosing terminal/clear/resize
24
+ // render-state issues; it lights up the same `terminal` trace channel.
25
+ enabled = env["UBUME_RENDER_DEBUG"] === "1"
26
+ || env["UBUME_DEBUG_MODEL_STATE"] === "1"
27
+ || env["UBUME_DEBUG_RENDER"] === "1"
28
+ || env["UBUME_TERMINAL_TRACE"] === "1"
29
+ || env["CODEXA_RENDER_DEBUG"] === "1"
30
+ || env["CODEXA_DEBUG_MODEL_STATE"] === "1"
31
+ || env["CODEXA_DEBUG_RENDER"] === "1"
32
+ || env["CODEXA_TERMINAL_TRACE"] === "1"
33
+ || renderTraceEnabled;
34
+ lifecycleEnabled = env["UBUME_DEBUG_LIFECYCLE"] === "1" || env["CODEXA_DEBUG_LIFECYCLE"] === "1";
35
+ flickerEnabled = env["UBUME_DEBUG_FLICKER"] === "1" || env["CODEXA_DEBUG_FLICKER"] === "1";
36
+ plainActionsEnabled = env["UBUME_DEBUG_PLAIN_ACTIONS"] === "1" || env["CODEXA_DEBUG_PLAIN_ACTIONS"] === "1";
37
+ logPath = env["UBUME_RENDER_DEBUG_FILE"]?.trim()
38
+ || env["CODEXA_RENDER_DEBUG_FILE"]?.trim()
39
+ || resolveUbumeDebugLogPath(env);
40
+ sessionId = `${Date.now()}-${process.pid}`;
41
+ configured = true;
42
+ }
43
+
44
+ export function configureRenderDebug(env: DebugEnv = process.env): void {
45
+ configureFromEnv(env);
46
+ counters.clear();
47
+ if (enabled) {
48
+ writeRecord("session", { event: "start" });
49
+ }
50
+ }
51
+
52
+ export function isRenderDebugEnabled(): boolean {
53
+ if (!configured) {
54
+ configureFromEnv();
55
+ }
56
+ return enabled;
57
+ }
58
+
59
+ export function isRenderTraceEnabled(): boolean {
60
+ if (!configured) {
61
+ configureFromEnv();
62
+ }
63
+ return renderTraceEnabled;
64
+ }
65
+
66
+ export function isLifecycleDebugEnabled(): boolean {
67
+ if (!configured) {
68
+ configureFromEnv();
69
+ }
70
+ return lifecycleEnabled;
71
+ }
72
+
73
+ export function isFlickerDebugEnabled(): boolean {
74
+ if (!configured) {
75
+ configureFromEnv();
76
+ }
77
+ return flickerEnabled;
78
+ }
79
+
80
+ export function isPlainActionsDebugEnabled(): boolean {
81
+ if (!configured) {
82
+ configureFromEnv();
83
+ }
84
+ return plainActionsEnabled;
85
+ }
86
+
87
+ export function getRenderDebugLogPath(): string {
88
+ if (!configured) {
89
+ configureFromEnv();
90
+ }
91
+ return logPath;
92
+ }
93
+
94
+ function nextCounter(name: string, by = 1): number {
95
+ const next = (counters.get(name) ?? 0) + by;
96
+ counters.set(name, next);
97
+ return next;
98
+ }
99
+
100
+ function sanitizeValue(value: unknown): unknown {
101
+ if (value == null) return value;
102
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
103
+ return value;
104
+ }
105
+ if (Array.isArray(value)) {
106
+ return value.map(sanitizeValue);
107
+ }
108
+ if (typeof value === "object") {
109
+ const record: Record<string, unknown> = {};
110
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
111
+ record[key] = sanitizeValue(nested);
112
+ }
113
+ return record;
114
+ }
115
+ return String(value);
116
+ }
117
+
118
+ function writeRecord(kind: string, fields: Record<string, unknown>): void {
119
+ try {
120
+ mkdirSync(dirname(logPath), { recursive: true });
121
+ appendFileSync(
122
+ logPath,
123
+ JSON.stringify({
124
+ ts: Date.now(),
125
+ pid: process.pid,
126
+ sessionId,
127
+ kind,
128
+ ...(sanitizeValue(fields) as Record<string, unknown>),
129
+ }) + "\n",
130
+ "utf8",
131
+ );
132
+ } catch {
133
+ // Debug logging must never disturb the TUI.
134
+ }
135
+ }
136
+
137
+ export function traceLifecycleEvent(
138
+ component: string,
139
+ event: "mount" | "unmount" | "blankFrame" | "emptyFrame" | "stateTransition" | string,
140
+ fields: Record<string, unknown> = {},
141
+ ): void {
142
+ if (!isRenderDebugEnabled()) return;
143
+ const count = nextCounter(`lifecycle.${component}.${event}`);
144
+ writeRecord("lifecycle", { component, event, count, ...fields });
145
+ }
146
+
147
+ export function useLifecycleDebug(
148
+ component: string,
149
+ fields: Record<string, unknown> = {},
150
+ ): void {
151
+ useEffect(() => {
152
+ traceLifecycleEvent(component, "mount", fields);
153
+ return () => {
154
+ traceLifecycleEvent(component, "unmount", fields);
155
+ };
156
+ }, []);
157
+ }
158
+
159
+ export function traceBlankFrame(
160
+ component: string,
161
+ fields: Record<string, unknown> = {},
162
+ ): void {
163
+ if (!isRenderDebugEnabled()) return;
164
+ const count = nextCounter(`blankFrame.${component}`);
165
+ writeRecord("blankFrame", { component, event: "blankFrame", count, ...fields });
166
+ }
167
+
168
+ export function traceLayoutValidity(
169
+ component: string,
170
+ fields: Record<string, unknown> = {},
171
+ ): void {
172
+ if (!isRenderDebugEnabled()) return;
173
+ const values = Object.entries(fields).filter(([, value]) => typeof value === "number") as Array<[string, number]>;
174
+ const invalidValues = values
175
+ .filter(([, value]) => !Number.isFinite(value) || value <= 0)
176
+ .map(([key, value]) => ({ key, value }));
177
+ const count = nextCounter(`layoutValidity.${component}`);
178
+ writeRecord("layout", {
179
+ event: invalidValues.length > 0 ? "invalidLayout" : "validLayout",
180
+ component,
181
+ count,
182
+ invalidValues,
183
+ ...fields,
184
+ });
185
+ }
186
+
187
+ export function traceStateTransition(fields: Record<string, unknown>): void {
188
+ if (!isRenderDebugEnabled() && !isLifecycleDebugEnabled()) return;
189
+ writeRecord("state", { event: "transition", ...fields });
190
+ }
191
+
192
+ function diffKeys(
193
+ previous: Record<string, unknown> | null,
194
+ next: Record<string, unknown>,
195
+ ): string {
196
+ if (!previous) return "mount";
197
+ const changed: string[] = [];
198
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
199
+ for (const key of keys) {
200
+ if (!Object.is(previous[key], next[key])) {
201
+ changed.push(key);
202
+ }
203
+ }
204
+ return changed.length > 0 ? changed.join(",") : "parent";
205
+ }
206
+
207
+ function summarizeWatchedValue(value: unknown): unknown {
208
+ if (value == null) return value;
209
+ if (typeof value === "string") {
210
+ return value.length > 160 ? `${value.slice(0, 157)}...` : value;
211
+ }
212
+ if (typeof value === "number" || typeof value === "boolean") {
213
+ return value;
214
+ }
215
+ if (Array.isArray(value)) {
216
+ return { type: "array", length: value.length };
217
+ }
218
+ if (typeof value === "object") {
219
+ const record = value as Record<string, unknown>;
220
+ const reactType = record["type"];
221
+ if ("$$typeof" in record) {
222
+ return {
223
+ type: "reactElement",
224
+ name: typeof reactType === "string"
225
+ ? reactType
226
+ : typeof reactType === "function"
227
+ ? reactType.name
228
+ : "unknown",
229
+ };
230
+ }
231
+ if (typeof record["kind"] === "string") {
232
+ return { type: "object", kind: record["kind"] };
233
+ }
234
+ if (typeof record["key"] === "string") {
235
+ return { type: "object", key: record["key"] };
236
+ }
237
+ return { type: "object", keys: Object.keys(record).slice(0, 8) };
238
+ }
239
+ return String(value);
240
+ }
241
+
242
+ function summarizeWatched(watched: Record<string, unknown>): Record<string, unknown> {
243
+ const summary: Record<string, unknown> = {};
244
+ for (const [key, value] of Object.entries(watched)) {
245
+ summary[key] = summarizeWatchedValue(value);
246
+ }
247
+ return summary;
248
+ }
249
+
250
+ export function traceRender(
251
+ component: string,
252
+ reason = "unknown",
253
+ fields: Record<string, unknown> = {},
254
+ ): void {
255
+ if (!isRenderDebugEnabled()) return;
256
+ const count = nextCounter(`render.${component}`);
257
+ writeRecord("render", { component, count, reason, ...fields });
258
+ }
259
+
260
+ export function useRenderDebug(
261
+ component: string,
262
+ watched: Record<string, unknown> = {},
263
+ ): void {
264
+ const renderCount = useRef(0);
265
+ const previous = useRef<Record<string, unknown> | null>(null);
266
+ renderCount.current += 1;
267
+ const reason = diffKeys(previous.current, watched);
268
+ if (isRenderDebugEnabled()) {
269
+ writeRecord("render", {
270
+ component,
271
+ count: renderCount.current,
272
+ reason,
273
+ watched: summarizeWatched(watched),
274
+ });
275
+ }
276
+ previous.current = watched;
277
+ }
278
+
279
+ export function useFlickerDebug(
280
+ event: string,
281
+ watched: Record<string, unknown> = {},
282
+ ): void {
283
+ const renderCount = useRef(0);
284
+ const previous = useRef<Record<string, unknown> | null>(null);
285
+ renderCount.current += 1;
286
+ const reason = diffKeys(previous.current, watched);
287
+ if (isFlickerDebugEnabled() || isRenderTraceEnabled()) {
288
+ writeRecord("flicker", {
289
+ event,
290
+ count: renderCount.current,
291
+ reason,
292
+ watched: summarizeWatched(watched),
293
+ });
294
+ }
295
+ previous.current = watched;
296
+ }
297
+
298
+ export function traceEvent(
299
+ channel: string,
300
+ event: string,
301
+ fields: Record<string, unknown> = {},
302
+ ): void {
303
+ if (!isRenderDebugEnabled()) return;
304
+ const count = nextCounter(`${channel}.${event}`);
305
+ writeRecord(channel, { event, count, ...fields });
306
+ }
307
+
308
+ /**
309
+ * Like traceEvent, but the field object is only built when tracing is on.
310
+ * Use it wherever computing the payload is expensive (hashing or scanning
311
+ * output), so a disabled trace costs nothing on the render hot path.
312
+ */
313
+ export function traceEventLazy(
314
+ channel: string,
315
+ event: string,
316
+ buildFields: () => Record<string, unknown>,
317
+ ): void {
318
+ if (!isRenderDebugEnabled()) return;
319
+ traceEvent(channel, event, buildFields());
320
+ }
321
+
322
+ export function traceSchedulerFlush(fields: Record<string, unknown>): void {
323
+ traceEvent("scheduler", "flush", fields);
324
+ }
325
+
326
+ export function traceStatusTick(fields: Record<string, unknown>): void {
327
+ traceEvent("status", "tick", fields);
328
+ traceFlickerEvent("statusTick", fields);
329
+ }
330
+
331
+ export function traceTimelineUpdate(fields: Record<string, unknown>): void {
332
+ traceEvent("timeline", "update", fields);
333
+ }
334
+
335
+ /**
336
+ * Set UBUME_DEBUG_LIFECYCLE=1 to write one JSONL record for every UIState
337
+ * transition, including the derived composer and animation state.
338
+ */
339
+ export function traceLifecycleTransition(fields: Record<string, unknown>): void {
340
+ if (!isLifecycleDebugEnabled()) return;
341
+ writeRecord("lifecycle", fields);
342
+ }
343
+
344
+ export function traceFlickerEvent(event: string, fields: Record<string, unknown> = {}): void {
345
+ if (!isRenderDebugEnabled() && !isFlickerDebugEnabled() && !isRenderTraceEnabled()) return;
346
+ const count = nextCounter(`flicker.${event}`);
347
+ writeRecord("flicker", { event, count, ...fields });
348
+ }
349
+
350
+ export function traceTerminalWrite(
351
+ stream: "stdout" | "stderr",
352
+ source: string,
353
+ chunk: unknown,
354
+ ): void {
355
+ if (!isRenderDebugEnabled()) return;
356
+ const text = typeof chunk === "string"
357
+ ? chunk
358
+ : chunk instanceof Uint8Array
359
+ ? Buffer.from(chunk).toString("utf8")
360
+ : String(chunk ?? "");
361
+ writeRecord(stream, {
362
+ event: "directWrite",
363
+ count: nextCounter(`${stream}.directWrite`),
364
+ source,
365
+ bytes: Buffer.byteLength(text),
366
+ containsViewportClear: text.includes("\x1b[2J"),
367
+ containsScrollbackClear: text.includes("\x1b[3J"),
368
+ containsCursorHome: text.includes("\x1b[H"),
369
+ containsTerminalReset: text.includes("\x1bc"),
370
+ containsAlternateScreen: text.includes("\x1b[?1049h"),
371
+ containsTitleSequence: text.includes("\x1b]0;") || text.includes("\x1b]2;"),
372
+ containsBracketedPaste: text.includes("\x1b[?2004h") || text.includes("\x1b[?2004l"),
373
+ containsMouseMode: text.includes("\x1b[?1000h") || text.includes("\x1b[?1000l")
374
+ || text.includes("\x1b[?1002h") || text.includes("\x1b[?1002l")
375
+ || text.includes("\x1b[?1003h") || text.includes("\x1b[?1003l")
376
+ || text.includes("\x1b[?1006h") || text.includes("\x1b[?1006l")
377
+ || text.includes("\x1b[?1015h") || text.includes("\x1b[?1015l"),
378
+ });
379
+ }
380
+
381
+ export function traceTerminalClear(source: string, fields: Record<string, unknown> = {}): void {
382
+ traceEvent("terminal", "clearScreen", { source, ...fields });
383
+ }
384
+
385
+ /**
386
+ * Returns accumulated render counts for all tracked components.
387
+ * Useful with `/debug renders` to verify that Header/Composer/Footer
388
+ * stay low during streaming while Timeline updates frequently.
389
+ */
390
+ export function dumpRenderCounts(): Record<string, number> {
391
+ const result: Record<string, number> = {};
392
+ for (const [key, value] of counters) {
393
+ if (key.startsWith("render.")) {
394
+ result[key.slice("render.".length)] = value;
395
+ }
396
+ }
397
+ return result;
398
+ }
@@ -0,0 +1,280 @@
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
+ stdinData?: string;
13
+ }
14
+
15
+ export interface CommandResult {
16
+ status: "completed" | "failed" | "spawn_error" | "timeout" | "canceled";
17
+ exitCode: number | null;
18
+ signal: NodeJS.Signals | null;
19
+ stdout: string;
20
+ stderr: string;
21
+ startedAt: number;
22
+ endedAt: number;
23
+ durationMs: number;
24
+ errorCode?: string;
25
+ userMessage: string;
26
+ debugMessage?: string;
27
+ }
28
+
29
+ export interface CommandStreamHandlers {
30
+ onStdout?: (text: string) => void;
31
+ onStderr?: (text: string) => void;
32
+ onProcessLifecycle?: (event: "before-spawn" | "spawned" | "exit" | "error" | "cancel") => void;
33
+ }
34
+
35
+ interface InternalCommandSpec extends CommandSpec {
36
+ displayExecutable?: string;
37
+ }
38
+
39
+ // Sanitize before splitting: title sequences can span newlines and must not corrupt the line output.
40
+ function splitOutputLines(text: string): string[] {
41
+ return sanitizeTerminalOutput(text)
42
+ .replace(/\r\n/g, "\n")
43
+ .replace(/\r/g, "\n")
44
+ .split("\n")
45
+ .map((line) => line.trim())
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
50
+ return `${count} ${count === 1 ? singular : plural}`;
51
+ }
52
+
53
+ function looksLikePath(line: string): boolean {
54
+ return /[\\/]/.test(line) || /\.[a-z0-9_-]+$/i.test(line);
55
+ }
56
+
57
+ function buildUserMessage(result: {
58
+ executable: string;
59
+ code?: string;
60
+ exitCode: number | null;
61
+ stderr: string;
62
+ signal: NodeJS.Signals | null;
63
+ status: CommandResult["status"];
64
+ }): string {
65
+ const stderrLine = sanitizeTerminalOutput(result.stderr).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
66
+ if (result.status === "spawn_error" && result.code === "ENOENT") {
67
+ return `\`${result.executable}\` is not installed or not available on PATH.`;
68
+ }
69
+ if (result.status === "spawn_error" && result.code === "EACCES") {
70
+ return `\`${result.executable}\` could not be executed because permission was denied.`;
71
+ }
72
+ if (result.status === "timeout") {
73
+ return `Command timed out before it could finish.`;
74
+ }
75
+ if (result.status === "canceled") {
76
+ return "Command was canceled.";
77
+ }
78
+ if (result.signal) {
79
+ return `Command exited after receiving signal ${result.signal}.`;
80
+ }
81
+ if (result.exitCode === 1 && stderrLine?.match(/not recognized|not found|No such file/i)) {
82
+ return stderrLine;
83
+ }
84
+ if (result.exitCode === 1 && result.executable === "rg" && !stderrLine) {
85
+ return "ripgrep returned no matches.";
86
+ }
87
+ if (result.exitCode && result.exitCode !== 0) {
88
+ return stderrLine ?? `Command exited with code ${result.exitCode}.`;
89
+ }
90
+ return "Command completed.";
91
+ }
92
+
93
+ export function summarizeCommandResult(command: string, result: Pick<CommandResult, "status" | "exitCode" | "signal" | "stdout" | "stderr" | "userMessage">): string {
94
+ if (result.status !== "completed" || result.exitCode !== 0 || result.signal) {
95
+ return result.userMessage;
96
+ }
97
+
98
+ const stdoutLines = splitOutputLines(result.stdout);
99
+ if (stdoutLines.length === 0) {
100
+ return "Completed with no output.";
101
+ }
102
+
103
+ const lowerCommand = command.toLowerCase();
104
+ if (/\brg\b/.test(lowerCommand) && /--files\b/.test(lowerCommand)) {
105
+ return `Found ${pluralize(stdoutLines.length, "file")}.`;
106
+ }
107
+
108
+ if (/\b(get-childitem|ls|dir)\b/.test(lowerCommand)) {
109
+ return `Listed ${pluralize(stdoutLines.length, "item")}.`;
110
+ }
111
+
112
+ if (/\b(rg|grep|select-string|findstr)\b/.test(lowerCommand)) {
113
+ return `Found ${pluralize(stdoutLines.length, "match", "matches")}.`;
114
+ }
115
+
116
+ if (stdoutLines.length === 1) {
117
+ return stdoutLines[0]!;
118
+ }
119
+
120
+ if (stdoutLines.every(looksLikePath)) {
121
+ return `Returned ${pluralize(stdoutLines.length, "path")}.`;
122
+ }
123
+
124
+ return `Produced ${pluralize(stdoutLines.length, "line")} of output.`;
125
+ }
126
+
127
+ export function runCommand(
128
+ spec: CommandSpec,
129
+ handlers: CommandStreamHandlers = {},
130
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
131
+ return runProcess(spec, handlers);
132
+ }
133
+
134
+ export function runShellCommand(
135
+ command: string,
136
+ options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
137
+ handlers: CommandStreamHandlers = {},
138
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
139
+ const shellSpec = process.platform === "win32"
140
+ ? { executable: "cmd.exe", args: ["/d", "/s", "/c", command] }
141
+ : { executable: "/bin/sh", args: ["-c", command] };
142
+
143
+ return runProcess({
144
+ ...options,
145
+ executable: shellSpec.executable,
146
+ args: shellSpec.args,
147
+ displayExecutable: command,
148
+ }, handlers);
149
+ }
150
+
151
+ function runProcess(
152
+ spec: InternalCommandSpec,
153
+ handlers: CommandStreamHandlers,
154
+ ): { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void } {
155
+ const startedAt = Date.now();
156
+ const executable = validateExecutableForSpawn(spec.executable, {
157
+ label: "Command executable",
158
+ cwd: spec.cwd,
159
+ });
160
+ const displayExecutable = spec.displayExecutable ?? executable;
161
+ let stdout = "";
162
+ let stderr = "";
163
+ let canceled = false;
164
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
165
+ const stdoutTitleStripper = createTerminalTitleSequenceStripper({
166
+ source: "src/core/process/CommandRunner.ts:shell.stdout",
167
+ stream: "stdout",
168
+ origin: "shell",
169
+ });
170
+ const stderrTitleStripper = createTerminalTitleSequenceStripper({
171
+ source: "src/core/process/CommandRunner.ts:shell.stderr",
172
+ stream: "stderr",
173
+ origin: "shell",
174
+ });
175
+
176
+ handlers.onProcessLifecycle?.("before-spawn");
177
+ const child = spawn(executable, spec.args, {
178
+ cwd: spec.cwd,
179
+ env: spec.env,
180
+ shell: false,
181
+ stdio: [spec.stdinData !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
182
+ });
183
+ handlers.onProcessLifecycle?.("spawned");
184
+
185
+ if (spec.stdinData !== undefined) {
186
+ try {
187
+ child.stdin?.on("error", () => { /* EPIPE when the process exits before reading stdin */ });
188
+ child.stdin?.write(spec.stdinData);
189
+ child.stdin?.end();
190
+ } catch {
191
+ // stdin already closed; the close/error handlers report the outcome
192
+ }
193
+ }
194
+
195
+ const result = new Promise<CommandResult>((resolve) => {
196
+ const finish = (partial: Omit<CommandResult, "stdout" | "stderr" | "startedAt" | "endedAt" | "durationMs" | "userMessage"> & { endedAt?: number }) => {
197
+ if (timeoutHandle) clearTimeout(timeoutHandle);
198
+ stdout += stdoutTitleStripper.flush();
199
+ stderr += stderrTitleStripper.flush();
200
+ const endedAt = partial.endedAt ?? Date.now();
201
+ resolve({
202
+ ...partial,
203
+ stdout: sanitizeTerminalOutput(stdout),
204
+ stderr: sanitizeTerminalOutput(stderr),
205
+ startedAt,
206
+ endedAt,
207
+ durationMs: endedAt - startedAt,
208
+ userMessage: buildUserMessage({
209
+ executable: displayExecutable,
210
+ code: partial.errorCode,
211
+ exitCode: partial.exitCode,
212
+ stderr,
213
+ signal: partial.signal,
214
+ status: partial.status,
215
+ }),
216
+ });
217
+ };
218
+
219
+ child.stdout?.on("data", (buffer: Buffer) => {
220
+ const text = stdoutTitleStripper.process(buffer);
221
+ stdout += text;
222
+ handlers.onStdout?.(sanitizeTerminalOutput(text));
223
+ });
224
+
225
+ child.stderr?.on("data", (buffer: Buffer) => {
226
+ const text = stderrTitleStripper.process(buffer);
227
+ stderr += text;
228
+ handlers.onStderr?.(sanitizeTerminalOutput(text));
229
+ });
230
+
231
+ child.once("error", (error: NodeJS.ErrnoException) => {
232
+ handlers.onProcessLifecycle?.("error");
233
+ finish({
234
+ status: canceled ? "canceled" : "spawn_error",
235
+ exitCode: null,
236
+ signal: null,
237
+ errorCode: error.code,
238
+ debugMessage: error.message,
239
+ });
240
+ });
241
+
242
+ child.once("close", (code, signal) => {
243
+ handlers.onProcessLifecycle?.("exit");
244
+ finish({
245
+ status: canceled ? "canceled" : code === 0 ? "completed" : "failed",
246
+ exitCode: code,
247
+ signal,
248
+ });
249
+ });
250
+
251
+ if (spec.timeoutMs && spec.timeoutMs > 0) {
252
+ timeoutHandle = setTimeout(() => {
253
+ if (child.killed) return;
254
+ child.kill();
255
+ finish({
256
+ status: "timeout",
257
+ exitCode: null,
258
+ signal: null,
259
+ debugMessage: `Timed out after ${spec.timeoutMs}ms`,
260
+ });
261
+ }, spec.timeoutMs);
262
+ }
263
+ });
264
+
265
+ return {
266
+ child,
267
+ result,
268
+ cancel: () => {
269
+ canceled = true;
270
+ handlers.onProcessLifecycle?.("cancel");
271
+ if (!child.killed) {
272
+ try {
273
+ child.kill();
274
+ } catch {
275
+ // ignore cancellation failures
276
+ }
277
+ }
278
+ },
279
+ };
280
+ }