@cleocode/adapters 2026.4.62 → 2026.4.64

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.
@@ -143,16 +143,20 @@ export declare class ClaudeCodeHookProvider implements AdapterHookProvider {
143
143
  /**
144
144
  * Extract a plain-text transcript from Claude Code session JSONL files.
145
145
  *
146
- * Reads the most recent .jsonl file under `~/.claude/projects/` and
146
+ * Claude Code stores session data under `~/.claude/projects/<project-slug>/`:
147
+ * - Root-level session JSONLs: `<sessionId>.jsonl` (primary transcript)
148
+ * - UUID subdirectories contain `subagents/agent-*.jsonl` (subagent turns)
149
+ *
150
+ * Reads the most-recent root-level JSONL plus all subagent JSONLs and
147
151
  * extracts user/assistant turn text into a flat string for brain
148
152
  * observation extraction.
149
153
  *
150
154
  * Returns null when no session data is found or on any read error.
151
155
  *
152
- * @param _sessionId - CLEO session ID (unused; reads the most recent file)
156
+ * @param sessionId - CLEO session ID (available for future subagent filtering)
153
157
  * @param _projectDir - Project directory (unused; Claude Code uses global paths)
154
- * @task T144 @epic T134
158
+ * @task T729 @task T144 @epic T134
155
159
  */
156
- getTranscript(_sessionId: string, _projectDir: string): Promise<string | null>;
160
+ getTranscript(sessionId: string, _projectDir: string): Promise<string | null>;
157
161
  }
158
162
  //# sourceMappingURL=hooks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6E5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;OAYG;IACG,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA2DrF"}
1
+ {"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AA8C/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,sBAAuB,YAAW,mBAAmB;IAChE,kEAAkE;IAClE,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAItD,+DAA+D;IAC/D,OAAO,CAAC,UAAU,CAAuB;IAEzC;;;;;;;;;;;;;;;;OAgBG;IACG,mBAAmB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA6E5D;;;;;;;OAOG;IACG,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2C5C;;OAEG;IACH,YAAY,IAAI,OAAO;IAIvB;;;;OAIG;IACH,aAAa,IAAI,MAAM,GAAG,IAAI;IAI9B;;;;;;;;OAQG;IACH,WAAW,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAI/C;;;;;;;;;;OAUG;IACG,2BAA2B,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAStD;;;;;;;;;;OAUG;IACG,kBAAkB,IAAI,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IASnD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAW9D;;;;;;;;;;;;;;;;OAgBG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CA4FpF"}
@@ -9,12 +9,13 @@
9
9
  * - Awaits full completion before returning (synchronous output capture)
10
10
  * - Session IDs from the SDK enable future multi-turn resumption
11
11
  * - No temp files, no OS PIDs — tracking is purely in-memory session IDs
12
- * - `canSpawn()` checks for `ANTHROPIC_API_KEY` rather than CLI availability
12
+ * - `canSpawn()` uses 3-tier key resolution (env var → stored key → Claude Code OAuth)
13
13
  *
14
14
  * CANT enrichment is identical to the CLI provider: `buildCantEnrichedPrompt()`
15
15
  * is called before `query()` and the result is passed as the SDK prompt string.
16
16
  *
17
17
  * @task T581
18
+ * @see T752 — canSpawn() OAuth fix
18
19
  */
19
20
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
20
21
  /**
@@ -38,10 +39,15 @@ export declare class ClaudeSDKSpawnProvider implements AdapterSpawnProvider {
38
39
  /**
39
40
  * Check whether the SDK can be used in the current environment.
40
41
  *
41
- * Returns `true` if `ANTHROPIC_API_KEY` is set. No binary check is needed
42
- * because the SDK manages the Claude Code subprocess internally.
42
+ * Uses 3-tier key resolution so the provider works with:
43
+ * - `ANTHROPIC_API_KEY` environment variable (explicit)
44
+ * - `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
45
+ * - Claude Code OAuth token (zero-config for Claude Code users)
43
46
  *
44
- * @returns `true` when an API key is present
47
+ * No binary check is needed because the SDK manages the Claude Code
48
+ * subprocess internally.
49
+ *
50
+ * @returns `true` when any Anthropic credential is available
45
51
  */
