@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/store.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { HarnessSdkError } from "@anureo/sdk";
|
|
5
|
+
/** Persist JSON-safe, opaque Claude transcript entries in a host-owned directory.
|
|
6
|
+
* Supply this as sessionStore to ClaudeNativeAdapter. With a configured state directory and no
|
|
7
|
+
* supplied store, the adapter creates one in its ahp-session-store subdirectory and also uses it
|
|
8
|
+
* to persist turn IDs for boundary lookup. The native SDK invokes the SessionStore methods for
|
|
9
|
+
* mirroring, resume and history. Transcript keys include the project, session and optional
|
|
10
|
+
* subpath. The host owns retention and coordinates deletion with writers. Construction opens no
|
|
11
|
+
* resources and requires no disposal. */
|
|
12
|
+
export class FileSessionStore {
|
|
13
|
+
directory;
|
|
14
|
+
/** Use directory for transcripts and turn maps. Writes create missing directories with mode 0700. */
|
|
15
|
+
constructor(directory) {
|
|
16
|
+
this.directory = directory;
|
|
17
|
+
}
|
|
18
|
+
/** Commit a batch of JSON-safe entries under the lock for this transcript key.
|
|
19
|
+
* A truthy UUID replaces its existing entry in place. New UUIDs and entries without a truthy
|
|
20
|
+
* UUID append in batch order. Resolve after file sync, atomic rename and lock cleanup.
|
|
21
|
+
* An unreclaimable lock that is still contended at the 10-second retry deadline rejects with
|
|
22
|
+
* SESSION_STORE_BUSY. Transcript read, write and cleanup failures also reject, including
|
|
23
|
+
* cleanup after publication. ClaudeBackendSession.observedStore awaits successful
|
|
24
|
+
* main-transcript appends before scheduling a title refresh for the active query. Rejections
|
|
25
|
+
* return to the native SDK. */
|
|
26
|
+
async append(key, entries) {
|
|
27
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
const file = this.transcriptPath(key);
|
|
29
|
+
const lockPath = `${file}.lock`;
|
|
30
|
+
const lockHandle = await acquireSessionLock(lockPath);
|
|
31
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
32
|
+
try {
|
|
33
|
+
const stored = await this.readTranscript(file) ?? { key: structuredClone(key), entries: [], mtime: 0 };
|
|
34
|
+
mergeSessionEntries(stored, entries);
|
|
35
|
+
await publishJson({ file, temporary, value: stored });
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await rm(temporary, { force: true });
|
|
39
|
+
await lockHandle.close();
|
|
40
|
+
await rm(lockPath, { force: true });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Load a detached full transcript, or null when the requested file is absent.
|
|
44
|
+
* An envelope without a truthy key or an entries array rejects with SESSION_STORE_INVALID.
|
|
45
|
+
* Other filesystem and JSON parse failures propagate. ClaudeBackendSession.boundaryPoint and
|
|
46
|
+
* structuredHistory also read these entries to include opaque records omitted by native history
|
|
47
|
+
* in fork/revert cutoffs and structured output in history pages, respectively. */
|
|
48
|
+
async load(key) {
|
|
49
|
+
const stored = await this.readTranscript(this.transcriptPath(key));
|
|
50
|
+
return stored ? structuredClone(stored.entries) : null;
|
|
51
|
+
}
|
|
52
|
+
/** List transcripts with no truthy subpath for a project in filesystem order.
|
|
53
|
+
* The stored mtime is in Unix epoch milliseconds and advances on every committed append.
|
|
54
|
+
* A missing directory yields an empty list. Read and envelope errors anywhere in the
|
|
55
|
+
* transcript scan reject the listing. */
|
|
56
|
+
async listSessions(projectKey) {
|
|
57
|
+
return (await this.scanTranscripts())
|
|
58
|
+
.filter((entry) => entry.key.projectKey === projectKey && !entry.key.subpath)
|
|
59
|
+
.map(({ key, mtime }) => ({ sessionId: key.sessionId, mtime }));
|
|
60
|
+
}
|
|
61
|
+
/** List nonempty transcript subpaths for a project and session in filesystem order.
|
|
62
|
+
* A missing directory yields an empty list. Scan failures propagate as in listSessions. */
|
|
63
|
+
async listSubkeys(key) {
|
|
64
|
+
return (await this.scanTranscripts()).flatMap((entry) => {
|
|
65
|
+
if (entry.key.projectKey !== key.projectKey || entry.key.sessionId !== key.sessionId)
|
|
66
|
+
return [];
|
|
67
|
+
return entry.key.subpath ? [entry.key.subpath] : [];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Remove the transcript for this exact key, accepting an absent file.
|
|
71
|
+
* Other subpaths and turn maps remain. The caller must coordinate this unlocked deletion with
|
|
72
|
+
* writers. Other removal failures reject. */
|
|
73
|
+
async delete(key) {
|
|
74
|
+
await rm(this.transcriptPath(key), { force: true });
|
|
75
|
+
}
|
|
76
|
+
/** Load adapter boundary metadata mapping native input UUIDs to AHP turn IDs by native session ID.
|
|
77
|
+
* Return the parsed JSON as stored, or an empty map when the file is absent. Other read and
|
|
78
|
+
* parse failures reject.
|
|
79
|
+
* ClaudeBackendSession.open restores the map for the active session or reads it from the fork
|
|
80
|
+
* source. ClaudeBackendSession.boundaryPoint uses the map to resolve AHP turn IDs to candidates
|
|
81
|
+
* that it verifies in native history. */
|
|
82
|
+
async loadTurnMap(sessionId) {
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(await readFile(this.turnMapPath(sessionId), "utf8"));
|
|
85
|
+
}
|
|
86
|
+
catch (cause) {
|
|
87
|
+
if (cause.code === "ENOENT")
|
|
88
|
+
return {};
|
|
89
|
+
throw cause;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Replace the adapter's turn map for a native session ID.
|
|
93
|
+
* Resolve after file sync, atomic rename and temporary-file cleanup. Callers must order
|
|
94
|
+
* concurrent replacements if publication order matters. Cleanup can reject after publication.
|
|
95
|
+
* ClaudeBackendSession.open and restoreConversation await the save before starting a query.
|
|
96
|
+
* Saves during input association or a native ID change can fail the query pump. */
|
|
97
|
+
async saveTurnMap(sessionId, turns) {
|
|
98
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
99
|
+
const file = this.turnMapPath(sessionId);
|
|
100
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
101
|
+
try {
|
|
102
|
+
await publishJson({ file, temporary, value: turns });
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await rm(temporary, { force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Keep each transcript at its stable hashed path. Omitted subpaths use the null slot in the
|
|
109
|
+
* tuple, preserving the on-disk key format and keeping an explicit empty string distinct. */
|
|
110
|
+
transcriptPath(key) {
|
|
111
|
+
return join(this.directory, `${createHash("sha256")
|
|
112
|
+
.update(JSON.stringify([key.projectKey, key.sessionId, key.subpath ?? null]))
|
|
113
|
+
.digest("hex")}.json`);
|
|
114
|
+
}
|
|
115
|
+
/** Read a transcript envelope for load, append or a scan. Return null on absence and reject
|
|
116
|
+
* malformed JSON or a missing key/entries array so append cannot overwrite those records. */
|
|
117
|
+
async readTranscript(file) {
|
|
118
|
+
try {
|
|
119
|
+
const value = JSON.parse(await readFile(file, "utf8"));
|
|
120
|
+
if (!value || !value.key || !Array.isArray(value.entries))
|
|
121
|
+
throw new HarnessSdkError("SESSION_STORE_INVALID");
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
if (cause.code === "ENOENT")
|
|
126
|
+
return null;
|
|
127
|
+
throw cause;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Scan the hashed transcript filenames for both listing methods. Preserve directory order and
|
|
131
|
+
* skip files removed before they can be read. Directory absence also yields an empty result.
|
|
132
|
+
* Other read or envelope errors reject before the caller filters by project or session. */
|
|
133
|
+
async scanTranscripts() {
|
|
134
|
+
const names = await readdir(this.directory).catch((cause) => {
|
|
135
|
+
if (cause.code === "ENOENT")
|
|
136
|
+
return [];
|
|
137
|
+
throw cause;
|
|
138
|
+
});
|
|
139
|
+
const transcripts = names.filter((name) => /^[a-f0-9]{64}\.json$/.test(name));
|
|
140
|
+
const entries = await Promise.all(transcripts.map((name) => this.readTranscript(join(this.directory, name))));
|
|
141
|
+
return entries.filter((entry) => entry !== null);
|
|
142
|
+
}
|
|
143
|
+
/** Keep boundary metadata at its stable session-ID path, outside the transcript filename pattern. */
|
|
144
|
+
turnMapPath(sessionId) {
|
|
145
|
+
return join(this.directory, `${createHash("sha256").update(`turns:${sessionId}`).digest("hex")}.turns.json`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Acquire append's exclusive lock and record its PID. Once the handle is returned, append owns
|
|
149
|
+
* close and removal. A rejected PID write can strand the handle and lock file before transfer. */
|
|
150
|
+
async function acquireSessionLock(lockPath) {
|
|
151
|
+
const deadline = Date.now() + 10_000;
|
|
152
|
+
let lockHandle;
|
|
153
|
+
while (!lockHandle) {
|
|
154
|
+
try {
|
|
155
|
+
lockHandle = await open(lockPath, "wx", 0o600);
|
|
156
|
+
await lockHandle.writeFile(String(process.pid));
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
if (cause.code !== "EEXIST")
|
|
160
|
+
throw cause;
|
|
161
|
+
await waitForSessionLock({ lockPath, deadline });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return lockHandle;
|
|
165
|
+
}
|
|
166
|
+
/** Resolve contention before acquireSessionLock retries. Reclaim only a lock whose PID is gone,
|
|
167
|
+
* since an unreadable PID or an old lock can still belong to a live writer. For other owners,
|
|
168
|
+
* wait 10 ms before retrying or reject with SESSION_STORE_BUSY at the deadline. A failed removal
|
|
169
|
+
* also rejects append. */
|
|
170
|
+
async function waitForSessionLock({ lockPath, deadline }) {
|
|
171
|
+
const pid = Number(await readFile(lockPath, "utf8").catch(() => ""));
|
|
172
|
+
if (isLockOwnerDefinitelyAbsent(pid)) {
|
|
173
|
+
await rm(lockPath, { force: true });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (Date.now() >= deadline)
|
|
177
|
+
throw new HarnessSdkError("SESSION_STORE_BUSY");
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
179
|
+
}
|
|
180
|
+
/** Treat only ESRCH for a valid positive PID as proof; permission errors leave the lock intact. */
|
|
181
|
+
function isLockOwnerDefinitelyAbsent(pid) {
|
|
182
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
183
|
+
return false;
|
|
184
|
+
try {
|
|
185
|
+
process.kill(pid, 0);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return error.code === "ESRCH";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Mutate append's locked snapshot, cloning incoming entries and preserving UUID positions.
|
|
193
|
+
* Index new UUIDs immediately so repeats in the same batch also replace in place. */
|
|
194
|
+
function mergeSessionEntries(stored, entries) {
|
|
195
|
+
const entryIndexByUuid = new Map(stored.entries.flatMap((entry, index) => entry.uuid ? [[entry.uuid, index]] : []));
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const existingIndex = entry.uuid ? entryIndexByUuid.get(entry.uuid) : undefined;
|
|
198
|
+
if (existingIndex !== undefined) {
|
|
199
|
+
stored.entries[existingIndex] = structuredClone(entry);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
if (entry.uuid)
|
|
203
|
+
entryIndexByUuid.set(entry.uuid, stored.entries.length);
|
|
204
|
+
stored.entries.push(structuredClone(entry));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
stored.mtime = Math.max(Date.now(), stored.mtime + 1);
|
|
208
|
+
}
|
|
209
|
+
/** Publish JSON by renaming an exclusive mode-0600 temporary file after sync and close.
|
|
210
|
+
* append and saveTurnMap supply a unique path in the destination directory for atomic rename
|
|
211
|
+
* and own path cleanup. File and serialization failures reject, and the temporary handle closes
|
|
212
|
+
* before the destination can be replaced. */
|
|
213
|
+
async function publishJson({ file, temporary, value }) {
|
|
214
|
+
const temporaryHandle = await open(temporary, "wx", 0o600);
|
|
215
|
+
try {
|
|
216
|
+
await temporaryHandle.writeFile(JSON.stringify(value));
|
|
217
|
+
await temporaryHandle.sync();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await temporaryHandle.close();
|
|
221
|
+
}
|
|
222
|
+
await rename(temporary, file);
|
|
223
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SDKAssistantMessageError, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import type { HarnessError } from '@anureo/protocol';
|
|
3
|
+
/**
|
|
4
|
+
* Classify a native terminal result, returning undefined for a successful non-error result.
|
|
5
|
+
* Execution limits take precedence. For API failures, a recognized assistant category takes
|
|
6
|
+
* precedence over a valid HTTP status. Returned errors use fixed messages and omit native prose.
|
|
7
|
+
* ClaudeBackendSession.completeTurn supplies the latest main-assistant category and emits the
|
|
8
|
+
* returned error with turn/completed unless interrupted. The caller receives it in the failed
|
|
9
|
+
* result from Turn.result(). Retryability is advice for the consumer.
|
|
10
|
+
*/
|
|
11
|
+
export declare function claudeTurnError(result: SDKResultMessage, assistantError?: SDKAssistantMessageError): HarnessError | undefined;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a native terminal result, returning undefined for a successful non-error result.
|
|
3
|
+
* Execution limits take precedence. For API failures, a recognized assistant category takes
|
|
4
|
+
* precedence over a valid HTTP status. Returned errors use fixed messages and omit native prose.
|
|
5
|
+
* ClaudeBackendSession.completeTurn supplies the latest main-assistant category and emits the
|
|
6
|
+
* returned error with turn/completed unless interrupted. The caller receives it in the failed
|
|
7
|
+
* result from Turn.result(). Retryability is advice for the consumer.
|
|
8
|
+
*/
|
|
9
|
+
export function claudeTurnError(result, assistantError) {
|
|
10
|
+
if (result.subtype === 'success' && !result.is_error)
|
|
11
|
+
return undefined;
|
|
12
|
+
const limitCode = executionLimitCode(result.subtype);
|
|
13
|
+
if (limitCode)
|
|
14
|
+
return { code: limitCode, message: 'Claude execution limit reached', retryable: false, outcomeUnknown: false };
|
|
15
|
+
if (result.subtype !== 'success' && result.terminal_reason !== 'api_error') {
|
|
16
|
+
return { code: 'CLAUDE_TURN_FAILED', message: 'Claude turn failed', retryable: false, outcomeUnknown: false };
|
|
17
|
+
}
|
|
18
|
+
const status = apiErrorStatus(result);
|
|
19
|
+
const code = apiErrorCode(assistantError, status);
|
|
20
|
+
return {
|
|
21
|
+
code,
|
|
22
|
+
message: 'Claude model request failed',
|
|
23
|
+
retryable: code === 'RATE_LIMITED' || code === 'NATIVE_SERVICE_UNAVAILABLE',
|
|
24
|
+
outcomeUnknown: false,
|
|
25
|
+
...(status !== undefined ? { details: { httpStatusCode: status } } : {}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Recognize execution limits before consulting potentially stale API failure evidence. */
|
|
29
|
+
function executionLimitCode(subtype) {
|
|
30
|
+
switch (subtype) {
|
|
31
|
+
case 'error_max_structured_output_retries': return 'CLAUDE_STRUCTURED_OUTPUT_RETRIES_EXCEEDED';
|
|
32
|
+
case 'error_max_turns': return 'CLAUDE_MAX_TURNS_EXCEEDED';
|
|
33
|
+
case 'error_max_budget_usd': return 'CLAUDE_MAX_BUDGET_EXCEEDED';
|
|
34
|
+
default: return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Accept HTTP status integers from 100 through 599 on the success variant for classification and public details. */
|
|
38
|
+
function apiErrorStatus(result) {
|
|
39
|
+
if (result.subtype !== 'success')
|
|
40
|
+
return undefined;
|
|
41
|
+
const status = result.api_error_status;
|
|
42
|
+
if (typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599)
|
|
43
|
+
return status;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const assistantErrorCodes = {
|
|
47
|
+
authentication_failed: 'AUTH_REQUIRED',
|
|
48
|
+
oauth_org_not_allowed: 'AUTH_FORBIDDEN',
|
|
49
|
+
account_on_hold: 'AUTH_FORBIDDEN',
|
|
50
|
+
billing_error: 'USAGE_LIMIT_EXCEEDED',
|
|
51
|
+
rate_limit: 'RATE_LIMITED',
|
|
52
|
+
overloaded: 'NATIVE_SERVICE_UNAVAILABLE',
|
|
53
|
+
server_error: 'NATIVE_SERVICE_UNAVAILABLE',
|
|
54
|
+
invalid_request: 'NATIVE_REQUEST_INVALID',
|
|
55
|
+
model_not_found: 'MODEL_NOT_FOUND',
|
|
56
|
+
max_output_tokens: 'CLAUDE_MAX_OUTPUT_TOKENS',
|
|
57
|
+
};
|
|
58
|
+
/** Use a recognized assistant category first, then fall back to HTTP status. */
|
|
59
|
+
function apiErrorCode(assistantError, status) {
|
|
60
|
+
if (assistantError && Object.hasOwn(assistantErrorCodes, assistantError)) {
|
|
61
|
+
const code = assistantErrorCodes[assistantError];
|
|
62
|
+
if (code)
|
|
63
|
+
return code;
|
|
64
|
+
}
|
|
65
|
+
if (status === 401)
|
|
66
|
+
return 'AUTH_REQUIRED';
|
|
67
|
+
if (status === 403)
|
|
68
|
+
return 'AUTH_FORBIDDEN';
|
|
69
|
+
if (status === 429)
|
|
70
|
+
return 'RATE_LIMITED';
|
|
71
|
+
if (status !== undefined && status >= 500)
|
|
72
|
+
return 'NATIVE_SERVICE_UNAVAILABLE';
|
|
73
|
+
return 'CLAUDE_TURN_FAILED';
|
|
74
|
+
}
|