@dobby.ai/dobby 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 (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,1018 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { Readable, Writable } from "node:stream";
5
+ import type { ImageContent } from "@mariozechner/pi-ai";
6
+ import {
7
+ query as runClaudeQuery,
8
+ type HookCallback,
9
+ type HookCallbackMatcher,
10
+ type HookEvent,
11
+ type Options as ClaudeSdkOptions,
12
+ type Query as ClaudeSdkQuery,
13
+ type SDKAssistantMessage,
14
+ type SDKMessage,
15
+ type SDKUserMessage,
16
+ type SpawnOptions as ClaudeSdkSpawnOptions,
17
+ type SpawnedProcess as ClaudeSdkSpawnedProcess,
18
+ } from "@anthropic-ai/claude-agent-sdk";
19
+ import { z } from "zod";
20
+ import type {
21
+ GatewayAgentEvent,
22
+ GatewayAgentRuntime,
23
+ ProviderContributionModule,
24
+ ProviderInstance,
25
+ ProviderInstanceCreateOptions,
26
+ ProviderSessionArchiveOptions,
27
+ ProviderRuntimeCreateOptions,
28
+ SpawnOptions as GatewaySpawnOptions,
29
+ SpawnedProcess as GatewaySpawnedProcess,
30
+ } from "@dobby.ai/plugin-sdk";
31
+
32
+ const BOXLITE_CONTEXT_CONVERSATION_KEY_ENV = "__IM_AGENT_BOXLITE_CONVERSATION_KEY";
33
+ const BOXLITE_CONTEXT_PROJECT_ROOT_ENV = "__IM_AGENT_BOXLITE_PROJECT_ROOT";
34
+
35
+ const DEFAULT_ENV_ALLOW_LIST = [
36
+ "ANTHROPIC_AUTH_TOKEN",
37
+ "ANTHROPIC_API_KEY",
38
+ "ANTHROPIC_BASE_URL",
39
+ "ANTHROPIC_MODEL",
40
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
41
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
42
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
43
+ "API_TIMEOUT_MS",
44
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
45
+ "PATH",
46
+ "HOME",
47
+ "TMPDIR",
48
+ "LANG",
49
+ "LC_ALL",
50
+ ] as const;
51
+
52
+ const DEFAULT_AUTH_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
53
+ const DEFAULT_READONLY_TOOLS = ["Read", "Grep", "Glob", "LS"] as const;
54
+ const DEFAULT_FULL_TOOLS = [...DEFAULT_READONLY_TOOLS, "Edit", "Write", "Bash"] as const;
55
+
56
+ type SettingSource = "user" | "project" | "local";
57
+ type ClaudeImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
58
+
59
+ interface ClaudeProviderConfig {
60
+ model: string;
61
+ maxTurns: number;
62
+ executable?: string;
63
+ executableArgs: string[];
64
+ sandboxedProcess: boolean;
65
+ requireSandboxSpawn: boolean;
66
+ dangerouslySkipPermissions: boolean;
67
+ settingSources: SettingSource[];
68
+ authMode: "env";
69
+ envAllowList: string[];
70
+ authEnvKeys: string[];
71
+ readonlyTools: string[];
72
+ fullTools: string[];
73
+ }
74
+
75
+ interface SessionMeta {
76
+ sessionId: string;
77
+ updatedAtMs: number;
78
+ }
79
+
80
+ const claudeProviderConfigSchema = z.object({
81
+ model: z.string().min(1),
82
+ maxTurns: z.number().int().positive().default(20),
83
+ executable: z.string().optional(),
84
+ executableArgs: z.array(z.string()).default([]),
85
+ sandboxedProcess: z.boolean().default(true),
86
+ requireSandboxSpawn: z.boolean().default(true),
87
+ dangerouslySkipPermissions: z.boolean().default(true),
88
+ settingSources: z.array(z.enum(["user", "project", "local"]))
89
+ .nonempty()
90
+ .default(["project", "local"]),
91
+ authMode: z.literal("env").default("env"),
92
+ envAllowList: z.array(z.string().min(1)).default([...DEFAULT_ENV_ALLOW_LIST]),
93
+ authEnvKeys: z.array(z.string().min(1)).default([...DEFAULT_AUTH_ENV_KEYS]),
94
+ readonlyTools: z.array(z.string().min(1)).default([...DEFAULT_READONLY_TOOLS]),
95
+ fullTools: z.array(z.string().min(1)).default([...DEFAULT_FULL_TOOLS]),
96
+ });
97
+
98
+ function isRecord(value: unknown): value is Record<string, unknown> {
99
+ return Boolean(value) && typeof value === "object";
100
+ }
101
+
102
+ function normalizeExecutable(configBaseDir: string, value: string | undefined): string | undefined {
103
+ if (!value) return undefined;
104
+ const trimmed = value.trim();
105
+ if (trimmed.length === 0) return undefined;
106
+ if (trimmed === "~") return resolve(process.env.HOME ?? "", ".");
107
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
108
+ return resolve(process.env.HOME ?? "", trimmed.slice(2));
109
+ }
110
+
111
+ // Treat bare command names (for example `claude`) as command lookup in PATH.
112
+ const looksLikePath = trimmed.startsWith(".")
113
+ || trimmed.startsWith("/")
114
+ || trimmed.startsWith("\\")
115
+ || /^[a-zA-Z]:[\\/]/.test(trimmed)
116
+ || trimmed.includes("/")
117
+ || trimmed.includes("\\");
118
+ if (!looksLikePath) {
119
+ return trimmed;
120
+ }
121
+
122
+ return resolve(configBaseDir, trimmed);
123
+ }
124
+
125
+ function safeSegment(value: string): string {
126
+ return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
127
+ }
128
+
129
+ function assertWithinRoot(pathToCheck: string, rootDir: string): void {
130
+ const normalizedRoot = resolve(rootDir);
131
+ const normalizedPath = resolve(pathToCheck);
132
+ if (normalizedPath === normalizedRoot) return;
133
+
134
+ const rootPrefix = normalizedRoot.endsWith("/") || normalizedRoot.endsWith("\\")
135
+ ? normalizedRoot
136
+ : `${normalizedRoot}${process.platform === "win32" ? "\\" : "/"}`;
137
+
138
+ if (!normalizedPath.startsWith(rootPrefix)) {
139
+ throw new Error(`Path '${normalizedPath}' is outside allowed project root '${normalizedRoot}'`);
140
+ }
141
+ }
142
+
143
+ function normalizeToolName(toolName: string): string {
144
+ const gatewayMatch = /^gateway__([^_].+)$/.exec(toolName);
145
+ if (gatewayMatch?.[1]) {
146
+ return gatewayMatch[1];
147
+ }
148
+ return toolName;
149
+ }
150
+
151
+ function normalizeClaudeImageMimeType(mimeType: string): ClaudeImageMediaType | null {
152
+ const normalized = mimeType.toLowerCase();
153
+ if (normalized === "image/jpeg" || normalized === "image/jpg") return "image/jpeg";
154
+ if (normalized === "image/png") return "image/png";
155
+ if (normalized === "image/gif") return "image/gif";
156
+ if (normalized === "image/webp") return "image/webp";
157
+ return null;
158
+ }
159
+
160
+ function stringifyOutput(value: unknown): string {
161
+ if (typeof value === "string") return value;
162
+ if (value === null || value === undefined) return "(no output)";
163
+ try {
164
+ return JSON.stringify(value, null, 2);
165
+ } catch {
166
+ return String(value);
167
+ }
168
+ }
169
+
170
+ function extractTextFromToolResponse(value: unknown): string {
171
+ if (!isRecord(value)) return stringifyOutput(value);
172
+
173
+ const content = value.content;
174
+ if (!Array.isArray(content)) return stringifyOutput(value);
175
+
176
+ const textBlocks = content
177
+ .map((item) => {
178
+ if (!isRecord(item)) return null;
179
+ return typeof item.text === "string" ? item.text : null;
180
+ })
181
+ .filter((item): item is string => typeof item === "string");
182
+
183
+ if (textBlocks.length === 0) return stringifyOutput(value);
184
+ return textBlocks.join("\n");
185
+ }
186
+
187
+ function parseToolResult(value: unknown): { isError: boolean; output: string } {
188
+ const text = extractTextFromToolResponse(value);
189
+ const isError = isRecord(value) && (value.isError === true || value.is_error === true);
190
+ return { isError, output: text };
191
+ }
192
+
193
+ function extractAssistantText(message: SDKAssistantMessage): string {
194
+ return message.message.content
195
+ .map((block) => (block.type === "text" ? block.text : null))
196
+ .filter((part): part is string => typeof part === "string")
197
+ .join("\n")
198
+ .trim();
199
+ }
200
+
201
+ function extractTextDelta(message: SDKMessage): string | null {
202
+ if (message.type !== "stream_event") return null;
203
+ const event = message.event;
204
+ if (event.type !== "content_block_delta" || event.delta.type !== "text_delta") {
205
+ return null;
206
+ }
207
+
208
+ return event.delta.text;
209
+ }
210
+
211
+ function isResumeError(error: unknown): boolean {
212
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
213
+ if (!message.includes("session") && !message.includes("resume")) {
214
+ return false;
215
+ }
216
+
217
+ return (
218
+ message.includes("not found") ||
219
+ message.includes("no conversation") ||
220
+ message.includes("unknown") ||
221
+ message.includes("invalid") ||
222
+ message.includes("not exist") ||
223
+ message.includes("cannot resume")
224
+ );
225
+ }
226
+
227
+ function formatArchiveStamp(timestampMs: number): string {
228
+ return new Date(timestampMs).toISOString().replaceAll(":", "-").replaceAll(".", "-");
229
+ }
230
+
231
+ async function archiveSessionPath(
232
+ sessionsDir: string,
233
+ sourcePath: string,
234
+ archivedAtMs: number,
235
+ ): Promise<string | undefined> {
236
+ const relativePath = relative(sessionsDir, sourcePath);
237
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
238
+ throw new Error(`Session path '${sourcePath}' is outside sessions dir '${sessionsDir}'`);
239
+ }
240
+
241
+ const archiveRoot = join(sessionsDir, "_archived", `${formatArchiveStamp(archivedAtMs)}-${randomUUID().slice(0, 8)}`);
242
+ const archivePath = join(archiveRoot, relativePath);
243
+ await mkdir(dirname(archivePath), { recursive: true });
244
+
245
+ try {
246
+ await rename(sourcePath, archivePath);
247
+ return archivePath;
248
+ } catch (error) {
249
+ const asErr = error as NodeJS.ErrnoException;
250
+ if (asErr.code === "ENOENT") {
251
+ return undefined;
252
+ }
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ class ClaudeGatewayRuntime implements GatewayAgentRuntime {
258
+ private readonly listeners = new Set<(event: GatewayAgentEvent) => void>();
259
+ private readonly allowedTools: string[];
260
+ private readonly hooks: Partial<Record<HookEvent, HookCallbackMatcher[]>>;
261
+ private readonly activeToolIds = new Set<string>();
262
+ private activeAbortController: AbortController | null = null;
263
+ private lastSpawnCommandPreview: string | null = null;
264
+ private lastSpawnStdoutPreview: string | null = null;
265
+ private lastSpawnStderrPreview: string | null = null;
266
+ private lastApiKeySource: string | null = null;
267
+ private activeQuery: ClaudeSdkQuery | null = null;
268
+
269
+ constructor(
270
+ private readonly providerId: string,
271
+ private readonly conversationKey: string,
272
+ private readonly route: ProviderRuntimeCreateOptions["route"],
273
+ private readonly executor: ProviderRuntimeCreateOptions["executor"],
274
+ private readonly logger: ProviderInstanceCreateOptions["host"]["logger"],
275
+ private readonly providerConfig: ClaudeProviderConfig,
276
+ private readonly sessionMetaPath: string,
277
+ private readonly systemPrompt: string | undefined,
278
+ private sessionId: string | undefined,
279
+ ) {
280
+ this.allowedTools = this.buildAllowedTools();
281
+ this.hooks = this.buildHooks();
282
+ }
283
+
284
+ subscribe(listener: (event: GatewayAgentEvent) => void): () => void {
285
+ this.listeners.add(listener);
286
+ return () => {
287
+ this.listeners.delete(listener);
288
+ };
289
+ }
290
+
291
+ async prompt(text: string, options?: { images?: ImageContent[] }): Promise<void> {
292
+ const images = options?.images ?? [];
293
+ const resumeSessionId = this.sessionId;
294
+
295
+ try {
296
+ await this.runPrompt(text, images, resumeSessionId);
297
+ return;
298
+ } catch (error) {
299
+ if (!resumeSessionId || !isResumeError(error)) {
300
+ throw error;
301
+ }
302
+
303
+ this.logger.warn(
304
+ {
305
+ err: error,
306
+ providerInstance: this.providerId,
307
+ conversationKey: this.conversationKey,
308
+ previousSessionId: resumeSessionId,
309
+ },
310
+ "Failed to resume Claude session; recreating session",
311
+ );
312
+
313
+ await this.clearSessionMeta();
314
+ this.sessionId = undefined;
315
+ await this.runPrompt(text, images, undefined);
316
+ }
317
+ }
318
+
319
+ async abort(): Promise<void> {
320
+ const activeQuery = this.activeQuery;
321
+ const activeAbortController = this.activeAbortController;
322
+
323
+ if (activeAbortController) {
324
+ activeAbortController.abort();
325
+ }
326
+
327
+ if (activeQuery) {
328
+ await activeQuery.interrupt();
329
+ }
330
+ }
331
+
332
+ dispose(): void {
333
+ this.listeners.clear();
334
+ this.activeToolIds.clear();
335
+
336
+ const activeQuery = this.activeQuery;
337
+ if (activeQuery?.close) {
338
+ activeQuery.close();
339
+ }
340
+
341
+ this.activeQuery = null;
342
+ this.activeAbortController = null;
343
+ }
344
+
345
+ private emit(event: GatewayAgentEvent): void {
346
+ for (const listener of this.listeners) {
347
+ listener(event);
348
+ }
349
+ }
350
+
351
+ private async runPrompt(text: string, images: ImageContent[], resumeSessionId: string | undefined): Promise<void> {
352
+ const userSessionId = resumeSessionId ?? this.sessionId ?? randomUUID();
353
+ const userMessage = this.buildUserMessage(text, images, userSessionId);
354
+ const abortController = new AbortController();
355
+ const pathToClaudeCodeExecutable = this.resolvePathToClaudeCodeExecutable();
356
+
357
+ const queryOptions: ClaudeSdkOptions = {
358
+ cwd: this.route.profile.projectRoot,
359
+ model: this.providerConfig.model,
360
+ abortController,
361
+ maxTurns: this.providerConfig.maxTurns,
362
+ tools: this.allowedTools,
363
+ allowedTools: this.allowedTools,
364
+ hooks: this.hooks,
365
+ env: this.buildSdkEnv(),
366
+ settingSources: this.providerConfig.settingSources,
367
+ permissionMode: this.providerConfig.dangerouslySkipPermissions ? "bypassPermissions" : "default",
368
+ ...(this.providerConfig.dangerouslySkipPermissions ? { allowDangerouslySkipPermissions: true } : {}),
369
+ ...(this.providerConfig.sandboxedProcess
370
+ ? {
371
+ spawnClaudeCodeProcess: (spawnOptions: ClaudeSdkSpawnOptions) => this.spawnClaudeProcess(spawnOptions),
372
+ }
373
+ : {}),
374
+ ...(this.systemPrompt ? { systemPrompt: this.systemPrompt } : {}),
375
+ ...(resumeSessionId ? { resume: resumeSessionId } : {}),
376
+ ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
377
+ ...(this.providerConfig.executableArgs.length > 0 ? { executableArgs: this.providerConfig.executableArgs } : {}),
378
+ };
379
+
380
+ let queryHandle: ClaudeSdkQuery;
381
+ try {
382
+ queryHandle = runClaudeQuery({
383
+ prompt: this.singleMessageStream(userMessage),
384
+ options: queryOptions,
385
+ });
386
+ } catch (error) {
387
+ throw this.enhanceSandboxSpawnError(error);
388
+ }
389
+
390
+ this.activeAbortController = abortController;
391
+ this.activeQuery = queryHandle;
392
+ this.sessionId = userSessionId;
393
+
394
+ let assistantFromDeltas = "";
395
+ let assistantFromMessage = "";
396
+ let assistantFromResult = "";
397
+
398
+ try {
399
+ for await (const message of queryHandle) {
400
+ if (typeof message.session_id === "string" && message.session_id.trim().length > 0) {
401
+ this.sessionId = message.session_id;
402
+ }
403
+
404
+ if (message.type === "system" && message.subtype === "init") {
405
+ this.lastApiKeySource = message.apiKeySource;
406
+
407
+ if (this.providerConfig.authMode === "env" && this.lastApiKeySource === "none") {
408
+ throw new Error(
409
+ "Claude Code did not detect credentials from environment (apiKeySource=none). " +
410
+ "Set ANTHROPIC_API_KEY in gateway process env. ANTHROPIC_AUTH_TOKEN alone is not sufficient in this SDK/CLI mode.",
411
+ );
412
+ }
413
+ continue;
414
+ }
415
+
416
+ const delta = extractTextDelta(message);
417
+ if (delta !== null) {
418
+ assistantFromDeltas += delta;
419
+ this.emit({ type: "message_delta", delta });
420
+ continue;
421
+ }
422
+
423
+ if (message.type === "assistant") {
424
+ const textFromAssistant = extractAssistantText(message);
425
+ if (textFromAssistant.length > 0) {
426
+ assistantFromMessage = textFromAssistant;
427
+ }
428
+ continue;
429
+ }
430
+
431
+ if (message.type === "system" && message.subtype === "status" && message.status === "compacting") {
432
+ this.emit({ type: "status", message: "Compacting context..." });
433
+ continue;
434
+ }
435
+
436
+ if (message.type === "result") {
437
+ const resultSubtype = message.subtype;
438
+ if (resultSubtype === "success") {
439
+ assistantFromResult = message.result;
440
+ }
441
+
442
+ if (resultSubtype === "error_max_turns") {
443
+ this.emit({ type: "status", message: "Reached max turns; returning partial response." });
444
+ continue;
445
+ }
446
+
447
+ if (message.is_error === true || resultSubtype.startsWith("error_")) {
448
+ const errors = "errors" in message ? message.errors : [];
449
+ const details = errors.length > 0 ? errors.join("\n") : `Claude query failed (${String(resultSubtype)})`;
450
+ throw new Error(details);
451
+ }
452
+ }
453
+ }
454
+
455
+ const finalText = [assistantFromDeltas, assistantFromMessage, assistantFromResult].find(
456
+ (candidate) => candidate.trim().length > 0,
457
+ );
458
+
459
+ if (finalText) {
460
+ this.emit({ type: "message_complete", text: finalText });
461
+ }
462
+
463
+ await this.persistSessionMeta();
464
+ } catch (error) {
465
+ throw this.enhanceSandboxSpawnError(error);
466
+ } finally {
467
+ this.activeAbortController = null;
468
+ this.activeQuery = null;
469
+ }
470
+ }
471
+
472
+ private spawnClaudeProcess(spawnOptions: ClaudeSdkSpawnOptions): ClaudeSdkSpawnedProcess {
473
+ const normalizedCwd = resolve(spawnOptions.cwd ?? this.route.profile.projectRoot);
474
+ assertWithinRoot(normalizedCwd, this.route.profile.projectRoot);
475
+ const attemptedCommand = [spawnOptions.command, ...spawnOptions.args].join(" ").trim();
476
+ this.lastSpawnCommandPreview = attemptedCommand.length <= 240
477
+ ? attemptedCommand
478
+ : `${attemptedCommand.slice(0, 237)}...`;
479
+
480
+ const spawned = this.executor.spawn({
481
+ command: spawnOptions.command,
482
+ args: spawnOptions.args,
483
+ cwd: normalizedCwd,
484
+ env: {
485
+ ...spawnOptions.env,
486
+ ...this.buildSandboxProcessEnv(spawnOptions.env),
487
+ ...this.buildSandboxContextEnv(),
488
+ },
489
+ signal: spawnOptions.signal,
490
+ tty: false,
491
+ } satisfies GatewaySpawnOptions);
492
+
493
+ this.captureSpawnStderr(spawned);
494
+ return this.toClaudeSdkSpawnedProcess(spawned);
495
+ }
496
+
497
+ private toClaudeSdkSpawnedProcess(process: GatewaySpawnedProcess): ClaudeSdkSpawnedProcess {
498
+ if (!(process.stdin instanceof Writable) || !(process.stdout instanceof Readable)) {
499
+ throw new Error("Sandbox executor returned non-Node streams; incompatible with Claude SDK spawn contract");
500
+ }
501
+
502
+ return {
503
+ stdin: process.stdin,
504
+ stdout: process.stdout,
505
+ get killed() {
506
+ return process.killed;
507
+ },
508
+ get exitCode() {
509
+ return process.exitCode;
510
+ },
511
+ kill(signal: NodeJS.Signals) {
512
+ return process.kill(signal);
513
+ },
514
+ on(event, listener) {
515
+ if (event === "exit") {
516
+ process.on("exit", listener as (code: number | null, signal: NodeJS.Signals | null) => void);
517
+ return;
518
+ }
519
+ process.on("error", listener as (error: Error) => void);
520
+ },
521
+ once(event, listener) {
522
+ if (event === "exit") {
523
+ process.once("exit", listener as (code: number | null, signal: NodeJS.Signals | null) => void);
524
+ return;
525
+ }
526
+ process.once("error", listener as (error: Error) => void);
527
+ },
528
+ off(event, listener) {
529
+ if (event === "exit") {
530
+ process.off("exit", listener as (code: number | null, signal: NodeJS.Signals | null) => void);
531
+ return;
532
+ }
533
+ process.off("error", listener as (error: Error) => void);
534
+ },
535
+ };
536
+ }
537
+
538
+ private resolvePathToClaudeCodeExecutable(): string | undefined {
539
+ if (this.providerConfig.executable) {
540
+ return this.providerConfig.executable;
541
+ }
542
+
543
+ // Sandboxed mode must avoid host absolute cli.js path from SDK defaults.
544
+ if (this.providerConfig.sandboxedProcess) {
545
+ return "claude";
546
+ }
547
+
548
+ return undefined;
549
+ }
550
+
551
+ private buildSandboxProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
552
+ const fallbackPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
553
+ const pathSegments = [`/home/claude/.local/bin`, `/root/.local/bin`, baseEnv.PATH ?? fallbackPath]
554
+ .join(":")
555
+ .split(":")
556
+ .map((segment) => segment.trim())
557
+ .filter((segment) => segment.length > 0);
558
+
559
+ const dedupedPath = [...new Set(pathSegments)].join(":");
560
+
561
+ return {
562
+ HOME: "/home/claude",
563
+ TMPDIR: "/tmp",
564
+ PATH: dedupedPath,
565
+ };
566
+ }
567
+
568
+ private captureSpawnStderr(process: GatewaySpawnedProcess): void {
569
+ let stdoutTail = "";
570
+ let stderrTail = "";
571
+ const maxTailLength = 8_000;
572
+
573
+ process.stdout.on("data", (chunk: unknown) => {
574
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
575
+ if (!text) {
576
+ return;
577
+ }
578
+
579
+ stdoutTail += text;
580
+ if (stdoutTail.length > maxTailLength) {
581
+ stdoutTail = stdoutTail.slice(stdoutTail.length - maxTailLength);
582
+ }
583
+ });
584
+
585
+ process.stderr.on("data", (chunk: unknown) => {
586
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
587
+ if (!text) {
588
+ return;
589
+ }
590
+
591
+ stderrTail += text;
592
+ if (stderrTail.length > maxTailLength) {
593
+ stderrTail = stderrTail.slice(stderrTail.length - maxTailLength);
594
+ }
595
+ });
596
+
597
+ const recordTail = () => {
598
+ const stdoutTrimmed = stdoutTail.trim();
599
+ const trimmed = stderrTail.trim();
600
+ this.lastSpawnStdoutPreview = stdoutTrimmed.length > 0 ? stdoutTrimmed : null;
601
+ this.lastSpawnStderrPreview = trimmed.length > 0 ? trimmed : null;
602
+ };
603
+
604
+ process.once("exit", () => {
605
+ recordTail();
606
+ });
607
+ process.once("error", () => {
608
+ recordTail();
609
+ });
610
+ }
611
+
612
+ private enhanceSandboxSpawnError(error: unknown): Error {
613
+ const normalized = error instanceof Error ? error : new Error(String(error));
614
+ if (!this.providerConfig.sandboxedProcess) {
615
+ return normalized;
616
+ }
617
+
618
+ const exitCodeMatch = /claude code process exited with code (\d+)/i.exec(normalized.message);
619
+ const exitCode = exitCodeMatch ? Number.parseInt(exitCodeMatch[1] ?? "", 10) : NaN;
620
+ if (Number.isNaN(exitCode)) {
621
+ return normalized;
622
+ }
623
+
624
+ const attempted = this.lastSpawnCommandPreview ?? this.resolvePathToClaudeCodeExecutable() ?? "(unknown)";
625
+ const stdoutSuffix = this.lastSpawnStdoutPreview
626
+ ? ` stdout: ${this.lastSpawnStdoutPreview}`
627
+ : "";
628
+ const stderrSuffix = this.lastSpawnStderrPreview
629
+ ? ` stderr: ${this.lastSpawnStderrPreview}`
630
+ : "";
631
+
632
+ if (exitCode === 127) {
633
+ return new Error(
634
+ `${normalized.message}. Sandbox command was not found: '${attempted}'. ` +
635
+ "Ensure the sandbox image has Claude Code installed and available in PATH, " +
636
+ "or set providers.items.<id>.executable to a valid in-sandbox executable." +
637
+ stdoutSuffix +
638
+ stderrSuffix,
639
+ );
640
+ }
641
+
642
+ if (exitCode === 1) {
643
+ const authHint = this.lastApiKeySource === "none"
644
+ ? " Claude init reported apiKeySource=none; ensure ANTHROPIC_API_KEY is exported to the gateway process."
645
+ : "";
646
+ return new Error(
647
+ `${normalized.message}. Claude process started but failed during initialization inside sandbox.` +
648
+ ` Command: '${attempted}'.` +
649
+ " Verify ANTHROPIC_* auth/model envs and sandbox runtime HOME/PATH assumptions." +
650
+ authHint +
651
+ stdoutSuffix +
652
+ stderrSuffix,
653
+ );
654
+ }
655
+
656
+ return new Error(`${normalized.message}. Command: '${attempted}'.${stdoutSuffix}${stderrSuffix}`);
657
+ }
658
+
659
+ private buildSdkEnv(): NodeJS.ProcessEnv {
660
+ const env: NodeJS.ProcessEnv = {
661
+ CLAUDE_AGENT_SDK_CLIENT_APP: "dobby/provider-claude",
662
+ };
663
+
664
+ for (const key of this.providerConfig.envAllowList) {
665
+ const value = process.env[key];
666
+ if (value !== undefined) {
667
+ env[key] = value;
668
+ }
669
+ }
670
+
671
+ // Claude Code SDK/CLI auth detection relies on ANTHROPIC_API_KEY.
672
+ // If user only provides ANTHROPIC_AUTH_TOKEN (common with gateway/proxy setups),
673
+ // alias it to ANTHROPIC_API_KEY for the spawned sandbox process.
674
+ if (!env.ANTHROPIC_API_KEY && env.ANTHROPIC_AUTH_TOKEN) {
675
+ env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN;
676
+ }
677
+
678
+ return env;
679
+ }
680
+
681
+ private buildSandboxContextEnv(): NodeJS.ProcessEnv {
682
+ return {
683
+ [BOXLITE_CONTEXT_CONVERSATION_KEY_ENV]: this.conversationKey,
684
+ [BOXLITE_CONTEXT_PROJECT_ROOT_ENV]: this.route.profile.projectRoot,
685
+ };
686
+ }
687
+
688
+ private buildUserMessage(text: string, images: ImageContent[], sessionId: string): SDKUserMessage {
689
+ const content: NonNullable<SDKUserMessage["message"]["content"]> = [{ type: "text", text }];
690
+
691
+ for (const image of images) {
692
+ const mimeType = normalizeClaudeImageMimeType(image.mimeType);
693
+ if (!mimeType) {
694
+ continue;
695
+ }
696
+
697
+ content.push({
698
+ type: "image",
699
+ source: {
700
+ type: "base64",
701
+ media_type: mimeType,
702
+ data: image.data,
703
+ },
704
+ });
705
+ }
706
+
707
+ return {
708
+ type: "user",
709
+ session_id: sessionId,
710
+ parent_tool_use_id: null,
711
+ message: {
712
+ role: "user",
713
+ content,
714
+ },
715
+ };
716
+ }
717
+
718
+ private async *singleMessageStream(message: SDKUserMessage): AsyncGenerator<SDKUserMessage, void, undefined> {
719
+ yield message;
720
+ }
721
+
722
+ private buildAllowedTools(): string[] {
723
+ const source = this.route.profile.tools === "readonly"
724
+ ? this.providerConfig.readonlyTools
725
+ : this.providerConfig.fullTools;
726
+ return [...source];
727
+ }
728
+
729
+ private buildHooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
730
+ const preToolUse: HookCallback = async (input) => {
731
+ if (input.hook_event_name !== "PreToolUse") {
732
+ return { continue: true };
733
+ }
734
+
735
+ const rawToolName = input.tool_name;
736
+ const displayToolName = normalizeToolName(rawToolName);
737
+ const toolUseId = input.tool_use_id;
738
+
739
+ if (toolUseId.length > 0 && this.activeToolIds.has(toolUseId)) {
740
+ return { continue: true };
741
+ }
742
+
743
+ if (toolUseId.length > 0) {
744
+ this.activeToolIds.add(toolUseId);
745
+ }
746
+
747
+ this.emit({ type: "tool_start", toolName: displayToolName });
748
+ return { continue: true };
749
+ };
750
+
751
+ const postToolUse: HookCallback = async (input) => {
752
+ if (input.hook_event_name !== "PostToolUse") {
753
+ return { continue: true };
754
+ }
755
+
756
+ const rawToolName = input.tool_name;
757
+ const displayToolName = normalizeToolName(rawToolName);
758
+ const toolUseId = input.tool_use_id;
759
+ const parsed = parseToolResult(input.tool_response);
760
+
761
+ if (toolUseId.length > 0) {
762
+ this.activeToolIds.delete(toolUseId);
763
+ }
764
+
765
+ this.emit({
766
+ type: "tool_end",
767
+ toolName: displayToolName,
768
+ isError: parsed.isError,
769
+ output: parsed.output,
770
+ });
771
+ return { continue: true };
772
+ };
773
+
774
+ const postToolUseFailure: HookCallback = async (input) => {
775
+ if (input.hook_event_name !== "PostToolUseFailure") {
776
+ return { continue: true };
777
+ }
778
+
779
+ const rawToolName = input.tool_name;
780
+ const displayToolName = normalizeToolName(rawToolName);
781
+ const toolUseId = input.tool_use_id;
782
+
783
+ if (toolUseId.length > 0) {
784
+ this.activeToolIds.delete(toolUseId);
785
+ }
786
+
787
+ const errorText = input.error || "Tool failed";
788
+ this.emit({
789
+ type: "tool_end",
790
+ toolName: displayToolName,
791
+ isError: true,
792
+ output: errorText,
793
+ });
794
+ return { continue: true };
795
+ };
796
+
797
+ const notification: HookCallback = async (input) => {
798
+ if (input.hook_event_name !== "Notification") {
799
+ return { continue: true };
800
+ }
801
+
802
+ const message = input.message;
803
+ if (message && message.trim().length > 0) {
804
+ this.emit({ type: "status", message });
805
+ }
806
+ return { continue: true };
807
+ };
808
+
809
+ const sessionStart: HookCallback = async (input) => {
810
+ if (input.hook_event_name === "SessionStart" && input.source === "resume") {
811
+ this.emit({ type: "status", message: "Resumed previous Claude session." });
812
+ }
813
+ return { continue: true };
814
+ };
815
+
816
+ return {
817
+ PreToolUse: [{ hooks: [preToolUse] }],
818
+ PostToolUse: [{ hooks: [postToolUse] }],
819
+ PostToolUseFailure: [{ hooks: [postToolUseFailure] }],
820
+ Notification: [{ hooks: [notification] }],
821
+ SessionStart: [{ hooks: [sessionStart] }],
822
+ };
823
+ }
824
+
825
+ private async persistSessionMeta(): Promise<void> {
826
+ if (!this.sessionId) return;
827
+ await mkdir(dirname(this.sessionMetaPath), { recursive: true });
828
+ const payload: SessionMeta = {
829
+ sessionId: this.sessionId,
830
+ updatedAtMs: Date.now(),
831
+ };
832
+ await writeFile(this.sessionMetaPath, JSON.stringify(payload, null, 2), "utf-8");
833
+ }
834
+
835
+ private async clearSessionMeta(): Promise<void> {
836
+ try {
837
+ await unlink(this.sessionMetaPath);
838
+ } catch (error) {
839
+ const asErr = error as NodeJS.ErrnoException;
840
+ if (asErr.code !== "ENOENT") {
841
+ throw error;
842
+ }
843
+ }
844
+ }
845
+ }
846
+
847
+ class ClaudeProviderInstanceImpl implements ProviderInstance {
848
+ constructor(
849
+ readonly id: string,
850
+ private readonly providerConfig: ClaudeProviderConfig,
851
+ private readonly dataConfig: ProviderInstanceCreateOptions["data"],
852
+ private readonly logger: ProviderInstanceCreateOptions["host"]["logger"],
853
+ ) { }
854
+
855
+ async createRuntime(options: ProviderRuntimeCreateOptions): Promise<GatewayAgentRuntime> {
856
+ await mkdir(this.dataConfig.sessionsDir, { recursive: true });
857
+
858
+ const isEphemeral = options.sessionPolicy === "ephemeral";
859
+ const sessionMetaPath = isEphemeral
860
+ ? this.getEphemeralSessionMetaPath(options.conversationKey)
861
+ : this.getSessionMetaPath(options.inbound);
862
+ let restoredSessionId: string | undefined;
863
+
864
+ if (!isEphemeral) {
865
+ try {
866
+ const raw = await readFile(sessionMetaPath, "utf-8");
867
+ const parsed = JSON.parse(raw) as SessionMeta;
868
+ if (typeof parsed.sessionId === "string" && parsed.sessionId.trim().length > 0) {
869
+ restoredSessionId = parsed.sessionId;
870
+ }
871
+ } catch (error) {
872
+ const asErr = error as NodeJS.ErrnoException;
873
+ if (asErr.code !== "ENOENT") {
874
+ this.logger.warn(
875
+ { err: error, providerInstance: this.id, conversationKey: options.conversationKey },
876
+ "Failed to load Claude session metadata; starting fresh session",
877
+ );
878
+ }
879
+ }
880
+ }
881
+
882
+ let systemPrompt: string | undefined;
883
+ if (options.route.profile.systemPromptFile) {
884
+ try {
885
+ systemPrompt = await readFile(options.route.profile.systemPromptFile, "utf-8");
886
+ } catch (error) {
887
+ this.logger.warn(
888
+ {
889
+ err: error,
890
+ providerInstance: this.id,
891
+ routeId: options.route.routeId,
892
+ file: options.route.profile.systemPromptFile,
893
+ },
894
+ "Failed to load route system prompt; continuing without custom system prompt",
895
+ );
896
+ }
897
+ }
898
+
899
+ this.logger.info(
900
+ {
901
+ providerInstance: this.id,
902
+ model: this.providerConfig.model,
903
+ routeId: options.route.routeId,
904
+ tools: options.route.profile.tools,
905
+ sandboxedProcess: this.providerConfig.sandboxedProcess,
906
+ dangerouslySkipPermissions: this.providerConfig.dangerouslySkipPermissions,
907
+ settingSources: this.providerConfig.settingSources,
908
+ restoredSession: restoredSessionId ?? null,
909
+ },
910
+ "Claude provider runtime initialized",
911
+ );
912
+
913
+ return new ClaudeGatewayRuntime(
914
+ this.id,
915
+ options.conversationKey,
916
+ options.route,
917
+ options.executor,
918
+ this.logger,
919
+ this.providerConfig,
920
+ sessionMetaPath,
921
+ systemPrompt,
922
+ restoredSessionId,
923
+ );
924
+ }
925
+
926
+ async archiveSession(options: ProviderSessionArchiveOptions): Promise<{ archived: boolean; archivePath?: string }> {
927
+ const sessionMetaPath = options.sessionPolicy === "ephemeral"
928
+ ? this.getEphemeralSessionMetaPath(options.conversationKey)
929
+ : this.getSessionMetaPath(options.inbound);
930
+ const archivePath = await archiveSessionPath(
931
+ this.dataConfig.sessionsDir,
932
+ sessionMetaPath,
933
+ options.archivedAtMs ?? Date.now(),
934
+ );
935
+ return archivePath ? { archived: true, archivePath } : { archived: false };
936
+ }
937
+
938
+ private getSessionMetaPath(inbound: ProviderRuntimeCreateOptions["inbound"]): string {
939
+ const guildSegment = safeSegment(inbound.guildId ?? "dm");
940
+ const connectorSegment = safeSegment(inbound.connectorId);
941
+ const sourceSegment = safeSegment(inbound.source.id);
942
+ const threadSegment = safeSegment(inbound.threadId ?? "root");
943
+ const chatSegment = safeSegment(inbound.chatId);
944
+
945
+ return join(
946
+ this.dataConfig.sessionsDir,
947
+ connectorSegment,
948
+ inbound.platform,
949
+ safeSegment(inbound.accountId),
950
+ guildSegment,
951
+ sourceSegment,
952
+ threadSegment,
953
+ `${chatSegment}.claude-session.json`,
954
+ );
955
+ }
956
+
957
+ private getEphemeralSessionMetaPath(conversationKey: string): string {
958
+ return join(
959
+ this.dataConfig.sessionsDir,
960
+ "_cron-ephemeral",
961
+ `${safeSegment(conversationKey)}.claude-session.json`,
962
+ );
963
+ }
964
+ }
965
+
966
+ export const providerClaudeContribution: ProviderContributionModule = {
967
+ kind: "provider",
968
+ configSchema: z.toJSONSchema(claudeProviderConfigSchema),
969
+ async createInstance(options) {
970
+ const parsed = claudeProviderConfigSchema.parse(options.config);
971
+ const executable = normalizeExecutable(options.host.configBaseDir, parsed.executable);
972
+
973
+ const authCandidates = [...new Set(parsed.authEnvKeys)];
974
+ const hasAuthEnv = authCandidates.some((key) => {
975
+ const value = process.env[key];
976
+ return typeof value === "string" && value.trim().length > 0;
977
+ });
978
+ const hasApiKey = typeof process.env.ANTHROPIC_API_KEY === "string"
979
+ && process.env.ANTHROPIC_API_KEY.trim().length > 0;
980
+ const hasAuthToken = typeof process.env.ANTHROPIC_AUTH_TOKEN === "string"
981
+ && process.env.ANTHROPIC_AUTH_TOKEN.trim().length > 0;
982
+
983
+ if (!hasAuthEnv) {
984
+ throw new Error(
985
+ `Provider instance '${options.instanceId}' requires one of auth envs: ${authCandidates.join(", ")}`,
986
+ );
987
+ }
988
+
989
+ if (!hasApiKey && hasAuthToken) {
990
+ options.host.logger.info(
991
+ {
992
+ providerInstance: options.instanceId,
993
+ },
994
+ "ANTHROPIC_AUTH_TOKEN detected without ANTHROPIC_API_KEY; provider.claude will alias token to ANTHROPIC_API_KEY for sandbox process",
995
+ );
996
+ }
997
+
998
+ const config: ClaudeProviderConfig = {
999
+ model: parsed.model,
1000
+ maxTurns: parsed.maxTurns,
1001
+ executableArgs: parsed.executableArgs,
1002
+ sandboxedProcess: parsed.sandboxedProcess,
1003
+ requireSandboxSpawn: parsed.requireSandboxSpawn,
1004
+ dangerouslySkipPermissions: parsed.dangerouslySkipPermissions,
1005
+ settingSources: parsed.settingSources,
1006
+ authMode: parsed.authMode,
1007
+ envAllowList: parsed.envAllowList,
1008
+ authEnvKeys: parsed.authEnvKeys,
1009
+ readonlyTools: parsed.readonlyTools,
1010
+ fullTools: parsed.fullTools,
1011
+ ...(executable ? { executable } : {}),
1012
+ };
1013
+
1014
+ return new ClaudeProviderInstanceImpl(options.instanceId, config, options.data, options.host.logger);
1015
+ },
1016
+ };
1017
+
1018
+ export default providerClaudeContribution;