46
52
  canSpawn(): Promise<boolean>;
47
53
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-sdk/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAS3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,sBAAuB,YAAW,oBAAoB;IACjE,kCAAkC;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAE/C;;;;;;;OAOG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IAIlC;;;;;;;;;OASG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA6IxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAU3C;;;;;;;;;OASG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnD"}
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-sdk/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAgE3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,sBAAuB,YAAW,oBAAoB;IACjE,kCAAkC;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsB;IAE/C;;;;;;;;;;;;OAYG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IAIlC;;;;;;;;;OASG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA6IxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAU3C;;;;;;;;;OASG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAGnD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/adapters",
3
- "version": "2026.4.62",
3
+ "version": "2026.4.64",
4
4
  "description": "Unified provider adapters for CLEO (Claude Code, OpenCode, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,8 +14,8 @@
14
14
  "dependencies": {
15
15
  "@anthropic-ai/claude-agent-sdk": "0.2.108",
16
16
  "@openai/agents": "0.8.3",
17
- "@cleocode/caamp": "2026.4.62",
18
- "@cleocode/contracts": "2026.4.62"
17
+ "@cleocode/caamp": "2026.4.64",
18
+ "@cleocode/contracts": "2026.4.64"
19
19
  },
20
20
  "license": "MIT",
