@anureo/adapter-claude 0.0.0-stage → 0.3.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/LICENSE +201 -0
- package/README.md +161 -2
- package/package.json +58 -4
- package/src/adapter.d.ts +253 -0
- package/src/adapter.js +1328 -0
- package/src/catalog.d.ts +26 -0
- package/src/catalog.js +168 -0
- package/src/file-changes.d.ts +22 -0
- package/src/file-changes.js +124 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +4 -0
- package/src/options.d.ts +47 -0
- package/src/options.js +0 -0
- package/src/output-schema.d.ts +17 -0
- package/src/output-schema.js +23 -0
- package/src/store.d.ts +71 -0
- package/src/store.js +223 -0
- package/src/turn-error.d.ts +11 -0
- package/src/turn-error.js +74 -0
package/src/adapter.js
ADDED
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
import { claudeFileChanges, completeClaudeTool } from "./file-changes.js";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { query, getSessionMessages, getSessionInfo, forkSession, } from "@anthropic-ai/claude-agent-sdk";
|
|
7
|
+
import { BoundedEventQueue, HarnessSdkError, sdkError, } from "@anureo/sdk";
|
|
8
|
+
import { AHP_PROTOCOL_CONTRACT, assertRequest, negotiateVersion, publicValue, validateJsonSchema, } from "@anureo/protocol";
|
|
9
|
+
import { FileSessionStore } from "./store.js";
|
|
10
|
+
import { compileOutputSchema } from "./output-schema.js";
|
|
11
|
+
import { claudeTurnError } from "./turn-error.js";
|
|
12
|
+
/** Connect a client to Claude queries and retain ownership of opened backend sessions.
|
|
13
|
+
* createClient initializes and disposes this adapter through its output wrapper; direct consumers
|
|
14
|
+
* initialize before opening sessions and await disposal when finished. */
|
|
15
|
+
export class ClaudeNativeAdapter {
|
|
16
|
+
/** Select the launch environment and store without starting Claude. stateDir takes precedence
|
|
17
|
+
* for adapter storage. With defaultHome enabled, an explicitly supplied CLAUDE_CONFIG_DIR is
|
|
18
|
+
* forwarded as supplied. */
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
const stateDir = options.stateDir ?? options.env?.CLAUDE_CONFIG_DIR;
|
|
22
|
+
this.env = { ...basicEnvironment(), ...options.env, ...(stateDir && !options.defaultHome ? { CLAUDE_CONFIG_DIR: stateDir } : {}) };
|
|
23
|
+
if (!options.sessionStore && stateDir)
|
|
24
|
+
this.defaultStore = new FileSessionStore(join(stateDir, "ahp-session-store"));
|
|
25
|
+
this.sessionStore = options.sessionStore ?? this.defaultStore;
|
|
26
|
+
}
|
|
27
|
+
/** Report store/checkpoint-dependent support without native discovery or resource acquisition. */
|
|
28
|
+
get capabilities() { return capabilities({ store: !!this.sessionStore, checkpoints: !!this.options.enableFileCheckpointing }); }
|
|
29
|
+
/** Negotiate protocol support for Client.initialize through withStructuredOutput. Reject invalid
|
|
30
|
+
* negotiation, a disposed adapter, or an absent Claude home unless defaultHome or a query
|
|
31
|
+
* factory is supplied. Native launch is deferred to discovery or a session open. */
|
|
32
|
+
async initialize(params) {
|
|
33
|
+
assertRequest("initialize", params);
|
|
34
|
+
negotiateVersion(params.protocolVersions);
|
|
35
|
+
if (this.disposed)
|
|
36
|
+
throw new HarnessSdkError("RUNTIME_DISPOSED");
|
|
37
|
+
if (!this.env.CLAUDE_CONFIG_DIR && !this.options.defaultHome && !this.options.queryFactory)
|
|
38
|
+
throw new HarnessSdkError("HARNESS_CONFIG_INVALID", "Claude requires an explicit isolated CLAUDE_CONFIG_DIR");
|
|
39
|
+
return { protocolVersion: 1, protocolContract: AHP_PROTOCOL_CONTRACT, runtime: { name: "claude", version: "0.3.265", adapter: "claude-native/0.1.0" }, capabilities: this.capabilities };
|
|
40
|
+
}
|
|
41
|
+
/** Open and retain a backend for OutputSession.open to attach. Await resume/fork setup, query
|
|
42
|
+
* construction and any resumed-title refresh. Native handshake and execution events arrive
|
|
43
|
+
* independently through the sink. Setup errors reject the open. The client disposes the backend
|
|
44
|
+
* it attaches, and adapter disposal also releases retained sessions. */
|
|
45
|
+
async openSession(params, sink) {
|
|
46
|
+
if (this.disposed)
|
|
47
|
+
throw new HarnessSdkError("RUNTIME_DISPOSED");
|
|
48
|
+
if (params.binding.adapterId !== "claude" && params.binding.adapterId !== "claude-native")
|
|
49
|
+
throw new HarnessSdkError("SESSION_BINDING_MISMATCH");
|
|
50
|
+
const session = new ClaudeBackendSession(this, params, sink, this.defaultStore);
|
|
51
|
+
await session.open();
|
|
52
|
+
this.sessions.add(session);
|
|
53
|
+
return session;
|
|
54
|
+
}
|
|
55
|
+
/** Read model metadata with a one-shot query using cwd, which defaults to process.cwd().
|
|
56
|
+
* The adapter supplies an empty input stream and closes the query after discovery. Startup,
|
|
57
|
+
* discovery and close failures reject, with a close failure taking precedence over discovery. */
|
|
58
|
+
async listModels(cwd = process.cwd()) {
|
|
59
|
+
const input = new BoundedEventQueue();
|
|
60
|
+
const stream = (this.options.queryFactory ?? query)({ prompt: input, options: this.queryOptions(cwd) });
|
|
61
|
+
try {
|
|
62
|
+
return await stream.supportedModels();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
input.end();
|
|
66
|
+
stream.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Build native launch options for internal use without starting a query. listModels uses them
|
|
70
|
+
* directly. ClaudeBackendSession.sessionQueryOptions and createClaudeCatalogSession's openConnection
|
|
71
|
+
* add their own options. The operation that starts the query owns its cleanup. */
|
|
72
|
+
queryOptions(cwd) {
|
|
73
|
+
return {
|
|
74
|
+
cwd, env: this.env, settingSources: [], persistSession: true,
|
|
75
|
+
includePartialMessages: true, includeHookEvents: true,
|
|
76
|
+
...(this.options.command ?? this.options.executable ? { pathToClaudeCodeExecutable: this.options.command ?? this.options.executable } : {}),
|
|
77
|
+
...(this.options.executableRuntime ? { executable: this.options.executableRuntime } : {}),
|
|
78
|
+
...(this.options.args?.length ? {
|
|
79
|
+
// Read host arguments at spawn time, when the native SDK supplies its own launch arguments.
|
|
80
|
+
spawnClaudeCodeProcess: (options) => spawn(options.command, [...options.args, ...this.options.args], {
|
|
81
|
+
cwd: options.cwd,
|
|
82
|
+
env: options.env,
|
|
83
|
+
signal: options.signal,
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
+
shell: false,
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
}),
|
|
88
|
+
} : {}),
|
|
89
|
+
...(this.sessionStore ? { sessionStore: this.sessionStore, sessionStoreFlush: "eager" } : {}),
|
|
90
|
+
enableFileCheckpointing: this.options.enableFileCheckpointing ?? false,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Release retained sessions. Await all attempts and reject with the first cleanup failure in
|
|
94
|
+
* registry order. Once disposal starts, subsequent calls resolve independently of that cleanup. */
|
|
95
|
+
async dispose() {
|
|
96
|
+
if (this.disposed)
|
|
97
|
+
return;
|
|
98
|
+
this.disposed = true;
|
|
99
|
+
const results = await Promise.allSettled([...this.sessions].map(session => session.dispose()));
|
|
100
|
+
this.sessions.clear();
|
|
101
|
+
const failed = results.find((value) => value.status === 'rejected');
|
|
102
|
+
if (failed)
|
|
103
|
+
throw failed.reason;
|
|
104
|
+
}
|
|
105
|
+
/** Retained launch/extension configuration used by later queries; avoid mutating it during work. */
|
|
106
|
+
options;
|
|
107
|
+
/** Selected native store, shared with sessions; an injected store keeps its host-owned lifecycle. */
|
|
108
|
+
sessionStore;
|
|
109
|
+
/** Native launch environment; may contain credentials and is not a safe diagnostic projection. */
|
|
110
|
+
env;
|
|
111
|
+
sessions = new Set();
|
|
112
|
+
/** Internal map from native session ID to the input UUIDs verified against SDK turns.
|
|
113
|
+
* Backend sessions reuse it across in-memory resume and copy it for forks. */
|
|
114
|
+
turnMaps = new Map();
|
|
115
|
+
defaultStore;
|
|
116
|
+
disposed = false;
|
|
117
|
+
}
|
|
118
|
+
/** Adapter-internal BackendSession implementation retained in the root export for compatibility.
|
|
119
|
+
* ClaudeNativeAdapter.openSession constructs and opens it; OutputSession.execute forwards SDK
|
|
120
|
+
* requests and OutputSession.dispose awaits its cleanup on behalf of the client.
|
|
121
|
+
* Native receipt, execution association and terminal completion remain separate observations. */
|
|
122
|
+
export class ClaudeBackendSession {
|
|
123
|
+
adapter;
|
|
124
|
+
params;
|
|
125
|
+
sink;
|
|
126
|
+
fileStore;
|
|
127
|
+
/** Prepare binding/configuration for ClaudeNativeAdapter.openSession without starting a query.
|
|
128
|
+
* Reject unsupported config keys or invalid values before native open. */
|
|
129
|
+
constructor(adapter, params, sink, fileStore) {
|
|
130
|
+
this.adapter = adapter;
|
|
131
|
+
this.params = params;
|
|
132
|
+
this.sink = sink;
|
|
133
|
+
this.fileStore = fileStore;
|
|
134
|
+
this.binding = { ...params.binding, nativeRef: params.binding.nativeRef ?? { provider: "claude", value: randomUUID() } };
|
|
135
|
+
this.capabilities = adapter.capabilities;
|
|
136
|
+
this.config = structuredClone(params.config ?? {});
|
|
137
|
+
this.model = params.model ?? str(this.config.model);
|
|
138
|
+
for (const [key, value] of Object.entries(this.config)) {
|
|
139
|
+
const option = this.capabilities.config?.options.find((entry) => entry.id === key);
|
|
140
|
+
if (!option)
|
|
141
|
+
throw new HarnessSdkError("CAPABILITY_UNSUPPORTED", "Unknown Claude config option");
|
|
142
|
+
if (!validateJsonSchema(option.schema, value))
|
|
143
|
+
throw new HarnessSdkError("INVALID_PARAMS", "Invalid Claude config value");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Resolve resume/fork state and start a query for ClaudeNativeAdapter.openSession. Store,
|
|
147
|
+
* boundary and query-setup failures reject before the adapter registers the backend. */
|
|
148
|
+
async open() {
|
|
149
|
+
if (this.params.mode === "resume" && !this.params.binding.nativeRef)
|
|
150
|
+
throw new HarnessSdkError("SESSION_NOT_FOUND");
|
|
151
|
+
if (this.params.mode === "fork") {
|
|
152
|
+
if (!this.params.fork?.nativeRef)
|
|
153
|
+
throw new HarnessSdkError("SESSION_NOT_FOUND");
|
|
154
|
+
const source = this.params.fork.nativeRef.value;
|
|
155
|
+
const point = await this.boundaryPoint(source, this.params.fork.boundary);
|
|
156
|
+
const forked = point.uuid ? await this.forkTranscript(source, point.uuid) : { sessionId: randomUUID() };
|
|
157
|
+
this.binding = { ...this.binding, nativeRef: { provider: "claude", value: forked.sessionId } };
|
|
158
|
+
this.turns = this.fileStore ? await this.fileStore.loadTurnMap(source) : structuredClone(this.adapter.turnMaps.get(source) ?? {});
|
|
159
|
+
}
|
|
160
|
+
else
|
|
161
|
+
this.turns = this.fileStore ? await this.fileStore.loadTurnMap(this.nativeId) : this.adapter.turnMaps.get(this.nativeId) ?? {};
|
|
162
|
+
await this.rememberTurnMap();
|
|
163
|
+
const fresh = this.params.mode === "new" || (this.params.mode === "fork" && !await this.hasHistory(this.nativeId));
|
|
164
|
+
await this.startQuery({ mode: fresh ? "fresh" : "resume" });
|
|
165
|
+
}
|
|
166
|
+
/** Dispatch requests forwarded by OutputSession.execute. Submission returns after enqueueing
|
|
167
|
+
* native input. Association and terminal results arrive through the sink. Busy or unsupported
|
|
168
|
+
* operations and native failures reject. Close awaits query cleanup with execution stop unconfirmed. */
|
|
169
|
+
async execute(method, params) {
|
|
170
|
+
if (this.closed)
|
|
171
|
+
throw new HarnessSdkError("SESSION_CLOSED");
|
|
172
|
+
if (method === "history/read")
|
|
173
|
+
return await this.readHistory(params);
|
|
174
|
+
if (method === "session/close") {
|
|
175
|
+
await this.dispose();
|
|
176
|
+
return { closed: true, observationClosed: true, executionStopped: "unconfirmed" };
|
|
177
|
+
}
|
|
178
|
+
const query = this.requireQuery();
|
|
179
|
+
let result;
|
|
180
|
+
switch (method) {
|
|
181
|
+
case 'input/submit': return await this.submitInput(params, query);
|
|
182
|
+
case 'session/interrupt': {
|
|
183
|
+
const receipt = await query.interrupt();
|
|
184
|
+
const stillQueued = receipt?.still_queued?.flatMap(id => {
|
|
185
|
+
const pending = this.nativeInputs.get(id);
|
|
186
|
+
return pending ? [pending.submissionId] : [];
|
|
187
|
+
});
|
|
188
|
+
result = { accepted: true, ...(stillQueued ? { stillQueued } : {}) };
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'turn/interrupt':
|
|
192
|
+
case 'turn/steer': throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'Claude controls target the session, not an atomically matched Turn');
|
|
193
|
+
case "session/config/set":
|
|
194
|
+
if (this.reverting)
|
|
195
|
+
throw new HarnessSdkError('SESSION_BUSY', 'Claude configuration cannot change during revert');
|
|
196
|
+
result = await this.setConfig(params.patch);
|
|
197
|
+
break;
|
|
198
|
+
case "session/revert":
|
|
199
|
+
result = await this.revert(params);
|
|
200
|
+
break;
|
|
201
|
+
default: throw new HarnessSdkError("CAPABILITY_UNSUPPORTED");
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
/** Share query close/return completion between the close request and OutputSession.dispose.
|
|
206
|
+
* Stop input admission, clear pending associations and invalidate message observation immediately.
|
|
207
|
+
* Cleanup rejects on release failure and leaves native execution stop unconfirmed. */
|
|
208
|
+
async dispose() {
|
|
209
|
+
return this.release ??= (async () => {
|
|
210
|
+
this.closed = true;
|
|
211
|
+
this.generation++;
|
|
212
|
+
this.nativeInputs.clear();
|
|
213
|
+
this.input?.end();
|
|
214
|
+
if (this.query)
|
|
215
|
+
await this.releaseQuery(this.query);
|
|
216
|
+
})();
|
|
217
|
+
}
|
|
218
|
+
/** Current native session identity, updated by fork/revert and native initialization. */
|
|
219
|
+
binding;
|
|
220
|
+
/** Store/checkpoint capability projection captured from the adapter at construction. */
|
|
221
|
+
capabilities;
|
|
222
|
+
/** Enforce execute's query admission policy for operations other than history and close. */
|
|
223
|
+
requireQuery() {
|
|
224
|
+
if (this.switchingOutput)
|
|
225
|
+
throw new HarnessSdkError('SESSION_BUSY', 'Claude output format is changing');
|
|
226
|
+
if (this.queryFailure)
|
|
227
|
+
throw this.queryFailure;
|
|
228
|
+
if (!this.query)
|
|
229
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
230
|
+
return this.query;
|
|
231
|
+
}
|
|
232
|
+
/** Apply execute's configuration controls in order and publish state after all controls succeed.
|
|
233
|
+
* Earlier native effects, including model changes, survive a rejected patch. Keep output-format
|
|
234
|
+
* replacement blocked until the patch settles. Session.onRequestFailed enters recovery when
|
|
235
|
+
* the returned SDK error carries an unknown outcome. */
|
|
236
|
+
async setConfig(patch) {
|
|
237
|
+
this.configOperations++;
|
|
238
|
+
try {
|
|
239
|
+
// Apply settings sequentially without rollback on failure. Model changes
|
|
240
|
+
// take effect immediately; commit the config snapshot only after all settings succeed.
|
|
241
|
+
if (patch.model !== undefined) {
|
|
242
|
+
if (typeof patch.model !== "string")
|
|
243
|
+
throw new HarnessSdkError("INVALID_PARAMS");
|
|
244
|
+
await this.query.setModel(patch.model);
|
|
245
|
+
this.model = patch.model;
|
|
246
|
+
}
|
|
247
|
+
if (patch.permissionMode !== undefined) {
|
|
248
|
+
await this.query.setPermissionMode(permission(patch.permissionMode));
|
|
249
|
+
}
|
|
250
|
+
if (patch.fastMode !== undefined) {
|
|
251
|
+
if (typeof patch.fastMode !== "boolean")
|
|
252
|
+
throw new HarnessSdkError("INVALID_PARAMS");
|
|
253
|
+
await this.query.applyFlagSettings({ fastMode: patch.fastMode });
|
|
254
|
+
}
|
|
255
|
+
if (patch.effort !== undefined) {
|
|
256
|
+
if (patch.effort !== null && !isEffort(patch.effort))
|
|
257
|
+
throw new HarnessSdkError("INVALID_PARAMS");
|
|
258
|
+
await this.query.applyFlagSettings({ effortLevel: patch.effort });
|
|
259
|
+
}
|
|
260
|
+
if (patch.maxThinkingTokens !== undefined) {
|
|
261
|
+
if (patch.maxThinkingTokens !== null && typeof patch.maxThinkingTokens !== "number")
|
|
262
|
+
throw new HarnessSdkError("INVALID_PARAMS");
|
|
263
|
+
await this.query.setMaxThinkingTokens(patch.maxThinkingTokens);
|
|
264
|
+
}
|
|
265
|
+
this.config = { ...this.config, ...patch };
|
|
266
|
+
this.capabilityRevision++;
|
|
267
|
+
const state = this.snapshot();
|
|
268
|
+
await this.sink.emit({ type: "session/state", payload: state });
|
|
269
|
+
return state;
|
|
270
|
+
}
|
|
271
|
+
catch (cause) {
|
|
272
|
+
throw sdkError(cause, "CLAUDE_CONFIG_FAILED", true);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
this.configOperations--;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
release;
|
|
279
|
+
nativeInputs = new Map();
|
|
280
|
+
query;
|
|
281
|
+
pumping;
|
|
282
|
+
queryReleases = new WeakMap();
|
|
283
|
+
outputSchema;
|
|
284
|
+
switchingOutput = false;
|
|
285
|
+
queryMode = "resume";
|
|
286
|
+
queryResumeAt;
|
|
287
|
+
queryFailure;
|
|
288
|
+
configOperations = 0;
|
|
289
|
+
input;
|
|
290
|
+
active;
|
|
291
|
+
generation = 0;
|
|
292
|
+
closed = false;
|
|
293
|
+
reverting = false;
|
|
294
|
+
config;
|
|
295
|
+
capabilityRevision = 1;
|
|
296
|
+
model;
|
|
297
|
+
turns = {};
|
|
298
|
+
historySnapshots = new Map();
|
|
299
|
+
/** Native fast-mode outcome as the CLI reports it (the setting may be on but not in effect). */
|
|
300
|
+
fastMode;
|
|
301
|
+
storeKeys = new Map();
|
|
302
|
+
sessionTitle;
|
|
303
|
+
metadataTail = Promise.resolve();
|
|
304
|
+
metadataPending;
|
|
305
|
+
/** Wrap startQuery's store so successful main-session appends schedule title refreshes.
|
|
306
|
+
* Forward store failures unchanged and ignore appends from replaced queries; metadata refresh runs
|
|
307
|
+
* independently so title reads cannot reject a committed append. Store lifecycle stays host-owned. */
|
|
308
|
+
observedStore(store, generation) {
|
|
309
|
+
return {
|
|
310
|
+
append: async (key, entries) => {
|
|
311
|
+
await store.append(key, entries);
|
|
312
|
+
if (!key.subpath && key.sessionId === this.nativeId && generation === this.generation && !this.closed)
|
|
313
|
+
void this.refreshMetadata();
|
|
314
|
+
},
|
|
315
|
+
load: store.load.bind(store),
|
|
316
|
+
...(store.listSessions ? { listSessions: store.listSessions.bind(store) } : {}),
|
|
317
|
+
...(store.listSubkeys ? { listSubkeys: store.listSubkeys.bind(store) } : {}),
|
|
318
|
+
...(store.delete ? { delete: store.delete.bind(store) } : {}),
|
|
319
|
+
...(store.listSessionSummaries ? { listSessionSummaries: store.listSessionSummaries.bind(store) } : {}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/** Coalesce title reads for observedStore, startQuery and completeTurn. startQuery awaits the
|
|
323
|
+
* refresh on resume, while append and terminal publication proceed independently. Ignore stale
|
|
324
|
+
* reads after replacement or close and report read failures with a best-effort warning. */
|
|
325
|
+
refreshMetadata() {
|
|
326
|
+
const generation = this.generation, nativeId = this.nativeId;
|
|
327
|
+
if (this.metadataPending?.generation === generation) {
|
|
328
|
+
this.metadataPending.dirty = true;
|
|
329
|
+
return this.metadataPending.promise;
|
|
330
|
+
}
|
|
331
|
+
const pending = { generation, dirty: true, promise: Promise.resolve() };
|
|
332
|
+
pending.promise = this.metadataTail.then(async () => {
|
|
333
|
+
try {
|
|
334
|
+
while (pending.dirty && !this.closed && generation === this.generation && nativeId === this.nativeId) {
|
|
335
|
+
pending.dirty = false;
|
|
336
|
+
try {
|
|
337
|
+
const info = await (this.adapter.options.getSessionInfo ?? getSessionInfo)(nativeId, { dir: this.params.cwd, sessionStore: this.adapter.sessionStore });
|
|
338
|
+
if (!info || this.closed || generation !== this.generation || nativeId !== this.nativeId)
|
|
339
|
+
continue;
|
|
340
|
+
const title = info.summary.trim() || null;
|
|
341
|
+
if (this.sessionTitle === title)
|
|
342
|
+
continue;
|
|
343
|
+
this.sessionTitle = title;
|
|
344
|
+
await this.sink.emit({ type: "session/metadata", payload: { title } });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// A metadata read must not reject a committed append or fail the turn.
|
|
348
|
+
if (!this.closed && generation === this.generation)
|
|
349
|
+
await this.sink.emit({ type: "notice", payload: { severity: "warning", code: "SESSION_METADATA_UNAVAILABLE", message: "Session title could not be refreshed" } }).catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
if (this.metadataPending === pending)
|
|
355
|
+
this.metadataPending = undefined;
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
this.metadataPending = pending;
|
|
359
|
+
this.metadataTail = pending.promise;
|
|
360
|
+
return pending.promise;
|
|
361
|
+
}
|
|
362
|
+
get nativeId() { return this.binding.nativeRef.value; }
|
|
363
|
+
/** Let open choose a fresh query for an empty fork. A history read failure rejects open. */
|
|
364
|
+
async hasHistory(id) {
|
|
365
|
+
return (await this.messages(id)).length > 0;
|
|
366
|
+
}
|
|
367
|
+
/** Copy the source transcript through a verified cutoff for open or restoreConversation.
|
|
368
|
+
* The caller installs the returned session ID. Native copy failures reject that phase. */
|
|
369
|
+
forkTranscript(nativeId, cutoff) {
|
|
370
|
+
return (this.adapter.options.forkSession ?? forkSession)(nativeId, {
|
|
371
|
+
dir: this.params.cwd, sessionStore: this.adapter.sessionStore, upToMessageId: cutoff,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
/** Open, association, init and conversation restore update the in-memory map before awaiting
|
|
375
|
+
* its optional disk copy. Save failure rejects the caller without undoing the map update. */
|
|
376
|
+
rememberTurnMap() {
|
|
377
|
+
this.adapter.turnMaps.set(this.nativeId, this.turns);
|
|
378
|
+
return this.fileStore?.saveTurnMap(this.nativeId, this.turns);
|
|
379
|
+
}
|
|
380
|
+
/** Accept native init/result fast-mode states and report whether the public snapshot changed. */
|
|
381
|
+
trackFastMode(value) {
|
|
382
|
+
const state = str(value.fast_mode_state);
|
|
383
|
+
if (state !== "off" && state !== "cooldown" && state !== "on")
|
|
384
|
+
return false;
|
|
385
|
+
const reason = str(value.fast_mode_disabled_reason);
|
|
386
|
+
const next = { state, ...(reason ? { reason } : {}) };
|
|
387
|
+
const changed = JSON.stringify(next) !== JSON.stringify(this.fastMode);
|
|
388
|
+
this.fastMode = next;
|
|
389
|
+
return changed;
|
|
390
|
+
}
|
|
391
|
+
/** Clone binding and config for state publications during initialization, configuration,
|
|
392
|
+
* completion and conversation restore. Report running while an execution occupies the active slot. */
|
|
393
|
+
snapshot() { return { ...(this.sessionTitle !== undefined ? { metadata: { title: this.sessionTitle } } : {}), binding: structuredClone(this.binding), status: this.active ? "running" : "ready", config: structuredClone(this.config), capabilities: this.capabilities, capabilityRevision: this.capabilityRevision, ...(this.model ? { model: this.model } : {}), ...(this.fastMode ? { fastMode: this.fastMode } : {}), activeTurnIds: this.active ? [this.active.id] : [] }; }
|
|
394
|
+
/** Construct a generation-specific query for open, restoreConversation or replaceOutputFormat,
|
|
395
|
+
* then start background pumping and await resumed-title refresh. Fresh mode opens the current native
|
|
396
|
+
* ID as a new session; resume mode reopens its transcript, optionally at resumeAt. Factory/setup
|
|
397
|
+
* failures reject here; subsequent source failures are reported to the sink by pump. */
|
|
398
|
+
async startQuery(start) {
|
|
399
|
+
const { mode, resumeAt } = start;
|
|
400
|
+
if (this.closed)
|
|
401
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
402
|
+
const generation = ++this.generation;
|
|
403
|
+
this.queryMode = mode;
|
|
404
|
+
this.queryResumeAt = resumeAt;
|
|
405
|
+
this.input = new BoundedEventQueue({ maxEvents: 64 });
|
|
406
|
+
const options = this.sessionQueryOptions(start, generation);
|
|
407
|
+
this.query = (this.adapter.options.queryFactory ?? query)({ prompt: this.input, options });
|
|
408
|
+
this.pumping = this.pump(this.query, generation);
|
|
409
|
+
if (mode === "resume")
|
|
410
|
+
await this.refreshMetadata();
|
|
411
|
+
}
|
|
412
|
+
/** Project the session and generation into launch options for startQuery. Native tool callbacks
|
|
413
|
+
* are invoked by the SDK. The observed store schedules title refreshes only for this generation. */
|
|
414
|
+
sessionQueryOptions({ mode, resumeAt }, generation) {
|
|
415
|
+
const mcpServers = {};
|
|
416
|
+
for (const server of this.params.mcpServers ?? [])
|
|
417
|
+
mcpServers[server.name] = server.type === "stdio" ? { type: "stdio", command: server.command, args: server.args, env: server.env } : { type: "http", url: server.url, headers: server.headers };
|
|
418
|
+
return {
|
|
419
|
+
...this.adapter.queryOptions(this.params.cwd),
|
|
420
|
+
...(this.adapter.sessionStore ? { sessionStore: this.observedStore(this.adapter.sessionStore, generation) } : {}),
|
|
421
|
+
...(mode === "fresh" ? { sessionId: this.nativeId } : { resume: this.nativeId }),
|
|
422
|
+
...(resumeAt ? { resumeSessionAt: resumeAt } : {}),
|
|
423
|
+
...(this.model ? { model: this.model } : {}),
|
|
424
|
+
...(this.params.instructions ? { systemPrompt: this.params.instructions } : {}),
|
|
425
|
+
...(this.config.permissionMode !== undefined || this.params.permissionMode ? { permissionMode: permission(this.config.permissionMode ?? this.params.permissionMode) } : {}),
|
|
426
|
+
// Permit an explicit later mode switch; this flag does not select bypass mode.
|
|
427
|
+
allowDangerouslySkipPermissions: true,
|
|
428
|
+
...(typeof this.config.maxThinkingTokens === "number" ? { maxThinkingTokens: this.config.maxThinkingTokens } : {}),
|
|
429
|
+
...(isEffort(this.config.effort) ? { effort: this.config.effort } : {}),
|
|
430
|
+
...(typeof this.config.fastMode === 'boolean' ? { settings: { fastMode: this.config.fastMode } } : {}),
|
|
431
|
+
...(this.outputSchema ? { outputFormat: { type: 'json_schema', schema: this.outputSchema.schema } } : {}),
|
|
432
|
+
mcpServers,
|
|
433
|
+
canUseTool: async (toolName, input, details) => this.permissionRequest(toolName, input, details),
|
|
434
|
+
onElicitation: async (request, details) => this.elicitationRequest(request, details),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** Consume the query started by startQuery until closure/generation replacement. Fail SDK
|
|
438
|
+
* observation on source errors or early EOF with pending work; never replay native input. */
|
|
439
|
+
async pump(stream, generation) {
|
|
440
|
+
try {
|
|
441
|
+
for await (const message of stream) {
|
|
442
|
+
if (this.closed || generation !== this.generation)
|
|
443
|
+
return;
|
|
444
|
+
await this.mapMessage(message);
|
|
445
|
+
}
|
|
446
|
+
if (!this.closed && generation === this.generation && (this.active || this.nativeInputs.size))
|
|
447
|
+
await this.sink.fail(new HarnessSdkError("CONNECTION_CLOSED", "Claude query ended before a result", false, true));
|
|
448
|
+
}
|
|
449
|
+
catch (cause) {
|
|
450
|
+
if (!this.closed && generation === this.generation)
|
|
451
|
+
await this.sink.fail(sdkError(cause, "CLAUDE_QUERY_FAILED", true));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Return the active execution to associateInputs. Allocate and publish it on the first native
|
|
455
|
+
* execution evidence from associateInputs or permissionRequest. Starting work also makes later
|
|
456
|
+
* schema replacements resume the native transcript. Publication failure rejects to the caller. */
|
|
457
|
+
async beginExecution(userUuid = randomUUID()) {
|
|
458
|
+
if (!this.active) {
|
|
459
|
+
this.queryMode = "resume";
|
|
460
|
+
this.queryResumeAt = undefined;
|
|
461
|
+
this.active = { id: randomUUID(), userUuid, consumed: new Set(), output: new Map(), stoppedTools: new Map(), blockIndex: 0 };
|
|
462
|
+
await this.sink.emit({ type: 'turn/started', payload: { turnId: this.active.id } });
|
|
463
|
+
}
|
|
464
|
+
return this.active;
|
|
465
|
+
}
|
|
466
|
+
/** Route pump messages after processing native input refusal or association evidence. Bind
|
|
467
|
+
* inputs before projecting the associated reply. Sink and store failures reject back to pump. */
|
|
468
|
+
async mapMessage(message) {
|
|
469
|
+
const value = record(message);
|
|
470
|
+
const refused = str(value.refused_user_message_uuid);
|
|
471
|
+
if (refused && this.nativeInputs.has(refused)) {
|
|
472
|
+
await this.rejectInput(refused, { code: 'NATIVE_INPUT_REJECTED', message: 'Native input rejected' });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const mainReply = !value.parent_tool_use_id && (message.type === 'assistant' || message.type === 'stream_event' || message.type === 'result');
|
|
476
|
+
const uuids = Array.isArray(value.user_message_uuids) ? value.user_message_uuids.filter((id) => typeof id === 'string') : str(value.user_message_uuid) ? [String(value.user_message_uuid)] : [];
|
|
477
|
+
// A message UUID is input identity. New UUID echoes inside an active native
|
|
478
|
+
// loop bind additional inputs to that loop; they never create another turn.
|
|
479
|
+
if (mainReply && (this.active || uuids.some(id => this.nativeInputs.has(id)) || this.nativeInputs.size && message.type !== 'result')) {
|
|
480
|
+
if (message.type === 'result' && value.num_turns === 0 && !this.active) {
|
|
481
|
+
for (const id of uuids) {
|
|
482
|
+
if (!this.nativeInputs.has(id))
|
|
483
|
+
continue;
|
|
484
|
+
await this.rejectInput(id, { code: 'NATIVE_INPUT_NOT_EXECUTED', message: 'Input ended without an execution' });
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
await this.associateInputs(uuids);
|
|
489
|
+
}
|
|
490
|
+
const active = this.active;
|
|
491
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
492
|
+
this.trackFastMode(value);
|
|
493
|
+
if (message.session_id !== this.nativeId) {
|
|
494
|
+
this.binding = { ...this.binding, nativeRef: { provider: "claude", value: message.session_id } };
|
|
495
|
+
await this.rememberTurnMap();
|
|
496
|
+
}
|
|
497
|
+
await this.sink.emit({ type: "session/state", payload: this.snapshot() });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!active) {
|
|
501
|
+
await this.notice(value);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (message.type === "assistant") {
|
|
505
|
+
if (!message.parent_tool_use_id)
|
|
506
|
+
active.apiError = message.error;
|
|
507
|
+
for (const block of message.message.content)
|
|
508
|
+
await this.completeBlock({ block: record(block), messageId: message.message.id, context: message.parent_tool_use_id !== null ? "subagent" : "main", active });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (message.type === "stream_event") {
|
|
512
|
+
await this.streamEvent(record(message.event), active);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (message.type === "user") {
|
|
516
|
+
return this.publishToolResults(value, active);
|
|
517
|
+
}
|
|
518
|
+
if (message.type === "result")
|
|
519
|
+
return this.completeTurn(message, active);
|
|
520
|
+
if (message.type === "system" && typeof value.subtype === "string") {
|
|
521
|
+
const subtype = value.subtype;
|
|
522
|
+
if (subtype.startsWith("task_"))
|
|
523
|
+
return this.publishTask(value, active);
|
|
524
|
+
if (subtype.startsWith("hook_"))
|
|
525
|
+
return this.publishHook(subtype, value, active);
|
|
526
|
+
if (subtype === "compact_boundary") {
|
|
527
|
+
await this.sink.emit({ type: "item/completed", payload: { item: { id: str(value.uuid) ?? randomUUID(), type: "compaction", status: "completed", summary: str(value.summary), trigger: str(record(value.compact_metadata).trigger) } } }, { turnId: active.id });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
await this.notice(value);
|
|
532
|
+
}
|
|
533
|
+
/** Complete live tool calls from matching native user-message results. mapMessage awaits
|
|
534
|
+
* publication to keep these effects ordered before later native messages. */
|
|
535
|
+
async publishToolResults(value, active) {
|
|
536
|
+
const body = record(value.message).content;
|
|
537
|
+
if (!Array.isArray(body))
|
|
538
|
+
return;
|
|
539
|
+
for (const part of body) {
|
|
540
|
+
const block = record(part);
|
|
541
|
+
if (block.type !== "tool_result")
|
|
542
|
+
continue;
|
|
543
|
+
const toolId = str(block.tool_use_id);
|
|
544
|
+
if (!toolId)
|
|
545
|
+
continue;
|
|
546
|
+
const previous = active.output.get(toolId);
|
|
547
|
+
if (previous?.type !== "toolCall")
|
|
548
|
+
continue;
|
|
549
|
+
const item = completeClaudeTool(previous, toolResult(block, value.tool_use_result));
|
|
550
|
+
active.output.set(toolId, item);
|
|
551
|
+
await this.sink.emit({ type: "item/completed", payload: { item } }, { turnId: active.id });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** Map native task updates to an item's start, patch or terminal event. mapMessage awaits
|
|
555
|
+
* publication, falling back to a notice when the native task identity is missing. */
|
|
556
|
+
async publishTask(value, active) {
|
|
557
|
+
const id = str(value.task_id);
|
|
558
|
+
if (!id)
|
|
559
|
+
return this.notice(value);
|
|
560
|
+
const previous = active.output.get(`task:${id}`);
|
|
561
|
+
const patch = record(value.patch);
|
|
562
|
+
const status = str(value.status) ?? str(patch.status) ?? "running";
|
|
563
|
+
const final = ["completed", "failed", "stopped", "killed"].includes(status);
|
|
564
|
+
const item = {
|
|
565
|
+
id: `task:${id}`,
|
|
566
|
+
type: "subagent",
|
|
567
|
+
subagentId: id,
|
|
568
|
+
state: status === "completed" || status === "failed" ? status : final ? "interrupted" : "running",
|
|
569
|
+
status: status === "completed" ? "completed" : final ? "failed" : "running",
|
|
570
|
+
task: str(value.description) ?? str(patch.description) ?? str(value.summary),
|
|
571
|
+
...(str(value.tool_use_id) ? { parentItemId: str(value.tool_use_id) } : {}),
|
|
572
|
+
};
|
|
573
|
+
active.output.set(item.id, item);
|
|
574
|
+
if (final)
|
|
575
|
+
await this.sink.emit({ type: "item/completed", payload: { item } }, { turnId: active.id });
|
|
576
|
+
else if (!previous)
|
|
577
|
+
await this.sink.emit({ type: "item/started", payload: { item } }, { turnId: active.id });
|
|
578
|
+
else
|
|
579
|
+
await this.sink.emit({ type: "item/delta", payload: { itemId: item.id, kind: "patch", value: safe(item) } }, { turnId: active.id });
|
|
580
|
+
}
|
|
581
|
+
/** Give hook progress a start event when the hook was previously unseen. mapMessage awaits
|
|
582
|
+
* publication before consuming another message, including when a missing hook ID emits a notice. */
|
|
583
|
+
async publishHook(subtype, value, active) {
|
|
584
|
+
const id = str(value.hook_id);
|
|
585
|
+
if (!id)
|
|
586
|
+
return this.notice(value);
|
|
587
|
+
const previous = active.output.get(`hook:${id}`);
|
|
588
|
+
const item = {
|
|
589
|
+
id: `hook:${id}`,
|
|
590
|
+
type: "hook",
|
|
591
|
+
name: str(value.hook_name) ?? "hook",
|
|
592
|
+
status: subtype === "hook_response" ? value.outcome === "success" ? "completed" : "failed" : "running",
|
|
593
|
+
...(value.output !== undefined ? { output: safe(value.output) } : {}),
|
|
594
|
+
};
|
|
595
|
+
active.output.set(item.id, item);
|
|
596
|
+
if (subtype === "hook_progress") {
|
|
597
|
+
if (!previous)
|
|
598
|
+
await this.sink.emit({ type: "item/started", payload: { item } }, { turnId: active.id });
|
|
599
|
+
await this.sink.emit({ type: "item/delta", payload: { itemId: item.id, kind: "output", text: str(value.output) ?? "" } }, { turnId: active.id });
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
await this.sink.emit({ type: previous || subtype === "hook_response" ? "item/completed" : "item/started", payload: { item } }, { turnId: active.id });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** Publish mapMessage's native refusal/no-execution evidence before removing a pending input.
|
|
606
|
+
* Unknown UUIDs are ignored; publication failure leaves the input retained and rejects into pump. */
|
|
607
|
+
async rejectInput(id, error) {
|
|
608
|
+
const pending = this.nativeInputs.get(id);
|
|
609
|
+
if (!pending)
|
|
610
|
+
return;
|
|
611
|
+
await this.sink.emit({ type: 'submission/status', payload: { submissionId: pending.submissionId, status: 'rejected', error: { ...error, retryable: false, outcomeUnknown: false } } });
|
|
612
|
+
this.nativeInputs.delete(id);
|
|
613
|
+
}
|
|
614
|
+
/** Bind mapMessage's echoed UUIDs to the current execution and persist the verified map.
|
|
615
|
+
* Native evidence can add inputs to an existing execution, but cannot move a bound input to a
|
|
616
|
+
* different execution. Binding or store failures reject and are reported by pump. */
|
|
617
|
+
async associateInputs(uuids) {
|
|
618
|
+
const execution = await this.beginExecution(uuids[0]);
|
|
619
|
+
for (const id of uuids) {
|
|
620
|
+
const pending = this.nativeInputs.get(id);
|
|
621
|
+
if (!pending)
|
|
622
|
+
continue;
|
|
623
|
+
if (pending.turnId && pending.turnId !== execution.id)
|
|
624
|
+
throw new HarnessSdkError('PROTOCOL_ERROR', 'Native input changed executions');
|
|
625
|
+
if (!pending.turnId) {
|
|
626
|
+
pending.turnId = execution.id;
|
|
627
|
+
execution.consumed.add(id);
|
|
628
|
+
this.turns[id] = execution.id;
|
|
629
|
+
await this.sink.bindSubmission(pending.submissionId, execution.id);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
await this.rememberTurnMap();
|
|
633
|
+
}
|
|
634
|
+
/** Finish mapMessage's captured execution. Validate final output, publish items and usage, then
|
|
635
|
+
* clear consumed input IDs before the terminal and ready-state events. Uncorrelated inputs stay
|
|
636
|
+
* pending. A sink failure rejects into pump. */
|
|
637
|
+
async completeTurn(message, active) {
|
|
638
|
+
const value = record(message);
|
|
639
|
+
const { interrupted, error, resultText, structuredText } = this.terminalOutput(message, active, value);
|
|
640
|
+
const textItems = [...active.output.values()].filter((item) => item.type === "agentMessage" && item.status !== "completed");
|
|
641
|
+
for (let index = 0; index < textItems.length; index++) {
|
|
642
|
+
const item = textItems[index];
|
|
643
|
+
if (item.type !== "agentMessage")
|
|
644
|
+
continue;
|
|
645
|
+
const completed = { ...item, text: index === textItems.length - 1 && resultText !== undefined ? resultText : item.text, phase: !this.outputSchema && index === textItems.length - 1 ? "final" : "commentary", status: "completed" };
|
|
646
|
+
active.output.set(item.id, completed);
|
|
647
|
+
await this.sink.emit({ type: "item/completed", payload: { item: completed } }, { turnId: active.id });
|
|
648
|
+
}
|
|
649
|
+
if (!textItems.length && resultText) {
|
|
650
|
+
const item = { id: `${active.userUuid}:final`, type: "agentMessage", phase: "final", text: resultText, status: "completed" };
|
|
651
|
+
await this.sink.emit({ type: "item/completed", payload: { item } }, { turnId: active.id });
|
|
652
|
+
}
|
|
653
|
+
if (structuredText !== undefined) {
|
|
654
|
+
const item = { id: `${active.id}:structured-output`, type: 'agentMessage', phase: 'final', text: structuredText, status: 'completed' };
|
|
655
|
+
await this.sink.emit({ type: 'item/completed', payload: { item } }, { turnId: active.id });
|
|
656
|
+
}
|
|
657
|
+
const nativeUsage = record(message.usage);
|
|
658
|
+
const usage = { provider: "claude", scope: "turn", source: "native", ...(typeof nativeUsage.input_tokens === "number" ? { input: nativeUsage.input_tokens } : {}), ...(typeof nativeUsage.output_tokens === "number" ? { output: nativeUsage.output_tokens } : {}), ...(typeof nativeUsage.cache_read_input_tokens === "number" ? { cacheRead: nativeUsage.cache_read_input_tokens } : {}), ...(typeof nativeUsage.cache_creation_input_tokens === "number" ? { cacheWrite: nativeUsage.cache_creation_input_tokens } : {}) };
|
|
659
|
+
await this.sink.emit({ type: "usage/updated", payload: usage }, { turnId: active.id });
|
|
660
|
+
const failed = error !== undefined;
|
|
661
|
+
for (const pending of active.output.values()) {
|
|
662
|
+
if (pending.type !== "toolCall" || pending.kind !== "fileChange" || pending.status === "completed" || pending.status === "failed")
|
|
663
|
+
continue;
|
|
664
|
+
const item = { ...pending, status: active.stoppedTools.get(pending.id) ?? (interrupted ? "interrupted" : failed ? "failed" : undefined) };
|
|
665
|
+
active.output.set(item.id, item);
|
|
666
|
+
await this.sink.emit({ type: "item/completed", payload: { item } }, { turnId: active.id });
|
|
667
|
+
}
|
|
668
|
+
this.active = undefined;
|
|
669
|
+
for (const id of active.consumed)
|
|
670
|
+
this.nativeInputs.delete(id);
|
|
671
|
+
this.trackFastMode(value);
|
|
672
|
+
await this.sink.emit({ type: "turn/completed", payload: { turnId: active.id, status: interrupted ? "interrupted" : failed ? "failed" : "completed", usage, ...(error && !interrupted ? { error } : {}) } });
|
|
673
|
+
void this.refreshMetadata();
|
|
674
|
+
// The init snapshot was taken mid-turn ("running"); publish the settled state.
|
|
675
|
+
await this.sink.emit({ type: "session/state", payload: this.snapshot() });
|
|
676
|
+
}
|
|
677
|
+
/** Classify native termination and validate structured output before completeTurn publishes
|
|
678
|
+
* any final item. Missing or invalid output becomes a terminal error. An interruption bypasses
|
|
679
|
+
* schema validation and retains precedence in the terminal event. */
|
|
680
|
+
terminalOutput(message, active, value) {
|
|
681
|
+
const interrupted = str(message.stop_reason) === "interrupted" || str(value.terminal_reason) === "interrupted";
|
|
682
|
+
let error = claudeTurnError(message, active.apiError);
|
|
683
|
+
let structuredText;
|
|
684
|
+
if (this.outputSchema && !error && !interrupted) {
|
|
685
|
+
if (value.structured_output === undefined) {
|
|
686
|
+
error = { code: 'CLAUDE_STRUCTURED_OUTPUT_MISSING', message: 'Claude completed without the requested structured output', retryable: false, outcomeUnknown: false };
|
|
687
|
+
}
|
|
688
|
+
else if (!this.outputSchema.validate(value.structured_output)) {
|
|
689
|
+
error = { code: 'CLAUDE_STRUCTURED_OUTPUT_INVALID', message: 'Claude structured output does not match outputSchema', retryable: false, outcomeUnknown: false };
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
structuredText = JSON.stringify(value.structured_output);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const resultText = !this.outputSchema && message.subtype === "success" ? message.result : undefined;
|
|
696
|
+
return { interrupted, error, resultText, structuredText };
|
|
697
|
+
}
|
|
698
|
+
/** Update the active turn from streamed content, publishing starts/deltas before final blocks.
|
|
699
|
+
* mapMessage calls only with an active turn; sink rejection propagates to the query pump. */
|
|
700
|
+
async streamEvent(event, active) {
|
|
701
|
+
const type = event.type;
|
|
702
|
+
if (type === "message_start") {
|
|
703
|
+
active.streamMessageId = str(record(event.message).id);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const index = typeof event.index === "number" ? event.index : 0;
|
|
707
|
+
const id = `${active.streamMessageId ?? active.userUuid}:${index}`;
|
|
708
|
+
if (type === "content_block_start") {
|
|
709
|
+
const block = record(event.content_block);
|
|
710
|
+
const blockId = str(block.id) ?? id;
|
|
711
|
+
const status = active.stoppedTools.get(blockId) ?? "running";
|
|
712
|
+
const item = this.blockItem(block, blockId, status);
|
|
713
|
+
active.output.set(item.id, item);
|
|
714
|
+
await this.sink.emit({ type: "item/started", payload: { item } }, { turnId: active.id });
|
|
715
|
+
}
|
|
716
|
+
else if (type === "content_block_delta") {
|
|
717
|
+
const delta = record(event.delta);
|
|
718
|
+
const previous = active.output.get(id);
|
|
719
|
+
if (!previous || (previous.type !== "agentMessage" && previous.type !== "reasoning"))
|
|
720
|
+
return;
|
|
721
|
+
const text = str(delta.text) ?? str(delta.thinking) ?? "";
|
|
722
|
+
if (previous.type === "agentMessage")
|
|
723
|
+
previous.text += text;
|
|
724
|
+
else
|
|
725
|
+
previous.text = (previous.text ?? "") + text;
|
|
726
|
+
await this.sink.emit({ type: "item/delta", payload: { itemId: id, kind: previous.type === "reasoning" ? "reasoningSummary" : "text", text } }, { turnId: active.id });
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Project a live/history content block to a public item, preserving unknown native content safely. */
|
|
730
|
+
blockItem(block, id, status) {
|
|
731
|
+
if (block.type === "text")
|
|
732
|
+
return { id, type: "agentMessage", text: str(block.text) ?? "", phase: "commentary", status };
|
|
733
|
+
if (block.type === "thinking")
|
|
734
|
+
return { id, type: "reasoning", text: str(block.thinking) ?? "", status };
|
|
735
|
+
if (block.type === "tool_use") {
|
|
736
|
+
const name = str(block.name) ?? "unknown";
|
|
737
|
+
const fileChanges = claudeFileChanges(name, block.input);
|
|
738
|
+
return { id, type: "toolCall", kind: name === "Bash" ? "command" : ["Write", "Edit", "MultiEdit"].includes(name) ? "fileChange" : name.startsWith("mcp__") ? "mcp" : "other", title: name, input: safe(block.input), status, nativeType: name, ...(fileChanges !== undefined ? { fileChanges } : {}) };
|
|
739
|
+
}
|
|
740
|
+
return { id, type: "toolCall", kind: "other", title: "Unknown Claude content", nativeType: str(block.type) ?? "unknown", raw: safe(block), status };
|
|
741
|
+
}
|
|
742
|
+
/** Reconcile completeBlock with unfinished text/thinking streams before allocating a fallback ID.
|
|
743
|
+
* Mutates the turn's fallback counter only when no native or unfinished-stream identity exists. */
|
|
744
|
+
resolveCompleteBlockId(block, messageId, active) {
|
|
745
|
+
const nativeBlockId = str(block.id);
|
|
746
|
+
if (nativeBlockId)
|
|
747
|
+
return nativeBlockId;
|
|
748
|
+
// Only text and thinking reuse unfinished stream items, in Map insertion order.
|
|
749
|
+
const existingStreamItem = [...active.output.values()].find((item) => {
|
|
750
|
+
if (!item.id.startsWith(`${messageId}:`) || item.status === "completed")
|
|
751
|
+
return false;
|
|
752
|
+
if (block.type === "text")
|
|
753
|
+
return item.type === "agentMessage";
|
|
754
|
+
if (block.type === "thinking")
|
|
755
|
+
return item.type === "reasoning";
|
|
756
|
+
return false;
|
|
757
|
+
});
|
|
758
|
+
if (existingStreamItem)
|
|
759
|
+
return existingStreamItem.id;
|
|
760
|
+
return `${messageId}:${active.blockIndex++}`;
|
|
761
|
+
}
|
|
762
|
+
/** Merge a complete assistant block into active output and publish its start/patch/completion.
|
|
763
|
+
* Text and tool calls stay pending until native result/tool evidence; sink failures reach pump. */
|
|
764
|
+
async completeBlock({ block, messageId, context, active }) {
|
|
765
|
+
const id = this.resolveCompleteBlockId(block, messageId, active);
|
|
766
|
+
const previous = active.output.get(id);
|
|
767
|
+
const running = block.type === "text" || block.type === "tool_use";
|
|
768
|
+
const status = active.stoppedTools.get(id) ?? (running ? "running" : "completed");
|
|
769
|
+
const item = this.blockItem(block, id, status);
|
|
770
|
+
if (context === "subagent")
|
|
771
|
+
item.extensions = { ...(item.extensions ?? {}), nativeParentTool: "subagent" };
|
|
772
|
+
active.output.set(id, item);
|
|
773
|
+
if (!previous)
|
|
774
|
+
await this.sink.emit({ type: "item/started", payload: { item: { ...item, status: "running" } } }, { turnId: active.id });
|
|
775
|
+
else if (block.type === "tool_use")
|
|
776
|
+
await this.sink.emit({ type: "item/delta", payload: { itemId: item.id, kind: "patch", value: safe(item) } }, { turnId: active.id });
|
|
777
|
+
if (item.type !== "agentMessage" && block.type !== "tool_use")
|
|
778
|
+
await this.sink.emit({ type: "item/completed", payload: { item } }, { turnId: active.id });
|
|
779
|
+
}
|
|
780
|
+
/** Publish otherwise unmapped native messages through the sink with privacy-safe raw content. */
|
|
781
|
+
async notice(value) {
|
|
782
|
+
const subtype = str(value.subtype) ?? str(value.type) ?? "unknown";
|
|
783
|
+
await this.sink.emit({ type: "notice", payload: { severity: /error|denied|retry|rate_limit/.test(subtype) ? "warning" : "info", code: `CLAUDE_${subtype.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}`, message: str(value.content) ?? str(value.message) ?? subtype, raw: safe(value) } }, this.active ? { turnId: this.active.id } : {});
|
|
784
|
+
}
|
|
785
|
+
/** Native canUseTool callback supplied by sessionQueryOptions. Await SDK approval/user answers or
|
|
786
|
+
* native cancellation, translating unavailable interactions to denial and removing the abort listener.
|
|
787
|
+
* Session and InteractionRegistry invoke the registered responder for answers or cancellation.
|
|
788
|
+
* Execution-start publication failures reject this native callback before interaction registration. */
|
|
789
|
+
async permissionRequest(toolName, input, details) {
|
|
790
|
+
// A native tool permission request is execution evidence even when the SDK
|
|
791
|
+
// callback arrives before its assistant message. Binding still waits for UUID evidence.
|
|
792
|
+
if (!this.active && this.nativeInputs.size)
|
|
793
|
+
await this.beginExecution();
|
|
794
|
+
const turnId = this.active?.id;
|
|
795
|
+
if (!turnId)
|
|
796
|
+
return { behavior: "deny", message: "No active AHP turn" };
|
|
797
|
+
const rawQuestions = Array.isArray(input.questions) ? input.questions.map(record) : [];
|
|
798
|
+
const isUserQuestionRequest = toolName === "AskUserQuestion" && rawQuestions.length > 0 && rawQuestions.every((question) => typeof question.question === "string");
|
|
799
|
+
const request = isUserQuestionRequest
|
|
800
|
+
? this.createUserQuestionRequest(turnId, details.toolUseID, rawQuestions)
|
|
801
|
+
: this.createToolApprovalRequest({ turnId, toolName, input, details });
|
|
802
|
+
return this.awaitInteraction({
|
|
803
|
+
request,
|
|
804
|
+
signal: details.signal,
|
|
805
|
+
cancelled: { behavior: "deny", message: "Request cancelled" },
|
|
806
|
+
unavailable: { behavior: "deny", message: "Interaction unavailable" },
|
|
807
|
+
respond: async (answer, finish) => {
|
|
808
|
+
const stopped = !answer ? "interrupted" : answer.kind === "approval" && answer.optionId === "deny" ? "declined" : undefined;
|
|
809
|
+
if (stopped && this.active?.id === turnId) {
|
|
810
|
+
this.active.stoppedTools.set(details.toolUseID, stopped);
|
|
811
|
+
const previous = this.active.output.get(details.toolUseID);
|
|
812
|
+
if (previous?.type === "toolCall") {
|
|
813
|
+
const item = { ...previous, status: stopped };
|
|
814
|
+
this.active.output.set(item.id, item);
|
|
815
|
+
await this.sink.emit({ type: "item/delta", payload: { itemId: item.id, kind: "patch", value: safe(item) } }, { turnId });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!answer)
|
|
819
|
+
finish({ behavior: "deny", message: "Request cancelled" });
|
|
820
|
+
else if (answer.kind === "approval")
|
|
821
|
+
finish(answer.optionId === "deny" ? { behavior: "deny", message: "User denied tool execution" } : { behavior: "allow", updatedInput: input, ...(answer.optionId === "allow-session" ? { updatedPermissions: details.suggestions?.filter((entry) => entry.destination === "session") } : {}) });
|
|
822
|
+
else if (answer.kind === "userInput")
|
|
823
|
+
finish({
|
|
824
|
+
behavior: "allow",
|
|
825
|
+
updatedInput: {
|
|
826
|
+
...input,
|
|
827
|
+
answers: Object.fromEntries(rawQuestions.map((question, index) => [
|
|
828
|
+
str(question.question), answer.answers[`q${index}`],
|
|
829
|
+
])),
|
|
830
|
+
},
|
|
831
|
+
});
|
|
832
|
+
else
|
|
833
|
+
finish({ behavior: "deny", message: "Invalid response" });
|
|
834
|
+
},
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
/** Build stable qN question identities for permissionRequest so answers map back to native text. */
|
|
838
|
+
createUserQuestionRequest(turnId, toolUseId, rawQuestions) {
|
|
839
|
+
return {
|
|
840
|
+
interactionId: toolUseId,
|
|
841
|
+
kind: "userInput",
|
|
842
|
+
turnId,
|
|
843
|
+
itemId: toolUseId,
|
|
844
|
+
questions: rawQuestions.map((question, index) => ({
|
|
845
|
+
// Answers above use the same zero-based qN IDs to recover the native question text.
|
|
846
|
+
id: `q${index}`,
|
|
847
|
+
prompt: question.question,
|
|
848
|
+
...(Array.isArray(question.options) ? {
|
|
849
|
+
options: question.options.map((option) => ({
|
|
850
|
+
id: str(record(option).label) ?? String(option),
|
|
851
|
+
label: str(record(option).label) ?? String(option),
|
|
852
|
+
})),
|
|
853
|
+
} : {}),
|
|
854
|
+
...(question.secret === true ? { secret: true } : {}),
|
|
855
|
+
})),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/** Offer session approval to permissionRequest only when the native callback supplied a
|
|
859
|
+
* session-scoped permission update it can return. */
|
|
860
|
+
createToolApprovalRequest({ turnId, toolName, input, details }) {
|
|
861
|
+
return {
|
|
862
|
+
interactionId: details.toolUseID,
|
|
863
|
+
kind: "approval",
|
|
864
|
+
turnId,
|
|
865
|
+
itemId: details.toolUseID,
|
|
866
|
+
title: details.title ?? toolName,
|
|
867
|
+
input: safe(input),
|
|
868
|
+
options: [
|
|
869
|
+
{ id: "allow-once", label: "Allow once", semantic: "allowOnce" },
|
|
870
|
+
...(details.suggestions?.some((entry) => entry.destination === "session") ? [
|
|
871
|
+
{ id: "allow-session", label: "Allow for this session", semantic: "allowSession" },
|
|
872
|
+
] : []),
|
|
873
|
+
{ id: "deny", label: "Deny", semantic: "deny" },
|
|
874
|
+
],
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/** Native onElicitation callback supplied by sessionQueryOptions. Await a sink-delivered answer
|
|
878
|
+
* or native abort. The answer can reach Claude before the sink publishes interaction/resolved.
|
|
879
|
+
* Unrepresentable form content rejects responder delivery. Native abort or a rejected sink wait
|
|
880
|
+
* returns cancellation. */
|
|
881
|
+
async elicitationRequest(request, details) {
|
|
882
|
+
const ahp = {
|
|
883
|
+
interactionId: details.requestId,
|
|
884
|
+
kind: "elicitation",
|
|
885
|
+
mode: request.mode ?? "form",
|
|
886
|
+
...(this.active ? { turnId: this.active.id } : {}),
|
|
887
|
+
message: request.message,
|
|
888
|
+
...(request.requestedSchema ? { schema: json(request.requestedSchema) } : {}),
|
|
889
|
+
...(request.url ? { privatePayload: { url: request.url }, privatePayloadAvailable: true } : {}),
|
|
890
|
+
};
|
|
891
|
+
return this.awaitInteraction({
|
|
892
|
+
request: ahp,
|
|
893
|
+
signal: details.signal,
|
|
894
|
+
cancelled: { action: "cancel" },
|
|
895
|
+
unavailable: { action: "cancel" },
|
|
896
|
+
respond: async (answer, finish) => finish(answer?.kind === "elicitation"
|
|
897
|
+
? { action: answer.action, ...(answer.content ? { content: elicitationContent(answer.content) } : {}) }
|
|
898
|
+
: { action: "cancel" }),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
/** Register the responder for permissionRequest and elicitationRequest. Resolve the native
|
|
902
|
+
* wait once and remove its abort listener. Native abort reports expiry to the sink without
|
|
903
|
+
* awaiting publication. A rejected sink wait supplies the operation's fallback response. */
|
|
904
|
+
awaitInteraction({ request, signal, cancelled, unavailable, respond }) {
|
|
905
|
+
const interactionId = request.interactionId;
|
|
906
|
+
return new Promise((resolve) => {
|
|
907
|
+
let answered = false;
|
|
908
|
+
const finish = (result) => {
|
|
909
|
+
if (answered)
|
|
910
|
+
return;
|
|
911
|
+
answered = true;
|
|
912
|
+
signal.removeEventListener("abort", abort);
|
|
913
|
+
resolve(result);
|
|
914
|
+
};
|
|
915
|
+
const abort = () => {
|
|
916
|
+
void this.sink.resolveInteraction({ interactionId, outcome: "expired", reason: "native_cancelled" });
|
|
917
|
+
finish(cancelled);
|
|
918
|
+
};
|
|
919
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
920
|
+
void this.sink.requestInteraction(request, answer => respond(answer, finish)).catch(() => finish(unavailable));
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
/** Read native history for boundary/history callers through the supplied public store. Capture
|
|
924
|
+
* the SDK-derived store key for opaque attachment reads; reject absent stores and read failures. */
|
|
925
|
+
async messages(nativeId = this.nativeId) {
|
|
926
|
+
const store = this.adapter.sessionStore;
|
|
927
|
+
if (!store)
|
|
928
|
+
throw new HarnessSdkError("CAPABILITY_UNSUPPORTED");
|
|
929
|
+
// Let the SDK derive its own project key. Observing that public load key avoids copying
|
|
930
|
+
// the SDK's cwd-sanitization algorithm or reading its private transcript paths.
|
|
931
|
+
const scoped = {
|
|
932
|
+
append: store.append.bind(store),
|
|
933
|
+
load: async (key) => { if (key.sessionId === nativeId && !key.subpath)
|
|
934
|
+
this.storeKeys.set(nativeId, key); return store.load(key); },
|
|
935
|
+
...(store.listSessions ? { listSessions: store.listSessions.bind(store) } : {}),
|
|
936
|
+
...(store.listSubkeys ? { listSubkeys: store.listSubkeys.bind(store) } : {}),
|
|
937
|
+
};
|
|
938
|
+
return (this.adapter.options.getSessionMessages ?? getSessionMessages)(nativeId, { dir: this.params.cwd, sessionStore: scoped, includeSystemMessages: true });
|
|
939
|
+
}
|
|
940
|
+
/** Resolve the checkpoint and transcript cutoff for open and revert. Without a store, use
|
|
941
|
+
* native prompt IDs or known associations for file checkpoints. With a store, verify the prompt
|
|
942
|
+
* in history and include opaque trailing entries for through boundaries when available.
|
|
943
|
+
* Unknown boundaries and store failures reject the caller. */
|
|
944
|
+
async boundaryPoint(nativeId, boundary) {
|
|
945
|
+
const store = this.adapter.sessionStore;
|
|
946
|
+
if (!store) {
|
|
947
|
+
const promptUuid = boundary.nativeTurnId ?? Object.entries(this.turns).find(([, turn]) => turn === boundary.turnId)?.[0];
|
|
948
|
+
if (!promptUuid)
|
|
949
|
+
throw new HarnessSdkError("TURN_NOT_FOUND");
|
|
950
|
+
const prompts = Object.keys(this.turns);
|
|
951
|
+
const index = prompts.indexOf(promptUuid);
|
|
952
|
+
return { promptUuid, ...(index >= 0 && prompts[index + 1] ? { nextPromptUuid: prompts[index + 1] } : {}) };
|
|
953
|
+
}
|
|
954
|
+
const messages = await this.messages(nativeId);
|
|
955
|
+
const turns = this.fileStore ? await this.fileStore.loadTurnMap(nativeId) : this.adapter.turnMaps.get(nativeId) ?? this.turns;
|
|
956
|
+
const promptUuid = boundary.nativeTurnId ?? Object.entries(turns).find(([, id]) => id === boundary.turnId)?.[0];
|
|
957
|
+
if (!promptUuid)
|
|
958
|
+
throw new HarnessSdkError("TURN_NOT_FOUND", "No verified native turn boundary");
|
|
959
|
+
const index = messages.findIndex((message) => message.uuid === promptUuid && isPrompt(message));
|
|
960
|
+
if (index < 0)
|
|
961
|
+
throw new HarnessSdkError("TURN_NOT_FOUND");
|
|
962
|
+
const next = messages.findIndex((message, position) => position > index && isPrompt(message));
|
|
963
|
+
let uuid = boundary.relation === "before" ? index > 0 ? messages[index - 1].uuid : undefined : messages[(next >= 0 ? next : messages.length) - 1].uuid;
|
|
964
|
+
const key = this.storeKeys.get(nativeId);
|
|
965
|
+
if (boundary.relation === "through" && key) {
|
|
966
|
+
const entries = await store.load(key);
|
|
967
|
+
if (entries) {
|
|
968
|
+
const start = entries.findIndex((entry) => entry.uuid === promptUuid);
|
|
969
|
+
const end = next >= 0 ? entries.findIndex((entry) => entry.uuid === messages[next].uuid) : entries.length;
|
|
970
|
+
if (start < 0 || end < start)
|
|
971
|
+
throw new HarnessSdkError("TURN_NOT_FOUND");
|
|
972
|
+
// Preserve structured-output and transcript-only trailing entries too; their
|
|
973
|
+
// opaque payloads are neither decoded nor synthesized.
|
|
974
|
+
const last = entries.slice(start, end).reverse().find((entry) => typeof entry.uuid === "string");
|
|
975
|
+
if (last?.uuid)
|
|
976
|
+
uuid = last.uuid;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return { promptUuid, ...(next >= 0 ? { nextPromptUuid: messages[next].uuid } : {}), ...(uuid ? { uuid } : {}) };
|
|
980
|
+
}
|
|
981
|
+
/** Restore execute's requested file/conversation scope only while idle. Drain the old query
|
|
982
|
+
* before replacement. Hold admission across lookup and cleanup, releasing it on any outcome.
|
|
983
|
+
* Failed conversation restoration after file rewind rejects with REVERT_PARTIAL and appliedScope. */
|
|
984
|
+
async revert(request) {
|
|
985
|
+
const query = this.query;
|
|
986
|
+
if (this.closed || !query)
|
|
987
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
988
|
+
if (this.reverting || this.active || this.nativeInputs.size)
|
|
989
|
+
throw new HarnessSdkError('SESSION_BUSY', 'Claude revert requires no active or pending inputs');
|
|
990
|
+
this.reverting = true;
|
|
991
|
+
try {
|
|
992
|
+
const point = await this.boundaryPoint(this.nativeId, request.boundary);
|
|
993
|
+
if (this.closed)
|
|
994
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
995
|
+
const rewindFiles = request.scope === 'files' || request.scope === 'both';
|
|
996
|
+
if (rewindFiles) {
|
|
997
|
+
await this.restoreFiles(query, request.boundary, point);
|
|
998
|
+
}
|
|
999
|
+
if (request.scope !== 'files') {
|
|
1000
|
+
try {
|
|
1001
|
+
await this.restoreConversation(query, point);
|
|
1002
|
+
}
|
|
1003
|
+
catch (cause) {
|
|
1004
|
+
if (rewindFiles)
|
|
1005
|
+
throw new HarnessSdkError('REVERT_PARTIAL', 'Files were restored but conversation restore could not be confirmed', false, true, { appliedScope: 'files' });
|
|
1006
|
+
throw sdkError(cause, 'CLAUDE_REVERT_FAILED', true);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return { appliedScope: request.scope, boundary: request.boundary, nativeRef: this.binding.nativeRef };
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
this.reverting = false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** Revert's file phase verifies a checkpoint with a dry run before applying it. Recheck close
|
|
1016
|
+
* after preview; a failed/partial native rewind rejects without attempting conversation restore. */
|
|
1017
|
+
async restoreFiles(query, boundary, point) {
|
|
1018
|
+
if (!this.adapter.options.enableFileCheckpointing)
|
|
1019
|
+
throw new HarnessSdkError('CAPABILITY_UNSUPPORTED');
|
|
1020
|
+
const checkpoint = boundary.relation === 'before' ? point.promptUuid : point.nextPromptUuid;
|
|
1021
|
+
if (!checkpoint)
|
|
1022
|
+
throw new HarnessSdkError('CAPABILITY_UNSUPPORTED', 'No verified post-turn checkpoint exists');
|
|
1023
|
+
const preview = await query.rewindFiles(checkpoint, { dryRun: true });
|
|
1024
|
+
if (!preview.canRewind)
|
|
1025
|
+
throw new HarnessSdkError('CHECKPOINT_UNAVAILABLE');
|
|
1026
|
+
if (this.closed)
|
|
1027
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1028
|
+
const rewound = await query.rewindFiles(checkpoint);
|
|
1029
|
+
if (!rewound.canRewind || rewound.skippedLinks)
|
|
1030
|
+
throw new HarnessSdkError('REVERT_PARTIAL', 'Some files could not be restored', false, true, { appliedScope: 'files' });
|
|
1031
|
+
}
|
|
1032
|
+
/** Revert's conversation phase forks at the verified cutoff, drains the old query, then publishes
|
|
1033
|
+
* the replacement binding/state. Recheck close after native waits so disposal cannot reopen work;
|
|
1034
|
+
* revert translates failure to a REVERT_PARTIAL rejection when its file phase already applied. */
|
|
1035
|
+
async restoreConversation(query, point) {
|
|
1036
|
+
if (this.closed)
|
|
1037
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1038
|
+
const native = point.uuid ? await this.forkTranscript(this.nativeId, point.uuid) : { sessionId: randomUUID() };
|
|
1039
|
+
if (this.closed)
|
|
1040
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1041
|
+
this.endQueryInput();
|
|
1042
|
+
await this.releaseQuery(query);
|
|
1043
|
+
await this.pumping;
|
|
1044
|
+
if (this.closed)
|
|
1045
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1046
|
+
this.binding = { ...this.binding, nativeRef: { provider: 'claude', value: native.sessionId } };
|
|
1047
|
+
await this.rememberTurnMap();
|
|
1048
|
+
await this.startQuery({ mode: point.uuid ? "resume" : "fresh" });
|
|
1049
|
+
await this.sink.emit({ type: 'session/state', payload: this.snapshot() });
|
|
1050
|
+
}
|
|
1051
|
+
/** Admit execute's bounded input and enqueue native content, returning a dispatch receipt.
|
|
1052
|
+
* Changed schemas replace an idle query first; rejection never retries or replays the new input. */
|
|
1053
|
+
async submitInput(start, query) {
|
|
1054
|
+
if (this.closed || !this.input)
|
|
1055
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1056
|
+
if (this.reverting)
|
|
1057
|
+
throw new HarnessSdkError('SESSION_BUSY', 'Claude input cannot be submitted during revert');
|
|
1058
|
+
// Native association echoes are bounded to 64 UUIDs. Do not admit more
|
|
1059
|
+
// unresolved inputs than we can correlate without guessing.
|
|
1060
|
+
if (this.nativeInputs.size >= 64)
|
|
1061
|
+
throw new HarnessSdkError('BACKPRESSURE_EXCEEDED');
|
|
1062
|
+
const nativeContent = content(start.input);
|
|
1063
|
+
const changing = !isDeepStrictEqual(this.outputSchema?.schema, start.outputSchema);
|
|
1064
|
+
if (changing && (this.active || this.nativeInputs.size || this.configOperations))
|
|
1065
|
+
throw new HarnessSdkError('SESSION_BUSY', 'Claude output format can change only with no active execution, pending input or configuration change');
|
|
1066
|
+
const outputSchema = changing && start.outputSchema !== undefined ? compileOutputSchema(start.outputSchema) : undefined;
|
|
1067
|
+
if (changing)
|
|
1068
|
+
this.switchingOutput = true;
|
|
1069
|
+
try {
|
|
1070
|
+
// Do not yield admission for an unchanged format: another submission could
|
|
1071
|
+
// otherwise start a switch before this input is recorded as pending.
|
|
1072
|
+
if (changing)
|
|
1073
|
+
await this.replaceOutputFormat(query, outputSchema);
|
|
1074
|
+
if (this.closed)
|
|
1075
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1076
|
+
const userUuid = randomUUID();
|
|
1077
|
+
this.nativeInputs.set(userUuid, { submissionId: start.submissionId });
|
|
1078
|
+
try {
|
|
1079
|
+
this.input.push({ type: 'user', uuid: userUuid, session_id: this.nativeId,
|
|
1080
|
+
message: { role: 'user', content: nativeContent }, parent_tool_use_id: null,
|
|
1081
|
+
priority: start.delivery === 'afterTurn' ? 'later' : 'next' });
|
|
1082
|
+
return { submissionId: start.submissionId, disposition: 'dispatched' };
|
|
1083
|
+
}
|
|
1084
|
+
catch (cause) {
|
|
1085
|
+
this.nativeInputs.delete(userUuid);
|
|
1086
|
+
throw cause;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
finally {
|
|
1090
|
+
if (changing)
|
|
1091
|
+
this.switchingOutput = false;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/** Drain and replace the query for submitInput before enqueueing its input. Reject startup or
|
|
1095
|
+
* cleanup failure as a definite dispatch failure and latch it for later query operations while open. */
|
|
1096
|
+
async replaceOutputFormat(query, outputSchema) {
|
|
1097
|
+
const pumping = this.pumping;
|
|
1098
|
+
this.endQueryInput();
|
|
1099
|
+
try {
|
|
1100
|
+
// The optional native return() also waits for the SDK's mirror flush.
|
|
1101
|
+
await this.releaseQuery(query);
|
|
1102
|
+
await pumping;
|
|
1103
|
+
if (this.closed)
|
|
1104
|
+
throw new HarnessSdkError('SESSION_CLOSED');
|
|
1105
|
+
this.query = undefined;
|
|
1106
|
+
this.outputSchema = outputSchema;
|
|
1107
|
+
await this.startQuery({ mode: this.queryMode, resumeAt: this.queryResumeAt });
|
|
1108
|
+
}
|
|
1109
|
+
catch (cause) {
|
|
1110
|
+
const error = sdkError(cause, 'CLAUDE_OUTPUT_FORMAT_FAILED');
|
|
1111
|
+
// The new input has not been enqueued. Never replay it to recover a Query.
|
|
1112
|
+
const failure = new HarnessSdkError(error.code, error.message, false, false, error.details);
|
|
1113
|
+
if (!this.closed)
|
|
1114
|
+
this.queryFailure = failure;
|
|
1115
|
+
throw failure;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/** Invalidate the retired query's observation and end its input before release. Conversation
|
|
1119
|
+
* restore and output replacement each await close/return and their own pump before reopening. */
|
|
1120
|
+
endQueryInput() {
|
|
1121
|
+
this.generation++;
|
|
1122
|
+
this.input?.end();
|
|
1123
|
+
}
|
|
1124
|
+
/** Share query close/return completion across replacement, revert and disposal. Attempt both
|
|
1125
|
+
* stages and reject RESOURCE_RELEASE_FAILED with safe codes when either fails. */
|
|
1126
|
+
releaseQuery(query) {
|
|
1127
|
+
const previous = this.queryReleases.get(query);
|
|
1128
|
+
if (previous)
|
|
1129
|
+
return previous;
|
|
1130
|
+
const release = Promise.resolve().then(async () => {
|
|
1131
|
+
const errors = [];
|
|
1132
|
+
try {
|
|
1133
|
+
query.close();
|
|
1134
|
+
}
|
|
1135
|
+
catch {
|
|
1136
|
+
errors.push({ code: 'CLAUDE_CLOSE_FAILED' });
|
|
1137
|
+
}
|
|
1138
|
+
try {
|
|
1139
|
+
await query.return?.();
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
errors.push({ code: 'CLAUDE_RETURN_FAILED' });
|
|
1143
|
+
}
|
|
1144
|
+
if (errors.length)
|
|
1145
|
+
throw new HarnessSdkError('RESOURCE_RELEASE_FAILED', 'Claude resources were not fully released', false, true, { errors });
|
|
1146
|
+
});
|
|
1147
|
+
this.queryReleases.set(query, release);
|
|
1148
|
+
return release;
|
|
1149
|
+
}
|
|
1150
|
+
/** Build/reuse one of eight retained history snapshots for execute, then clone the requested page.
|
|
1151
|
+
* A new snapshot projects native entries before loading structured-output attachments. Reject expired
|
|
1152
|
+
* snapshots, invalid offsets or store failures; pagination reuses the snapshot without rereading it. */
|
|
1153
|
+
async readHistory(request) {
|
|
1154
|
+
let snapshotToken = request.snapshotToken;
|
|
1155
|
+
if (!snapshotToken) {
|
|
1156
|
+
const items = this.projectHistory(await this.messages());
|
|
1157
|
+
const withStructuredOutput = await this.structuredHistory(items);
|
|
1158
|
+
snapshotToken = randomUUID();
|
|
1159
|
+
this.historySnapshots.set(snapshotToken, withStructuredOutput);
|
|
1160
|
+
if (this.historySnapshots.size > 8)
|
|
1161
|
+
this.historySnapshots.delete(this.historySnapshots.keys().next().value);
|
|
1162
|
+
}
|
|
1163
|
+
const snapshot = this.historySnapshots.get(snapshotToken);
|
|
1164
|
+
if (!snapshot)
|
|
1165
|
+
throw new HarnessSdkError("CURSOR_EXPIRED");
|
|
1166
|
+
const offset = request.cursor === undefined ? 0 : Number(request.cursor);
|
|
1167
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > snapshot.length)
|
|
1168
|
+
throw new HarnessSdkError("CURSOR_INVALID");
|
|
1169
|
+
const limit = Math.min(request.limit ?? 200, 1000);
|
|
1170
|
+
const page = snapshot.slice(offset, offset + limit);
|
|
1171
|
+
return { items: structuredClone(page), ...(offset + page.length < snapshot.length ? { nextCursor: String(offset + page.length) } : {}), snapshotToken, source: this.binding.nativeRef };
|
|
1172
|
+
}
|
|
1173
|
+
/** Reconstruct readHistory's live-compatible item IDs and tool results from ordered native entries.
|
|
1174
|
+
* Keep grouping and per-API-message block counters local to this snapshot; tool results amend only
|
|
1175
|
+
* an earlier call in the same turn, and unmatched results remain visible as unknown content. */
|
|
1176
|
+
projectHistory(messages) {
|
|
1177
|
+
const items = [];
|
|
1178
|
+
let currentTurn = "native:session";
|
|
1179
|
+
let currentNativeTurn;
|
|
1180
|
+
// Transcripts split one API message across entries; live items use `${apiMessageId}:${contentBlockIndex}`, so count blocks per API message.
|
|
1181
|
+
const blockCounts = new Map();
|
|
1182
|
+
for (const message of messages) {
|
|
1183
|
+
if (isPrompt(message)) {
|
|
1184
|
+
currentTurn = this.turns[message.uuid] ?? `native:${message.uuid}`;
|
|
1185
|
+
currentNativeTurn = message.uuid;
|
|
1186
|
+
}
|
|
1187
|
+
const body = record(message.message).content;
|
|
1188
|
+
const blocks = typeof body === "string" ? [{ type: "text", text: body }] : Array.isArray(body) ? body.map(record) : [];
|
|
1189
|
+
if (message.type === "user" && isPrompt(message))
|
|
1190
|
+
items.push({ turnId: currentTurn, nativeTurnId: message.uuid, nativeItemId: message.uuid, item: { id: message.uuid, type: "userMessage", content: blocks.filter((block) => block.type === "text").map((block) => ({ type: "text", text: str(block.text) ?? "" })), status: "completed" } });
|
|
1191
|
+
else
|
|
1192
|
+
for (let index = 0; index < blocks.length; index++) {
|
|
1193
|
+
const block = blocks[index];
|
|
1194
|
+
if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
1195
|
+
let previous;
|
|
1196
|
+
for (let position = items.length - 1; position >= 0; position--) {
|
|
1197
|
+
const candidate = items[position];
|
|
1198
|
+
if (candidate.turnId === currentTurn && candidate.item.id === block.tool_use_id && candidate.item.type === "toolCall") {
|
|
1199
|
+
previous = candidate;
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (previous?.item.type === "toolCall") {
|
|
1204
|
+
previous.item = completeClaudeTool(previous.item, toolResult(block, record(message).tool_use_result));
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const apiMessageId = str(record(message.message).id);
|
|
1209
|
+
const blockIndex = apiMessageId ? blockCounts.get(apiMessageId) ?? 0 : index;
|
|
1210
|
+
if (apiMessageId)
|
|
1211
|
+
blockCounts.set(apiMessageId, blockIndex + 1);
|
|
1212
|
+
const id = str(block.id) ?? `${apiMessageId ?? message.uuid}:${blockIndex}`;
|
|
1213
|
+
const running = block.type === "tool_use";
|
|
1214
|
+
const status = this.active?.stoppedTools.get(id) ?? (running ? "running" : "completed");
|
|
1215
|
+
const item = this.blockItem(block, id, status);
|
|
1216
|
+
if (block.type === "tool_use")
|
|
1217
|
+
delete item.status;
|
|
1218
|
+
items.push({ turnId: currentTurn, ...(currentNativeTurn ? { nativeTurnId: currentNativeTurn } : {}), nativeItemId: message.uuid, item });
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return items;
|
|
1222
|
+
}
|
|
1223
|
+
/** Add saved structured-output attachments only for tool calls surviving readHistory's transcript.
|
|
1224
|
+
* Read the captured public-store key; missing keys leave history unchanged and load failures reject. */
|
|
1225
|
+
async structuredHistory(items) {
|
|
1226
|
+
const key = this.storeKeys.get(this.nativeId), store = this.adapter.sessionStore;
|
|
1227
|
+
if (!key || !store)
|
|
1228
|
+
return items;
|
|
1229
|
+
// The public history helper omits attachments. Only expose native output
|
|
1230
|
+
// attachments linked to a StructuredOutput call surviving in that history.
|
|
1231
|
+
const calls = new Map(items.flatMap(entry => entry.item.type === 'toolCall' && entry.item.nativeType === 'StructuredOutput' ? [[entry.item.id, entry]] : []));
|
|
1232
|
+
if (!calls.size)
|
|
1233
|
+
return items;
|
|
1234
|
+
const finals = new Map();
|
|
1235
|
+
for (const entry of await store.load(key) ?? []) {
|
|
1236
|
+
if (entry.type !== 'attachment' || entry.isSidechain || entry.teamName)
|
|
1237
|
+
continue;
|
|
1238
|
+
const attachment = record(entry.attachment), call = calls.get(str(attachment.toolUseID) ?? '');
|
|
1239
|
+
if (attachment.type !== 'structured_output' || attachment.data === undefined || !call)
|
|
1240
|
+
continue;
|
|
1241
|
+
finals.set(call.turnId, {
|
|
1242
|
+
turnId: call.turnId, ...(call.nativeTurnId ? { nativeTurnId: call.nativeTurnId } : {}), ...(typeof entry.uuid === 'string' ? { nativeItemId: entry.uuid } : {}),
|
|
1243
|
+
item: { id: `${call.turnId}:structured-output`, type: 'agentMessage', phase: 'final', status: 'completed', text: JSON.stringify(attachment.data) },
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
const last = new Map(items.map((entry, index) => [entry.turnId, index]));
|
|
1247
|
+
return items.flatMap((entry, index) => {
|
|
1248
|
+
const final = finals.get(entry.turnId);
|
|
1249
|
+
return final && last.get(entry.turnId) === index ? [entry, final] : [entry];
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/** Construct the adapter supplied to createClient without opening Claude. The client initializes
|
|
1254
|
+
* and disposes it through its output wrapper; direct adapter consumers initialize before session opens
|
|
1255
|
+
* and await disposal after use. Options and injected store lifecycle remain host-owned. */
|
|
1256
|
+
export function createClaudeAdapter(options) { return new ClaudeNativeAdapter(options); }
|
|
1257
|
+
export { FileSessionStore } from "./store.js";
|
|
1258
|
+
function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
|
|
1259
|
+
function str(value) { return typeof value === "string" ? value : undefined; }
|
|
1260
|
+
/** Normalize native values to JSON; absent or unserializable content becomes null. */
|
|
1261
|
+
function json(value) { try {
|
|
1262
|
+
return JSON.parse(JSON.stringify(value ?? null));
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
return null;
|
|
1266
|
+
} }
|
|
1267
|
+
/** Apply the protocol privacy projection to JSON-normalized native event/history content. */
|
|
1268
|
+
function safe(value) { return publicValue(json(value)); }
|
|
1269
|
+
/** Share live/history tool-result privacy projection before completeClaudeTool derives output and edits. */
|
|
1270
|
+
function toolResult(block, structuredContent) {
|
|
1271
|
+
return { ...block, content: safe(block.content), ...(structuredContent !== undefined ? { structuredContent: safe(structuredContent) } : {}) };
|
|
1272
|
+
}
|
|
1273
|
+
/** Translate SDK elicitation answers to native MCP field shapes; reject unrepresentable content. */
|
|
1274
|
+
function elicitationContent(value) {
|
|
1275
|
+
const content = {};
|
|
1276
|
+
for (const [key, field] of Object.entries(value)) {
|
|
1277
|
+
if (typeof field === "string" || typeof field === "number" || typeof field === "boolean" || (Array.isArray(field) && field.every((entry) => typeof entry === "string")))
|
|
1278
|
+
content[key] = field;
|
|
1279
|
+
else
|
|
1280
|
+
throw new HarnessSdkError("INVALID_PARAMS", "Elicitation response cannot be represented by MCP");
|
|
1281
|
+
}
|
|
1282
|
+
return content;
|
|
1283
|
+
}
|
|
1284
|
+
const PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"];
|
|
1285
|
+
// Claude's effort levels (Options.effort); per-model support is reported by supportedModels().supportedEffortLevels.
|
|
1286
|
+
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
1287
|
+
const isEffort = (value) => typeof value === "string" && EFFORT_LEVELS.includes(value);
|
|
1288
|
+
/** Validate native permission configuration before query setup or an explicit mode change. */
|
|
1289
|
+
function permission(value) { if (typeof value !== "string" || !PERMISSION_MODES.includes(value))
|
|
1290
|
+
throw new HarnessSdkError("INVALID_PARAMS", "Unsupported permission mode"); return value; }
|
|
1291
|
+
/** Share capability projection between adapter negotiation and newly constructed backend sessions. */
|
|
1292
|
+
function capabilities({ store, checkpoints }) {
|
|
1293
|
+
return {
|
|
1294
|
+
session: { resume: store, fork: store ? ["before", "through"] : [], revert: [...(store ? ["conversation"] : []), ...(checkpoints ? ["files"] : []), ...(store && checkpoints ? ["both"] : [])], history: store },
|
|
1295
|
+
input: { delivery: ['native', 'afterTurn'], interrupt: { targets: ['session'], reportsPendingInputs: true } },
|
|
1296
|
+
turn: { outputSchema: true, overrides: [], inputModalities: ["text", "image"] },
|
|
1297
|
+
interactions: ["approval", "userInput", "elicitation"], subagents: { events: "nested" },
|
|
1298
|
+
mcp: { stdio: true, http: true, elicitation: ["form", "url"] },
|
|
1299
|
+
config: { options: [{ id: "model", scope: "session", schema: { type: "string", minLength: 1 } }, { id: "permissionMode", title: "Permissions", scope: "session", schema: { type: "string", enum: PERMISSION_MODES, default: "default" } }, { id: "effort", title: "Effort", scope: "session", schema: { type: "string", enum: [...EFFORT_LEVELS], default: "high" } }, { id: "fastMode", title: "Fast mode", scope: "session", schema: { type: "boolean", default: false } }, { id: "maxThinkingTokens", scope: "session", schema: { type: ["integer", "null"], minimum: 0 } }] },
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
/** Forward only launch/platform essentials before explicit host environment overrides are applied. */
|
|
1303
|
+
function basicEnvironment() {
|
|
1304
|
+
const env = {};
|
|
1305
|
+
for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP", "TMPDIR", "HOME", "USERPROFILE", "USER", "LOGNAME", "LANG", "LC_ALL"])
|
|
1306
|
+
if (process.env[key] !== undefined)
|
|
1307
|
+
env[key] = process.env[key];
|
|
1308
|
+
return env;
|
|
1309
|
+
}
|
|
1310
|
+
/** Translate admitted input to Claude text/image blocks; reject unsupported modalities before enqueue. */
|
|
1311
|
+
function content(input) {
|
|
1312
|
+
return input.map((block) => {
|
|
1313
|
+
if (block.type === "text")
|
|
1314
|
+
return { type: "text", text: block.text };
|
|
1315
|
+
if (block.type === "image" && block.data && block.mimeType && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(block.mimeType))
|
|
1316
|
+
return { type: "image", source: { type: "base64", data: block.data, media_type: block.mimeType } };
|
|
1317
|
+
if (block.type === "image" && block.url)
|
|
1318
|
+
return { type: "image", source: { type: "url", url: block.url } };
|
|
1319
|
+
throw new HarnessSdkError("CAPABILITY_UNSUPPORTED", "Input cannot be represented by the Claude SDK");
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
/** Distinguish real user prompts from tool-result-only entries for history grouping and boundaries. */
|
|
1323
|
+
function isPrompt(message) {
|
|
1324
|
+
if (message.type !== "user")
|
|
1325
|
+
return false;
|
|
1326
|
+
const body = record(message.message).content;
|
|
1327
|
+
return typeof body === "string" || (Array.isArray(body) && body.some((part) => record(part).type !== "tool_result"));
|
|
1328
|
+
}
|