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,400 @@
1
+ import { APP_NAME, type TerminalTitleMode, formatTerminalTitlePath } from "../../config/settings.js";
2
+ import { appendFileSync, mkdirSync } from "fs";
3
+ import { dirname } from "path";
4
+ import * as renderDebug from "../perf/renderDebug.js";
5
+ import { resolveUbumeDebugLogPath } from "../workspace/appData.js";
6
+
7
+ export const DEFAULT_TERMINAL_TITLE = APP_NAME;
8
+
9
+ // ─── Constants & diagnostics ──────────────────────────────────────────────────
10
+
11
+ const DEBUG_TERMINAL_TITLE = Boolean(process.env["UBUME_DEBUG_TERMINAL_TITLE"]);
12
+
13
+ /** Hard cap applied at every entry-point to prevent O(n²) scanning cost on adversarial input. */
14
+ const MAX_TERMINAL_TITLE_INPUT_LENGTH = 65536;
15
+
16
+ // Returns start index of the next ESC]0; or ESC]2; header at/after `from`, or -1.
17
+ function findNextOscTitleStart(text: string, from: number): number {
18
+ const limit = text.length - 3;
19
+ for (let i = from; i <= limit; i++) {
20
+ if (
21
+ text.charCodeAt(i) === 0x1b &&
22
+ text.charCodeAt(i + 1) === 0x5d &&
23
+ (text.charCodeAt(i + 2) === 0x30 || text.charCodeAt(i + 2) === 0x32) &&
24
+ text.charCodeAt(i + 3) === 0x3b
25
+ ) return i;
26
+ }
27
+ return -1;
28
+ }
29
+
30
+ // Finds the first BEL (0x07) or ST (ESC \) at/after `from`.
31
+ // Returns { contentEnd: terminator-start, seqEnd: after-terminator } or null.
32
+ function findOscSequenceEnd(text: string, from: number): { contentEnd: number; seqEnd: number } | null {
33
+ for (let i = from; i < text.length; i++) {
34
+ if (text.charCodeAt(i) === 0x07) return { contentEnd: i, seqEnd: i + 1 };
35
+ if (text.charCodeAt(i) === 0x1b && i + 1 < text.length && text.charCodeAt(i + 1) === 0x5c)
36
+ return { contentEnd: i, seqEnd: i + 2 };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // Returns the start of the first unterminated OSC 0/2 sequence, or -1 if none.
42
+ function findIncompleteOscTitleStart(text: string): number {
43
+ let pos = 0;
44
+ while (pos < text.length) {
45
+ const start = findNextOscTitleStart(text, pos);
46
+ if (start === -1) return -1;
47
+ const term = findOscSequenceEnd(text, start + 4);
48
+ if (term === null) return start;
49
+ pos = term.seqEnd;
50
+ }
51
+ return -1;
52
+ }
53
+
54
+ const TERMINAL_TITLE_DEBUG_LOG_PATH = process.env["UBUME_TERMINAL_TITLE_DEBUG_FILE"]?.trim()
55
+ || process.env["UBUME_RENDER_DEBUG_FILE"]?.trim()
56
+ || resolveUbumeDebugLogPath();
57
+
58
+ let terminalTitleLifecycleState = "unknown";
59
+
60
+ function debugLog(msg: string): void {
61
+ if (DEBUG_TERMINAL_TITLE) {
62
+ writeTerminalTitleDebugRecord({ event: "debug", message: msg });
63
+ }
64
+ }
65
+
66
+ function writeTerminalTitleDebugRecord(fields: Record<string, unknown>): void {
67
+ if (!DEBUG_TERMINAL_TITLE) return;
68
+ try {
69
+ mkdirSync(dirname(TERMINAL_TITLE_DEBUG_LOG_PATH), { recursive: true });
70
+ appendFileSync(
71
+ TERMINAL_TITLE_DEBUG_LOG_PATH,
72
+ JSON.stringify({
73
+ ts: Date.now(),
74
+ pid: process.pid,
75
+ lifecycleState: terminalTitleLifecycleState,
76
+ ...fields,
77
+ }) + "\n",
78
+ "utf8",
79
+ );
80
+ } catch {
81
+ // Diagnostics must never disturb the TUI.
82
+ }
83
+ }
84
+
85
+ // ─── Title write helpers ──────────────────────────────────────────────────────
86
+
87
+ let lastWrittenTerminalTitle = "";
88
+ let intendedTerminalTitle = DEFAULT_TERMINAL_TITLE;
89
+
90
+ /** FOR TESTING ONLY: Reset the cached title. */
91
+ export function __resetTerminalTitleCache() {
92
+ lastWrittenTerminalTitle = "";
93
+ intendedTerminalTitle = DEFAULT_TERMINAL_TITLE;
94
+ }
95
+
96
+ export function setTerminalTitleLifecycleState(state: string): void {
97
+ terminalTitleLifecycleState = state;
98
+ }
99
+
100
+ export interface TerminalTitleOptions {
101
+ force?: boolean;
102
+ /** Optional custom write function, e.g. for testing or using a specific stdout/stderr instance. */
103
+ write?: (chunk: string) => void;
104
+ reason?: string;
105
+ }
106
+
107
+ function looksLikeRawWindowsPath(title: string): boolean {
108
+ return /^[a-zA-Z]:[\\/]/.test(title.trim()) || /^\\\\/.test(title.trim());
109
+ }
110
+
111
+ export function normalizeTerminalTitle(title: string | null | undefined): string {
112
+ const cleanTitle = sanitizeTerminalTitle(title ?? "");
113
+ if (!cleanTitle || looksLikeRawWindowsPath(cleanTitle)) {
114
+ return DEFAULT_TERMINAL_TITLE;
115
+ }
116
+ return cleanTitle;
117
+ }
118
+
119
+ export function setIntendedTerminalTitle(title: string | null | undefined, options?: TerminalTitleOptions): string {
120
+ intendedTerminalTitle = normalizeTerminalTitle(title);
121
+ writeUbumeTerminalTitle(intendedTerminalTitle, {
122
+ ...options,
123
+ reason: options?.reason ?? "set-intended-title",
124
+ });
125
+ return intendedTerminalTitle;
126
+ }
127
+
128
+ export function getIntendedTerminalTitle(): string {
129
+ return intendedTerminalTitle;
130
+ }
131
+
132
+ export function reassertIntendedTerminalTitle(options?: TerminalTitleOptions): void {
133
+ writeUbumeTerminalTitle(intendedTerminalTitle, {
134
+ force: true,
135
+ ...options,
136
+ reason: options?.reason ?? "reassert-intended-title",
137
+ });
138
+ }
139
+
140
+ export function writeUbumeTerminalTitle(title: string, options?: TerminalTitleOptions) {
141
+ const cleanTitle = normalizeTerminalTitle(title);
142
+
143
+ if (!options?.force && cleanTitle === lastWrittenTerminalTitle) {
144
+ debugLog(`skipped title="${cleanTitle}" reason=${options?.reason ?? "unknown"} (unchanged)`);
145
+ return;
146
+ }
147
+
148
+ lastWrittenTerminalTitle = cleanTitle;
149
+
150
+ const sequence = buildTerminalTitleSequence(cleanTitle);
151
+ renderDebug.traceTerminalWrite("stdout", `terminalTitle:${options?.reason ?? "unknown"}`, sequence);
152
+ writeTerminalTitleDebugRecord({
153
+ event: "ubumeTitleWrite",
154
+ title: cleanTitle,
155
+ reason: options?.reason ?? "unknown",
156
+ force: !!options?.force,
157
+ bytes: Buffer.byteLength(sequence),
158
+ });
159
+
160
+ if (options?.write) {
161
+ options.write(sequence);
162
+ debugLog(`write(custom) title="${cleanTitle}" force=${!!options?.force} reason=${options?.reason ?? "unknown"}`);
163
+ return;
164
+ }
165
+
166
+ const stdoutIsTTY = Boolean(process.stdout?.isTTY);
167
+ const stderrIsTTY = Boolean(process.stderr?.isTTY);
168
+
169
+ debugLog(`writeUbumeTerminalTitle("${cleanTitle}") force=${!!options?.force} reason=${options?.reason ?? "unknown"} stdoutIsTTY=${stdoutIsTTY} stderrIsTTY=${stderrIsTTY}`);
170
+
171
+ if (stderrIsTTY) {
172
+ process.stderr.write(sequence);
173
+ debugLog(`write(stderr) sequence length=${sequence.length}`);
174
+ } else if (stdoutIsTTY) {
175
+ process.stdout.write(sequence);
176
+ debugLog(`write(stdout) sequence length=${sequence.length}`);
177
+ } else {
178
+ // Fallback if neither is explicitly a TTY but we might still be in a terminal (e.g. Bun quirk)
179
+ process.stdout.write(sequence);
180
+ debugLog(`write(stdout-fallback) sequence length=${sequence.length} (no TTY detected)`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Directly writes the terminal title escape sequence to process.stdout or process.stderr,
186
+ * bypassing any Ink/React state management to ensure it reaches the terminal.
187
+ */
188
+ export const writeCodexaTerminalTitle = writeUbumeTerminalTitle;
189
+
190
+ export function setTerminalTitle(title: string, options?: TerminalTitleOptions) {
191
+ writeUbumeTerminalTitle(title, options);
192
+ }
193
+
194
+ /**
195
+ * Pure mapper that converts terminal title mode and workspace into a displayable string.
196
+ */
197
+ export function computeTerminalTitle(options: {
198
+ terminalTitleMode: "dir" | "name" | "simple";
199
+ workspaceName?: string;
200
+ appName?: string;
201
+ }) {
202
+ const appName = options.appName || APP_NAME;
203
+
204
+ if (options.terminalTitleMode === "dir") {
205
+ return options.workspaceName || appName;
206
+ }
207
+
208
+ return appName;
209
+ }
210
+
211
+ /**
212
+ * Force a refresh of the terminal title using current settings and workspace.
213
+ */
214
+ export function refreshTerminalTitle(options: {
215
+ terminalTitleMode: "dir" | "name" | "simple";
216
+ workspaceName?: string;
217
+ appName?: string;
218
+ force?: boolean;
219
+ write?: (chunk: string) => void;
220
+ debugEventName?: string;
221
+ busyState?: boolean;
222
+ }) {
223
+ const title = normalizeTerminalTitle(computeTerminalTitle(options));
224
+ if (DEBUG_TERMINAL_TITLE) {
225
+ debugLog(
226
+ `refreshTerminalTitle(event=${options.debugEventName || "unknown"}, mode=${options.terminalTitleMode}, workspace=${options.workspaceName}, busy=${!!options.busyState}) -> "${title}"`,
227
+ );
228
+ }
229
+ setIntendedTerminalTitle(title, {
230
+ force: options.force,
231
+ write: options.write,
232
+ reason: options.debugEventName ?? "refreshTerminalTitle",
233
+ });
234
+ }
235
+
236
+ // ─── Sequence stripping ───────────────────────────────────────────────────────
237
+
238
+ export interface TerminalTitleSequenceTraceContext {
239
+ source: string;
240
+ stream: "stdout" | "stderr" | "unknown";
241
+ origin: "ubume" | "child" | "shell" | "codex-cli" | "unknown";
242
+ action?: "observed" | "stripped" | "allowed";
243
+ lifecycleState?: string;
244
+ }
245
+
246
+ export function traceTerminalTitleSequences(
247
+ input: Buffer | string,
248
+ context: TerminalTitleSequenceTraceContext,
249
+ ): boolean {
250
+ const raw = Buffer.isBuffer(input) ? input.toString("utf8") : input;
251
+ const text = raw.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH);
252
+ if (!text) return false;
253
+
254
+ let found = false;
255
+ let pos = 0;
256
+
257
+ while (pos < text.length) {
258
+ const start = findNextOscTitleStart(text, pos);
259
+ if (start === -1) break;
260
+ const term = findOscSequenceEnd(text, start + 4);
261
+ if (term === null) break; // unterminated — nothing to trace
262
+
263
+ found = true;
264
+ const title = text.slice(start + 4, term.contentEnd);
265
+ const oscCode = text.charCodeAt(start + 2); // 0x30='0', 0x32='2'
266
+ writeTerminalTitleDebugRecord({
267
+ event: "terminalTitleSequence",
268
+ source: context.source,
269
+ stream: context.stream,
270
+ origin: context.origin,
271
+ action: context.action ?? "observed",
272
+ lifecycleState: context.lifecycleState ?? terminalTitleLifecycleState,
273
+ osc: oscCode === 0x32 ? "OSC 2" : "OSC 0",
274
+ title,
275
+ containsWindowsSystem: title.toLowerCase().includes("c:\\windows\\system"),
276
+ bytes: Buffer.byteLength(text.slice(start, term.seqEnd)),
277
+ });
278
+
279
+ pos = term.seqEnd;
280
+ }
281
+
282
+ return found;
283
+ }
284
+
285
+ export function stripTerminalTitleSequences(input: string): string {
286
+ const text = input.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH);
287
+ if (!text) return "";
288
+
289
+ let result = "";
290
+ let pos = 0;
291
+
292
+ while (pos < text.length) {
293
+ const start = findNextOscTitleStart(text, pos);
294
+ if (start === -1) { result += text.slice(pos); break; }
295
+ result += text.slice(pos, start);
296
+ const term = findOscSequenceEnd(text, start + 4);
297
+ if (term === null) { result += text.slice(start); break; } // unterminated: pass through unchanged
298
+ pos = term.seqEnd;
299
+ }
300
+
301
+ return result;
302
+ }
303
+
304
+ export function stripTerminalTitleSequencesFromChunk(chunk: Buffer | string): string {
305
+ return stripTerminalTitleSequences(
306
+ Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk,
307
+ );
308
+ }
309
+
310
+ // Each stripper maintains a carryover buffer so that a title escape sequence
311
+ // split across two Node `data` events is still detected and stripped correctly.
312
+ export function createTerminalTitleSequenceStripper(context: TerminalTitleSequenceTraceContext): {
313
+ process(chunk: Buffer | string): string;
314
+ flush(): string;
315
+ } {
316
+ let carryover = "";
317
+
318
+ const strip = (text: string): string => {
319
+ if (!text) return "";
320
+ const found = traceTerminalTitleSequences(text, { ...context, action: "stripped" });
321
+ return found ? stripTerminalTitleSequences(text) : text;
322
+ };
323
+
324
+ return {
325
+ process(chunk: Buffer | string): string {
326
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
327
+ const input = carryover + text;
328
+ carryover = "";
329
+
330
+ const incompleteStart = findIncompleteOscTitleStart(input.slice(0, MAX_TERMINAL_TITLE_INPUT_LENGTH));
331
+ if (incompleteStart !== -1) {
332
+ carryover = input.slice(incompleteStart);
333
+ return strip(input.slice(0, incompleteStart));
334
+ }
335
+
336
+ return strip(input);
337
+ },
338
+
339
+ flush(): string {
340
+ const remaining = carryover;
341
+ carryover = "";
342
+ return strip(remaining);
343
+ },
344
+ };
345
+ }
346
+
347
+ export function writeGuardedTerminalOutput(
348
+ write: (chunk: string) => boolean | void,
349
+ chunk: Buffer | string,
350
+ context: TerminalTitleSequenceTraceContext,
351
+ ): boolean {
352
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
353
+ if (context.action === "allowed") {
354
+ return write(text) !== false;
355
+ }
356
+ const found = traceTerminalTitleSequences(text, { ...context, action: "stripped" });
357
+ const safeText = found ? stripTerminalTitleSequences(text) : text;
358
+ if (!safeText) return true;
359
+ return write(safeText) !== false;
360
+ }
361
+
362
+ export function sanitizeTerminalTitle(title: string): string {
363
+ return title
364
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
365
+ .replace(/\u001b/g, "")
366
+ .trim();
367
+ }
368
+
369
+ export function buildTerminalTitleSequence(title: string): string {
370
+ const safeTitle = normalizeTerminalTitle(title);
371
+ // \x1b]0; sets both icon name and window title.
372
+ // \x1b]2; sets window title.
373
+ // We use both for compatibility.
374
+ return `\x1b]0;${safeTitle}\x07\x1b]2;${safeTitle}\x07`;
375
+ }
376
+
377
+ export function formatTerminalTitleLabel(
378
+ workspaceRoot: string,
379
+ terminalTitleMode: TerminalTitleMode,
380
+ ): string {
381
+ return formatTerminalTitlePath(workspaceRoot, terminalTitleMode);
382
+ }
383
+
384
+ export function deriveTerminalTitle(
385
+ workspaceRoot: string,
386
+ terminalTitleMode: TerminalTitleMode,
387
+ ): string {
388
+ return formatTerminalTitlePath(workspaceRoot, terminalTitleMode) || DEFAULT_TERMINAL_TITLE;
389
+ }
390
+
391
+ /** Write the title sequence to stdout to (re-)assert the window title. */
392
+ export function reassertTerminalTitle(
393
+ title: string = DEFAULT_TERMINAL_TITLE,
394
+ write: (chunk: string) => void = (chunk) => {
395
+ process.stdout.write(chunk);
396
+ },
397
+ ): void {
398
+ write(buildTerminalTitleSequence(title));
399
+ }
400
+
@@ -0,0 +1,27 @@
1
+ import { APP_VERSION } from "../../config/settings.js";
2
+
3
+ export const UBUME_CHANNEL_ENV = "UBUME_CHANNEL";
4
+ export const CODEXA_CHANNEL_ENV = "CODEXA_CHANNEL";
5
+ export const LOCAL_DEV_CHANNEL = "local-dev";
6
+
7
+ export function getUbumeChannel(env: NodeJS.ProcessEnv = process.env): string {
8
+ return env[UBUME_CHANNEL_ENV]?.trim() || env[CODEXA_CHANNEL_ENV]?.trim() || "published";
9
+ }
10
+ export const getCodexaChannel = getUbumeChannel;
11
+
12
+ export function isLocalDevChannel(env: NodeJS.ProcessEnv = process.env): boolean {
13
+ return getUbumeChannel(env) === LOCAL_DEV_CHANNEL;
14
+ }
15
+
16
+ export function formatUbumeVersionLabel(
17
+ version: string = APP_VERSION,
18
+ env: NodeJS.ProcessEnv = process.env,
19
+ ): string {
20
+ return isLocalDevChannel(env) ? `${version}-dev local` : version;
21
+ }
22
+ export const formatCodexaVersionLabel = formatUbumeVersionLabel;
23
+
24
+ export function formatUbumeBrandLabel(env: NodeJS.ProcessEnv = process.env): string {
25
+ return `Ubume v${formatUbumeVersionLabel(APP_VERSION, env)}`;
26
+ }
27
+ export const formatCodexaBrandLabel = formatUbumeBrandLabel;
@@ -0,0 +1,119 @@
1
+ import type { ChildProcess } from "child_process";
2
+ import {
3
+ runCommand,
4
+ runShellCommand,
5
+ type CommandResult,
6
+ type CommandSpec,
7
+ type CommandStreamHandlers,
8
+ } from "../process/CommandRunner.js";
9
+ import { UBUME_NPM_PACKAGE } from "./updateCheck.js";
10
+
11
+ export type GlobalPackageManager = "npm" | "pnpm" | "yarn" | "bun";
12
+
13
+ const UPDATE_TIMEOUT_MS = 300_000;
14
+
15
+ const PACKAGE_SPEC = `${UBUME_NPM_PACKAGE}@latest`;
16
+
17
+ // Yarn Classic only — Yarn Berry (v2+) removed `yarn global`, but Berry installs
18
+ // don't produce the global launcher paths we detect, so Classic is the only case.
19
+ const UPDATE_ARGV: Record<GlobalPackageManager, readonly string[]> = {
20
+ npm: ["npm", "install", "-g", PACKAGE_SPEC],
21
+ pnpm: ["pnpm", "add", "-g", PACKAGE_SPEC],
22
+ yarn: ["yarn", "global", "add", PACKAGE_SPEC],
23
+ bun: ["bun", "add", "-g", PACKAGE_SPEC],
24
+ };
25
+
26
+ /**
27
+ * Infers which package manager owns the global Ubume install from the
28
+ * launcher script location (UBUME_LAUNCHER_SCRIPT / CODEXA_LAUNCHER_SCRIPT, set by bin/ubume.js).
29
+ */
30
+ export function detectGlobalPackageManager(
31
+ env: NodeJS.ProcessEnv = process.env,
32
+ launcherPathOverride?: string,
33
+ ): GlobalPackageManager {
34
+ const launcherPath = launcherPathOverride ?? env.UBUME_LAUNCHER_SCRIPT ?? env.CODEXA_LAUNCHER_SCRIPT ?? process.argv[1] ?? "";
35
+ const normalized = launcherPath.toLowerCase().replace(/\\/g, "/");
36
+ if (!normalized) return "npm";
37
+
38
+ if (normalized.includes("pnpm")) return "pnpm";
39
+ if (normalized.includes("/.bun/") || normalized.includes("/bun/install/global/")) return "bun";
40
+ if (normalized.includes("/.yarn/") || normalized.includes("/yarn/") || normalized.includes(".config/yarn")) {
41
+ return "yarn";
42
+ }
43
+ return "npm";
44
+ }
45
+
46
+ export function getUpdateCommand(pm: GlobalPackageManager): {
47
+ displayCommand: string;
48
+ argv: readonly string[];
49
+ } {
50
+ const argv = UPDATE_ARGV[pm];
51
+ return { displayCommand: argv.join(" "), argv };
52
+ }
53
+
54
+ export interface RunUpdateCommandDeps {
55
+ platform?: NodeJS.Platform;
56
+ cwd?: string;
57
+ runCommandFn?: (
58
+ spec: CommandSpec,
59
+ handlers?: CommandStreamHandlers,
60
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
61
+ runShellCommandFn?: (
62
+ command: string,
63
+ options: Pick<CommandSpec, "cwd" | "env" | "timeoutMs">,
64
+ handlers?: CommandStreamHandlers,
65
+ ) => { child: ChildProcess; result: Promise<CommandResult>; cancel: () => void };
66
+ }
67
+
68
+ /**
69
+ * Runs the global update command for the detected package manager.
70
+ * POSIX spawns the argv directly; Windows routes the constant, whitelisted
71
+ * command string through cmd.exe so .cmd shims (npm.cmd, pnpm.cmd) resolve —
72
+ * spawning them without a shell throws EINVAL on Node >= 18.20.
73
+ */
74
+ export function runUpdateCommand(
75
+ pm: GlobalPackageManager,
76
+ handlers: CommandStreamHandlers = {},
77
+ deps: RunUpdateCommandDeps = {},
78
+ ): { result: Promise<CommandResult>; cancel: () => void } {
79
+ const platform = deps.platform ?? process.platform;
80
+ const cwd = deps.cwd ?? process.cwd();
81
+ const { displayCommand, argv } = getUpdateCommand(pm);
82
+
83
+ if (platform === "win32") {
84
+ const runShell = deps.runShellCommandFn ?? runShellCommand;
85
+ const { result, cancel } = runShell(displayCommand, { cwd, timeoutMs: UPDATE_TIMEOUT_MS }, handlers);
86
+ return { result, cancel };
87
+ }
88
+
89
+ const run = deps.runCommandFn ?? runCommand;
90
+ const { result, cancel } = run(
91
+ { executable: argv[0]!, args: [...argv.slice(1)], cwd, timeoutMs: UPDATE_TIMEOUT_MS },
92
+ handlers,
93
+ );
94
+ return { result, cancel };
95
+ }
96
+
97
+ const PERMISSION_STDERR_RE = /EACCES|EPERM|permission denied/i;
98
+
99
+ /** npm reports EACCES via stderr with a nonzero exit, not as a spawn error. */
100
+ export function isPermissionError(result: CommandResult): boolean {
101
+ if (result.errorCode === "EACCES" || result.errorCode === "EPERM") return true;
102
+ return result.exitCode !== 0 && PERMISSION_STDERR_RE.test(result.stderr);
103
+ }
104
+
105
+ export function formatPermissionGuidance(pm: GlobalPackageManager): string {
106
+ const { displayCommand } = getUpdateCommand(pm);
107
+ const lines = [
108
+ `${pm} could not write to its global package directory, so the update was not installed.`,
109
+ ];
110
+ if (pm === "npm") {
111
+ lines.push(
112
+ "Check where global packages are installed with `npm config get prefix` and make sure that directory is writable by your user, or switch to a user-writable prefix (for example `npm config set prefix ~/.npm-global`, then add its bin directory to PATH).",
113
+ );
114
+ } else {
115
+ lines.push(`Make sure the ${pm} global package directory is writable by your user.`);
116
+ }
117
+ lines.push(`You can also run the command manually in a terminal with the right permissions: ${displayCommand}`);
118
+ return lines.join("\n");
119
+ }