@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.
- package/.env.example +9 -0
- package/AGENTS.md +267 -0
- package/README.md +382 -0
- package/ROADMAP.md +34 -0
- package/config/cron.example.json +9 -0
- package/config/gateway.example.json +128 -0
- package/config/models.custom.example.json +27 -0
- package/dist/src/agent/event-forwarder.js +341 -0
- package/dist/src/agent/tests/event-forwarder.test.js +113 -0
- package/dist/src/cli/commands/config.js +243 -0
- package/dist/src/cli/commands/configure.js +61 -0
- package/dist/src/cli/commands/cron.js +288 -0
- package/dist/src/cli/commands/doctor.js +189 -0
- package/dist/src/cli/commands/extension.js +151 -0
- package/dist/src/cli/commands/init.js +286 -0
- package/dist/src/cli/commands/start.js +177 -0
- package/dist/src/cli/commands/topology.js +254 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/program.js +386 -0
- package/dist/src/cli/shared/config-io.js +223 -0
- package/dist/src/cli/shared/config-mutators.js +345 -0
- package/dist/src/cli/shared/config-path.js +207 -0
- package/dist/src/cli/shared/config-schema.js +159 -0
- package/dist/src/cli/shared/config-types.js +1 -0
- package/dist/src/cli/shared/configure-sections.js +429 -0
- package/dist/src/cli/shared/discord-config.js +12 -0
- package/dist/src/cli/shared/init-catalog.js +115 -0
- package/dist/src/cli/shared/init-models-file.js +65 -0
- package/dist/src/cli/shared/presets.js +86 -0
- package/dist/src/cli/shared/runtime.js +29 -0
- package/dist/src/cli/shared/schema-prompts.js +325 -0
- package/dist/src/cli/tests/config-command.test.js +42 -0
- package/dist/src/cli/tests/config-io.test.js +64 -0
- package/dist/src/cli/tests/config-mutators.test.js +47 -0
- package/dist/src/cli/tests/config-path.test.js +21 -0
- package/dist/src/cli/tests/discord-config.test.js +23 -0
- package/dist/src/cli/tests/doctor.test.js +107 -0
- package/dist/src/cli/tests/init-catalog.test.js +87 -0
- package/dist/src/cli/tests/presets.test.js +41 -0
- package/dist/src/cli/tests/program-options.test.js +92 -0
- package/dist/src/cli/tests/routing-config.test.js +199 -0
- package/dist/src/cli/tests/routing-legacy.test.js +191 -0
- package/dist/src/core/control-command.js +12 -0
- package/dist/src/core/dedup-store.js +92 -0
- package/dist/src/core/gateway.js +432 -0
- package/dist/src/core/routing.js +306 -0
- package/dist/src/core/runtime-registry.js +119 -0
- package/dist/src/core/tests/control-command.test.js +17 -0
- package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
- package/dist/src/core/tests/runtime-registry.test.js +116 -0
- package/dist/src/core/tests/typing-controller.test.js +103 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/typing-controller.js +88 -0
- package/dist/src/cron/config.js +114 -0
- package/dist/src/cron/schedule.js +49 -0
- package/dist/src/cron/service.js +196 -0
- package/dist/src/cron/store.js +142 -0
- package/dist/src/cron/types.js +1 -0
- package/dist/src/extension/loader.js +97 -0
- package/dist/src/extension/manager.js +269 -0
- package/dist/src/extension/manifest.js +21 -0
- package/dist/src/extension/registry.js +137 -0
- package/dist/src/main.js +6 -0
- package/dist/src/sandbox/executor.js +1 -0
- package/dist/src/sandbox/host-executor.js +111 -0
- package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
- package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
- package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
- package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
- package/docs/MVP.md +135 -0
- package/docs/RUNBOOK.md +242 -0
- package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
- package/package.json +43 -0
- package/plugins/connector-discord/dobby.manifest.json +18 -0
- package/plugins/connector-discord/index.js +1 -0
- package/plugins/connector-discord/package-lock.json +360 -0
- package/plugins/connector-discord/package.json +38 -0
- package/plugins/connector-discord/src/connector.ts +350 -0
- package/plugins/connector-discord/src/contribution.ts +21 -0
- package/plugins/connector-discord/src/mapper.ts +102 -0
- package/plugins/connector-discord/tsconfig.json +19 -0
- package/plugins/connector-feishu/dobby.manifest.json +18 -0
- package/plugins/connector-feishu/index.js +1 -0
- package/plugins/connector-feishu/package-lock.json +618 -0
- package/plugins/connector-feishu/package.json +38 -0
- package/plugins/connector-feishu/src/connector.ts +343 -0
- package/plugins/connector-feishu/src/contribution.ts +26 -0
- package/plugins/connector-feishu/src/mapper.ts +401 -0
- package/plugins/connector-feishu/tsconfig.json +19 -0
- package/plugins/plugin-sdk/index.d.ts +261 -0
- package/plugins/plugin-sdk/index.js +1 -0
- package/plugins/plugin-sdk/package-lock.json +12 -0
- package/plugins/plugin-sdk/package.json +22 -0
- package/plugins/provider-claude/dobby.manifest.json +17 -0
- package/plugins/provider-claude/index.js +1 -0
- package/plugins/provider-claude/package-lock.json +3398 -0
- package/plugins/provider-claude/package.json +39 -0
- package/plugins/provider-claude/src/contribution.ts +1018 -0
- package/plugins/provider-claude/tsconfig.json +19 -0
- package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
- package/plugins/provider-claude-cli/index.js +1 -0
- package/plugins/provider-claude-cli/package-lock.json +2898 -0
- package/plugins/provider-claude-cli/package.json +38 -0
- package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
- package/plugins/provider-claude-cli/tsconfig.json +19 -0
- package/plugins/provider-pi/dobby.manifest.json +17 -0
- package/plugins/provider-pi/index.js +1 -0
- package/plugins/provider-pi/package-lock.json +3877 -0
- package/plugins/provider-pi/package.json +40 -0
- package/plugins/provider-pi/src/contribution.ts +476 -0
- package/plugins/provider-pi/tsconfig.json +19 -0
- package/plugins/sandbox-core/boxlite.js +1 -0
- package/plugins/sandbox-core/dobby.manifest.json +17 -0
- package/plugins/sandbox-core/docker.js +1 -0
- package/plugins/sandbox-core/package-lock.json +136 -0
- package/plugins/sandbox-core/package.json +39 -0
- package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
- package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
- package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
- package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
- package/plugins/sandbox-core/src/docker-executor.ts +217 -0
- package/plugins/sandbox-core/tsconfig.json +19 -0
- package/scripts/local-extensions.mjs +168 -0
- package/src/agent/event-forwarder.ts +414 -0
- package/src/cli/commands/config.ts +328 -0
- package/src/cli/commands/configure.ts +92 -0
- package/src/cli/commands/cron.ts +410 -0
- package/src/cli/commands/doctor.ts +230 -0
- package/src/cli/commands/extension.ts +205 -0
- package/src/cli/commands/init.ts +396 -0
- package/src/cli/commands/start.ts +223 -0
- package/src/cli/commands/topology.ts +383 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/program.ts +465 -0
- package/src/cli/shared/config-io.ts +277 -0
- package/src/cli/shared/config-mutators.ts +440 -0
- package/src/cli/shared/config-schema.ts +228 -0
- package/src/cli/shared/config-types.ts +121 -0
- package/src/cli/shared/configure-sections.ts +551 -0
- package/src/cli/shared/discord-config.ts +14 -0
- package/src/cli/shared/init-catalog.ts +189 -0
- package/src/cli/shared/init-models-file.ts +77 -0
- package/src/cli/shared/runtime.ts +33 -0
- package/src/cli/shared/schema-prompts.ts +414 -0
- package/src/cli/tests/config-command.test.ts +56 -0
- package/src/cli/tests/config-io.test.ts +92 -0
- package/src/cli/tests/config-mutators.test.ts +59 -0
- package/src/cli/tests/doctor.test.ts +120 -0
- package/src/cli/tests/init-catalog.test.ts +96 -0
- package/src/cli/tests/program-options.test.ts +113 -0
- package/src/cli/tests/routing-config.test.ts +209 -0
- package/src/core/control-command.ts +12 -0
- package/src/core/dedup-store.ts +103 -0
- package/src/core/gateway.ts +607 -0
- package/src/core/routing.ts +379 -0
- package/src/core/runtime-registry.ts +141 -0
- package/src/core/tests/control-command.test.ts +20 -0
- package/src/core/tests/runtime-registry.test.ts +140 -0
- package/src/core/tests/typing-controller.test.ts +129 -0
- package/src/core/types.ts +318 -0
- package/src/core/typing-controller.ts +119 -0
- package/src/cron/config.ts +154 -0
- package/src/cron/schedule.ts +61 -0
- package/src/cron/service.ts +249 -0
- package/src/cron/store.ts +155 -0
- package/src/cron/types.ts +60 -0
- package/src/extension/loader.ts +145 -0
- package/src/extension/manager.ts +355 -0
- package/src/extension/manifest.ts +26 -0
- package/src/extension/registry.ts +229 -0
- package/src/main.ts +8 -0
- package/src/sandbox/executor.ts +44 -0
- package/src/sandbox/host-executor.ts +118 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1673 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import type { ImageContent } from "@mariozechner/pi-ai";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type {
|
|
8
|
+
GatewayAgentEvent,
|
|
9
|
+
GatewayAgentRuntime,
|
|
10
|
+
ProviderContributionModule,
|
|
11
|
+
ProviderInstance,
|
|
12
|
+
ProviderInstanceCreateOptions,
|
|
13
|
+
ProviderSessionArchiveOptions,
|
|
14
|
+
ProviderRuntimeCreateOptions,
|
|
15
|
+
} from "@dobby.ai/plugin-sdk";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ENV_ALLOW_LIST = [
|
|
18
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
19
|
+
"ANTHROPIC_API_KEY",
|
|
20
|
+
"ANTHROPIC_BASE_URL",
|
|
21
|
+
"ANTHROPIC_MODEL",
|
|
22
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
23
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
24
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
25
|
+
"API_TIMEOUT_MS",
|
|
26
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
|
27
|
+
"PATH",
|
|
28
|
+
"HOME",
|
|
29
|
+
"TMPDIR",
|
|
30
|
+
"LANG",
|
|
31
|
+
"LC_ALL",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_READONLY_TOOLS = ["Read", "Grep", "Glob", "LS"] as const;
|
|
35
|
+
const DEFAULT_FULL_TOOLS = [...DEFAULT_READONLY_TOOLS, "Edit", "Write", "Bash"] as const;
|
|
36
|
+
|
|
37
|
+
const DEFAULT_AUTH_STATUS_TIMEOUT_MS = 10_000;
|
|
38
|
+
const EMPTY_SUCCESS_FALLBACK_TEXT = "_Claude completed this turn without user-visible text._";
|
|
39
|
+
|
|
40
|
+
type ClaudeImageMediaType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
|
|
41
|
+
type AuthMode = "auto" | "subscription" | "apiKey";
|
|
42
|
+
type PermissionMode = "acceptEdits" | "bypassPermissions" | "default" | "dontAsk" | "plan";
|
|
43
|
+
|
|
44
|
+
interface ClaudeCliProviderConfig {
|
|
45
|
+
model: string;
|
|
46
|
+
maxTurns: number;
|
|
47
|
+
command: string;
|
|
48
|
+
commandArgs: string[];
|
|
49
|
+
authMode: AuthMode;
|
|
50
|
+
envAllowList: string[];
|
|
51
|
+
readonlyTools: string[];
|
|
52
|
+
fullTools: string[];
|
|
53
|
+
permissionMode: PermissionMode;
|
|
54
|
+
streamVerbose: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface SessionMeta {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
updatedAtMs: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ClaudeStreamState {
|
|
63
|
+
assistantFromDeltas: string;
|
|
64
|
+
assistantFromMessage: string;
|
|
65
|
+
assistantFromResult: string;
|
|
66
|
+
sawMaxTurnsError: boolean;
|
|
67
|
+
messageTypeCounts: Record<string, number>;
|
|
68
|
+
streamEventTypeCounts: Record<string, number>;
|
|
69
|
+
streamBlockTypeByIndex: Map<number, string>;
|
|
70
|
+
lastResultSubtype: string | null;
|
|
71
|
+
lastAssistantPreview: string | null;
|
|
72
|
+
lastResultPreview: string | null;
|
|
73
|
+
lastStreamEventPreview: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface JsonFallbackOutput {
|
|
77
|
+
text: string;
|
|
78
|
+
subtype: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const claudeCliProviderConfigSchema = z.object({
|
|
82
|
+
model: z.string().min(1).default("claude-sonnet-4-5"),
|
|
83
|
+
maxTurns: z.number().int().positive().default(1024),
|
|
84
|
+
command: z.string().min(1).default("claude"),
|
|
85
|
+
commandArgs: z.array(z.string()).default([]),
|
|
86
|
+
authMode: z.enum(["auto", "subscription", "apiKey"]).default("auto"),
|
|
87
|
+
envAllowList: z.array(z.string().min(1)).default([...DEFAULT_ENV_ALLOW_LIST]),
|
|
88
|
+
readonlyTools: z.array(z.string().min(1)).default([...DEFAULT_READONLY_TOOLS]),
|
|
89
|
+
fullTools: z.array(z.string().min(1)).default([...DEFAULT_FULL_TOOLS]),
|
|
90
|
+
permissionMode: z.enum(["acceptEdits", "bypassPermissions", "default", "dontAsk", "plan"]).default("bypassPermissions"),
|
|
91
|
+
streamVerbose: z.boolean().default(true),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return Boolean(value) && typeof value === "object";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeCommand(configBaseDir: string, value: string): string {
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
if (trimmed === "~") {
|
|
101
|
+
return resolve(process.env.HOME ?? "", ".");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
105
|
+
return resolve(process.env.HOME ?? "", trimmed.slice(2));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const looksLikePath = trimmed.startsWith(".")
|
|
109
|
+
|| trimmed.startsWith("/")
|
|
110
|
+
|| trimmed.startsWith("\\")
|
|
111
|
+
|| /^[a-zA-Z]:[\\/]/.test(trimmed)
|
|
112
|
+
|| trimmed.includes("/")
|
|
113
|
+
|| trimmed.includes("\\");
|
|
114
|
+
|
|
115
|
+
if (!looksLikePath) {
|
|
116
|
+
return trimmed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return resolve(configBaseDir, trimmed);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function safeSegment(value: string): string {
|
|
123
|
+
return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeToolName(toolName: string): string {
|
|
127
|
+
const gatewayMatch = /^gateway__([^_].+)$/.exec(toolName);
|
|
128
|
+
if (gatewayMatch?.[1]) {
|
|
129
|
+
return gatewayMatch[1];
|
|
130
|
+
}
|
|
131
|
+
return toolName;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeClaudeImageMimeType(mimeType: string): ClaudeImageMediaType | null {
|
|
135
|
+
const normalized = mimeType.toLowerCase();
|
|
136
|
+
if (normalized === "image/jpeg" || normalized === "image/jpg") return "image/jpeg";
|
|
137
|
+
if (normalized === "image/png") return "image/png";
|
|
138
|
+
if (normalized === "image/gif") return "image/gif";
|
|
139
|
+
if (normalized === "image/webp") return "image/webp";
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseJsonString(value: string): unknown {
|
|
144
|
+
const trimmed = value.trim();
|
|
145
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return JSON.parse(trimmed);
|
|
151
|
+
} catch {
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
157
|
+
if (isRecord(value)) {
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === "string") {
|
|
161
|
+
const parsed = parseJsonString(value);
|
|
162
|
+
if (isRecord(parsed)) {
|
|
163
|
+
return parsed;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toMessageBody(message: unknown): Record<string, unknown> | null {
|
|
170
|
+
if (!isRecord(message)) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const wrapped = toRecord(message.message);
|
|
175
|
+
if (wrapped) {
|
|
176
|
+
return wrapped;
|
|
177
|
+
}
|
|
178
|
+
return message;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatArchiveStamp(timestampMs: number): string {
|
|
182
|
+
return new Date(timestampMs).toISOString().replaceAll(":", "-").replaceAll(".", "-");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function archiveSessionPath(
|
|
186
|
+
sessionsDir: string,
|
|
187
|
+
sourcePath: string,
|
|
188
|
+
archivedAtMs: number,
|
|
189
|
+
): Promise<string | undefined> {
|
|
190
|
+
const relativePath = relative(sessionsDir, sourcePath);
|
|
191
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
192
|
+
throw new Error(`Session path '${sourcePath}' is outside sessions dir '${sessionsDir}'`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const archiveRoot = join(sessionsDir, "_archived", `${formatArchiveStamp(archivedAtMs)}-${randomUUID().slice(0, 8)}`);
|
|
196
|
+
const archivePath = join(archiveRoot, relativePath);
|
|
197
|
+
await mkdir(dirname(archivePath), { recursive: true });
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await rename(sourcePath, archivePath);
|
|
201
|
+
return archivePath;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const asErr = error as NodeJS.ErrnoException;
|
|
204
|
+
if (asErr.code === "ENOENT") {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isReasoningLikeEventType(value: string): boolean {
|
|
212
|
+
const normalized = value.toLowerCase();
|
|
213
|
+
return normalized.includes("thinking") || normalized.includes("reasoning");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collectTextFromContent(content: unknown, includeThinking: boolean): string {
|
|
217
|
+
if (typeof content === "string") {
|
|
218
|
+
const trimmed = content.trim();
|
|
219
|
+
const looksLikeStructured = trimmed.startsWith("{") || trimmed.startsWith("[");
|
|
220
|
+
const parsed = parseJsonString(content);
|
|
221
|
+
if (parsed !== content) {
|
|
222
|
+
const structured = collectTextFromContent(parsed, includeThinking);
|
|
223
|
+
if (structured.length > 0) {
|
|
224
|
+
return structured;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Avoid leaking raw JSON-ish blobs (often assistant thinking payloads).
|
|
229
|
+
if (looksLikeStructured) {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return trimmed;
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(content)) {
|
|
236
|
+
return "";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const parts: string[] = [];
|
|
240
|
+
for (const rawItem of content) {
|
|
241
|
+
if (typeof rawItem === "string") {
|
|
242
|
+
const trimmed = rawItem.trim();
|
|
243
|
+
if (trimmed.length > 0) {
|
|
244
|
+
parts.push(trimmed);
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const item = toRecord(rawItem);
|
|
250
|
+
if (!item) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const itemType = typeof item.type === "string" ? item.type : "";
|
|
255
|
+
if ((itemType === "text" || itemType === "output_text" || itemType === "input_text") && typeof item.text === "string") {
|
|
256
|
+
const trimmed = item.text.trim();
|
|
257
|
+
if (trimmed.length > 0) {
|
|
258
|
+
parts.push(trimmed);
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (itemType === "output_text" && typeof item.output_text === "string") {
|
|
264
|
+
const trimmed = item.output_text.trim();
|
|
265
|
+
if (trimmed.length > 0) {
|
|
266
|
+
parts.push(trimmed);
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (includeThinking && itemType === "thinking" && typeof item.thinking === "string") {
|
|
272
|
+
const trimmed = item.thinking.trim();
|
|
273
|
+
if (trimmed.length > 0) {
|
|
274
|
+
parts.push(trimmed);
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (itemType.length === 0) {
|
|
280
|
+
for (const candidate of [item.text, item.output_text, item.content]) {
|
|
281
|
+
if (typeof candidate !== "string") {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const trimmed = candidate.trim();
|
|
286
|
+
if (trimmed.length > 0) {
|
|
287
|
+
parts.push(trimmed);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return parts.join("\n").trim();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectTextFromChoices(choices: unknown, includeThinking: boolean): string {
|
|
298
|
+
if (!Array.isArray(choices)) {
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const parts: string[] = [];
|
|
303
|
+
for (const rawChoice of choices) {
|
|
304
|
+
const choice = toRecord(rawChoice);
|
|
305
|
+
if (!choice) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const messageText = collectTextFromRecord(toRecord(choice.message), includeThinking, 1);
|
|
310
|
+
if (messageText.length > 0) {
|
|
311
|
+
parts.push(messageText);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const delta = toRecord(choice.delta);
|
|
316
|
+
let pushedFromDelta = false;
|
|
317
|
+
if (delta) {
|
|
318
|
+
const deltaContent = collectTextFromContent(delta.content, includeThinking);
|
|
319
|
+
if (deltaContent.length > 0) {
|
|
320
|
+
parts.push(deltaContent);
|
|
321
|
+
pushedFromDelta = true;
|
|
322
|
+
} else {
|
|
323
|
+
for (const candidate of [delta.text, delta.output_text]) {
|
|
324
|
+
if (typeof candidate !== "string") {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const trimmed = candidate.trim();
|
|
328
|
+
if (trimmed.length > 0) {
|
|
329
|
+
parts.push(trimmed);
|
|
330
|
+
pushedFromDelta = true;
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (pushedFromDelta) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof choice.text === "string") {
|
|
342
|
+
const trimmed = choice.text.trim();
|
|
343
|
+
if (trimmed.length > 0) {
|
|
344
|
+
parts.push(trimmed);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return parts.join("\n").trim();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function collectTextFromRecord(record: Record<string, unknown> | null, includeThinking: boolean, depth = 0): string {
|
|
353
|
+
if (!record || depth > 4) {
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const fromContent = collectTextFromContent(record.content, includeThinking);
|
|
358
|
+
if (fromContent.length > 0) {
|
|
359
|
+
return fromContent;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const fromChoices = collectTextFromChoices(record.choices, includeThinking);
|
|
363
|
+
if (fromChoices.length > 0) {
|
|
364
|
+
return fromChoices;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const candidate of [record.text, record.output_text, record.output]) {
|
|
368
|
+
if (typeof candidate !== "string") {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const trimmed = candidate.trim();
|
|
372
|
+
if (trimmed.length > 0) {
|
|
373
|
+
return trimmed;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (includeThinking && typeof record.thinking === "string") {
|
|
378
|
+
const trimmed = record.thinking.trim();
|
|
379
|
+
if (trimmed.length > 0) {
|
|
380
|
+
return trimmed;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const nested of [record.message, record.result, record.response]) {
|
|
385
|
+
const nestedRecord = toRecord(nested);
|
|
386
|
+
if (!nestedRecord) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const nestedText = collectTextFromRecord(nestedRecord, includeThinking, depth + 1);
|
|
391
|
+
if (nestedText.length > 0) {
|
|
392
|
+
return nestedText;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function extractAssistantTextFromMessage(message: unknown): string {
|
|
400
|
+
const body = toMessageBody(message);
|
|
401
|
+
return collectTextFromRecord(body, false);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function extractAssistantTextFromResultField(result: unknown): string {
|
|
405
|
+
if (typeof result === "string") {
|
|
406
|
+
const parsed = parseJsonString(result);
|
|
407
|
+
if (parsed !== result) {
|
|
408
|
+
const fromContent = collectTextFromContent(parsed, false);
|
|
409
|
+
if (fromContent.length > 0) {
|
|
410
|
+
return fromContent;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const parsedText = collectTextFromRecord(toRecord(parsed), false);
|
|
414
|
+
if (parsedText.length > 0) {
|
|
415
|
+
return parsedText;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return result.trim();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const fromContent = collectTextFromContent(result, false);
|
|
423
|
+
if (fromContent.length > 0) {
|
|
424
|
+
return fromContent;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return collectTextFromRecord(toRecord(result), false);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function extractAssistantTextFromStructuredOutputField(structuredOutput: unknown): string {
|
|
431
|
+
const fromContent = collectTextFromContent(structuredOutput, false);
|
|
432
|
+
if (fromContent.length > 0) {
|
|
433
|
+
return fromContent;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const fromRecord = collectTextFromRecord(toRecord(structuredOutput), false);
|
|
437
|
+
if (fromRecord.length > 0) {
|
|
438
|
+
return fromRecord;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (typeof structuredOutput === "string") {
|
|
442
|
+
return structuredOutput.trim();
|
|
443
|
+
}
|
|
444
|
+
if (typeof structuredOutput === "number" || typeof structuredOutput === "boolean") {
|
|
445
|
+
return String(structuredOutput);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
return JSON.stringify(structuredOutput, null, 2).trim();
|
|
450
|
+
} catch {
|
|
451
|
+
return "";
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function safePreview(value: unknown, maxLength = 500): string | null {
|
|
456
|
+
try {
|
|
457
|
+
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
|
458
|
+
if (!raw) return null;
|
|
459
|
+
if (raw.length <= maxLength) return raw;
|
|
460
|
+
return `${raw.slice(0, maxLength)}...(truncated)`;
|
|
461
|
+
} catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function extractTextDelta(message: unknown, state: ClaudeStreamState): string | null {
|
|
467
|
+
if (!isRecord(message) || message.type !== "stream_event") {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const event = toRecord(message.event);
|
|
472
|
+
if (!event) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const eventType = typeof event.type === "string" ? event.type : "";
|
|
477
|
+
if (isReasoningLikeEventType(eventType)) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (eventType === "message_start") {
|
|
482
|
+
state.streamBlockTypeByIndex.clear();
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (eventType === "content_block_start") {
|
|
487
|
+
const index = typeof event.index === "number" ? event.index : -1;
|
|
488
|
+
const contentBlock = toRecord(event.content_block);
|
|
489
|
+
const blockType = typeof contentBlock?.type === "string" ? contentBlock.type.toLowerCase() : "";
|
|
490
|
+
if (index >= 0) {
|
|
491
|
+
state.streamBlockTypeByIndex.set(index, blockType);
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const delta = toRecord(event.delta);
|
|
497
|
+
|
|
498
|
+
if (eventType === "content_block_delta" && delta) {
|
|
499
|
+
const index = typeof event.index === "number" ? event.index : -1;
|
|
500
|
+
const blockType = index >= 0 ? (state.streamBlockTypeByIndex.get(index) ?? "") : "";
|
|
501
|
+
if (isReasoningLikeEventType(blockType)) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (blockType.length > 0 && blockType !== "text" && blockType !== "output_text") {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const deltaType = typeof delta.type === "string" ? delta.type : "";
|
|
509
|
+
if (isReasoningLikeEventType(deltaType)) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if ((deltaType === "text_delta" || deltaType === "output_text_delta") && typeof delta.text === "string") {
|
|
514
|
+
return delta.text;
|
|
515
|
+
}
|
|
516
|
+
if (deltaType === "output_text_delta" && typeof delta.output_text === "string") {
|
|
517
|
+
return delta.output_text;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (deltaType.length > 0) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Be tolerant to non-standard payloads only when delta type is absent.
|
|
525
|
+
if (typeof delta.text === "string") return delta.text;
|
|
526
|
+
if (typeof delta.output_text === "string") return delta.output_text;
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// OpenAI-compatible streamed text delta shape.
|
|
531
|
+
if (eventType.endsWith("output_text.delta") && typeof event.delta === "string") {
|
|
532
|
+
return event.delta;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (eventType.endsWith("text.delta") && typeof event.delta === "string") {
|
|
536
|
+
return event.delta;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (delta) {
|
|
540
|
+
if (typeof delta.text === "string") return delta.text;
|
|
541
|
+
if (typeof delta.output_text === "string") return delta.output_text;
|
|
542
|
+
const deltaContent = collectTextFromContent(delta.content, false);
|
|
543
|
+
if (deltaContent.length > 0) {
|
|
544
|
+
return deltaContent;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const nestedMessage = toRecord(event.message);
|
|
549
|
+
if (nestedMessage) {
|
|
550
|
+
const nestedText = collectTextFromRecord(nestedMessage, false);
|
|
551
|
+
if (nestedText.length > 0) {
|
|
552
|
+
return nestedText;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function extractSessionId(value: unknown): string | null {
|
|
560
|
+
if (!isRecord(value)) return null;
|
|
561
|
+
|
|
562
|
+
const direct = value.session_id;
|
|
563
|
+
if (typeof direct === "string" && direct.trim().length > 0) {
|
|
564
|
+
return direct;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const camel = value.sessionId;
|
|
568
|
+
if (typeof camel === "string" && camel.trim().length > 0) {
|
|
569
|
+
return camel;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isResumeError(error: unknown): boolean {
|
|
576
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
577
|
+
if (!message.includes("session") && !message.includes("resume")) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
message.includes("not found")
|
|
583
|
+
|| message.includes("no conversation")
|
|
584
|
+
|| message.includes("unknown")
|
|
585
|
+
|| message.includes("invalid")
|
|
586
|
+
|| message.includes("not exist")
|
|
587
|
+
|| message.includes("cannot resume")
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
class ClaudeCliGatewayRuntime implements GatewayAgentRuntime {
|
|
592
|
+
private readonly listeners = new Set<(event: GatewayAgentEvent) => void>();
|
|
593
|
+
private readonly allowedTools: string[];
|
|
594
|
+
private readonly activeToolUses = new Map<string, string>();
|
|
595
|
+
private activeChild: ChildProcessWithoutNullStreams | null = null;
|
|
596
|
+
private activeAbortController: AbortController | null = null;
|
|
597
|
+
private authChecked = false;
|
|
598
|
+
|
|
599
|
+
constructor(
|
|
600
|
+
private readonly providerId: string,
|
|
601
|
+
private readonly conversationKey: string,
|
|
602
|
+
private readonly route: ProviderRuntimeCreateOptions["route"],
|
|
603
|
+
private readonly logger: ProviderInstanceCreateOptions["host"]["logger"],
|
|
604
|
+
private readonly providerConfig: ClaudeCliProviderConfig,
|
|
605
|
+
private readonly sessionMetaPath: string,
|
|
606
|
+
private readonly systemPrompt: string | undefined,
|
|
607
|
+
private sessionId: string | undefined,
|
|
608
|
+
) {
|
|
609
|
+
this.allowedTools = this.route.profile.tools === "readonly"
|
|
610
|
+
? [...this.providerConfig.readonlyTools]
|
|
611
|
+
: [...this.providerConfig.fullTools];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
subscribe(listener: (event: GatewayAgentEvent) => void): () => void {
|
|
615
|
+
this.listeners.add(listener);
|
|
616
|
+
return () => {
|
|
617
|
+
this.listeners.delete(listener);
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<void> {
|
|
622
|
+
const images = options?.images ?? [];
|
|
623
|
+
const resumeSessionId = this.sessionId;
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
await this.runPrompt(text, images, resumeSessionId);
|
|
627
|
+
return;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (!resumeSessionId || !isResumeError(error)) {
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
this.logger.warn(
|
|
634
|
+
{
|
|
635
|
+
err: error,
|
|
636
|
+
providerInstance: this.providerId,
|
|
637
|
+
conversationKey: this.conversationKey,
|
|
638
|
+
previousSessionId: resumeSessionId,
|
|
639
|
+
},
|
|
640
|
+
"Failed to resume Claude CLI session; recreating session",
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
await this.clearSessionMeta();
|
|
644
|
+
this.sessionId = undefined;
|
|
645
|
+
await this.runPrompt(text, images, undefined);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async abort(): Promise<void> {
|
|
650
|
+
const controller = this.activeAbortController;
|
|
651
|
+
if (controller) {
|
|
652
|
+
controller.abort();
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const child = this.activeChild;
|
|
656
|
+
if (child) {
|
|
657
|
+
this.killChildProcess(child);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
dispose(): void {
|
|
662
|
+
this.listeners.clear();
|
|
663
|
+
this.activeToolUses.clear();
|
|
664
|
+
|
|
665
|
+
const child = this.activeChild;
|
|
666
|
+
if (child) {
|
|
667
|
+
this.killChildProcess(child);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
this.activeChild = null;
|
|
671
|
+
this.activeAbortController = null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private emit(event: GatewayAgentEvent): void {
|
|
675
|
+
for (const listener of this.listeners) {
|
|
676
|
+
listener(event);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private async ensureAuthReady(): Promise<void> {
|
|
681
|
+
if (this.authChecked) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (this.providerConfig.authMode === "apiKey") {
|
|
686
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
687
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
688
|
+
throw new Error("provider.claude-cli requires ANTHROPIC_API_KEY when authMode is 'apiKey'");
|
|
689
|
+
}
|
|
690
|
+
this.authChecked = true;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (this.providerConfig.authMode !== "subscription") {
|
|
695
|
+
this.authChecked = true;
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const args = [...this.providerConfig.commandArgs, "auth", "status", "--json"];
|
|
700
|
+
const env = this.buildCliEnv();
|
|
701
|
+
|
|
702
|
+
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolvePromise, rejectPromise) => {
|
|
703
|
+
const child = spawn(this.providerConfig.command, args, {
|
|
704
|
+
cwd: this.route.profile.projectRoot,
|
|
705
|
+
env,
|
|
706
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
let stdout = "";
|
|
710
|
+
let stderr = "";
|
|
711
|
+
const timeoutHandle = setTimeout(() => {
|
|
712
|
+
this.killChildProcess(child as unknown as ChildProcessWithoutNullStreams);
|
|
713
|
+
}, DEFAULT_AUTH_STATUS_TIMEOUT_MS);
|
|
714
|
+
|
|
715
|
+
child.stdout.on("data", (chunk) => {
|
|
716
|
+
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
child.stderr.on("data", (chunk) => {
|
|
720
|
+
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
child.once("error", (error) => {
|
|
724
|
+
clearTimeout(timeoutHandle);
|
|
725
|
+
rejectPromise(error);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
child.once("close", (code) => {
|
|
729
|
+
clearTimeout(timeoutHandle);
|
|
730
|
+
resolvePromise({ stdout, stderr, code });
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
if (result.code !== 0) {
|
|
735
|
+
const stderr = result.stderr.trim();
|
|
736
|
+
throw new Error(
|
|
737
|
+
`Claude CLI auth check failed (authMode=subscription). `
|
|
738
|
+
+ `Run 'claude auth login' or 'claude setup-token'.`
|
|
739
|
+
+ (stderr.length > 0 ? ` stderr: ${stderr}` : ""),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let parsed: unknown;
|
|
744
|
+
try {
|
|
745
|
+
parsed = JSON.parse(result.stdout);
|
|
746
|
+
} catch {
|
|
747
|
+
throw new Error("Claude CLI auth status returned invalid JSON; cannot verify subscription login state");
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (!isRecord(parsed) || parsed.loggedIn !== true) {
|
|
751
|
+
throw new Error(
|
|
752
|
+
"Claude CLI is not logged in for subscription mode. "
|
|
753
|
+
+ "Run 'claude auth login' or 'claude setup-token' before starting gateway.",
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
this.authChecked = true;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async runPrompt(text: string, images: ImageContent[], resumeSessionId: string | undefined): Promise<void> {
|
|
761
|
+
await this.ensureAuthReady();
|
|
762
|
+
|
|
763
|
+
const sessionId = resumeSessionId ?? randomUUID();
|
|
764
|
+
const args = this.buildCliArgs(sessionId, resumeSessionId);
|
|
765
|
+
const inputPayload = this.buildInputPayload(text, images);
|
|
766
|
+
const env = this.buildCliEnv();
|
|
767
|
+
|
|
768
|
+
const commandPreview = [this.providerConfig.command, ...args].join(" ");
|
|
769
|
+
const abortController = new AbortController();
|
|
770
|
+
|
|
771
|
+
const state: ClaudeStreamState = {
|
|
772
|
+
assistantFromDeltas: "",
|
|
773
|
+
assistantFromMessage: "",
|
|
774
|
+
assistantFromResult: "",
|
|
775
|
+
sawMaxTurnsError: false,
|
|
776
|
+
messageTypeCounts: {},
|
|
777
|
+
streamEventTypeCounts: {},
|
|
778
|
+
streamBlockTypeByIndex: new Map<number, string>(),
|
|
779
|
+
lastResultSubtype: null,
|
|
780
|
+
lastAssistantPreview: null,
|
|
781
|
+
lastResultPreview: null,
|
|
782
|
+
lastStreamEventPreview: null,
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
this.activeAbortController = abortController;
|
|
786
|
+
this.activeToolUses.clear();
|
|
787
|
+
|
|
788
|
+
this.logger.info(
|
|
789
|
+
{
|
|
790
|
+
providerInstance: this.providerId,
|
|
791
|
+
conversationKey: this.conversationKey,
|
|
792
|
+
routeId: this.route.routeId,
|
|
793
|
+
resumeSessionId: resumeSessionId ?? null,
|
|
794
|
+
sessionId,
|
|
795
|
+
command: this.providerConfig.command,
|
|
796
|
+
},
|
|
797
|
+
"Starting Claude CLI prompt",
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
let child: ChildProcessWithoutNullStreams;
|
|
801
|
+
try {
|
|
802
|
+
child = spawn(this.providerConfig.command, args, {
|
|
803
|
+
cwd: this.route.profile.projectRoot,
|
|
804
|
+
env,
|
|
805
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
806
|
+
detached: process.platform !== "win32",
|
|
807
|
+
});
|
|
808
|
+
} catch (error) {
|
|
809
|
+
throw this.toSpawnError(error, commandPreview, "");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.activeChild = child;
|
|
813
|
+
this.sessionId = sessionId;
|
|
814
|
+
|
|
815
|
+
try {
|
|
816
|
+
const onAbort = () => {
|
|
817
|
+
this.killChildProcess(child);
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
if (abortController.signal.aborted) {
|
|
821
|
+
onAbort();
|
|
822
|
+
} else {
|
|
823
|
+
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
|
824
|
+
child.once("close", () => {
|
|
825
|
+
abortController.signal.removeEventListener("abort", onAbort);
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
let stdoutBuffer = "";
|
|
830
|
+
let stderrTail = "";
|
|
831
|
+
let parseFailure: Error | null = null;
|
|
832
|
+
|
|
833
|
+
const handleLine = (line: string) => {
|
|
834
|
+
let parsed: unknown;
|
|
835
|
+
try {
|
|
836
|
+
parsed = JSON.parse(line);
|
|
837
|
+
} catch {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
this.consumeClaudeMessage(parsed, state);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
parseFailure = error instanceof Error ? error : new Error(String(error));
|
|
845
|
+
this.killChildProcess(child);
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
child.stdout.on("data", (chunk) => {
|
|
850
|
+
const textChunk = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
851
|
+
if (!textChunk) return;
|
|
852
|
+
|
|
853
|
+
stdoutBuffer += textChunk;
|
|
854
|
+
let newlineIndex = stdoutBuffer.indexOf("\n");
|
|
855
|
+
while (newlineIndex >= 0) {
|
|
856
|
+
const line = stdoutBuffer.slice(0, newlineIndex).trim();
|
|
857
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
858
|
+
if (line.length > 0) {
|
|
859
|
+
handleLine(line);
|
|
860
|
+
}
|
|
861
|
+
newlineIndex = stdoutBuffer.indexOf("\n");
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
child.stderr.on("data", (chunk) => {
|
|
866
|
+
const textChunk = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
867
|
+
if (!textChunk) return;
|
|
868
|
+
|
|
869
|
+
stderrTail += textChunk;
|
|
870
|
+
if (stderrTail.length > 8_000) {
|
|
871
|
+
stderrTail = stderrTail.slice(stderrTail.length - 8_000);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const closeResult = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolvePromise, rejectPromise) => {
|
|
876
|
+
child.once("error", (error) => {
|
|
877
|
+
rejectPromise(error);
|
|
878
|
+
});
|
|
879
|
+
child.once("close", (code, signal) => {
|
|
880
|
+
resolvePromise({ code, signal });
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
child.stdin.write(`${inputPayload}\n`);
|
|
885
|
+
child.stdin.end();
|
|
886
|
+
} catch (error) {
|
|
887
|
+
rejectPromise(error);
|
|
888
|
+
}
|
|
889
|
+
}).catch((error) => {
|
|
890
|
+
throw this.toSpawnError(error, commandPreview, stderrTail);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const restLine = stdoutBuffer.trim();
|
|
894
|
+
if (restLine.length > 0) {
|
|
895
|
+
handleLine(restLine);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (parseFailure) {
|
|
899
|
+
throw parseFailure;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (closeResult.code !== 0 && !abortController.signal.aborted) {
|
|
903
|
+
const stderr = stderrTail.trim();
|
|
904
|
+
throw new Error(
|
|
905
|
+
`Claude CLI process exited with code ${closeResult.code} (signal=${closeResult.signal ?? "none"}). `
|
|
906
|
+
+ `Command: '${commandPreview}'.`
|
|
907
|
+
+ (stderr.length > 0 ? ` stderr: ${stderr}` : ""),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (state.sawMaxTurnsError) {
|
|
912
|
+
this.emit({ type: "status", message: "Reached max turns; returning partial response." });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
for (const [toolUseId, toolName] of this.activeToolUses.entries()) {
|
|
916
|
+
this.emit({
|
|
917
|
+
type: "tool_end",
|
|
918
|
+
toolName,
|
|
919
|
+
isError: false,
|
|
920
|
+
output: "(tool completed)",
|
|
921
|
+
});
|
|
922
|
+
this.activeToolUses.delete(toolUseId);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const candidateLengths = {
|
|
926
|
+
message: state.assistantFromMessage.trim().length,
|
|
927
|
+
result: state.assistantFromResult.trim().length,
|
|
928
|
+
deltas: state.assistantFromDeltas.trim().length,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
let finalTextSource: "result" | "message" | "deltas" | "fallback_json" | "empty_success" | "none" = "none";
|
|
932
|
+
const finalText = (() => {
|
|
933
|
+
const result = state.assistantFromResult.trim();
|
|
934
|
+
if (result.length > 0) {
|
|
935
|
+
finalTextSource = "result";
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const messageText = state.assistantFromMessage.trim();
|
|
940
|
+
const deltaText = state.assistantFromDeltas.trim();
|
|
941
|
+
if (messageText.length === 0) {
|
|
942
|
+
if (deltaText.length > 0) {
|
|
943
|
+
finalTextSource = "deltas";
|
|
944
|
+
return deltaText;
|
|
945
|
+
}
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
if (deltaText.length === 0) {
|
|
949
|
+
finalTextSource = "message";
|
|
950
|
+
return messageText;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Prefer the more complete stream text when assistant message contains only an intro line.
|
|
954
|
+
if (deltaText.length > messageText.length + 30) {
|
|
955
|
+
finalTextSource = "deltas";
|
|
956
|
+
return deltaText;
|
|
957
|
+
}
|
|
958
|
+
finalTextSource = "message";
|
|
959
|
+
return messageText;
|
|
960
|
+
})();
|
|
961
|
+
|
|
962
|
+
let resolvedFinalText = finalText;
|
|
963
|
+
if (!resolvedFinalText && !abortController.signal.aborted && state.lastResultSubtype !== "success") {
|
|
964
|
+
this.logger.warn(
|
|
965
|
+
{
|
|
966
|
+
providerInstance: this.providerId,
|
|
967
|
+
conversationKey: this.conversationKey,
|
|
968
|
+
messageTypeCounts: state.messageTypeCounts,
|
|
969
|
+
streamEventTypeCounts: state.streamEventTypeCounts,
|
|
970
|
+
lastResultSubtype: state.lastResultSubtype,
|
|
971
|
+
lastAssistantPreview: state.lastAssistantPreview,
|
|
972
|
+
lastResultPreview: state.lastResultPreview,
|
|
973
|
+
lastStreamEventPreview: state.lastStreamEventPreview,
|
|
974
|
+
},
|
|
975
|
+
"Claude CLI stream produced no assistant text; retrying with output-format=json",
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
const fallback = await this.runJsonFallback(text, sessionId, resumeSessionId, abortController);
|
|
979
|
+
if (fallback) {
|
|
980
|
+
resolvedFinalText = fallback.text;
|
|
981
|
+
finalTextSource = "fallback_json";
|
|
982
|
+
if (fallback.subtype) {
|
|
983
|
+
state.lastResultSubtype = fallback.subtype;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
if (!resolvedFinalText && !abortController.signal.aborted && state.lastResultSubtype === "success") {
|
|
989
|
+
resolvedFinalText = EMPTY_SUCCESS_FALLBACK_TEXT;
|
|
990
|
+
finalTextSource = "empty_success";
|
|
991
|
+
|
|
992
|
+
this.logger.warn(
|
|
993
|
+
{
|
|
994
|
+
providerInstance: this.providerId,
|
|
995
|
+
conversationKey: this.conversationKey,
|
|
996
|
+
messageTypeCounts: state.messageTypeCounts,
|
|
997
|
+
streamEventTypeCounts: state.streamEventTypeCounts,
|
|
998
|
+
lastResultSubtype: state.lastResultSubtype,
|
|
999
|
+
lastAssistantPreview: state.lastAssistantPreview,
|
|
1000
|
+
lastResultPreview: state.lastResultPreview,
|
|
1001
|
+
lastStreamEventPreview: state.lastStreamEventPreview,
|
|
1002
|
+
},
|
|
1003
|
+
"Claude CLI returned success without user-visible assistant text; using fallback placeholder",
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (resolvedFinalText) {
|
|
1008
|
+
this.emit({ type: "message_complete", text: resolvedFinalText });
|
|
1009
|
+
} else if (!abortController.signal.aborted) {
|
|
1010
|
+
this.logger.warn(
|
|
1011
|
+
{
|
|
1012
|
+
providerInstance: this.providerId,
|
|
1013
|
+
conversationKey: this.conversationKey,
|
|
1014
|
+
messageTypeCounts: state.messageTypeCounts,
|
|
1015
|
+
streamEventTypeCounts: state.streamEventTypeCounts,
|
|
1016
|
+
lastResultSubtype: state.lastResultSubtype,
|
|
1017
|
+
lastAssistantPreview: state.lastAssistantPreview,
|
|
1018
|
+
lastResultPreview: state.lastResultPreview,
|
|
1019
|
+
lastStreamEventPreview: state.lastStreamEventPreview,
|
|
1020
|
+
},
|
|
1021
|
+
"Claude CLI finished without assistant text",
|
|
1022
|
+
);
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
"Claude CLI completed without assistant text output. "
|
|
1025
|
+
+ "Likely stream event shape mismatch or non-text-only completion.",
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
if (!abortController.signal.aborted) {
|
|
1030
|
+
await this.persistSessionMeta();
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
this.logger.info(
|
|
1034
|
+
{
|
|
1035
|
+
providerInstance: this.providerId,
|
|
1036
|
+
conversationKey: this.conversationKey,
|
|
1037
|
+
routeId: this.route.routeId,
|
|
1038
|
+
sessionId: this.sessionId ?? null,
|
|
1039
|
+
messageTypeCounts: state.messageTypeCounts,
|
|
1040
|
+
streamEventTypeCounts: state.streamEventTypeCounts,
|
|
1041
|
+
lastResultSubtype: state.lastResultSubtype,
|
|
1042
|
+
finalTextSource,
|
|
1043
|
+
candidateLengths,
|
|
1044
|
+
},
|
|
1045
|
+
"Claude CLI prompt finished",
|
|
1046
|
+
);
|
|
1047
|
+
} finally {
|
|
1048
|
+
this.activeToolUses.clear();
|
|
1049
|
+
this.activeChild = null;
|
|
1050
|
+
this.activeAbortController = null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
private async runJsonFallback(
|
|
1055
|
+
text: string,
|
|
1056
|
+
sessionId: string,
|
|
1057
|
+
resumeSessionId: string | undefined,
|
|
1058
|
+
abortController: AbortController,
|
|
1059
|
+
): Promise<JsonFallbackOutput | null> {
|
|
1060
|
+
const args = this.buildCliArgsJsonFallback(text, sessionId, resumeSessionId);
|
|
1061
|
+
const commandPreview = [this.providerConfig.command, ...args].join(" ");
|
|
1062
|
+
const env = this.buildCliEnv();
|
|
1063
|
+
|
|
1064
|
+
this.logger.info(
|
|
1065
|
+
{
|
|
1066
|
+
providerInstance: this.providerId,
|
|
1067
|
+
conversationKey: this.conversationKey,
|
|
1068
|
+
routeId: this.route.routeId,
|
|
1069
|
+
resumeSessionId: resumeSessionId ?? null,
|
|
1070
|
+
sessionId: this.sessionId ?? sessionId,
|
|
1071
|
+
command: this.providerConfig.command,
|
|
1072
|
+
},
|
|
1073
|
+
"Starting Claude CLI json fallback",
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
const child = spawn(this.providerConfig.command, args, {
|
|
1077
|
+
cwd: this.route.profile.projectRoot,
|
|
1078
|
+
env,
|
|
1079
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1080
|
+
detached: process.platform !== "win32",
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
this.activeChild = child;
|
|
1084
|
+
|
|
1085
|
+
const onAbort = () => {
|
|
1086
|
+
this.killChildProcess(child);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
if (abortController.signal.aborted) {
|
|
1090
|
+
onAbort();
|
|
1091
|
+
} else {
|
|
1092
|
+
abortController.signal.addEventListener("abort", onAbort, { once: true });
|
|
1093
|
+
child.once("close", () => {
|
|
1094
|
+
abortController.signal.removeEventListener("abort", onAbort);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let stdout = "";
|
|
1099
|
+
let stderr = "";
|
|
1100
|
+
|
|
1101
|
+
child.stdout.on("data", (chunk) => {
|
|
1102
|
+
stdout += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
child.stderr.on("data", (chunk) => {
|
|
1106
|
+
stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : String(chunk);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
try {
|
|
1110
|
+
child.stdin.end();
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Best-effort close for parity with primary stream runner behavior.
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const closeResult = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolvePromise, rejectPromise) => {
|
|
1116
|
+
child.once("error", (error) => {
|
|
1117
|
+
rejectPromise(error);
|
|
1118
|
+
});
|
|
1119
|
+
child.once("close", (code, signal) => {
|
|
1120
|
+
resolvePromise({ code, signal });
|
|
1121
|
+
});
|
|
1122
|
+
}).catch((error) => {
|
|
1123
|
+
throw this.toSpawnError(error, commandPreview, stderr);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
if (closeResult.code !== 0 && !abortController.signal.aborted) {
|
|
1127
|
+
const stderrTrimmed = stderr.trim();
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
`Claude CLI json fallback exited with code ${closeResult.code} (signal=${closeResult.signal ?? "none"}). `
|
|
1130
|
+
+ `Command: '${commandPreview}'.`
|
|
1131
|
+
+ (stderrTrimmed.length > 0 ? ` stderr: ${stderrTrimmed}` : ""),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (abortController.signal.aborted) {
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const raw = stdout.trim();
|
|
1140
|
+
if (raw.length === 0) {
|
|
1141
|
+
this.logger.warn(
|
|
1142
|
+
{
|
|
1143
|
+
providerInstance: this.providerId,
|
|
1144
|
+
conversationKey: this.conversationKey,
|
|
1145
|
+
routeId: this.route.routeId,
|
|
1146
|
+
},
|
|
1147
|
+
"Claude CLI json fallback returned empty stdout",
|
|
1148
|
+
);
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
let parsed: unknown;
|
|
1153
|
+
try {
|
|
1154
|
+
parsed = JSON.parse(raw);
|
|
1155
|
+
} catch {
|
|
1156
|
+
return { text: raw, subtype: null };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const recoveredSessionId = extractSessionId(parsed);
|
|
1160
|
+
if (recoveredSessionId) {
|
|
1161
|
+
this.sessionId = recoveredSessionId;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (isRecord(parsed) && parsed.type === "result") {
|
|
1165
|
+
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : null;
|
|
1166
|
+
const isError = parsed.is_error === true || (subtype ?? "").startsWith("error_");
|
|
1167
|
+
if (isError) {
|
|
1168
|
+
const details = Array.isArray(parsed.errors)
|
|
1169
|
+
? parsed.errors.filter((item): item is string => typeof item === "string").join("\n")
|
|
1170
|
+
: "";
|
|
1171
|
+
throw new Error(details.length > 0 ? details : `Claude query failed (${subtype ?? "unknown_error"})`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const fromResult = extractAssistantTextFromResultField(parsed.result);
|
|
1175
|
+
if (fromResult.length > 0) {
|
|
1176
|
+
return { text: fromResult, subtype };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const fromStructuredOutput = extractAssistantTextFromStructuredOutputField(parsed.structured_output);
|
|
1180
|
+
if (fromStructuredOutput.length > 0) {
|
|
1181
|
+
return { text: fromStructuredOutput, subtype };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const fromMessage = extractAssistantTextFromMessage(parsed);
|
|
1186
|
+
if (fromMessage.length > 0) {
|
|
1187
|
+
return { text: fromMessage, subtype: null };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const fromGeneric = collectTextFromRecord(toRecord(parsed), false);
|
|
1191
|
+
if (fromGeneric.length > 0) {
|
|
1192
|
+
return { text: fromGeneric, subtype: null };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
this.logger.warn(
|
|
1196
|
+
{
|
|
1197
|
+
providerInstance: this.providerId,
|
|
1198
|
+
conversationKey: this.conversationKey,
|
|
1199
|
+
routeId: this.route.routeId,
|
|
1200
|
+
outputPreview: safePreview(parsed),
|
|
1201
|
+
},
|
|
1202
|
+
"Claude CLI json fallback produced no assistant text",
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
private consumeClaudeMessage(message: unknown, state: ClaudeStreamState): void {
|
|
1209
|
+
if (isRecord(message) && typeof message.type === "string") {
|
|
1210
|
+
state.messageTypeCounts[message.type] = (state.messageTypeCounts[message.type] ?? 0) + 1;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (isRecord(message) && message.type === "stream_event") {
|
|
1214
|
+
const event = toRecord(message.event);
|
|
1215
|
+
if (event && typeof event.type === "string" && event.type.length > 0) {
|
|
1216
|
+
state.streamEventTypeCounts[event.type] = (state.streamEventTypeCounts[event.type] ?? 0) + 1;
|
|
1217
|
+
} else {
|
|
1218
|
+
state.streamEventTypeCounts.unknown = (state.streamEventTypeCounts.unknown ?? 0) + 1;
|
|
1219
|
+
}
|
|
1220
|
+
state.lastStreamEventPreview = safePreview(event ?? message.event);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const sessionId = extractSessionId(message);
|
|
1224
|
+
if (sessionId) {
|
|
1225
|
+
this.sessionId = sessionId;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const delta = extractTextDelta(message, state);
|
|
1229
|
+
if (delta !== null) {
|
|
1230
|
+
state.assistantFromDeltas += delta;
|
|
1231
|
+
this.emit({ type: "message_delta", delta });
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
if (!isRecord(message)) {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (message.type === "assistant" || (message.type === "message" && message.role === "assistant")) {
|
|
1240
|
+
state.lastAssistantPreview = safePreview(message.message ?? message);
|
|
1241
|
+
const text = extractAssistantTextFromMessage(message);
|
|
1242
|
+
if (text.length > 0) {
|
|
1243
|
+
state.assistantFromMessage = text;
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (message.type === "system") {
|
|
1249
|
+
const subtype = message.subtype;
|
|
1250
|
+
if (subtype === "status" && message.status === "compacting") {
|
|
1251
|
+
this.emit({ type: "status", message: "Compacting context..." });
|
|
1252
|
+
}
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (message.type === "tool_progress") {
|
|
1257
|
+
const toolUseId = typeof message.tool_use_id === "string" ? message.tool_use_id : "";
|
|
1258
|
+
const rawToolName = typeof message.tool_name === "string" ? message.tool_name : "unknown";
|
|
1259
|
+
const toolName = normalizeToolName(rawToolName);
|
|
1260
|
+
|
|
1261
|
+
if (toolUseId.length > 0 && !this.activeToolUses.has(toolUseId)) {
|
|
1262
|
+
this.activeToolUses.set(toolUseId, toolName);
|
|
1263
|
+
this.emit({ type: "tool_start", toolName });
|
|
1264
|
+
}
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (message.type === "tool_use_summary") {
|
|
1269
|
+
const summary = typeof message.summary === "string" ? message.summary : "(tool completed)";
|
|
1270
|
+
const preceding = Array.isArray(message.preceding_tool_use_ids)
|
|
1271
|
+
? message.preceding_tool_use_ids.filter((item): item is string => typeof item === "string")
|
|
1272
|
+
: [];
|
|
1273
|
+
|
|
1274
|
+
for (const toolUseId of preceding) {
|
|
1275
|
+
const toolName = this.activeToolUses.get(toolUseId);
|
|
1276
|
+
if (!toolName) {
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
this.activeToolUses.delete(toolUseId);
|
|
1281
|
+
this.emit({
|
|
1282
|
+
type: "tool_end",
|
|
1283
|
+
toolName,
|
|
1284
|
+
isError: false,
|
|
1285
|
+
output: summary,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (message.type === "result") {
|
|
1292
|
+
state.lastResultPreview = safePreview(message.result ?? message.structured_output ?? message);
|
|
1293
|
+
const subtype = typeof message.subtype === "string" ? message.subtype : "";
|
|
1294
|
+
state.lastResultSubtype = subtype || null;
|
|
1295
|
+
const isError = message.is_error === true || subtype.startsWith("error_");
|
|
1296
|
+
|
|
1297
|
+
if (subtype === "success") {
|
|
1298
|
+
const resultText = extractAssistantTextFromResultField(message.result);
|
|
1299
|
+
if (resultText.length > 0) {
|
|
1300
|
+
state.assistantFromResult = resultText;
|
|
1301
|
+
} else {
|
|
1302
|
+
const structuredOutputText = extractAssistantTextFromStructuredOutputField(message.structured_output);
|
|
1303
|
+
if (structuredOutputText.length > 0) {
|
|
1304
|
+
state.assistantFromResult = structuredOutputText;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (subtype === "error_max_turns") {
|
|
1310
|
+
state.sawMaxTurnsError = true;
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (isError) {
|
|
1315
|
+
const details = Array.isArray(message.errors)
|
|
1316
|
+
? message.errors.filter((item): item is string => typeof item === "string").join("\n")
|
|
1317
|
+
: "";
|
|
1318
|
+
throw new Error(details.length > 0 ? details : `Claude query failed (${subtype || "unknown_error"})`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
private buildCliArgs(sessionId: string, resumeSessionId: string | undefined): string[] {
|
|
1324
|
+
const args: string[] = [
|
|
1325
|
+
...this.providerConfig.commandArgs,
|
|
1326
|
+
"-p",
|
|
1327
|
+
"--input-format",
|
|
1328
|
+
"stream-json",
|
|
1329
|
+
"--output-format",
|
|
1330
|
+
"stream-json",
|
|
1331
|
+
"--include-partial-messages",
|
|
1332
|
+
"--model",
|
|
1333
|
+
this.providerConfig.model,
|
|
1334
|
+
"--max-turns",
|
|
1335
|
+
String(this.providerConfig.maxTurns),
|
|
1336
|
+
"--permission-mode",
|
|
1337
|
+
this.providerConfig.permissionMode,
|
|
1338
|
+
"--allowedTools",
|
|
1339
|
+
...this.allowedTools,
|
|
1340
|
+
];
|
|
1341
|
+
|
|
1342
|
+
if (this.providerConfig.streamVerbose) {
|
|
1343
|
+
args.push("--verbose");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
if (this.providerConfig.permissionMode === "bypassPermissions") {
|
|
1347
|
+
args.push("--dangerously-skip-permissions");
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
if (this.systemPrompt) {
|
|
1351
|
+
args.push("--system-prompt", this.systemPrompt);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (resumeSessionId) {
|
|
1355
|
+
args.push("--resume", resumeSessionId);
|
|
1356
|
+
} else {
|
|
1357
|
+
args.push("--session-id", sessionId);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return args;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
private buildCliArgsJsonFallback(text: string, sessionId: string, resumeSessionId: string | undefined): string[] {
|
|
1364
|
+
const args: string[] = [
|
|
1365
|
+
...this.providerConfig.commandArgs,
|
|
1366
|
+
"-p",
|
|
1367
|
+
"--output-format",
|
|
1368
|
+
"json",
|
|
1369
|
+
"--model",
|
|
1370
|
+
this.providerConfig.model,
|
|
1371
|
+
"--max-turns",
|
|
1372
|
+
String(this.providerConfig.maxTurns),
|
|
1373
|
+
"--permission-mode",
|
|
1374
|
+
this.providerConfig.permissionMode,
|
|
1375
|
+
"--allowedTools",
|
|
1376
|
+
...this.allowedTools,
|
|
1377
|
+
];
|
|
1378
|
+
|
|
1379
|
+
if (this.providerConfig.permissionMode === "bypassPermissions") {
|
|
1380
|
+
args.push("--dangerously-skip-permissions");
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (this.systemPrompt) {
|
|
1384
|
+
args.push("--append-system-prompt", this.systemPrompt);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (resumeSessionId) {
|
|
1388
|
+
args.push("--resume", resumeSessionId);
|
|
1389
|
+
} else {
|
|
1390
|
+
args.push("--session-id", sessionId);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
args.push(text);
|
|
1394
|
+
return args;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
private buildCliEnv(): NodeJS.ProcessEnv {
|
|
1398
|
+
const env: NodeJS.ProcessEnv = {
|
|
1399
|
+
CLAUDE_AGENT_SDK_CLIENT_APP: "dobby/provider-claude-cli",
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
for (const key of this.providerConfig.envAllowList) {
|
|
1403
|
+
const value = process.env[key];
|
|
1404
|
+
if (value !== undefined) {
|
|
1405
|
+
env[key] = value;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (this.providerConfig.authMode === "subscription") {
|
|
1410
|
+
delete env.ANTHROPIC_API_KEY;
|
|
1411
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
return env;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
private buildInputPayload(text: string, images: ImageContent[]): string {
|
|
1418
|
+
const content: Array<Record<string, unknown>> = [
|
|
1419
|
+
{
|
|
1420
|
+
type: "text",
|
|
1421
|
+
text,
|
|
1422
|
+
},
|
|
1423
|
+
];
|
|
1424
|
+
|
|
1425
|
+
for (const image of images) {
|
|
1426
|
+
const mimeType = normalizeClaudeImageMimeType(image.mimeType);
|
|
1427
|
+
if (!mimeType) {
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
content.push({
|
|
1432
|
+
type: "image",
|
|
1433
|
+
source: {
|
|
1434
|
+
type: "base64",
|
|
1435
|
+
media_type: mimeType,
|
|
1436
|
+
data: image.data,
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
return JSON.stringify({
|
|
1442
|
+
type: "user",
|
|
1443
|
+
message: {
|
|
1444
|
+
role: "user",
|
|
1445
|
+
content,
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private killChildProcess(child: ChildProcessWithoutNullStreams): void {
|
|
1451
|
+
if (child.killed) {
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const pid = child.pid;
|
|
1456
|
+
if (pid && process.platform !== "win32") {
|
|
1457
|
+
try {
|
|
1458
|
+
process.kill(-pid, "SIGKILL");
|
|
1459
|
+
return;
|
|
1460
|
+
} catch {
|
|
1461
|
+
// Fall through to direct child kill.
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
child.kill("SIGKILL");
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
private toSpawnError(error: unknown, commandPreview: string, stderrTail: string): Error {
|
|
1469
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1470
|
+
const asErr = normalized as NodeJS.ErrnoException;
|
|
1471
|
+
|
|
1472
|
+
if (asErr.code === "ENOENT") {
|
|
1473
|
+
return new Error(
|
|
1474
|
+
`Claude CLI command not found: '${this.providerConfig.command}'. `
|
|
1475
|
+
+ "Install Claude Code CLI and ensure it is available in PATH, or set providers.items.<id>.command to an absolute executable path.",
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const stderr = stderrTail.trim();
|
|
1480
|
+
return new Error(
|
|
1481
|
+
`${normalized.message}. Command: '${commandPreview}'.`
|
|
1482
|
+
+ (stderr.length > 0 ? ` stderr: ${stderr}` : ""),
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
private async persistSessionMeta(): Promise<void> {
|
|
1487
|
+
if (!this.sessionId) return;
|
|
1488
|
+
|
|
1489
|
+
await mkdir(dirname(this.sessionMetaPath), { recursive: true });
|
|
1490
|
+
const payload: SessionMeta = {
|
|
1491
|
+
sessionId: this.sessionId,
|
|
1492
|
+
updatedAtMs: Date.now(),
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
await writeFile(this.sessionMetaPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
private async clearSessionMeta(): Promise<void> {
|
|
1499
|
+
try {
|
|
1500
|
+
await unlink(this.sessionMetaPath);
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
const asErr = error as NodeJS.ErrnoException;
|
|
1503
|
+
if (asErr.code !== "ENOENT") {
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
class ClaudeCliProviderInstanceImpl implements ProviderInstance {
|
|
1511
|
+
constructor(
|
|
1512
|
+
readonly id: string,
|
|
1513
|
+
private readonly providerConfig: ClaudeCliProviderConfig,
|
|
1514
|
+
private readonly dataConfig: ProviderInstanceCreateOptions["data"],
|
|
1515
|
+
private readonly logger: ProviderInstanceCreateOptions["host"]["logger"],
|
|
1516
|
+
) { }
|
|
1517
|
+
|
|
1518
|
+
async createRuntime(options: ProviderRuntimeCreateOptions): Promise<GatewayAgentRuntime> {
|
|
1519
|
+
await mkdir(this.dataConfig.sessionsDir, { recursive: true });
|
|
1520
|
+
|
|
1521
|
+
const executorName = options.executor.constructor?.name ?? "unknown";
|
|
1522
|
+
if (executorName !== "HostExecutor") {
|
|
1523
|
+
this.logger.warn(
|
|
1524
|
+
{
|
|
1525
|
+
providerInstance: this.id,
|
|
1526
|
+
routeId: options.route.routeId,
|
|
1527
|
+
sandboxExecutorType: executorName,
|
|
1528
|
+
},
|
|
1529
|
+
"provider.claude-cli is host-only in current phase; sandbox executor is ignored",
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const isEphemeral = options.sessionPolicy === "ephemeral";
|
|
1534
|
+
const sessionMetaPath = isEphemeral
|
|
1535
|
+
? this.getEphemeralSessionMetaPath(options.conversationKey)
|
|
1536
|
+
: this.getSessionMetaPath(options.inbound);
|
|
1537
|
+
let restoredSessionId: string | undefined;
|
|
1538
|
+
|
|
1539
|
+
if (!isEphemeral) {
|
|
1540
|
+
try {
|
|
1541
|
+
const raw = await readFile(sessionMetaPath, "utf-8");
|
|
1542
|
+
const parsed = JSON.parse(raw) as SessionMeta;
|
|
1543
|
+
if (typeof parsed.sessionId === "string" && parsed.sessionId.trim().length > 0) {
|
|
1544
|
+
restoredSessionId = parsed.sessionId;
|
|
1545
|
+
}
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
const asErr = error as NodeJS.ErrnoException;
|
|
1548
|
+
if (asErr.code !== "ENOENT") {
|
|
1549
|
+
this.logger.warn(
|
|
1550
|
+
{ err: error, providerInstance: this.id, conversationKey: options.conversationKey },
|
|
1551
|
+
"Failed to load Claude CLI session metadata; starting fresh session",
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
let systemPrompt: string | undefined;
|
|
1558
|
+
if (options.route.profile.systemPromptFile) {
|
|
1559
|
+
try {
|
|
1560
|
+
systemPrompt = await readFile(options.route.profile.systemPromptFile, "utf-8");
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
this.logger.warn(
|
|
1563
|
+
{
|
|
1564
|
+
err: error,
|
|
1565
|
+
providerInstance: this.id,
|
|
1566
|
+
routeId: options.route.routeId,
|
|
1567
|
+
file: options.route.profile.systemPromptFile,
|
|
1568
|
+
},
|
|
1569
|
+
"Failed to load route system prompt; continuing without custom system prompt",
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
this.logger.info(
|
|
1575
|
+
{
|
|
1576
|
+
providerInstance: this.id,
|
|
1577
|
+
model: this.providerConfig.model,
|
|
1578
|
+
routeId: options.route.routeId,
|
|
1579
|
+
tools: options.route.profile.tools,
|
|
1580
|
+
command: this.providerConfig.command,
|
|
1581
|
+
permissionMode: this.providerConfig.permissionMode,
|
|
1582
|
+
authMode: this.providerConfig.authMode,
|
|
1583
|
+
restoredSession: restoredSessionId ?? null,
|
|
1584
|
+
},
|
|
1585
|
+
"Claude CLI provider runtime initialized",
|
|
1586
|
+
);
|
|
1587
|
+
|
|
1588
|
+
return new ClaudeCliGatewayRuntime(
|
|
1589
|
+
this.id,
|
|
1590
|
+
options.conversationKey,
|
|
1591
|
+
options.route,
|
|
1592
|
+
this.logger,
|
|
1593
|
+
this.providerConfig,
|
|
1594
|
+
sessionMetaPath,
|
|
1595
|
+
systemPrompt,
|
|
1596
|
+
restoredSessionId,
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
async archiveSession(options: ProviderSessionArchiveOptions): Promise<{ archived: boolean; archivePath?: string }> {
|
|
1601
|
+
const sessionMetaPath = options.sessionPolicy === "ephemeral"
|
|
1602
|
+
? this.getEphemeralSessionMetaPath(options.conversationKey)
|
|
1603
|
+
: this.getSessionMetaPath(options.inbound);
|
|
1604
|
+
const archivePath = await archiveSessionPath(
|
|
1605
|
+
this.dataConfig.sessionsDir,
|
|
1606
|
+
sessionMetaPath,
|
|
1607
|
+
options.archivedAtMs ?? Date.now(),
|
|
1608
|
+
);
|
|
1609
|
+
return archivePath ? { archived: true, archivePath } : { archived: false };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
private getSessionMetaPath(inbound: ProviderRuntimeCreateOptions["inbound"]): string {
|
|
1613
|
+
const guildSegment = safeSegment(inbound.guildId ?? "dm");
|
|
1614
|
+
const connectorSegment = safeSegment(inbound.connectorId);
|
|
1615
|
+
const sourceSegment = safeSegment(inbound.source.id);
|
|
1616
|
+
const threadSegment = safeSegment(inbound.threadId ?? "root");
|
|
1617
|
+
const chatSegment = safeSegment(inbound.chatId);
|
|
1618
|
+
|
|
1619
|
+
return join(
|
|
1620
|
+
this.dataConfig.sessionsDir,
|
|
1621
|
+
connectorSegment,
|
|
1622
|
+
inbound.platform,
|
|
1623
|
+
safeSegment(inbound.accountId),
|
|
1624
|
+
guildSegment,
|
|
1625
|
+
sourceSegment,
|
|
1626
|
+
threadSegment,
|
|
1627
|
+
`${chatSegment}.claude-cli-session.json`,
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
private getEphemeralSessionMetaPath(conversationKey: string): string {
|
|
1632
|
+
return join(
|
|
1633
|
+
this.dataConfig.sessionsDir,
|
|
1634
|
+
"_cron-ephemeral",
|
|
1635
|
+
`${safeSegment(conversationKey)}.claude-cli-session.json`,
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
export const providerClaudeCliContribution: ProviderContributionModule = {
|
|
1641
|
+
kind: "provider",
|
|
1642
|
+
configSchema: z.toJSONSchema(claudeCliProviderConfigSchema),
|
|
1643
|
+
async createInstance(options) {
|
|
1644
|
+
const parsed = claudeCliProviderConfigSchema.parse(options.config);
|
|
1645
|
+
const command = normalizeCommand(options.host.configBaseDir, parsed.command);
|
|
1646
|
+
|
|
1647
|
+
if (parsed.authMode === "apiKey") {
|
|
1648
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1649
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
1650
|
+
throw new Error(
|
|
1651
|
+
`Provider instance '${options.instanceId}' requires ANTHROPIC_API_KEY when authMode is 'apiKey'`,
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
const config: ClaudeCliProviderConfig = {
|
|
1657
|
+
model: parsed.model,
|
|
1658
|
+
maxTurns: parsed.maxTurns,
|
|
1659
|
+
command,
|
|
1660
|
+
commandArgs: parsed.commandArgs,
|
|
1661
|
+
authMode: parsed.authMode,
|
|
1662
|
+
envAllowList: parsed.envAllowList,
|
|
1663
|
+
readonlyTools: parsed.readonlyTools,
|
|
1664
|
+
fullTools: parsed.fullTools,
|
|
1665
|
+
permissionMode: parsed.permissionMode,
|
|
1666
|
+
streamVerbose: parsed.streamVerbose,
|
|
1667
|
+
};
|
|
1668
|
+
|
|
1669
|
+
return new ClaudeCliProviderInstanceImpl(options.instanceId, config, options.data, options.host.logger);
|
|
1670
|
+
},
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
export default providerClaudeCliContribution;
|