21
21
  "engines": {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for ClaudeCodeHookProvider.getTranscript() — T729 bug fix.
3
+ *
4
+ * Verifies that getTranscript reads root-level session JSONLs (siblings
5
+ * to UUID subdirectories) instead of walking into UUID subdirs.
6
+ *
7
+ * Claude Code layout under ~/.claude/projects/<project>/:
8
+ * - <sessionId>.jsonl ← root-level session JSONL (the real transcript)
9
+ * - <uuid>/ ← UUID subdir (contains only subagents/ and tool-results/)
10
+ * - <uuid>/subagents/ ← subagent session JSONLs live here
11
+ * - <uuid>/tool-results/ ← not JSONL sessions
12
+ *
13
+ * @task T729
14
+ * @epic T726
15
+ */
16
+
17
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
21
+ import { ClaudeCodeHookProvider } from '../hooks.js';
22
+
23
+ let tempDir: string;
24
+ let hooks: ClaudeCodeHookProvider;
25
+
26
+ /** JSONL line for a user turn. */
27
+ function userLine(text: string): string {
28
+ return JSON.stringify({ role: 'user', content: text });
29
+ }
30
+
31
+ /** JSONL line for an assistant turn. */
32
+ function assistantLine(text: string): string {
33
+ return JSON.stringify({ role: 'assistant', content: text });
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ tempDir = await mkdtemp(join(tmpdir(), 'cleo-get-transcript-'));
38
+ hooks = new ClaudeCodeHookProvider();
39
+ // Override HOME so getTranscript reads from our temp fixture
40
+ process.env['HOME'] = tempDir;
41
+ });
42
+
43
+ afterEach(async () => {
44
+ delete process.env['HOME'];
45
+ await rm(tempDir, { recursive: true, force: true });
46
+ });
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Fixture builders
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Create the standard Claude Code directory layout under tempDir.
54
+ *
55
+ * ~/.claude/projects/
56
+ * <projectSlug>/
57
+ * <sessionId>.jsonl ← root-level session JSONL
58
+ * <uuid>/ ← UUID subdir (no JSONLs at root)
59
+ * <uuid>/subagents/
60
+ * agent-<agentId>.jsonl ← subagent JSONL
61
+ * <uuid>/tool-results/ ← not a session JSONL
62
+ */
63
+ async function createFixture(opts: {
64
+ projectSlug: string;
65
+ sessionId: string;
66
+ rootLines: string[];
67
+ uuid?: string;
68
+ subagentLines?: string[];
69
+ }): Promise<{ projectDir: string }> {
70
+ const projectsDir = join(tempDir, '.claude', 'projects');
71
+ const projectDir = join(projectsDir, opts.projectSlug);
72
+ await mkdir(projectDir, { recursive: true });
73
+
74
+ // Root-level session JSONL (the one getTranscript MUST read)
75
+ const rootJsonl = join(projectDir, `${opts.sessionId}.jsonl`);
76
+ await writeFile(rootJsonl, opts.rootLines.join('\n'));
77
+
78
+ if (opts.uuid) {
79
+ // UUID subdir — should NOT be read as a session JSONL source
80
+ const uuidDir = join(projectDir, opts.uuid);
81
+ await mkdir(uuidDir, { recursive: true });
82
+
83
+ if (opts.subagentLines) {
84
+ const subagentsDir = join(uuidDir, 'subagents');
85
+ await mkdir(subagentsDir, { recursive: true });
86
+ const saJsonl = join(subagentsDir, `agent-${opts.sessionId}.jsonl`);
87
+ await writeFile(saJsonl, opts.subagentLines.join('\n'));
88
+ }
89
+
90
+ // tool-results dir should never be iterated
91
+ await mkdir(join(uuidDir, 'tool-results'), { recursive: true });
92
+ }
93
+
94
+ return { projectDir };
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Tests
99
+ // ---------------------------------------------------------------------------
100
+
101
+ describe('getTranscript — T729 root-level JSONL fix', () => {
102
+ it('GT-1: reads root-level session JSONL, not UUID subdir contents', async () => {
103
+ await createFixture({
104
+ projectSlug: 'test-project',
105
+ sessionId: 'ses_abc123',
106
+ rootLines: [userLine('hello from root'), assistantLine('response from root')],
107
+ uuid: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
108
+ });
109
+
110
+ const result = await hooks.getTranscript('ses_abc123', '/tmp/test');
111
+
112
+ expect(result).not.toBeNull();
113
+ expect(result).toContain('user: hello from root');
114
+ expect(result).toContain('assistant: response from root');
115
+ });
116
+
117
+ it('GT-2: returns null when projects dir does not exist', async () => {
118
+ // HOME points to tempDir but no .claude/projects inside
119
+ const result = await hooks.getTranscript('ses_abc123', '/tmp/test');
120
+ expect(result).toBeNull();
121
+ });
122
+
123
+ it('GT-3: returns null when root-level JSONL has no recognizable turns', async () => {
124
+ await createFixture({
125
+ projectSlug: 'test-project',
126
+ sessionId: 'ses_empty',
127
+ rootLines: [JSON.stringify({ type: 'system', data: 'some system event' }), 'malformed line'],
128
+ });
129
+
130
+ const result = await hooks.getTranscript('ses_empty', '/tmp/test');
131
+ expect(result).toBeNull();
132
+ });
133
+
134
+ it('GT-4: picks most-recent root JSONL when multiple projects exist', async () => {
135
+ // Create two project dirs with root JSONLs; lexicographically later name wins
136
+ const projectsDir = join(tempDir, '.claude', 'projects');
137
+
138
+ const proj1 = join(projectsDir, 'project-a');
139
+ await mkdir(proj1, { recursive: true });
140
+ await writeFile(join(proj1, 'ses_older.jsonl'), userLine('older project'));
141
+
142
+ const proj2 = join(projectsDir, 'project-z');
143
+ await mkdir(proj2, { recursive: true });
144
+ await writeFile(join(proj2, 'ses_newer.jsonl'), userLine('newer project'));
145
+
146
+ const result = await hooks.getTranscript('ses_newer', '/tmp/test');
147
+ // project-z sorts after project-a, so project-z/ses_newer.jsonl wins
148
+ expect(result).toContain('user: newer project');
149
+ });
150
+
151
+ it('GT-5: also ingests subagent JSONLs from UUID subdir when present', async () => {
152
+ await createFixture({
153
+ projectSlug: 'test-project',
154
+ sessionId: 'ses_withagent',
155
+ rootLines: [userLine('main session turn')],
156
+ uuid: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
157
+ subagentLines: [assistantLine('subagent turn')],
158
+ });
159
+
160
+ const result = await hooks.getTranscript('ses_withagent', '/tmp/test');
161
+
162
+ expect(result).not.toBeNull();
163
+ expect(result).toContain('user: main session turn');
164
+ expect(result).toContain('assistant: subagent turn');
165
+ });
166
+
167
+ it('GT-6: does NOT read JSONL files placed directly inside UUID subdirs (wrong layout)', async () => {
168
+ const projectsDir = join(tempDir, '.claude', 'projects');
169
+ const projectDir = join(projectsDir, 'test-project');
170
+ await mkdir(projectDir, { recursive: true });
171
+
172
+ // Place a JSONL inside a UUID subdir (old/wrong location)
173
+ const uuidDir = join(projectDir, 'f47ac10b-58cc-4372-a567-0e02b2c3d479');
174
+ await mkdir(uuidDir, { recursive: true });
175
+ await writeFile(join(uuidDir, 'wrong-location.jsonl'), userLine('should not be read'));
176
+
177
+ // No root-level JSONL present
178
+ const result = await hooks.getTranscript('ses_abc', '/tmp/test');
179
+
180
+ // Should return null since no root-level JSONLs exist
181
+ expect(result).toBeNull();
182
+ });
183
+ });
@@ -351,67 +351,104 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
351
351
  /**
352
352
  * Extract a plain-text transcript from Claude Code session JSONL files.
353
353
  *
354
- * Reads the most recent .jsonl file under `~/.claude/projects/` and
354
+ * Claude Code stores session data under `~/.claude/projects/<project-slug>/`:
355
+ * - Root-level session JSONLs: `<sessionId>.jsonl` (primary transcript)
356
+ * - UUID subdirectories contain `subagents/agent-*.jsonl` (subagent turns)
357
+ *
358
+ * Reads the most-recent root-level JSONL plus all subagent JSONLs and
355
359
  * extracts user/assistant turn text into a flat string for brain
356
360
  * observation extraction.
357
361
  *
358
362
  * Returns null when no session data is found or on any read error.
359
363
  *
360
- * @param _sessionId - CLEO session ID (unused; reads the most recent file)
364
+ * @param sessionId - CLEO session ID (available for future subagent filtering)
361
365
  * @param _projectDir - Project directory (unused; Claude Code uses global paths)
362
- * @task T144 @epic T134
366
+ * @task T729 @task T144 @epic T134
363
367
  */
364
- async getTranscript(_sessionId: string, _projectDir: string): Promise<string | null> {
368
+ async getTranscript(sessionId: string, _projectDir: string): Promise<string | null> {
365
369
  try {
366
370
  const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? '/root';
367
371
  const projectsDir = join(homeDir, '.claude', 'projects');
368
372
 
369
- // Find all JSONL files across project subdirectories
370
- let allFiles: Array<{ path: string; mtime: number }> = [];
373
+ // Collect root-level session JSONLs (siblings to UUID subdirs).
374
+ // Claude Code layout: ~/.claude/projects/<project>/<sessionId>.jsonl
375
+ // UUID subdirs contain only subagents/ and tool-results/, not JSONLs.
376
+ let rootFiles: Array<{ path: string }> = [];
377
+ const subagentFiles: Array<{ path: string }> = [];
378
+
371
379
  try {
372
380
  const projectDirs = await readdir(projectsDir, { withFileTypes: true });
373
- for (const entry of projectDirs) {
374
- if (!entry.isDirectory()) continue;
375
- const subDir = join(projectsDir, entry.name);
381
+ for (const projectEntry of projectDirs) {
382
+ if (!projectEntry.isDirectory()) continue;
383
+ const projectDir = join(projectsDir, projectEntry.name);
384
+
376
385
  try {
377
- const files = await readdir(subDir);
378
- for (const file of files) {
379
- if (!file.endsWith('.jsonl')) continue;
380
- const filePath = join(subDir, file);
381
- // Use file path modification heuristic (filename usually includes timestamp)
382
- allFiles.push({ path: filePath, mtime: 0 });
386
+ const projectContents = await readdir(projectDir, { withFileTypes: true });
387
+ for (const entry of projectContents) {
388
+ // Root-level JSONL: direct child file ending in .jsonl
389
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) {
390
+ rootFiles.push({ path: join(projectDir, entry.name) });
391
+ continue;
392
+ }
393
+
394
+ // UUID subdir: check for subagent JSONLs
395
+ if (entry.isDirectory()) {
396
+ const subagentsDir = join(projectDir, entry.name, 'subagents');
397
+ try {
398
+ const subagentEntries = await readdir(subagentsDir);
399
+ for (const sa of subagentEntries) {
400
+ if (sa.startsWith('agent-') && sa.endsWith('.jsonl')) {
401
+ subagentFiles.push({ path: join(subagentsDir, sa) });
402
+ }
403
+ }
404
+ } catch {
405
+ // No subagents dir in this subdir — skip
406
+ }
407
+ }
383
408
  }
384
409
  } catch {
385
- // Skip unreadable subdirectories
410
+ // Skip unreadable project directories
386
411
  }
387
412
  }
388
413
  } catch {
389
414
  return null;
390
415
  }
391
416
 
392
- if (allFiles.length === 0) return null;
417
+ if (rootFiles.length === 0 && subagentFiles.length === 0) return null;
393
418
 
394
- // Sort by path descending (timestamps in filenames sort naturally)
395
- allFiles = allFiles.sort((a, b) => b.path.localeCompare(a.path));
396
- const mostRecent = allFiles[0];
397
- if (!mostRecent) return null;
419
+ // Sort root files by path descending (timestamps in filenames sort naturally)
420
+ rootFiles = rootFiles.sort((a, b) => b.path.localeCompare(a.path));
398
421
 
399
- const raw = await readFile(mostRecent.path, 'utf-8');
400
- const lines = raw.split('\n').filter((l) => l.trim());
422
+ // Collect all JSONL paths: most-recent root file first, then subagent files
423
+ const allPaths = [
424
+ ...(rootFiles[0] ? [rootFiles[0].path] : []),
425
+ ...subagentFiles.map((f) => f.path),
426
+ ];
427
+
428
+ // Suppress unused variable warning — sessionId available for future filtering
429
+ void sessionId;
401
430
 
402
431
  const turns: string[] = [];
403
- for (const line of lines) {
432
+ for (const filePath of allPaths) {
404
433
  try {
405
- const entry = JSON.parse(line) as Record<string, unknown>;
406
- const role = entry.role as string | undefined;
407
- const content = entry.content;
408
- if (role === 'assistant' && typeof content === 'string') {
409
- turns.push(`assistant: ${content}`);
410
- } else if (role === 'user' && typeof content === 'string') {
411
- turns.push(`user: ${content}`);
434
+ const raw = await readFile(filePath, 'utf-8');
435
+ const lines = raw.split('\n').filter((l) => l.trim());
436
+ for (const line of lines) {
437
+ try {
438
+ const entry = JSON.parse(line) as Record<string, unknown>;
439
+ const role = entry.role as string | undefined;
440
+ const content = entry.content;
441
+ if (role === 'assistant' && typeof content === 'string') {
442
+ turns.push(`assistant: ${content}`);
443
+ } else if (role === 'user' && typeof content === 'string') {
444
+ turns.push(`user: ${content}`);
445
+ }
446
+ } catch {
447
+ // Skip malformed lines
448
+ }
412
449
  }
413
450
  } catch {
414
- // Skip malformed lines
451
+ // Skip unreadable files
415
452
  }
416
453
  }
417
454
 
@@ -61,7 +61,7 @@ describe('ClaudeSDKSpawnProvider', () => {
61
61
  });
62
62
 
63
63
  // -------------------------------------------------------------------------
64
- // canSpawn
64
+ // canSpawn — 3-tier key resolution (T752)
65
65
  // -------------------------------------------------------------------------
66
66
 
67
67
  describe('canSpawn()', () => {
@@ -70,12 +70,61 @@ describe('ClaudeSDKSpawnProvider', () => {
70
70
  expect(await provider.canSpawn()).toBe(true);
71
71
  });
72
72
 
73
- it('returns false when ANTHROPIC_API_KEY is absent', async () => {
73
+ it('returns false when no credentials are available', async () => {
74
+ // Mock fs.existsSync to return false for all key paths so no tier
75
+ // resolves (env var, stored key file, or OAuth credentials file).
76
+ const { existsSync } = await import('node:fs');
77
+ const saved = process.env.ANTHROPIC_API_KEY;
78
+ delete process.env.ANTHROPIC_API_KEY;
79
+ vi.spyOn({ existsSync }, 'existsSync').mockReturnValue(false);
80
+ // Use vi.mock for node:fs to prevent reading real ~/.claude/.credentials.json
81
+ vi.doMock('node:fs', () => ({
82
+ existsSync: vi.fn().mockReturnValue(false),
83
+ readFileSync: vi.fn().mockImplementation(() => {
84
+ throw new Error('mocked: file not found');
85
+ }),
86
+ }));
87
+ try {
88
+ // Re-import spawn module with mocked fs to get fresh resolver state
89
+ vi.resetModules();
90
+ const { ClaudeSDKSpawnProvider: FreshProvider } = await import('../spawn.js');
91
+ const freshProvider = new FreshProvider();
92
+ expect(await freshProvider.canSpawn()).toBe(false);
93
+ } finally {
94
+ vi.resetModules();
95
+ vi.doUnmock('node:fs');
96
+ if (saved !== undefined) {
97
+ process.env.ANTHROPIC_API_KEY = saved;
98
+ }
99
+ }
100
+ });
101
+
102
+ it('returns true when OAuth credentials file exists (no API key env var)', async () => {
103
+ const validCreds = JSON.stringify({
104
+ claudeAiOauth: {
105
+ accessToken: 'oauth-token',
106
+ expiresAt: Date.now() + 3_600_000, // 1 hour from now
107
+ },
108
+ });
109
+ vi.doMock('node:fs', () => ({
110
+ existsSync: vi
111
+ .fn()
112
+ .mockImplementation((p: string) => String(p).endsWith('.credentials.json')),
113
+ readFileSync: vi.fn().mockImplementation((p: string) => {
114
+ if (String(p).endsWith('.credentials.json')) return validCreds;
115
+ throw new Error('mocked: file not found');
116
+ }),
117
+ }));
74
118
  const saved = process.env.ANTHROPIC_API_KEY;
75
119
  delete process.env.ANTHROPIC_API_KEY;
76
120
  try {
77
- expect(await provider.canSpawn()).toBe(false);
121
+ vi.resetModules();
122
+ const { ClaudeSDKSpawnProvider: FreshProvider } = await import('../spawn.js');
123
+ const freshProvider = new FreshProvider();
124
+ expect(await freshProvider.canSpawn()).toBe(true);
78
125
  } finally {
126
+ vi.resetModules();
127
+ vi.doUnmock('node:fs');
79
128
  if (saved !== undefined) {
80
129
  process.env.ANTHROPIC_API_KEY = saved;
81
130
  }
@@ -9,20 +9,79 @@
9
9
  * - Awaits full completion before returning (synchronous output capture)
10
10
  * - Session IDs from the SDK enable future multi-turn resumption
11
11
  * - No temp files, no OS PIDs — tracking is purely in-memory session IDs
12
- * - `canSpawn()` checks for `ANTHROPIC_API_KEY` rather than CLI availability
12
+ * - `canSpawn()` uses 3-tier key resolution (env var → stored key → Claude Code OAuth)
13
13
  *
14
14
  * CANT enrichment is identical to the CLI provider: `buildCantEnrichedPrompt()`
15
15
  * is called before `query()` and the result is passed as the SDK prompt string.
16
16
  *
17
17
  * @task T581
18
+ * @see T752 — canSpawn() OAuth fix
18
19
  */
19
20
 
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
20
24
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
21
25
  import { getErrorMessage } from '@cleocode/contracts';
22
26
  import { getServers } from './mcp-registry.js';
23
27
  import { SessionStore } from './session-store.js';
24
28
  import { resolveTools } from './tool-bridge.js';
25
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Inline 3-tier Anthropic key resolver
32
+ // NOTE: Cannot import from @cleocode/core — circular dependency
33
+ // (@cleocode/core depends on @cleocode/adapters). This is a deliberate
34
+ // inline copy of the resolution logic from anthropic-key-resolver.ts.
35
+ // Keep in sync with packages/core/src/memory/anthropic-key-resolver.ts.
36
+ // T752 — OAuth fix for canSpawn()
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Resolve the Anthropic API key using a 3-tier priority chain:
41
+ * 1. `ANTHROPIC_API_KEY` environment variable
42
+ * 2. `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
43
+ * 3. `~/.claude/.credentials.json` → claudeAiOauth.accessToken (Claude Code OAuth)
44
+ *
45
+ * @returns The key/token string, or null if unavailable.
46
+ */
47
+ function resolveAnthropicApiKey(): string | null {
48
+ // 1. Explicit env var
49
+ const envKey = process.env.ANTHROPIC_API_KEY;
50
+ if (envKey?.trim()) return envKey;
51
+
52
+ // 2. CLEO global stored key
53
+ try {
54
+ const xdg = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
55
+ const keyFile = join(xdg, 'cleo', 'anthropic-key');
56
+ if (existsSync(keyFile)) {
57
+ const stored = readFileSync(keyFile, 'utf-8').trim();
58
+ if (stored) return stored;
59
+ }
60
+ } catch {
61
+ // Not available — continue
62
+ }
63
+
64
+ // 3. Claude Code OAuth token (free for Claude Code users)
65
+ try {
66
+ const credPath = join(homedir(), '.claude', '.credentials.json');
67
+ if (!existsSync(credPath)) return null;
68
+ const raw = readFileSync(credPath, 'utf-8');
69
+ const creds = JSON.parse(raw) as {
70
+ claudeAiOauth?: { accessToken?: string; expiresAt?: number };
71
+ };
72
+ const token = creds.claudeAiOauth?.accessToken;
73
+ if (token?.trim()) {
74
+ const expiresAt = creds.claudeAiOauth?.expiresAt;
75
+ if (expiresAt && Date.now() > expiresAt) return null;
76
+ return token;
77
+ }
78
+ } catch {
79
+ // Credentials file missing or unreadable — not an error
80
+ }
81
+
82
+ return null;
83
+ }
84
+
26
85
  /** Model used when no model is specified in spawn options. */
27
86
  const DEFAULT_MODEL = 'claude-sonnet-4-5';
28
87
 
@@ -48,13 +107,18 @@ export class ClaudeSDKSpawnProvider implements AdapterSpawnProvider {
48
107
  /**
49
108
  * Check whether the SDK can be used in the current environment.
50
109
  *
51
- * Returns `true` if `ANTHROPIC_API_KEY` is set. No binary check is needed
52
- * because the SDK manages the Claude Code subprocess internally.
110
+ * Uses 3-tier key resolution so the provider works with:
111
+ * - `ANTHROPIC_API_KEY` environment variable (explicit)
112
+ * - `~/.local/share/cleo/anthropic-key` (user-stored via cleo config)
113
+ * - Claude Code OAuth token (zero-config for Claude Code users)
114
+ *
115
+ * No binary check is needed because the SDK manages the Claude Code
116
+ * subprocess internally.
53
117
  *
54
- * @returns `true` when an API key is present
118
+ * @returns `true` when any Anthropic credential is available
55
119
  */
56
120
  async canSpawn(): Promise<boolean> {
57
- return !!process.env.ANTHROPIC_API_KEY;
121
+ return !!resolveAnthropicApiKey();
58
122
  }
59
123
 
60
124
  /**