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,1090 @@
1
+ import { createHash, randomBytes, randomUUID, scryptSync } from "node:crypto";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createRequire } from "node:module";
7
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
8
+ import { JsonRpcLineTransport } from "@deepseek-ai/dsh-sdk-protocol";
9
+ import type { BackendRunHandlers, ToolApprovalDecision } from "../../providers/types.js";
10
+ import type { ProviderChatRequest } from "../types.js";
11
+ import { resolveDefaultMaxOutputTokens } from "../localOutputBudget.js";
12
+ import type { LocalHarnessSessionMetadata } from "../../workspace/conversationStore.js";
13
+ import { resolveUbumeWorkspaceDataDir } from "../../workspace/appData.js";
14
+ import { getShellWorkspaceGuardMessage, isPathInsideAllowedRoots } from "../../workspace/workspaceGuard.js";
15
+ import { ensureSessionScratchDir, pruneStaleScratchDirs } from "../../workspace/scratchDir.js";
16
+ import { isDangerousShellCommand } from "../../agent/tools.js";
17
+ import { traceLocalStream } from "../../debug/localStreamDebug.js";
18
+ import {
19
+ DEFAULT_MAX_IMAGE_BYTES,
20
+ DEFAULT_MAX_IMAGE_DIMENSION,
21
+ DEFAULT_MAX_IMAGE_PIXELS,
22
+ DEFAULT_MAX_IMAGES_PER_MESSAGE,
23
+ DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
24
+ DEFAULT_NORMALIZED_IMAGE_MAX_BYTES,
25
+ DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION,
26
+ saveImageFile,
27
+ } from "@deepseek-ai/dsh-attachment-local";
28
+ import type { ContentBlock } from "@deepseek-ai/dsh-llm";
29
+
30
+ const HARNESS_VERSION = "0.1.1-rc.2";
31
+ const PROFILE_NAME = "ubume-local";
32
+ const HARNESS_MAX_RSS_BYTES = 1024 * 1024 * 1024;
33
+ const HARNESS_HEAP_LIMIT_MIB = 768;
34
+ const HARNESS_MEMORY_POLL_MS = 500;
35
+ const MAX_DISPLAY_REASONING_CHARS = 32_768;
36
+ const REASONING_TRUNCATED_PREFIX = "… Earlier reasoning omitted for memory safety.\n";
37
+
38
+ function readLinuxProcessRssBytes(pid: number): number | null {
39
+ if (process.platform !== "linux") return null;
40
+ try {
41
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
42
+ const match = /^VmRSS:\s+(\d+) kB$/m.exec(status);
43
+ return match ? Number(match[1]) * 1024 : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function harnessMemoryLimitMessage(): string {
50
+ return "Local Harness hit a RAM safety limit (1 GiB process RAM or 768 MiB Node heap). The turn was stopped to protect your system. The partial response remains visible; your next prompt will start a fresh Harness session.";
51
+ }
52
+
53
+ export async function buildLocalHarnessPromptContentBlocks(
54
+ dshHome: string,
55
+ prompt: string,
56
+ attachments: readonly NonNullable<ProviderChatRequest["imageAttachments"]>[number][],
57
+ ): Promise<ContentBlock[]> {
58
+ const blocks: ContentBlock[] = [{ type: "text", text: prompt }];
59
+ if (attachments.length === 0) return blocks;
60
+ if (!dshHome) throw new Error("Local Harness image storage is unavailable.");
61
+ const limits = {
62
+ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
63
+ maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
64
+ maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
65
+ maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
66
+ maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION,
67
+ mediaTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"] as const,
68
+ };
69
+ if (attachments.length > limits.maxImagesPerMessage) {
70
+ throw new Error(`Local Harness accepts at most ${limits.maxImagesPerMessage} images in one prompt.`);
71
+ }
72
+ for (const attachment of attachments) {
73
+ const data = await readFile(attachment.path);
74
+ const ref = await saveImageFile(
75
+ join(dshHome, "attachments", "v1"),
76
+ { data, mediaType: attachment.mediaType, name: attachment.name },
77
+ limits,
78
+ { maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES },
79
+ );
80
+ blocks.push({ type: "image", attachment: ref });
81
+ }
82
+ return blocks;
83
+ }
84
+ const INTERNAL_PROVIDER = "ubume-local";
85
+ const require = createRequire(import.meta.url);
86
+ const PROCESS_FINGERPRINT_SALT = randomBytes(16);
87
+
88
+ interface HarnessNotification {
89
+ sessionId?: string;
90
+ status?: string;
91
+ event?: { seq?: number; type?: string; data?: Record<string, unknown> };
92
+ childSessionId?: string;
93
+ parentSessionId?: string;
94
+ }
95
+
96
+ interface HarnessRunState {
97
+ sessionId: string;
98
+ handlers: BackendRunHandlers;
99
+ request: ProviderChatRequest;
100
+ text: string;
101
+ runningSeen: boolean;
102
+ settled: boolean;
103
+ toolArguments: Map<string, { tool: string; arguments: Record<string, unknown> }>;
104
+ reasoningText: Map<string, string>;
105
+ approvals: Set<string>;
106
+ sessionMetadata: LocalHarnessSessionMetadata;
107
+ resolve: (text: string) => void;
108
+ reject: (error: Error) => void;
109
+ abortCleanup: () => void;
110
+ turnFailure?: string;
111
+ lastUsage?: { inputTokens: number; outputTokens: number; contextTokens: number; contextWindow: number | null; exact: boolean };
112
+ /** Why the last model turn stopped (`max-tokens`, `stop`, `aborted`, …), from the finish chunk or turn/end. */
113
+ stopReason?: string;
114
+ /** Number of output-window continuations issued inside this logical Ubume run. */
115
+ continuationCount: number;
116
+ /** Assistant-text length at the start of the current model turn. */
117
+ windowStartTextLength: number;
118
+ /** Tool-event count at the start of the current model turn. */
119
+ windowStartToolEventCount: number;
120
+ /** Run-wide count used to detect useful progress across output windows. */
121
+ toolEventCount: number;
122
+ /** Run-wide reasoning delta count, used to select the corrective continuation prompt. */
123
+ reasoningEventCount: number;
124
+ /** Reasoning delta count at the start of the current model turn. */
125
+ windowStartReasoningEventCount: number;
126
+ /** Consecutive max-token windows that produced neither assistant text nor tool activity. */
127
+ consecutiveNoProgressWindows: number;
128
+ /** Prevents a cancellation race from enqueueing another continuation prompt. */
129
+ cancelled: boolean;
130
+ }
131
+
132
+ interface HarnessConfig {
133
+ baseUrl: string;
134
+ apiKey: string;
135
+ model: string;
136
+ contextWindow: number;
137
+ maxTokens: number;
138
+ supportsVision: boolean;
139
+ /** Reasoning effort to request, or null when the model has not opted in. */
140
+ reasoningEffort: HarnessReasoningEffort | null;
141
+ }
142
+
143
+ type HarnessReasoningEffort = "low" | "medium" | "high";
144
+
145
+ function resolveHarnessReasoningEffort(request: ProviderChatRequest, model: string): HarnessReasoningEffort | null {
146
+ if (request.localConfig?.models?.[model]?.supportsReasoningEffort !== true) return null;
147
+ const level = (request.runtime as { reasoningLevel?: unknown }).reasoningLevel;
148
+ return level === "low" || level === "medium" || level === "high" ? level : null;
149
+ }
150
+
151
+ type HarnessSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
152
+
153
+ function resolveHarnessSandboxMode(request: ProviderChatRequest): HarnessSandboxMode {
154
+ if (request.runIntent === "plan" || request.runtime.planMode) return "read-only";
155
+ const mode = String(request.runtime.policy.sandboxMode);
156
+ if (mode === "read-only") return "read-only";
157
+ if (mode === "danger-full-access" || mode === "full-access") return "danger-full-access";
158
+ return "workspace-write";
159
+ }
160
+
161
+ function isRecord(value: unknown): value is Record<string, unknown> {
162
+ return typeof value === "object" && value !== null && !Array.isArray(value);
163
+ }
164
+
165
+ function textFromContent(value: unknown): string {
166
+ if (!Array.isArray(value)) return "";
167
+ return value.flatMap((block) => {
168
+ if (!isRecord(block)) return [];
169
+ if ((block.type === "text" || block.type === "output_text") && typeof block.text === "string") return [block.text];
170
+ if (Array.isArray(block.content)) return [textFromContent(block.content)];
171
+ return [];
172
+ }).join("");
173
+ }
174
+
175
+ function transcriptHash(request: ProviderChatRequest): string {
176
+ return createHash("sha256")
177
+ .update(JSON.stringify(request.conversationHistory ?? []))
178
+ .digest("hex");
179
+ }
180
+
181
+ function routeFingerprint(config: HarnessConfig, request: ProviderChatRequest): string {
182
+ return createHash("sha256").update(JSON.stringify({
183
+ baseUrl: config.baseUrl,
184
+ model: config.model,
185
+ contextWindow: config.contextWindow,
186
+ maxTokens: config.maxTokens,
187
+ supportsVision: config.supportsVision,
188
+ reasoningEffort: config.reasoningEffort,
189
+ sandbox: resolveHarnessSandboxMode(request),
190
+ writableRoots: request.runtime.policy.writableRoots,
191
+ })).digest("hex");
192
+ }
193
+
194
+ function secretFingerprint(value: string): string {
195
+ // This only detects credential changes during this process. A process-local
196
+ // salt and memory-hard KDF prevent an exposed fingerprint from becoming a
197
+ // reusable offline API-key oracle.
198
+ return scryptSync(value, PROCESS_FINGERPRINT_SALT, 32).toString("hex");
199
+ }
200
+
201
+ function formatTokens(value: number): string {
202
+ return Math.max(0, Math.round(value)).toLocaleString("en-US");
203
+ }
204
+
205
+ function yamlString(value: string): string {
206
+ return `'${value.replace(/'/g, "''")}'`;
207
+ }
208
+
209
+ function sanitizedEndpoint(value: string): string {
210
+ try {
211
+ const url = new URL(value);
212
+ url.username = "";
213
+ url.password = "";
214
+ url.search = "";
215
+ url.hash = "";
216
+ return url.toString().replace(/\/$/, "");
217
+ } catch {
218
+ return "configured Local endpoint";
219
+ }
220
+ }
221
+
222
+ function resolveDshBin(): string {
223
+ const packagePath = require.resolve("@deepseek-ai/dsh/package.json");
224
+ const manifest = JSON.parse(readFileSync(packagePath, "utf8")) as { bin?: { dsh?: string } };
225
+ if (!manifest.bin?.dsh) throw new Error("The installed @deepseek-ai/dsh package has no dsh executable.");
226
+ return resolve(dirname(packagePath), manifest.bin.dsh);
227
+ }
228
+
229
+ function prepareSessionScratch(request: ProviderChatRequest, sessionId: string, resumed: boolean): string | null {
230
+ if (resolveHarnessSandboxMode(request) === "read-only") return null;
231
+ try {
232
+ const scratch = ensureSessionScratchDir(request.workspaceRoot, sessionId);
233
+ if (!resumed) pruneStaleScratchDirs(request.workspaceRoot, { keep: sessionId });
234
+ return `Scratch directory for this session: ${scratch.relativePath}/ (put every temporary test, debug, or probe file there, not in the project).`;
235
+ } catch (error) {
236
+ traceLocalStream("harness.scratch.unavailable", { sessionId, error: error instanceof Error ? error.message : String(error) });
237
+ return null;
238
+ }
239
+ }
240
+
241
+ function bridgePath(): string {
242
+ return fileURLToPath(new URL("../../../../bin/ubume-local-harness-bridge.js", import.meta.url));
243
+ }
244
+
245
+ function profilePatch(supportsVision: boolean, reasoningEffortEnabled = false): string {
246
+ const input = supportsVision ? "[text, image]" : "[text]";
247
+ // pi-ai only accepts a reasoning effort for models that declare their
248
+ // levels, so both the declaration and the provider default are emitted only
249
+ // when the model opted in (supports_reasoning_effort in providers.json).
250
+ const providerReasoning = reasoningEffortEnabled
251
+ ? "\n reasoning: !!js process.env.UBUME_DSH_REASONING_EFFORT"
252
+ : "";
253
+ const modelReasoning = reasoningEffortEnabled
254
+ ? `
255
+ reasoningEfforts:
256
+ low: low
257
+ medium: medium
258
+ high: high
259
+ compat:
260
+ thinkingFormat: openai`
261
+ : "";
262
+ return `- id: hmr
263
+ disabled: true
264
+ - id: session-telemetry-otel
265
+ disabled: true
266
+ - id: llm-deepseek
267
+ disabled: true
268
+ - id: session-title-llm
269
+ disabled: true
270
+ - id: web
271
+ disabled: true
272
+ - id: web-search-deepseek
273
+ disabled: true
274
+ - id: tool-web
275
+ disabled: true
276
+ - id: agent-default-model
277
+ config:
278
+ provider: ${INTERNAL_PROVIDER}
279
+ model: !!js process.env.UBUME_DSH_MODEL
280
+ - id: llm-pi-ai
281
+ config:
282
+ providers:
283
+ ${INTERNAL_PROVIDER}:
284
+ displayName: Ubume Local
285
+ apiKeyEnv: UBUME_DSH_API_KEY
286
+ api: openai-completions
287
+ baseURL: !!js process.env.UBUME_DSH_BASE_URL
288
+ compat:
289
+ supportsDeveloperRole: false
290
+ maxTokensField: max_tokens
291
+ defaultContextWindow: !!js Number(process.env.UBUME_DSH_CONTEXT_WINDOW)
292
+ defaultMaxTokens: !!js Number(process.env.UBUME_DSH_MAX_TOKENS)
293
+ defaultInput: ${input}${providerReasoning}
294
+ models:
295
+ - id: !!js process.env.UBUME_DSH_MODEL
296
+ name: !!js process.env.UBUME_DSH_MODEL
297
+ contextWindow: !!js Number(process.env.UBUME_DSH_CONTEXT_WINDOW)
298
+ maxTokens: !!js Number(process.env.UBUME_DSH_MAX_TOKENS)
299
+ input: ${input}${modelReasoning}
300
+ - id: sandbox-policy
301
+ config:
302
+ mode: !!js process.env.DSH_PERMISSION_MODE
303
+ workspaceRoot: !!js process.cwd()
304
+ - id: approval
305
+ config:
306
+ policy: !!js process.env.UBUME_DSH_APPROVAL_POLICY
307
+ - id: permission
308
+ config:
309
+ defaultPreset: !!js process.env.UBUME_DSH_PERMISSION_PRESET
310
+ presets:
311
+ read-only:
312
+ sandbox: read-only
313
+ approval: ask
314
+ name: Read only
315
+ description: Read-only access controlled by Ubume.
316
+ workspace-write:
317
+ sandbox: workspace-write
318
+ approval: ask
319
+ name: Workspace write
320
+ description: Workspace writes controlled by Ubume.
321
+ danger-full-access:
322
+ sandbox: danger-full-access
323
+ approval: never
324
+ name: Full access
325
+ description: Full filesystem access controlled by Ubume.
326
+ - id: tools
327
+ config:
328
+ mode: native
329
+ - id: system-prompt
330
+ config:
331
+ persona: >-
332
+ You are a coding agent running inside Ubume. Work only in the active workspace,
333
+ use the provided Harness tools for shell and file operations, and respect every
334
+ Ubume permission decision. Put throwaway files you create only to test, debug,
335
+ or inspect your work (harness pages, probe scripts, logs, dumps, browser profiles)
336
+ in the session scratch directory under .ubume/scratch/ that Ubume names, never
337
+ in the project root or source tree. Only deliverables the user asked for belong
338
+ in the project.
339
+ - insert:
340
+ - id: ubume-local-harness-bridge
341
+ name: ${yamlString(bridgePath())}
342
+ `;
343
+ }
344
+
345
+ function ensureProfile(workspaceRoot: string, config: HarnessConfig): string {
346
+ const home = join(resolveUbumeWorkspaceDataDir(workspaceRoot), "local-harness", `v-${HARNESS_VERSION}`);
347
+ const profileDir = join(home, "profiles", PROFILE_NAME);
348
+ mkdirSync(profileDir, { recursive: true });
349
+ writeFileSync(join(profileDir, "package.json"), `${JSON.stringify({
350
+ private: true,
351
+ dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"] } },
352
+ }, null, 2)}\n`, "utf8");
353
+ writeFileSync(join(profileDir, "cordis.patch.yml"), profilePatch(config.supportsVision, config.reasoningEffort !== null), "utf8");
354
+ return home;
355
+ }
356
+
357
+ function resolveHarnessConfig(request: ProviderChatRequest): HarnessConfig {
358
+ const resolved = request.resolvedLocalAgentConfig;
359
+ const selectedBackend = request.route.localBackend ?? request.localConfig?.localBackend;
360
+ if (selectedBackend === "unsloth" && !resolved) {
361
+ throw new Error("Local agent request failed: the selected Unsloth connection was not resolved before Harness startup.");
362
+ }
363
+ const local = request.localConfig;
364
+ const model = resolved?.modelId ?? (request.route.modelId || local?.pinnedModel || local?.currentModel || local?.defaultModel || "");
365
+ if (!model) throw new Error("Local agent request failed: no Local model is selected.");
366
+ const modelConfig = local?.models?.[model];
367
+ if (resolved?.supportsToolCalls === false || modelConfig?.supportsToolCalls === false) {
368
+ throw new Error(`Local agent request failed.\n\nModel: ${model}\n\nThe selected model is configured without tool/function-calling support required by the Local agent harness.`);
369
+ }
370
+ if (resolved?.supportsStreaming === false || modelConfig?.supportsStreaming === false) {
371
+ throw new Error(`Local agent request failed.\n\nModel: ${model}\n\nThe selected model is configured without streaming support required by Ubume's Local agent harness.`);
372
+ }
373
+ if (resolved?.supportsSystemPrompt === false || modelConfig?.supportsSystemPrompt === false) {
374
+ throw new Error(`Local agent request failed.\n\nModel: ${model}\n\nThe selected model is configured without system-prompt support required by the Local agent harness.`);
375
+ }
376
+ return {
377
+ baseUrl: (resolved?.baseUrl ?? local?.baseUrl ?? process.env.UBUME_LOCAL_BASE_URL ?? "http://localhost:1234/v1").replace(/\/+$/, ""),
378
+ apiKey: resolved?.apiKey ?? local?.apiKey ?? process.env.UBUME_LOCAL_API_KEY ?? "lm-studio",
379
+ model,
380
+ contextWindow: resolved?.contextWindow ?? modelConfig?.contextLength ?? 32_768,
381
+ maxTokens: resolved?.maxTokens
382
+ ?? modelConfig?.maxOutputTokens
383
+ ?? resolveDefaultMaxOutputTokens(resolved?.contextWindow ?? modelConfig?.contextLength),
384
+ supportsVision: resolved?.supportsVision ?? modelConfig?.supportsVision === true,
385
+ reasoningEffort: resolveHarnessReasoningEffort(request, model),
386
+ };
387
+ }
388
+
389
+ function normalizedArgs(value: unknown): Record<string, unknown> {
390
+ return isRecord(value) ? value : {};
391
+ }
392
+
393
+ function commandFrom(tool: string, args: Record<string, unknown>): string {
394
+ if ((tool === "bash" || tool === "pwsh") && typeof args.command === "string") return args.command;
395
+ if (typeof args.path === "string") return `${tool} ${args.path}`;
396
+ if (typeof args.file_path === "string") return `${tool} ${args.file_path}`;
397
+ return tool;
398
+ }
399
+
400
+ function pathsFrom(args: Record<string, unknown>): string[] {
401
+ return [args.path, args.file_path, args.old_path, args.new_path]
402
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0);
403
+ }
404
+
405
+ function isMutatingTool(tool: string): boolean {
406
+ return ["bash", "pwsh", "write", "edit", "str_replace_editor"].includes(tool);
407
+ }
408
+
409
+ export interface LocalHarnessRunner {
410
+ run(request: ProviderChatRequest, handlers: BackendRunHandlers, signal: AbortSignal): Promise<string>;
411
+ shutdown(): Promise<void>;
412
+ terminate(): void;
413
+ closeSession?(sessionId: string): Promise<void>;
414
+ }
415
+
416
+ export class LocalHarnessProcess implements LocalHarnessRunner {
417
+ private child: ChildProcessWithoutNullStreams | null = null;
418
+ private transport: JsonRpcLineTransport | null = null;
419
+ private fingerprint = "";
420
+ private active: HarnessRunState | null = null;
421
+ private stderr = "";
422
+ private redactions: string[] = [];
423
+ private dshHome = "";
424
+ private memoryPoll: ReturnType<typeof setInterval> | null = null;
425
+ private failedSessionCleanup: Promise<void> = Promise.resolve();
426
+
427
+ private stopMemoryPoll(): void {
428
+ if (this.memoryPoll) clearInterval(this.memoryPoll);
429
+ this.memoryPoll = null;
430
+ }
431
+
432
+ private checkMemory(child: ChildProcessWithoutNullStreams, rssBytes: number | null): void {
433
+ if (this.child !== child || rssBytes === null || rssBytes < HARNESS_MAX_RSS_BYTES) return;
434
+ this.failActive(new Error(harnessMemoryLimitMessage()));
435
+ this.terminate();
436
+ }
437
+
438
+ async run(request: ProviderChatRequest, handlers: BackendRunHandlers, signal: AbortSignal): Promise<string> {
439
+ await this.failedSessionCleanup;
440
+ if (signal.aborted) throw new DOMException("Local request cancelled.", "AbortError");
441
+ const config = resolveHarnessConfig(request);
442
+ const fingerprint = routeFingerprint(config, request);
443
+ const processFingerprint = `${fingerprint}:${secretFingerprint(config.apiKey)}`;
444
+ await this.ensureStarted(request, config, processFingerprint, handlers);
445
+ const metadata = request.localHarnessSession;
446
+ const canResume = metadata?.routeFingerprint === fingerprint
447
+ && metadata.throughMessageCount === (request.conversationHistory?.length ?? 0)
448
+ && metadata.transcriptHash === transcriptHash(request);
449
+ if (metadata && !canResume) {
450
+ await this.transport!.request("session/close", { sessionId: metadata.sessionId }).catch(() => undefined);
451
+ }
452
+ const sessionId = canResume ? metadata.sessionId : randomUUID();
453
+ const scratchNote = prepareSessionScratch(request, sessionId, canResume);
454
+ traceLocalStream("harness.session.open", { sessionId, model: config.model, resumed: canResume, endpoint: sanitizedEndpoint(config.baseUrl) });
455
+ await this.transport!.request("session/open", {
456
+ sessionId,
457
+ resume: canResume,
458
+ }, signal);
459
+
460
+ const sessionMetadata: LocalHarnessSessionMetadata = {
461
+ version: 1,
462
+ sessionId,
463
+ harnessVersion: HARNESS_VERSION,
464
+ routeFingerprint: fingerprint,
465
+ throughMessageCount: request.conversationHistory?.length ?? 0,
466
+ transcriptHash: transcriptHash(request),
467
+ updatedAt: new Date().toISOString(),
468
+ };
469
+ handlers.onLocalHarnessSession?.(sessionMetadata, sessionId);
470
+
471
+ return new Promise<string>((resolveRun, rejectRun) => {
472
+ const state: HarnessRunState = {
473
+ sessionId,
474
+ handlers,
475
+ request,
476
+ text: "",
477
+ runningSeen: false,
478
+ settled: false,
479
+ toolArguments: new Map(),
480
+ reasoningText: new Map(),
481
+ approvals: new Set(),
482
+ continuationCount: 0,
483
+ windowStartTextLength: 0,
484
+ windowStartToolEventCount: 0,
485
+ toolEventCount: 0,
486
+ reasoningEventCount: 0,
487
+ windowStartReasoningEventCount: 0,
488
+ consecutiveNoProgressWindows: 0,
489
+ cancelled: false,
490
+ sessionMetadata,
491
+ resolve: resolveRun,
492
+ reject: rejectRun,
493
+ abortCleanup: () => undefined,
494
+ };
495
+ this.active = state;
496
+ const abort = () => {
497
+ traceLocalStream("harness.request.cancel", { sessionId });
498
+ state.cancelled = true;
499
+ this.failActive(new DOMException("Local request cancelled.", "AbortError"));
500
+ void this.transport?.request("session/cancel", { sessionId }).catch(() => this.terminate());
501
+ };
502
+ signal.addEventListener("abort", abort, { once: true });
503
+ state.abortCleanup = () => signal.removeEventListener("abort", abort);
504
+ const history = request.conversationHistory ?? [];
505
+ const conversationContent = !canResume && history.length > 0
506
+ ? [
507
+ "Ubume restored the following visible conversation into a new Local Harness session.",
508
+ "Treat it as prior dialogue; prior ephemeral tool state is unavailable.",
509
+ "",
510
+ ...history.map((message) => `${message.role.toUpperCase()}: ${message.content}`),
511
+ "",
512
+ `USER: ${request.prompt}`,
513
+ ].join("\n")
514
+ : request.prompt;
515
+ const promptContent = scratchNote ? `${scratchNote}\n\n${conversationContent}` : conversationContent;
516
+ if (!canResume && history.length > 0) {
517
+ handlers.onProgress?.({
518
+ id: "local-harness-session-migration",
519
+ source: "transcript",
520
+ text: "Restored visible Ubume history into a new Local Harness session; prior ephemeral tool state was not available.",
521
+ });
522
+ }
523
+ void buildLocalHarnessPromptContentBlocks(this.dshHome, promptContent, request.imageAttachments ?? [])
524
+ .then((contentBlocks) => this.transport!.request("session/prompt", { sessionId, contentBlocks }, signal))
525
+ .catch((error) => this.failActive(error instanceof Error ? error : new Error(String(error))));
526
+ });
527
+ }
528
+
529
+ private async ensureStarted(request: ProviderChatRequest, config: HarnessConfig, fingerprint: string, handlers: BackendRunHandlers): Promise<void> {
530
+ if (this.child && this.transport && this.fingerprint === fingerprint) return;
531
+ await this.shutdown();
532
+ handlers.onProcessLifecycle?.("before-spawn");
533
+ const dshHome = ensureProfile(request.workspaceRoot, config);
534
+ this.dshHome = dshHome;
535
+ const harnessSandboxMode = resolveHarnessSandboxMode(request);
536
+ const env: NodeJS.ProcessEnv = {
537
+ ...process.env,
538
+ NODE_OPTIONS: [process.env.NODE_OPTIONS?.trim(), `--max-old-space-size=${HARNESS_HEAP_LIMIT_MIB}`].filter(Boolean).join(" "),
539
+ UBUME_DSH_MAX_RSS_BYTES: String(HARNESS_MAX_RSS_BYTES),
540
+ DSH_HOME: dshHome,
541
+ DSH_TELEMETRY_DISABLED: "1",
542
+ DSH_PERMISSION_MODE: harnessSandboxMode,
543
+ UBUME_DSH_PERMISSION_PRESET: harnessSandboxMode,
544
+ UBUME_DSH_APPROVAL_POLICY: harnessSandboxMode === "danger-full-access" ? "never" : "ask",
545
+ UBUME_DSH_BASE_URL: config.baseUrl,
546
+ UBUME_DSH_API_KEY: config.apiKey,
547
+ UBUME_DSH_MODEL: config.model,
548
+ UBUME_DSH_CONTEXT_WINDOW: String(config.contextWindow),
549
+ UBUME_DSH_MAX_TOKENS: String(config.maxTokens),
550
+ UBUME_DSH_VISION: config.supportsVision ? "1" : "0",
551
+ ...(config.reasoningEffort ? { UBUME_DSH_REASONING_EFFORT: config.reasoningEffort } : {}),
552
+ };
553
+ const child = spawn(process.env.UBUME_NODE_PATH?.trim() || "node", [resolveDshBin(), "--profile", PROFILE_NAME], {
554
+ cwd: request.workspaceRoot,
555
+ env,
556
+ stdio: ["pipe", "pipe", "pipe"],
557
+ });
558
+ this.child = child;
559
+ this.stopMemoryPoll();
560
+ if (child.pid && process.platform === "linux") {
561
+ this.memoryPoll = setInterval(() => this.checkMemory(child, readLinuxProcessRssBytes(child.pid!)), HARNESS_MEMORY_POLL_MS);
562
+ this.memoryPoll.unref?.();
563
+ }
564
+ traceLocalStream("harness.start", { model: config.model, endpoint: sanitizedEndpoint(config.baseUrl), workspaceRoot: request.workspaceRoot });
565
+ this.stderr = "";
566
+ this.redactions = [config.apiKey].filter((value) => value.length >= 6);
567
+ let startupSettled = false;
568
+ let rejectStartup: (error: Error) => void = () => undefined;
569
+ const startupFailure = new Promise<never>((_resolve, reject) => {
570
+ rejectStartup = reject;
571
+ });
572
+ // Every listener below guards on `this.child === child`: shutdown() and
573
+ // terminate() null `this.child` before the outgoing child can emit, so a
574
+ // stale generation must never mutate state owned by its replacement.
575
+ child.stderr.on("data", (chunk) => {
576
+ if (this.child !== child) return;
577
+ this.stderr = `${this.stderr}${String(chunk)}`.slice(-12_000);
578
+ });
579
+ child.once("spawn", () => handlers.onProcessLifecycle?.("spawned"));
580
+ child.once("error", (error) => {
581
+ if (this.child !== child) return;
582
+ handlers.onProcessLifecycle?.("error");
583
+ if (!startupSettled) rejectStartup(error);
584
+ this.failActive(error);
585
+ });
586
+ child.once("exit", (code) => {
587
+ if (this.child !== child) return;
588
+ this.stopMemoryPoll();
589
+ handlers.onProcessLifecycle?.("exit");
590
+ this.child = null;
591
+ this.transport?.close();
592
+ this.transport = null;
593
+ if (!startupSettled) rejectStartup(new Error(`Local Harness exited during startup (${code ?? "signal"}).`));
594
+ if (this.active && !this.active.settled) {
595
+ const safeStderr = this.redactions.reduce((text, secret) => text.split(secret).join("[redacted]"), this.stderr).trim();
596
+ const memoryFailure = code === 85 || /heap out of memory|allocation failed.*heap/i.test(safeStderr);
597
+ const backpressureFailure = code === 86;
598
+ this.failActive(new Error(memoryFailure
599
+ ? harnessMemoryLimitMessage()
600
+ : backpressureFailure
601
+ ? "Local Harness output exceeded its 16 MiB safety buffer. The turn was stopped; your next prompt will start a fresh Harness session."
602
+ : `Local Harness exited unexpectedly (${code ?? "signal"}).${safeStderr ? `\n${safeStderr}` : ""}`));
603
+ }
604
+ });
605
+ const transport = new JsonRpcLineTransport(child.stdout, child.stdin);
606
+ this.transport = transport;
607
+ transport.onNotification((method, params) => this.onNotification(method, params as HarnessNotification, child));
608
+ transport.onRequest((method, params) => this.onBridgeRequest(method, params));
609
+ transport.start();
610
+ try {
611
+ await Promise.race([
612
+ transport.request("initialize", {
613
+ cwd: request.workspaceRoot,
614
+ provider: INTERNAL_PROVIDER,
615
+ model: config.model,
616
+ maxTokens: config.maxTokens,
617
+ }),
618
+ startupFailure,
619
+ ]);
620
+ startupSettled = true;
621
+ this.fingerprint = fingerprint;
622
+ } catch (error) {
623
+ startupSettled = true;
624
+ this.terminate();
625
+ const message = error instanceof Error ? error.message : String(error);
626
+ const safeStderr = this.redactions.reduce((text, secret) => text.split(secret).join("[redacted]"), this.stderr).trim();
627
+ throw new Error(`Local Harness startup failed.\n\nModel: ${config.model}\nEndpoint: ${sanitizedEndpoint(config.baseUrl)}\n\n${message}${safeStderr ? `\n${safeStderr}` : ""}`);
628
+ }
629
+ }
630
+
631
+ private onNotification(method: string, params: HarnessNotification, sourceChild?: ChildProcessWithoutNullStreams): void {
632
+ if (sourceChild && sourceChild !== this.child) return;
633
+ if (method === "harness.memory") {
634
+ if (this.child && typeof (params as { rssBytes?: unknown }).rssBytes === "number") {
635
+ this.checkMemory(this.child, (params as { rssBytes: number }).rssBytes);
636
+ }
637
+ return;
638
+ }
639
+ const state = this.active;
640
+ const ownsNotification = params.sessionId === state?.sessionId || params.parentSessionId === state?.sessionId;
641
+ if (!state || !ownsNotification || state.settled) return;
642
+ if (method === "subagent.started" || method === "subagent.finished") {
643
+ const childId = params.childSessionId ?? "subagent";
644
+ const finished = method === "subagent.finished";
645
+ state.handlers.onToolActivity?.({
646
+ id: `local-subagent-${childId}`,
647
+ command: `Subagent ${childId}`,
648
+ status: finished ? (params.status === "error" ? "failed" : "completed") : "running",
649
+ startedAt: Date.now(),
650
+ ...(finished ? { completedAt: Date.now() } : {}),
651
+ });
652
+ state.toolEventCount += 1;
653
+ return;
654
+ }
655
+ if (method === "session.status") {
656
+ if (params.status === "running") state.runningSeen = true;
657
+ if (params.status === "idle" && state.runningSeen) {
658
+ if (state.turnFailure) this.failActive(new Error(state.turnFailure));
659
+ else if (this.tryRecoverExhaustedTurn(state)) return;
660
+ else this.completeActive();
661
+ }
662
+ return;
663
+ }
664
+ if (method !== "session.event" || !params.event) return;
665
+ const event = params.event;
666
+ const data = event.data ?? {};
667
+ if (event.type?.startsWith("compaction/")) {
668
+ state.handlers.onProgress?.({
669
+ id: "local-harness-compaction",
670
+ source: "transcript",
671
+ text: event.type.endsWith("/end") ? "Local Harness compacted the conversation context." : "Local Harness is compacting conversation context.",
672
+ });
673
+ if (event.type.endsWith("/end") && state.lastUsage) {
674
+ state.handlers.onContextUsage?.({ ...state.lastUsage, compacted: true });
675
+ }
676
+ return;
677
+ }
678
+ if (event.type === "turn/end" && isRecord(data.reason)) {
679
+ const reason = data.reason;
680
+ if (reason.kind === "error") {
681
+ const failure = isRecord(reason.error) ? reason.error : {};
682
+ const message = typeof failure.message === "string" ? failure.message : "The Local Harness model request failed.";
683
+ state.turnFailure = [
684
+ `Local agent request failed: ${message}`,
685
+ "",
686
+ `Backend: ${state.request.resolvedLocalAgentConfig?.localBackend ?? state.request.route.localBackend ?? "local"}`,
687
+ `Model: ${state.request.route.modelId}`,
688
+ `Endpoint: ${sanitizedEndpoint(state.request.resolvedLocalAgentConfig?.baseUrl ?? state.request.localConfig?.baseUrl ?? "")}`,
689
+ "",
690
+ "Verify that the server supports OpenAI-compatible streaming and native tool/function calling, and that the model's chat template has tool support enabled.",
691
+ ].join("\n");
692
+ } else if (reason.kind === "blocked") {
693
+ state.turnFailure = "The Local Harness blocked this turn before completion.";
694
+ } else if (typeof reason.kind === "string") {
695
+ state.stopReason = reason.kind;
696
+ }
697
+ return;
698
+ }
699
+ if (event.type === "assistant/chunk" && isRecord(data.chunk)) {
700
+ const chunk = data.chunk;
701
+ if (chunk.type === "text-delta" && typeof chunk.text === "string") {
702
+ state.text += chunk.text;
703
+ state.handlers.onAssistantDelta?.(chunk.text);
704
+ } else if (chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
705
+ state.reasoningEventCount += 1;
706
+ const step = typeof data.step === "number" ? data.step : 0;
707
+ const index = typeof chunk.index === "number" ? chunk.index : 0;
708
+ const reasoningKey = `${step}:${index}`;
709
+ const previousDisplay = state.reasoningText.get(reasoningKey) ?? "";
710
+ const previous = previousDisplay.startsWith(REASONING_TRUNCATED_PREFIX)
711
+ ? previousDisplay.slice(REASONING_TRUNCATED_PREFIX.length)
712
+ : previousDisplay;
713
+ const combined = `${previous}${chunk.text}`;
714
+ const text = combined.length > MAX_DISPLAY_REASONING_CHARS
715
+ ? `${REASONING_TRUNCATED_PREFIX}${combined.slice(-MAX_DISPLAY_REASONING_CHARS)}`
716
+ : combined;
717
+ state.reasoningText.set(reasoningKey, text);
718
+ state.handlers.onProgress?.({
719
+ id: `local-reasoning-${state.sessionId}-${step}-${index}`,
720
+ source: "reasoning",
721
+ text,
722
+ });
723
+ } else if (chunk.type === "usage" && isRecord(chunk.usage)) {
724
+ this.emitUsage(state, chunk.usage);
725
+ } else if (chunk.type === "finish") {
726
+ const kind = isRecord(chunk.reason) && typeof chunk.reason.kind === "string" ? chunk.reason.kind : null;
727
+ const replay = isRecord(chunk.replayState) && isRecord(chunk.replayState.response) ? chunk.replayState.response : null;
728
+ const stopReason = kind ?? (replay?.stopReason === "length" ? "max-tokens" : typeof replay?.stopReason === "string" ? replay.stopReason : null);
729
+ if (stopReason) state.stopReason = stopReason;
730
+ }
731
+ return;
732
+ }
733
+ if (event.type === "assistant/message") {
734
+ if (isRecord(data.usage)) this.emitUsage(state, data.usage);
735
+ // Some compatible servers emit only a final assistant/message for a turn.
736
+ // Append it when this window has not already streamed text, while avoiding
737
+ // replay of the same turn after text-delta chunks.
738
+ if (state.text.length === state.windowStartTextLength && isRecord(data.message)) {
739
+ const finalText = textFromContent(data.message.content);
740
+ if (finalText) {
741
+ state.text += finalText;
742
+ state.handlers.onAssistantDelta?.(finalText);
743
+ }
744
+ }
745
+ return;
746
+ }
747
+ if (event.type === "tool/call") {
748
+ const callId = String(data.callId ?? event.seq ?? randomUUID());
749
+ const tool = String(data.name ?? "tool");
750
+ let args: Record<string, unknown> = {};
751
+ try { args = normalizedArgs(JSON.parse(String(data.arguments ?? "{}"))); } catch { /* malformed args stay empty */ }
752
+ state.toolArguments.set(callId, { tool, arguments: args });
753
+ state.toolEventCount = (state.toolEventCount ?? 0) + 1;
754
+ traceLocalStream("harness.tool.call", { sessionId: state.sessionId, callId, tool, arguments: args });
755
+ state.handlers.onToolActivity?.({
756
+ id: `local-tool-${callId}`,
757
+ command: commandFrom(tool, args),
758
+ status: "running",
759
+ startedAt: Date.now(),
760
+ });
761
+ return;
762
+ }
763
+ if (event.type === "tool/result" && isRecord(data.message)) {
764
+ const source = isRecord(data.message.source) ? data.message.source : {};
765
+ const callId = String(source.callId ?? event.seq ?? "result");
766
+ const known = state.toolArguments.get(callId);
767
+ const failed = isRecord(data.error);
768
+ state.handlers.onToolActivity?.({
769
+ id: `local-tool-${callId}`,
770
+ command: known ? commandFrom(known.tool, known.arguments) : "Harness tool",
771
+ status: failed ? "failed" : "completed",
772
+ startedAt: Date.now(),
773
+ completedAt: Date.now(),
774
+ summary: textFromContent(data.message.content).slice(0, 2_000) || (failed ? "Tool failed" : "Tool completed"),
775
+ });
776
+ state.toolEventCount += 1;
777
+ state.toolArguments.delete(callId);
778
+ }
779
+ }
780
+
781
+ private emitUsage(state: HarnessRunState, usage: Record<string, unknown>): void {
782
+ // Usage without token counts (e.g. a failed request) would reset the context meter to 0.
783
+ if (typeof usage.inputTokens !== "number" && typeof usage.outputTokens !== "number") return;
784
+ const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens : 0;
785
+ const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens : 0;
786
+ const normalized = {
787
+ inputTokens,
788
+ outputTokens,
789
+ contextTokens: inputTokens + outputTokens,
790
+ contextWindow: state.request.localConfig?.models?.[state.request.route.modelId]?.contextLength ?? null,
791
+ exact: true,
792
+ };
793
+ state.lastUsage = normalized;
794
+ state.handlers.onContextUsage?.(normalized);
795
+ }
796
+
797
+ private async onBridgeRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
798
+ const state = this.active;
799
+ if (!state || params.sessionId !== state.sessionId) return method === "approval/request" ? { outcome: "rejected" } : { kind: "deny", reason: "No active Ubume Local run owns this tool call." };
800
+ if (method === "tool/policy") {
801
+ const tool = String(params.tool ?? "tool");
802
+ const callId = String(params.callId ?? "");
803
+ const args = normalizedArgs(params.arguments);
804
+ if (callId) state.toolArguments.set(callId, { tool, arguments: args });
805
+ if (!isMutatingTool(tool)) return { kind: "allow" };
806
+ if (state.request.runIntent === "plan" || state.request.runtime.policy.sandboxMode === "read-only") {
807
+ return { kind: "deny", reason: "Ubume's current runtime policy is read-only." };
808
+ }
809
+ const command = typeof args.command === "string" ? args.command : "";
810
+ if (command && isDangerousShellCommand(command)) return { kind: "deny", reason: "Shell command blocked as dangerous." };
811
+ if (command) {
812
+ const guard = getShellWorkspaceGuardMessage(command, state.request.workspaceRoot, state.request.runtime.policy.writableRoots);
813
+ if (guard) return { kind: "deny", reason: guard };
814
+ }
815
+ for (const candidatePath of pathsFrom(args)) {
816
+ if (!isPathInsideAllowedRoots(candidatePath, state.request.workspaceRoot, state.request.runtime.policy.writableRoots)) {
817
+ return { kind: "deny", reason: `Path is outside the active workspace: ${candidatePath}` };
818
+ }
819
+ }
820
+ const signature = `${tool}:${command || pathsFrom(args).join(",")}`;
821
+ if (state.approvals.has(signature)) return { kind: "allow" };
822
+ if (state.request.runtime.policy.approvalPolicy === "on-request") return { kind: "ask", reason: `Allow ${commandFrom(tool, args)}?` };
823
+ return { kind: "allow" };
824
+ }
825
+ if (method === "approval/request") {
826
+ const callId = String(params.callId ?? "");
827
+ const known = state.toolArguments.get(callId);
828
+ const tool = String(params.tool ?? known?.tool ?? "tool");
829
+ const args = known?.arguments ?? {};
830
+ if (!state.handlers.onToolApproval) return { outcome: "rejected" };
831
+ const signature = `${tool}:${commandFrom(tool, args)}`;
832
+ const decision: ToolApprovalDecision = await state.handlers.onToolApproval({
833
+ tool,
834
+ signature,
835
+ command: typeof args.command === "string" ? args.command : undefined,
836
+ paths: pathsFrom(args),
837
+ });
838
+ if (decision === "allow-for-run") state.approvals.add(`${tool}:${typeof args.command === "string" ? args.command : pathsFrom(args).join(",")}`);
839
+ return { outcome: decision === "deny" ? "rejected" : "allowed-once" };
840
+ }
841
+ throw new Error(`Unknown Local Harness bridge request: ${method}`);
842
+ }
843
+
844
+ private outputBudgetExhausted(state: HarnessRunState): boolean {
845
+ if (state.stopReason === "max-tokens") return true;
846
+ const cap = this.outputBudget(state);
847
+ return cap !== null && (state.lastUsage?.outputTokens ?? 0) >= cap;
848
+ }
849
+
850
+ private outputBudget(state: HarnessRunState): number | null {
851
+ try {
852
+ return resolveHarnessConfig(state.request).maxTokens;
853
+ } catch {
854
+ return null;
855
+ }
856
+ }
857
+
858
+ /** Continue max-token turns inside the same Harness session and Ubume run. */
859
+ private tryRecoverExhaustedTurn(state: HarnessRunState): boolean {
860
+ if (state.cancelled || !this.outputBudgetExhausted(state) || !this.transport) return false;
861
+
862
+ const textProgress = state.text.length > (state.windowStartTextLength ?? 0);
863
+ const toolProgress = (state.toolEventCount ?? 0) > (state.windowStartToolEventCount ?? 0);
864
+ state.consecutiveNoProgressWindows = textProgress || toolProgress
865
+ ? 0
866
+ : (state.consecutiveNoProgressWindows ?? 0) + 1;
867
+ if (state.consecutiveNoProgressWindows >= 2) {
868
+ this.failActive(new Error([
869
+ "Local agent request failed: automatic continuation stopped after two output windows made no visible progress.",
870
+ "",
871
+ `Backend: ${state.request.resolvedLocalAgentConfig?.localBackend ?? state.request.route.localBackend ?? "local"}`,
872
+ `Model: ${state.request.route.modelId}`,
873
+ `Endpoint: ${sanitizedEndpoint(state.request.resolvedLocalAgentConfig?.baseUrl ?? state.request.localConfig?.baseUrl ?? "")}`,
874
+ "",
875
+ "The partial response remains visible. Lower the reasoning effort or raise max_output_tokens if the model supports a larger per-request limit.",
876
+ ].join("\n")));
877
+ return true;
878
+ }
879
+
880
+ state.continuationCount = (state.continuationCount ?? 0) + 1;
881
+ state.windowStartTextLength = state.text.length;
882
+ state.windowStartToolEventCount = state.toolEventCount ?? 0;
883
+ const reasoningProgress = state.reasoningEventCount > state.windowStartReasoningEventCount;
884
+ state.windowStartReasoningEventCount = state.reasoningEventCount;
885
+ state.runningSeen = false;
886
+ state.stopReason = undefined;
887
+ state.turnFailure = undefined;
888
+ state.lastUsage = undefined;
889
+ const reasoningOnly = !textProgress && !toolProgress && reasoningProgress;
890
+ state.handlers.onProgress?.({
891
+ id: "local-harness-output-recovery",
892
+ source: "transcript",
893
+ text: `Output window reached; continuing automatically (window ${state.continuationCount + 1}).`,
894
+ });
895
+ traceLocalStream("harness.request.recovery", {
896
+ sessionId: state.sessionId,
897
+ continuationCount: state.continuationCount,
898
+ responseCharacters: state.text.length,
899
+ reasoningOnly,
900
+ });
901
+ void this.transport.request("session/prompt", {
902
+ sessionId: state.sessionId,
903
+ contentBlocks: [{
904
+ type: "text",
905
+ text: reasoningOnly
906
+ ? [
907
+ "Your previous turn ran out of output budget while reasoning and produced no answer.",
908
+ "Do not restart the analysis. Keep reasoning to a few sentences and begin the work now with tool calls, or give the answer directly.",
909
+ ].join(" ")
910
+ : [
911
+ "Continue the current task exactly where the previous response stopped.",
912
+ "Do not repeat text already emitted or mention output limits or continuation.",
913
+ "If work remains, perform it with tools instead of describing what you will do.",
914
+ "Finish validation and give the final result only when the task is complete.",
915
+ ].join(" "),
916
+ }],
917
+ }).catch((error) => this.failActive(error instanceof Error ? error : new Error(String(error))));
918
+ return true;
919
+ }
920
+
921
+ private completeActive(): void {
922
+ const state = this.active;
923
+ if (!state || state.settled) return;
924
+ if (!state.text.trim()) {
925
+ const backendLines = [
926
+ `Backend: ${state.request.resolvedLocalAgentConfig?.localBackend ?? state.request.route.localBackend ?? "local"}`,
927
+ `Model: ${state.request.route.modelId}`,
928
+ `Endpoint: ${sanitizedEndpoint(state.request.resolvedLocalAgentConfig?.baseUrl ?? state.request.localConfig?.baseUrl ?? "")}`,
929
+ ];
930
+ const usage = state.lastUsage;
931
+ const usageLine = usage ? `Usage: ${formatTokens(usage.inputTokens)} input tokens, ${formatTokens(usage.outputTokens)} output tokens.` : null;
932
+ if (this.outputBudgetExhausted(state)) {
933
+ const cap = this.outputBudget(state) ?? usage?.outputTokens ?? 0;
934
+ this.failActive(new Error([
935
+ `Local agent request failed: the model used its entire output budget (${formatTokens(cap)} tokens) on reasoning and never started its answer.`,
936
+ "",
937
+ ...backendLines,
938
+ ...(usageLine ? [usageLine] : []),
939
+ "",
940
+ "Raise max_output_tokens for this model in providers.json, or lower its reasoning effort (set supports_reasoning_effort: true for the model and pick a lower reasoning level).",
941
+ ].join("\n")));
942
+ return;
943
+ }
944
+ if (state.reasoningText.size > 0) {
945
+ this.failActive(new Error([
946
+ "Local agent request failed: the model produced reasoning only and no answer or tool calls.",
947
+ "",
948
+ ...backendLines,
949
+ ...(usageLine ? [usageLine] : []),
950
+ "",
951
+ "The turn ended normally, so the server delivered no assistant text after the reasoning channel. Check the model's chat template and whether the server streams the final message.",
952
+ ].join("\n")));
953
+ return;
954
+ }
955
+ this.failActive(new Error([
956
+ "Local agent request failed: the Harness turn completed without visible assistant output.",
957
+ "",
958
+ ...backendLines,
959
+ "Verify the model chat template, streaming response format, and native tool/function-calling support.",
960
+ ].join("\n")));
961
+ return;
962
+ }
963
+ state.settled = true;
964
+ state.abortCleanup();
965
+ this.active = null;
966
+ const completedMessages = [
967
+ ...(state.request.conversationHistory ?? []),
968
+ { role: "user", content: state.request.prompt },
969
+ { role: "assistant", content: state.text },
970
+ ];
971
+ state.handlers.onLocalHarnessSession?.({
972
+ ...state.sessionMetadata,
973
+ throughMessageCount: completedMessages.length,
974
+ transcriptHash: createHash("sha256").update(JSON.stringify(completedMessages)).digest("hex"),
975
+ updatedAt: new Date().toISOString(),
976
+ }, state.sessionId);
977
+ state.handlers.onFinalAnswerObserved?.(state.text);
978
+ traceLocalStream("harness.request.complete", { sessionId: state.sessionId, responseCharacters: state.text.length });
979
+ state.resolve(state.text);
980
+ }
981
+
982
+ private failActive(error: Error): void {
983
+ const state = this.active;
984
+ if (!state || state.settled) return;
985
+ state.settled = true;
986
+ state.abortCleanup();
987
+ this.active = null;
988
+ state.handlers.onLocalHarnessSession?.(null, state.sessionId);
989
+ const child = this.child;
990
+ const transport = this.transport;
991
+ if (child && transport) {
992
+ this.failedSessionCleanup = new Promise<void>((resolveCleanup) => {
993
+ const timer = setTimeout(() => {
994
+ if (this.child === child) void this.shutdown().then(resolveCleanup, resolveCleanup);
995
+ else resolveCleanup();
996
+ }, 1_500);
997
+ void transport.request("session/close", { sessionId: state.sessionId }).then(() => {
998
+ clearTimeout(timer);
999
+ resolveCleanup();
1000
+ }).catch(() => {
1001
+ clearTimeout(timer);
1002
+ if (this.child === child) void this.shutdown().then(resolveCleanup, resolveCleanup);
1003
+ else resolveCleanup();
1004
+ });
1005
+ });
1006
+ }
1007
+ traceLocalStream("harness.request.error", {
1008
+ sessionId: state.sessionId,
1009
+ error: error.message,
1010
+ stopReason: state.stopReason ?? null,
1011
+ outputTokens: state.lastUsage?.outputTokens ?? null,
1012
+ });
1013
+ state.reject(error);
1014
+ }
1015
+
1016
+ async shutdown(): Promise<void> {
1017
+ this.stopMemoryPoll();
1018
+ const transport = this.transport;
1019
+ const child = this.child;
1020
+ this.transport = null;
1021
+ this.child = null;
1022
+ this.fingerprint = "";
1023
+ this.dshHome = "";
1024
+ if (!child) return;
1025
+ traceLocalStream("harness.shutdown", {});
1026
+ try {
1027
+ await Promise.race([
1028
+ transport?.request("shutdown", {}) ?? Promise.resolve(),
1029
+ new Promise((resolveWait) => setTimeout(resolveWait, 1_500)),
1030
+ ]);
1031
+ } catch { /* terminate below */ }
1032
+ transport?.close();
1033
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
1034
+ // Wait for the child to actually exit so the next ensureStarted() never
1035
+ // overlaps a dying generation with a freshly spawned one.
1036
+ if (child.exitCode === null && child.signalCode === null) {
1037
+ const exited = await new Promise<boolean>((resolveWait) => {
1038
+ const timer = setTimeout(() => resolveWait(false), 2_000);
1039
+ child.once("exit", () => {
1040
+ clearTimeout(timer);
1041
+ resolveWait(true);
1042
+ });
1043
+ });
1044
+ if (!exited) child.kill("SIGKILL");
1045
+ }
1046
+ }
1047
+
1048
+ async closeSession(sessionId: string): Promise<void> {
1049
+ if (!this.transport || !sessionId) return;
1050
+ await this.transport.request("session/close", { sessionId });
1051
+ }
1052
+
1053
+ terminate(): void {
1054
+ this.stopMemoryPoll();
1055
+ this.transport?.close();
1056
+ this.transport = null;
1057
+ if (this.child?.exitCode === null && this.child.signalCode === null) this.child.kill("SIGTERM");
1058
+ this.child = null;
1059
+ this.fingerprint = "";
1060
+ }
1061
+ }
1062
+
1063
+ let sharedProcess: LocalHarnessRunner = new LocalHarnessProcess();
1064
+
1065
+ export function resetLocalHarnessProcessForTests(processOverride: LocalHarnessRunner = new LocalHarnessProcess()): void {
1066
+ sharedProcess.terminate();
1067
+ sharedProcess = processOverride;
1068
+ }
1069
+
1070
+ export function runLocalHarness(request: ProviderChatRequest, handlers: BackendRunHandlers, signal: AbortSignal): Promise<string> {
1071
+ return sharedProcess.run(request, handlers, signal);
1072
+ }
1073
+
1074
+ export function shutdownLocalHarness(): Promise<void> {
1075
+ return sharedProcess.shutdown();
1076
+ }
1077
+
1078
+ export function closeLocalHarnessSession(sessionId: string | undefined): Promise<void> {
1079
+ if (!sessionId || !sharedProcess.closeSession) return Promise.resolve();
1080
+ return sharedProcess.closeSession(sessionId);
1081
+ }
1082
+
1083
+ export const localHarnessTestUtils = {
1084
+ resolveHarnessConfig,
1085
+ resolveHarnessSandboxMode,
1086
+ prepareSessionScratch,
1087
+ routeFingerprint,
1088
+ secretFingerprint,
1089
+ profilePatch,
1090
+ };