@anhlp/alp-runtime 0.0.0-stage → 0.0.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +6 -2
  4. package/dist/acp-transport.d.ts +130 -0
  5. package/dist/acp-transport.js +511 -0
  6. package/dist/antigravity-transport.d.ts +82 -0
  7. package/dist/antigravity-transport.js +619 -0
  8. package/dist/assignment-results.d.ts +18 -0
  9. package/dist/assignment-results.js +77 -0
  10. package/dist/attachments.d.ts +84 -0
  11. package/dist/attachments.js +213 -0
  12. package/dist/board-domain.d.ts +56 -0
  13. package/dist/board-domain.js +157 -0
  14. package/dist/board.d.ts +21 -0
  15. package/dist/board.js +69 -0
  16. package/dist/catalog.d.ts +31 -0
  17. package/dist/catalog.js +38 -0
  18. package/dist/claude-transport.d.ts +202 -0
  19. package/dist/claude-transport.js +817 -0
  20. package/dist/configure.d.ts +44 -0
  21. package/dist/configure.js +64 -0
  22. package/dist/contracts.d.ts +327 -0
  23. package/dist/contracts.js +16 -0
  24. package/dist/conversation.d.ts +45 -0
  25. package/dist/conversation.js +62 -0
  26. package/dist/events.d.ts +1 -0
  27. package/dist/events.js +0 -0
  28. package/dist/github.d.ts +10 -0
  29. package/dist/github.js +39 -0
  30. package/dist/handoff.d.ts +36 -0
  31. package/dist/handoff.js +73 -0
  32. package/dist/index.d.ts +30 -0
  33. package/dist/index.js +87 -0
  34. package/dist/language.d.ts +41 -0
  35. package/dist/language.js +68 -0
  36. package/dist/live.d.ts +48 -0
  37. package/dist/live.js +45 -0
  38. package/dist/mailbox.d.ts +26 -0
  39. package/dist/mailbox.js +38 -0
  40. package/dist/mcp-tools.d.ts +115 -0
  41. package/dist/mcp-tools.js +71 -0
  42. package/dist/permission-path.d.ts +2 -0
  43. package/dist/permission-path.js +44 -0
  44. package/dist/process-info.d.ts +14 -0
  45. package/dist/process-info.js +32 -0
  46. package/dist/providers.d.ts +28 -0
  47. package/dist/providers.js +71 -0
  48. package/dist/recall.d.ts +42 -0
  49. package/dist/recall.js +60 -0
  50. package/dist/resolve.d.ts +121 -0
  51. package/dist/resolve.js +238 -0
  52. package/dist/runtime-context.d.ts +43 -0
  53. package/dist/runtime-context.js +61 -0
  54. package/dist/runtime.d.ts +11 -0
  55. package/dist/runtime.js +3976 -0
  56. package/dist/session-config.d.ts +2102 -0
  57. package/dist/session-config.js +205 -0
  58. package/dist/session-state.d.ts +166 -0
  59. package/dist/session-state.js +0 -0
  60. package/dist/tool-detail.d.ts +81 -0
  61. package/dist/tool-detail.js +332 -0
  62. package/dist/tool-results.d.ts +12 -0
  63. package/dist/tool-results.js +25 -0
  64. package/dist/tools.d.ts +719 -0
  65. package/dist/tools.js +396 -0
  66. package/dist/transport.d.ts +47 -0
  67. package/dist/transport.js +169 -0
  68. package/dist/usage.d.ts +25 -0
  69. package/dist/usage.js +60 -0
  70. package/dist/workspace.d.ts +101 -0
  71. package/dist/workspace.js +259 -0
  72. package/dist/worktree-domain.d.ts +89 -0
  73. package/dist/worktree-domain.js +199 -0
  74. package/package.json +262 -4
  75. package/src/acp-transport.ts +508 -0
  76. package/src/antigravity-transport.ts +667 -0
  77. package/src/assignment-results.ts +64 -0
  78. package/src/attachments.ts +254 -0
  79. package/src/board-domain.ts +185 -0
  80. package/src/board.ts +80 -0
  81. package/src/catalog.ts +32 -0
  82. package/src/claude-transport.ts +938 -0
  83. package/src/configure.ts +93 -0
  84. package/src/contracts.ts +318 -0
  85. package/src/conversation.ts +81 -0
  86. package/src/events.ts +1 -0
  87. package/src/github.ts +37 -0
  88. package/src/handoff.ts +86 -0
  89. package/src/index.ts +32 -0
  90. package/src/language.ts +102 -0
  91. package/src/live.ts +86 -0
  92. package/src/mailbox.ts +49 -0
  93. package/src/mcp-tools.ts +73 -0
  94. package/src/permission-path.ts +37 -0
  95. package/src/process-info.ts +40 -0
  96. package/src/providers.ts +84 -0
  97. package/src/recall.ts +85 -0
  98. package/src/resolve.ts +230 -0
  99. package/src/runtime-context.ts +49 -0
  100. package/src/runtime.ts +4691 -0
  101. package/src/session-config.ts +304 -0
  102. package/src/session-state.ts +164 -0
  103. package/src/tool-detail.ts +397 -0
  104. package/src/tool-results.ts +26 -0
  105. package/src/tools.ts +397 -0
  106. package/src/transport.ts +116 -0
  107. package/src/usage.ts +79 -0
  108. package/src/workspace.ts +318 -0
  109. package/src/worktree-domain.ts +220 -0
@@ -0,0 +1,938 @@
1
+ import { writablePath } from './permission-path.js';
2
+ import { accessSync, constants, existsSync, mkdirSync, realpathSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { optionalRead, claudeUsage } from './runtime-context.js';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { z } from 'zod';
8
+ import { PIN_KINDS } from './board.js';
9
+ import { claudeToolDetail, resultText } from './tool-detail.js';
10
+ import { CLOSE_REASONS, GATE_KINDS, TASK_STATUSES, TASK_TYPES } from '@anhlp/alp-core/tasks';
11
+ import { commandDecision } from '@anhlp/alp-core/permissions';
12
+
13
+ // Keep the Claude SDK behind the runtime boundary. Paseo inspects static
14
+ // imports while compiling plugin entrypoints, including their declaration
15
+ // graph; the SDK is a daemon-only optional runtime dependency.
16
+ const CLAUDE_SDK = ['@anthropic-ai', 'claude-agent-sdk'].join('/');
17
+
18
+ type SDKUserMessage = any;
19
+ type SDKMessage = any;
20
+ type ClaudeQuery = AsyncGenerator<SDKMessage, void> & {
21
+ supportedModels(): Promise<Array<{ value: string; displayName: string; description: string; supportedEffortLevels?: string[] }>>;
22
+ usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(options: { skipBehaviors: boolean }): Promise<any>;
23
+ getContextUsage(options: { detail: 'summary' | 'full' }): Promise<any>;
24
+ interrupt(): Promise<unknown>;
25
+ setPermissionMode(mode: 'default' | 'acceptEdits' | 'bypassPermissions'): Promise<void>;
26
+ close(): void;
27
+ initializationResult?(): Promise<unknown>;
28
+ };
29
+
30
+ // Zod mirrors of the provider's dynamic tool schemas; the provider validates again.
31
+ const handoffList = z.array(z.string().min(1)).max(100).optional();
32
+ const taskIds = z.array(z.string().min(1)).max(50).optional();
33
+ const taskLinks = z.object({ blockedBy: taskIds, related: taskIds, parent: z.string().min(1).optional() }).strict().optional();
34
+ export const toolShapes: Record<string, Record<string, z.ZodType>> = {
35
+ alp_delegate: {
36
+ agent: z.string().min(1),
37
+ task: z.string().min(1).max(32_000),
38
+ model: z.string().min(1).optional(),
39
+ thinking: z.string().min(1).optional(),
40
+ modelReason: z.string().min(1).optional(),
41
+ mode: z.enum(['read-only', 'workspace-write', 'full-access']).optional(),
42
+ isolation: z.enum(['shared', 'worktree']).optional(),
43
+ writeRoot: z.string().min(1).optional(),
44
+ wait: z.boolean().optional(),
45
+ etaMinutes: z.number().int().min(1).max(1440).optional(),
46
+ taskId: z.string().min(1).optional(),
47
+ continueFrom: z.string().min(1).optional(),
48
+ },
49
+ alp_merge: {
50
+ assignmentId: z.string().min(1),
51
+ skipVerify: z.string().min(1).max(2000).optional(),
52
+ },
53
+ alp_verify: {
54
+ taskId: z.string().min(1).optional(),
55
+ },
56
+ alp_recall: {
57
+ assignmentId: z.string().min(1).optional(),
58
+ taskId: z.string().min(1).optional(),
59
+ question: z.string().min(1).max(4000),
60
+ },
61
+ alp_lesson: {
62
+ scope: z.enum(['project', 'user']),
63
+ lesson: z.string().min(1).max(600),
64
+ },
65
+ alp_skill: {
66
+ name: z.string().min(1),
67
+ description: z.string().min(1).max(300),
68
+ body: z.string().min(1).max(20_000),
69
+ roles: z.array(z.string().min(1)).min(1).max(10),
70
+ lessons: z.array(z.string().min(1).max(600)).max(50).optional(),
71
+ replace: z.boolean().optional(),
72
+ requestId: z.string().min(1).max(100).optional(),
73
+ },
74
+ alp_issue: {
75
+ action: z.enum(['search', 'create', 'comment']),
76
+ target: z.enum(['project', 'alp']),
77
+ query: z.string().min(1).max(200).optional(),
78
+ requestId: z.string().min(1).max(100).optional(),
79
+ title: z.string().min(1).max(200).optional(),
80
+ body: z.string().min(1).max(20_000).optional(),
81
+ issue: z.number().int().positive().optional(),
82
+ labels: z.array(z.string().min(1).max(50)).max(10).optional(),
83
+ },
84
+ alp_schedule: {
85
+ action: z.enum(['create', 'list', 'get', 'cancel']),
86
+ dueAt: z.string().min(1).max(80).optional(),
87
+ prompt: z.string().min(1).max(8000).optional(),
88
+ requestId: z.string().min(1).max(200).optional(),
89
+ id: z.string().min(1).optional(),
90
+ status: z.enum(['pending', 'delivered', 'failed', 'cancelled']).optional(),
91
+ },
92
+ alp_task: {
93
+ action: z.enum(['create', 'update', 'link', 'start', 'close', 'reopen', 'gate', 'clear', 'pour', 'formulas', 'show', 'list', 'ready']),
94
+ id: z.string().min(1).optional(),
95
+ title: z.string().min(1).max(200).optional(),
96
+ description: z.string().max(8000).optional(),
97
+ type: z.enum(TASK_TYPES as [string, ...string[]]).optional(),
98
+ priority: z.number().int().min(0).max(4).optional(),
99
+ labels: z.array(z.string().min(1).max(50)).max(10).optional(),
100
+ paths: z.array(z.string().min(1)).max(50).optional(),
101
+ parent: z.string().min(1).optional(),
102
+ blockedBy: taskIds,
103
+ discoveredFrom: z.string().min(1).optional(),
104
+ add: taskLinks,
105
+ remove: taskLinks,
106
+ reason: z.enum(CLOSE_REASONS as [string, ...string[]]).optional(),
107
+ kind: z.enum(GATE_KINDS as [string, ...string[]]).optional(),
108
+ until: z.string().min(1).optional(),
109
+ ref: z.string().min(1).optional(),
110
+ gate: z.string().min(1).optional(),
111
+ formula: z.string().min(1).optional(),
112
+ vars: z.record(z.string(), z.string()).optional(),
113
+ summary: z.string().min(1).max(2000).optional(),
114
+ unverified: z.string().min(1).max(2000).optional(),
115
+ note: z.string().min(1).max(2000).optional(),
116
+ status: z.enum(TASK_STATUSES as [string, ...string[]]).optional(),
117
+ label: z.string().min(1).optional(),
118
+ limit: z.number().int().positive().optional(),
119
+ },
120
+ alp_discard: {
121
+ assignmentId: z.string().min(1),
122
+ },
123
+ alp_cancel: {
124
+ assignmentId: z.string().min(1),
125
+ reason: z.string().optional(),
126
+ },
127
+ alp_pin: {
128
+ kind: z.enum(PIN_KINDS),
129
+ body: z.string().min(1),
130
+ paths: z.array(z.string().min(1)).optional(),
131
+ },
132
+ alp_board: {
133
+ kinds: z.array(z.enum(PIN_KINDS)).optional(),
134
+ limit: z.number().int().positive().optional(),
135
+ },
136
+ alp_unpin: {
137
+ pinId: z.string().min(1),
138
+ },
139
+ alp_wait: {
140
+ assignments: z.array(z.string().min(1)).max(32).optional(),
141
+ timeoutMs: z.number().int().positive().optional(),
142
+ },
143
+ alp_send: {
144
+ to: z.string().min(1),
145
+ kind: z.enum(['answer', 'note', 'steer']),
146
+ body: z.string().min(1).max(8000),
147
+ replyTo: z.string().min(1).optional(),
148
+ },
149
+ alp_ask: {
150
+ question: z.string().min(1).max(8000),
151
+ to: z.enum(['parent', 'user']).optional(),
152
+ options: z.array(z.string().min(1).max(200)).max(10).optional(),
153
+ },
154
+ alp_handoff: {
155
+ outcome: z.enum(['complete', 'partial', 'blocked', 'reconsider']),
156
+ summary: z.string().min(1),
157
+ candidate: handoffList,
158
+ scope: handoffList,
159
+ verification: handoffList,
160
+ risks: handoffList,
161
+ discovered: handoffList,
162
+ ownership: z.string().optional(),
163
+ // ALP checks that the result follows from the criteria and findings (ALPD §38).
164
+ verdict: z.object({
165
+ result: z.enum(['pass', 'pass_with_findings', 'fail', 'blocked']),
166
+ criteria: z.array(z.object({ criterion: z.string().min(1), result: z.enum(['pass', 'fail', 'not_checked']), evidence: z.string().min(1) }).strict()).min(1).max(50),
167
+ findings: z.array(z.object({ severity: z.enum(['critical', 'high', 'medium', 'low']), where: z.string().min(1), problem: z.string().min(1), fix: z.string().min(1).optional() }).strict()).max(100).optional(),
168
+ }).strict().optional(),
169
+ },
170
+ };
171
+
172
+ type Listener = (method: string, params: any) => void;
173
+ type RequestHandler = (method: string, params: any) => Promise<unknown>;
174
+
175
+ type NativeConfig = {
176
+ cwd: string;
177
+ model: string;
178
+ sandbox: string;
179
+ developerInstructions: string;
180
+ mcpServers: Record<string, any>;
181
+ thinking: string;
182
+ dynamicTools: Array<{
183
+ name: string;
184
+ description: string;
185
+ }>;
186
+ ephemeral?: boolean;
187
+ threadId?: string;
188
+ /** The session's permission profile: rules Claude enforces itself, in every permission mode. */
189
+ permissions?: PermissionRules | null;
190
+ /** The OS sandbox for Bash, when ALP chose one. */
191
+ floor?: 'read-only' | 'workspace-write';
192
+ /** The context window the session works in before Claude compacts it, in tokens; absent: the model's (ALPD §57). */
193
+ context?: number;
194
+ /** The agent's skills, each a SKILL.md, loaded as Claude Code skills alp:<name> (ALPD §59). */
195
+ skills?: Array<{ name: string; path: string }>;
196
+ };
197
+
198
+ /** The plugin name ALP's skills are loaded under, so Claude Code calls them alp:<name>. */
199
+ export const SKILL_PLUGIN = 'alp';
200
+
201
+ /**
202
+ * A Claude Code plugin in `directory` holding the given skills, linked to their own
203
+ * directories so an edit to a skill reaches the next session (ALPD §59).
204
+ */
205
+ export function skillPlugin(directory: string, skills: Array<{ name: string; path: string }>) {
206
+ rmSync(directory, { recursive: true, force: true });
207
+ mkdirSync(path.join(directory, '.claude-plugin'), { recursive: true });
208
+ mkdirSync(path.join(directory, 'skills'));
209
+ writeFileSync(path.join(directory, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: SKILL_PLUGIN, description: "The agent's ALP skills" }));
210
+ for (const skill of skills) symlinkSync(path.dirname(skill.path), path.join(directory, 'skills', skill.name), 'dir');
211
+ return directory;
212
+ }
213
+
214
+ type PermissionRules = {
215
+ allow: string[]; ask?: string[]; deny: string[]; beyondMode?: 'refuse' | 'ask';
216
+ /** The OS sandbox ALP puts Bash in, and the directory it may write besides temp files. */
217
+ floor?: 'read-only' | 'workspace-write'; floorRoot?: string;
218
+ };
219
+
220
+ /** Whether Claude Code can sandbox Bash here: Seatbelt on macOS, bubblewrap and socat on Linux. ALP_CLAUDE_SANDBOX=0 turns it off, =1 claims it (tests). */
221
+ export function claudeSandboxAvailable() {
222
+ if (process.env.ALP_CLAUDE_SANDBOX === '0') return false;
223
+ if (process.env.ALP_CLAUDE_SANDBOX === '1') return true;
224
+ if (process.platform === 'darwin') return existsSync('/usr/bin/sandbox-exec');
225
+ if (process.platform !== 'linux') return false;
226
+ const onPath = (name: string) => (process.env.PATH ?? '').split(path.delimiter).some(dir => dir && existsSync(path.join(dir, name)));
227
+ return onPath('bwrap') && onPath('socat');
228
+ }
229
+
230
+ const WRITERS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'];
231
+ /** What ALP asks the user for a permission Claude would otherwise not have. */
232
+ /** One turn-input part as Claude's: text, or a data-URL image as a base64 image block. */
233
+ export function claudeContent(part: any) {
234
+ const image = part?.type === 'image' && typeof part.url === 'string' ? /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(part.url) : null;
235
+ if (part?.type === 'image' && !image) throw new Error('Claude takes images as inline base64 PNG, JPEG, GIF or WebP only');
236
+ return image ? { type: 'image', source: { type: 'base64', media_type: image[1], data: image[2] } } : { type: 'text', text: part.text };
237
+ }
238
+
239
+ export type PermissionRequest = { tool: string; input: Record<string, unknown>; reason: 'rule' | 'mode'; rule?: string };
240
+ export type PermissionAnswer = { allow: boolean; always?: boolean; message?: string };
241
+
242
+ /** The rule an "always allow" adds: Claude's own suggestion, else the exact command or path. */
243
+ function suggestedRule(tool: string, input: Record<string, unknown>, suggestions: any) {
244
+ const suggestion = (Array.isArray(suggestions) ? suggestions : []).find((entry: any) => entry?.type === 'addRules' && entry.behavior === 'allow' && entry.rules?.length);
245
+ const rule = suggestion?.rules[0];
246
+ if (rule?.toolName) return rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : rule.toolName;
247
+ if (tool === 'Bash' && typeof input.command === 'string' && !input.command.includes(')')) return `Bash(${input.command})`;
248
+ if (typeof input.file_path === 'string' && !input.file_path.includes(')')) return `${tool}(${input.file_path})`;
249
+ return undefined;
250
+ }
251
+
252
+ class InputQueue implements AsyncIterable<SDKUserMessage> {
253
+ private values: SDKUserMessage[] = [];
254
+ private waiters: Array<(value: IteratorResult<SDKUserMessage>) => void> = [];
255
+ private ended = false;
256
+
257
+ push(value: SDKUserMessage) {
258
+ if (this.ended) throw new Error('Claude input is closed');
259
+ const waiter = this.waiters.shift();
260
+ if (waiter) waiter({ value, done: false });
261
+ else this.values.push(value);
262
+ }
263
+
264
+ close() {
265
+ this.ended = true;
266
+ for (const waiter of this.waiters.splice(0)) {
267
+ waiter({ value: undefined, done: true });
268
+ }
269
+ }
270
+
271
+ [Symbol.asyncIterator](): AsyncIterator<SDKUserMessage> {
272
+ return {
273
+ next: () => {
274
+ const value = this.values.shift();
275
+ if (value) return Promise.resolve({ value, done: false });
276
+ if (this.ended) {
277
+ return Promise.resolve({ value: undefined, done: true });
278
+ }
279
+ return new Promise((resolve) => this.waiters.push(resolve));
280
+ },
281
+ };
282
+ }
283
+ }
284
+
285
+ /** Claude's permission mode for an ALP mode; full-access skips every permission check. */
286
+ export const claudePermissionMode = (sandbox: string) => sandbox === 'read-only' ? 'default' : sandbox === 'full-access' ? 'bypassPermissions' : 'acceptEdits';
287
+
288
+ export function claudePermissions(sandbox: string, currentSandbox?: () => string, rules?: PermissionRules | null, ask?: (request: PermissionRequest) => Promise<PermissionAnswer>) {
289
+ const readOnly = sandbox === 'read-only';
290
+ // A skill only adds instructions; what they lead to is checked as itself (ALPD §59).
291
+ const readers = ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Skill'];
292
+ return {
293
+ // Plan mode permits writes to plan files and requires ExitPlanMode approval.
294
+ // Fixed read-only callers restrict the tool surface; live sessions keep
295
+ // tools available and consult the current sandbox at the permission gate.
296
+ permissionMode: claudePermissionMode(sandbox),
297
+ // Lets a live session switch to full-access later; it bypasses nothing by itself.
298
+ allowDangerouslySkipPermissions: true,
299
+ ...(readOnly && !currentSandbox ? { tools: [...readers, ...(rules?.floor === 'read-only' ? ['Bash'] : [])] } : {}),
300
+ // Allow rules run without asking, even in a read-only session; deny rules hold even with full access.
301
+ ...(rules?.allow.length ? { allowedTools: rules.allow } : {}),
302
+ disallowedTools: ['Agent', 'Task', 'TeamCreate', 'EnterPlanMode', 'ExitPlanMode', ...(rules?.deny ?? [])],
303
+ // Ask rules make Claude consult canUseTool in every mode, which asks the user.
304
+ ...(rules?.ask?.length ? { settings: { permissions: { ask: rules.ask } } } : {}),
305
+ // The floor: Bash runs in the OS sandbox, which writes only the workspace (or nothing, read-only) and temp files, offline.
306
+ // A command leaves it only with dangerouslyDisableSandbox, which Claude allows itself for allow rules and asks canUseTool otherwise.
307
+ ...(rules?.floor ? { sandbox: { enabled: true, autoAllowBashIfSandboxed: false, allowUnsandboxedCommands: true, ...(rules.floor === 'read-only' && rules.floorRoot ? { filesystem: { denyWrite: [rules.floorRoot] } } : {}) } } : {}),
308
+ canUseTool: async (name: string, input: Record<string, unknown>, context?: { decisionReasonType?: string; suggestions?: unknown }) => {
309
+ const mode = currentSandbox ? currentSandbox() : sandbox;
310
+ const leaving = Boolean(rules?.floor) && name === 'Bash' && input.dangerouslyDisableSandbox === true;
311
+ // Inside a read-only floor the OS keeps Bash from writing, so it may run.
312
+ const sandboxed = rules?.floor === 'read-only' && name === 'Bash' && !leaving;
313
+ const beyond = (mode === 'read-only' && !readers.includes(name) && !name.startsWith('mcp__alp__') && !sandboxed) ||
314
+ // With a floor, file tools write only the workspace and temp files, like Bash.
315
+ (Boolean(rules?.floor && rules.floorRoot) && WRITERS.includes(name) && !writablePath(name === 'NotebookEdit' ? input.notebook_path : input.file_path, rules!.floorRoot!)) ||
316
+ (leaving && mode !== 'full-access');
317
+ if (leaving && typeof input.command === 'string' && rules) {
318
+ const decision = commandDecision(rules, input.command);
319
+ if (decision === 'deny') return { behavior: 'deny', message: 'A deny rule of your permission profile covers it' };
320
+ if (decision === 'allow') return { behavior: 'allow', updatedInput: input };
321
+ if (decision === 'ask' && ask) context = { ...context, decisionReasonType: 'rule' };
322
+ }
323
+ const reason = context?.decisionReasonType === 'rule' && (rules?.ask?.length || leaving) ? 'rule' : beyond && rules?.beyondMode === 'ask' ? 'mode' : undefined;
324
+ if (reason && ask) {
325
+ const rule = reason === 'mode' ? suggestedRule(name, input, context?.suggestions) : undefined;
326
+ const answer = await ask({ tool: name, input, reason, ...(rule ? { rule } : {}) });
327
+ if (!answer.allow) return { behavior: 'deny', message: answer.message ?? 'The user did not allow it' };
328
+ const [, toolName, ruleContent] = /^([^(]+)(?:\((.*)\))?$/s.exec(rule ?? '') ?? [];
329
+ // The read-only floor would refuse the very write the user just allowed: run that command outside it.
330
+ const approved = sandboxed && reason === 'rule' ? { ...input, dangerouslyDisableSandbox: true } : input;
331
+ return { behavior: 'allow', updatedInput: approved, ...(answer.always && toolName ? { updatedPermissions: [{ type: 'addRules', rules: [{ toolName, ...(ruleContent ? { ruleContent } : {}) }], behavior: 'allow', destination: 'session' }] } : {}) };
332
+ }
333
+ if (leaving && beyond) return { behavior: 'deny', message: 'Bash runs inside the sandbox; leaving it needs an allow rule in your permission profile' };
334
+ if (beyond) return { behavior: 'deny', message: mode === 'read-only' ? 'ALP session is read-only' : 'Outside your workspace' };
335
+ return { behavior: 'allow', updatedInput: input };
336
+ },
337
+ };
338
+ }
339
+
340
+ /** Whether Claude's last rate limit report means requests are refused: rejected, with no overage to fall back on. */
341
+ export const claudeLimited = (info?: { status: string; overageStatus?: string }) =>
342
+ info?.status === 'rejected' && info.overageStatus !== 'allowed' && info.overageStatus !== 'allowed_warning';
343
+
344
+ /** How long a resume waits for Claude to start before it goes on (ClaudeTransport.booted). */
345
+ const BOOT_MS = 15_000;
346
+
347
+ /**
348
+ * A Claude session's main chain, read from its transcript by the Agent SDK: user and assistant entries
349
+ * in order, with their transcript uuids. Found under `dir`'s project, else in any project (a session
350
+ * resumed or forked from another directory). Empty when the session has no transcript yet.
351
+ */
352
+ export async function claudeChain(sessionId: string, dir?: string): Promise<Array<{ uuid: string; type: 'user' | 'assistant'; toolResult?: boolean }>> {
353
+ const { getSessionMessages } = await import(CLAUDE_SDK);
354
+ let messages: any[] = dir ? await getSessionMessages(sessionId, { dir }) : [];
355
+ if (!messages.length) messages = await getSessionMessages(sessionId);
356
+ return messages.filter(message => (message.type === 'user' || message.type === 'assistant') && typeof message.uuid === 'string').map(message => {
357
+ const content = message.message?.content;
358
+ const toolResult = message.type === 'user' && Array.isArray(content) && content.length > 0 && content.every((block: any) => block?.type === 'tool_result');
359
+ return { uuid: message.uuid, type: message.type, ...(toolResult ? { toolResult: true } : {}) };
360
+ });
361
+ }
362
+
363
+ /** Maps Claude Agent SDK streaming sessions to the normalized runtime protocol. */
364
+ export class ClaudeTransport {
365
+ private listeners = new Set<Listener>();
366
+ private failures = new Set<(error: Error) => void>();
367
+ private requestHandler?: RequestHandler;
368
+ /** Whether the workspace had a .claude directory before a sandboxed session started. */
369
+ private claudeDirectory?: boolean;
370
+ private input = new InputQueue();
371
+ private query?: ClaudeQuery;
372
+ private pump?: Promise<void>;
373
+ private config?: NativeConfig;
374
+ private threadId = '';
375
+ private activeTurn?: string;
376
+ private closed = false;
377
+ private assistantText = new Map<string, { blocks: Map<number, { text: string; final: boolean }>; seen: Set<string> }>();
378
+ private streamingMessage?: string;
379
+ private toolCalls = new Map<string, { name: string; command: string; input: unknown }>();
380
+ /** The last rate limit Claude reported, and whether the running turn hit one. */
381
+ private rateLimit?: { status: string; resetsAt?: number; overageStatus?: string; rateLimitType?: string; utilization?: number };
382
+ private limitedTurn = false;
383
+ /** The model's context window and the fill at which Claude compacts it, as Claude reports them (ALPD §57). */
384
+ private contextWindow?: number;
385
+ private compactAt?: number;
386
+ /** The compaction running now, from its start to its boundary. */
387
+ private compaction?: string;
388
+ /** The plugin directory that loads the agent's skills, removed on close. */
389
+ private skillDirectory?: string;
390
+ /** Whether a turn ran in this process; before one, an error result is a boot refusal. */
391
+ private turnsStarted = false;
392
+ /** Rejects when the process fails, for a resume that waits for Claude to start. */
393
+ private bootFailure?: Promise<never>;
394
+ private rejectBoot?: (error: Error) => void;
395
+ private bootError?: Error;
396
+
397
+ constructor(
398
+ private readonly command: string,
399
+ private readonly cwd: string,
400
+ private readonly env: NodeJS.ProcessEnv,
401
+ ) {
402
+ if (!path.isAbsolute(command)) {
403
+ const names = process.platform === 'win32' && !path.extname(command) ? [command, `${command}.exe`] : [command];
404
+ const candidates = (env.PATH ?? '').split(path.delimiter).filter(Boolean).flatMap(directory => names.map(name => path.resolve(directory, name)));
405
+ const executable = candidates.find(candidate => {
406
+ try { accessSync(candidate, constants.X_OK); return statSync(candidate).isFile(); } catch { return false; }
407
+ });
408
+ if (!executable) throw new Error('Claude executable not found on PATH; set ALP_CLAUDE_BIN to an absolute native executable');
409
+ this.command = realpathSync(executable);
410
+ }
411
+ if (/\.(cmd|bat|ps1)$/i.test(this.command)) {
412
+ throw new Error('Claude executable must be a native binary, not a shell launcher');
413
+ }
414
+ }
415
+
416
+ async orchestrationContext() {
417
+ const [catalog, usage] = await Promise.all([
418
+ optionalRead(async () => this.query ? this.query.supportedModels() : undefined),
419
+ // Without a session yet, a short-lived Claude process answers (ALPD §58); it takes a second or two.
420
+ this.query
421
+ ? optionalRead(async () => this.query!.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }))
422
+ : optionalRead(() => this.probeUsage(), 15_000),
423
+ ]);
424
+ return { runtime: 'claude', observedAt: new Date().toISOString(), catalogAvailable: Array.isArray(catalog),
425
+ models: (catalog ?? []).map(model => ({ id: `claude:${model.value}`, label: model.displayName,
426
+ description: model.description, thinking: model.supportedEffortLevels })), usage: claudeUsage(usage) };
427
+ }
428
+ async initialize() {}
429
+
430
+ /** The plan's usage windows before a session starts, from a Claude process that sends no prompt and is closed after. */
431
+ private async probeUsage() {
432
+ if (this.closed) return undefined;
433
+ const { query } = await import(CLAUDE_SDK);
434
+ const probe = query({ prompt: new InputQueue(), options: { cwd: this.cwd, env: this.env, settingSources: [], persistSession: false, strictMcpConfig: true, mcpServers: {}, pathToClaudeCodeExecutable: this.command } }) as ClaudeQuery;
435
+ try {
436
+ return await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
437
+ } finally {
438
+ probe.close();
439
+ }
440
+ }
441
+
442
+ onNotification(listener: Listener) {
443
+ this.listeners.add(listener);
444
+ return () => this.listeners.delete(listener);
445
+ }
446
+
447
+ onFailure(listener: (error: Error) => void) {
448
+ this.failures.add(listener);
449
+ return () => this.failures.delete(listener);
450
+ }
451
+
452
+ onRequest(handler: RequestHandler) {
453
+ this.requestHandler = handler;
454
+ }
455
+
456
+ async request(method: string, params: any): Promise<any> {
457
+ if (this.closed) throw new Error('Runtime is closed');
458
+
459
+ if (method === 'session/configure') {
460
+ if (!this.query || !this.config) throw new Error('Claude thread is not initialized');
461
+ await this.query.setPermissionMode(claudePermissionMode(params.sandbox));
462
+ this.config.sandbox = params.sandbox;
463
+ return {};
464
+ }
465
+
466
+ if (method === 'thread/chain') return { entries: await claudeChain(params.threadId, params.cwd ?? this.cwd) };
467
+
468
+ if (method === 'thread/delete') {
469
+ const { deleteSession } = await import(CLAUDE_SDK);
470
+ await deleteSession(params.threadId, params.cwd ? { dir: params.cwd } : {});
471
+ return {};
472
+ }
473
+
474
+ if (method === 'thread/start' || method === 'thread/resume' || method === 'thread/fork') {
475
+ this.clearAssistantText();
476
+ const { base, requireHistory, ...config } = params as NativeConfig & { base?: { threadId: string; at?: string }; requireHistory?: boolean };
477
+ this.config = config as NativeConfig;
478
+ this.threadId =
479
+ method === 'thread/resume' ? params.threadId : randomUUID();
480
+ // A fork resumes the thread's history under a new id, leaving the thread itself unchanged.
481
+ const from = method === 'thread/start' ? undefined
482
+ : method === 'thread/resume' ? await this.resumeFrom(params.threadId, base, requireHistory)
483
+ : { resume: params.threadId, fork: true };
484
+ await this.start(from);
485
+ // A resume Claude refuses at boot (a missing session, a cut it cannot make) fails this request, not a later turn.
486
+ if (method === 'thread/resume' && (base || requireHistory)) await this.booted();
487
+ return {
488
+ thread: { id: this.threadId, turns: [] },
489
+ cwd: this.cwd,
490
+ model: params.model,
491
+ reasoningEffort: params.thinking,
492
+ };
493
+ }
494
+
495
+ if (method === 'turn/start' || method === 'turn/steer') {
496
+ if (!this.query) throw new Error('Claude thread is not initialized');
497
+ const turnId = method === 'turn/steer'
498
+ ? params.expectedTurnId
499
+ : randomUUID();
500
+ if (method === 'turn/start') {
501
+ this.clearAssistantText();
502
+ this.activeTurn = turnId;
503
+ this.turnsStarted = true;
504
+ }
505
+ this.input.push({
506
+ type: 'user',
507
+ message: {
508
+ role: 'user',
509
+ content: params.input.map(claudeContent),
510
+ },
511
+ parent_tool_use_id: null,
512
+ // A turn's prompt entry in the transcript carries the turn's id, so ALP can cut the conversation at it (§12.7).
513
+ uuid: method === 'turn/start' ? turnId : randomUUID(),
514
+ session_id: this.threadId,
515
+ });
516
+ return method === 'turn/start' ? { turn: { id: turnId } } : {};
517
+ }
518
+
519
+ if (method === 'turn/interrupt') {
520
+ this.streamingMessage = undefined;
521
+ await this.query?.interrupt();
522
+ return {};
523
+ }
524
+
525
+ throw new Error(`Unsupported Claude runtime operation '${method}'`);
526
+ }
527
+
528
+ /**
529
+ * How a resume starts. With a base, a session whose transcript does not exist yet is made from the
530
+ * base's conversation cut at `at` (a fork or rewind, §12.7): Claude writes it, under this session's
531
+ * id, only when its first prompt runs. Otherwise, the session's own transcript.
532
+ */
533
+ private async resumeFrom(threadId: string, base?: { threadId: string; at?: string }, requireHistory?: boolean) {
534
+ const own = base || requireHistory ? await claudeChain(threadId, this.config!.cwd) : [];
535
+ if (!base || own.length) {
536
+ if (requireHistory && !own.length) throw new Error(`Claude session ${threadId} not found`);
537
+ return { resume: threadId, fork: false };
538
+ }
539
+ const source = await claudeChain(base.threadId, this.config!.cwd);
540
+ if (!source.length) throw new Error(`Claude session ${base.threadId} not found`);
541
+ if (base.at && !source.some(entry => entry.uuid === base.at)) throw new Error(`Claude session ${base.threadId} has no entry ${base.at}`);
542
+ return { resume: base.threadId, fork: true, at: base.at };
543
+ }
544
+
545
+ /**
546
+ * Waits until Claude has started: its answer to the SDK's initialize request, or the failure that
547
+ * ends it first. Past BOOT_MS ALP goes on, as any resume does, and a later refusal fails the session.
548
+ */
549
+ private async booted() {
550
+ let timer: NodeJS.Timeout | undefined;
551
+ try {
552
+ await Promise.race([
553
+ this.query!.initializationResult?.().then(() => undefined, () => undefined),
554
+ this.bootFailure,
555
+ new Promise<void>(resolve => { timer = setTimeout(resolve, BOOT_MS); }),
556
+ ]);
557
+ } finally { clearTimeout(timer); }
558
+ // A refusal can arrive with the answer itself; let the stream deliver it.
559
+ await new Promise(resolve => setImmediate(resolve));
560
+ if (this.bootError) throw this.bootError;
561
+ }
562
+
563
+ private async start(from?: { resume: string; fork: boolean; at?: string }) {
564
+ const { createSdkMcpServer, query, tool } = await import(CLAUDE_SDK);
565
+ const config = this.config!;
566
+ const mcpServers = this.convertMcp(config.mcpServers);
567
+
568
+ if (config.dynamicTools.length) {
569
+ const tools = config.dynamicTools.map(definition => {
570
+ const base = toolShapes[definition.name];
571
+ if (!base) throw new Error(`Unsupported ALP tool '${definition.name}'`);
572
+ // A role's definition may offer fewer actions (alp_task) and fewer fields than the full shape.
573
+ const schema = (definition as { inputSchema?: { properties?: Record<string, { enum?: string[] }> } }).inputSchema?.properties;
574
+ const actions = schema?.action?.enum;
575
+ const shape = Object.fromEntries(Object.entries(base)
576
+ .filter(([key]) => !schema || key in schema)
577
+ .map(([key, value]) => [key, key === 'action' && actions?.length ? z.enum(actions as [string, ...string[]]) : value]));
578
+ return tool(definition.name, definition.description, shape, async (args: Record<string, unknown>) => {
579
+ if (!this.requestHandler || !this.activeTurn) {
580
+ return {
581
+ content: [{ type: 'text', text: 'ALP tools are unavailable' }],
582
+ isError: true,
583
+ };
584
+ }
585
+ const callId = `claude-tool-${randomUUID()}`;
586
+ this.emit('item/started', {
587
+ threadId: this.threadId,
588
+ item: {
589
+ type: 'dynamicToolCall',
590
+ id: callId,
591
+ callId,
592
+ tool: definition.name,
593
+ arguments: args,
594
+ status: 'inProgress',
595
+ },
596
+ });
597
+ const result: any = await this.requestHandler('item/tool/call', {
598
+ threadId: this.threadId,
599
+ turnId: this.activeTurn,
600
+ callId,
601
+ namespace: null,
602
+ tool: definition.name,
603
+ arguments: args,
604
+ });
605
+ this.emit('item/completed', {
606
+ threadId: this.threadId,
607
+ item: {
608
+ type: 'dynamicToolCall',
609
+ id: callId,
610
+ callId,
611
+ tool: definition.name,
612
+ arguments: args,
613
+ status: result?.success === false ? 'failed' : 'completed',
614
+ success: result?.success !== false,
615
+ contentItems: result?.contentItems ?? [],
616
+ },
617
+ });
618
+ return {
619
+ content: (result?.contentItems ?? []).map((item: any) => ({
620
+ type: 'text' as const,
621
+ text: item.text ?? JSON.stringify(item),
622
+ })),
623
+ isError: result?.success === false,
624
+ };
625
+ });
626
+ });
627
+ mcpServers.alp = createSdkMcpServer({
628
+ name: 'alp',
629
+ version: '0.0.0',
630
+ tools,
631
+ });
632
+ }
633
+
634
+ if (config.floor) this.claudeDirectory ??= existsSync(path.join(config.cwd, '.claude'));
635
+ const rules = config.permissions || config.floor ? { allow: [], deny: [], ...config.permissions, ...(config.floor ? { floor: config.floor, floorRoot: config.cwd } : {}) } : null;
636
+ const { settings: ruleSettings, ...permissions } = claudePermissions(config.sandbox, () => config.sandbox, rules,
637
+ async request => (this.requestHandler ? await this.requestHandler('item/permission/request', request) as PermissionAnswer : { allow: false, message: 'No one can approve it' }));
638
+ const settings = { ...(config.thinking === 'ultracode' ? { ultracode: true } : {}), ...(config.context ? { autoCompactWindow: config.context } : {}), ...ruleSettings };
639
+ // The agent's skills as Claude Code's own, so Claude lists them with their descriptions and calls them with Skill.
640
+ const plugins: Array<{ type: 'local'; path: string; skipMcpDiscovery: boolean }> = [];
641
+ if (config.skills?.length) {
642
+ try {
643
+ this.skillDirectory ??= path.join(os.tmpdir(), 'alp-skills', randomUUID());
644
+ plugins.push({ type: 'local', path: skillPlugin(this.skillDirectory, config.skills), skipMcpDiscovery: true });
645
+ } catch {
646
+ // The skills stay listed in the instructions by file.
647
+ }
648
+ }
649
+ const options = {
650
+ cwd: config.cwd,
651
+ env: this.env,
652
+ model: config.model,
653
+ effort: ['none', 'off', 'ultracode'].includes(config.thinking) ? undefined : config.thinking,
654
+ thinking: ['none', 'off'].includes(config.thinking) ? { type: 'disabled' } : { type: 'adaptive' },
655
+ systemPrompt: config.developerInstructions,
656
+ ...permissions,
657
+ ...(Object.keys(settings).length ? { settings } : {}),
658
+ mcpServers,
659
+ strictMcpConfig: true,
660
+ settingSources: [],
661
+ ...(plugins.length ? { plugins } : {}),
662
+ persistSession: !config.ephemeral,
663
+ promptSuggestions: false,
664
+ includePartialMessages: true,
665
+ ...(from ? { resume: from.resume, ...(from.fork ? { forkSession: true, sessionId: this.threadId } : {}), ...(from.at ? { resumeSessionAt: from.at } : {}) } : { sessionId: this.threadId }),
666
+ // Explicit native path avoids SDK optional binaries inside Electron app.asar.
667
+ pathToClaudeCodeExecutable: this.command,
668
+ };
669
+
670
+ this.bootFailure = new Promise<never>((_, reject) => { this.rejectBoot = reject; });
671
+ this.bootFailure.catch(() => {});
672
+ this.query = query({ prompt: this.input, options }) as ClaudeQuery;
673
+ this.pump = this.consume(this.query).catch((error) => this.fail(error));
674
+ void this.measureContext();
675
+ }
676
+
677
+ /** Reads the window and the compaction threshold Claude resolved for this session, before any turn. */
678
+ private async measureContext() {
679
+ // The first read waits for Claude to start; a reply from usage lets ALP guess meanwhile.
680
+ const usage: any = await optionalRead(async () => this.query?.getContextUsage({ detail: 'summary' }), 60_000);
681
+ if (!usage) return;
682
+ const window = usage.rawMaxTokens ?? usage.maxTokens;
683
+ if (typeof window === 'number' && window > 0) this.contextWindow = window;
684
+ this.compactAt = usage.isAutoCompactEnabled !== false && typeof usage.autoCompactThreshold === 'number' && usage.autoCompactThreshold > 0 ? usage.autoCompactThreshold : undefined;
685
+ }
686
+
687
+ private convertMcp(values: Record<string, any>) {
688
+ const result: Record<string, any> = {};
689
+ for (const [name, value] of Object.entries(values)) {
690
+ result[name] = value.url
691
+ ? {
692
+ type: 'http',
693
+ url: value.url,
694
+ headers: value.http_headers ?? {},
695
+ }
696
+ : {
697
+ type: 'stdio',
698
+ command: value.command,
699
+ args: value.args ?? [],
700
+ env: value.env ?? {},
701
+ };
702
+ }
703
+ return result;
704
+ }
705
+
706
+ private async consume(stream: ClaudeQuery) {
707
+ for await (const message of stream) this.handle(message);
708
+ if (!this.closed) this.fail(new Error('Claude session exited'));
709
+ }
710
+
711
+ private handle(message: SDKMessage) {
712
+ if (this.closed) return;
713
+ if (message.type === 'rate_limit_event') {
714
+ this.rateLimit = message.rate_limit_info;
715
+ this.emit('account/rateLimits/updated', { claude: message.rate_limit_info });
716
+ return;
717
+ }
718
+ // Claude starts a turn by itself when a background task it ran ends (ALPD §56). Adopt it, so
719
+ // ALP's tools work in it and its end is reported like any other turn's.
720
+ const streamStart = message.type === 'stream_event' && message.event?.type === 'message_start';
721
+ if ((message.type === 'assistant' || streamStart) && !message.parent_tool_use_id && !this.activeTurn) {
722
+ this.activeTurn = randomUUID();
723
+ this.emit('turn/started', { threadId: this.threadId, turn: { id: this.activeTurn } });
724
+ }
725
+ if (message.type === 'stream_event') {
726
+ if (message.parent_tool_use_id) return;
727
+ const event = message.event;
728
+ if (event.type === 'message_start') {
729
+ this.streamingMessage = event.message.id;
730
+ } else if (event.type === 'message_stop') {
731
+ this.streamingMessage = undefined;
732
+ } else if (this.streamingMessage) {
733
+ const id = this.streamingMessage;
734
+ const state = this.assistantState(id);
735
+ let delta: string | undefined;
736
+ if (event.type === 'content_block_start' && event.content_block.type === 'text') {
737
+ state.blocks.set(event.index, { text: event.content_block.text, final: false });
738
+ delta = event.content_block.text;
739
+ } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
740
+ const block = state.blocks.get(event.index);
741
+ if (block && !block.final) {
742
+ block.text += event.delta.text;
743
+ delta = event.delta.text;
744
+ }
745
+ }
746
+ if (delta) this.emit('item/agentMessage/delta', { threadId: this.threadId, turnId: this.activeTurn, itemId: id, delta });
747
+ }
748
+ return;
749
+ }
750
+ if (message.type === 'assistant' && message.error === 'rate_limit') this.limitedTurn = true;
751
+ // The model Claude Code says it runs this query with (its init message), so ALP can show it took a configured one (§12.9).
752
+ if (message.type === 'system' && message.subtype === 'init' && typeof (message as any).model === 'string') this.emit('alp/nativeModel', { threadId: this.threadId, model: (message as any).model });
753
+ // A compaction, reported as Codex reports one: a contextCompaction item from start to end (ALPD §57).
754
+ if (message.type === 'system' && (message.subtype === 'status' || message.subtype === 'compact_boundary')) {
755
+ const system = message as any;
756
+ if (system.subtype === 'status' && system.status === 'compacting' && !this.compaction) {
757
+ this.compaction = randomUUID();
758
+ this.emit('item/started', { threadId: this.threadId, turnId: this.activeTurn, item: { type: 'contextCompaction', id: this.compaction } });
759
+ } else if (system.subtype === 'status' && system.compact_result === 'failed') {
760
+ this.emit('item/completed', { threadId: this.threadId, turnId: this.activeTurn, item: { type: 'contextCompaction', id: this.compaction ?? randomUUID(), status: 'failed', error: system.compact_error } });
761
+ this.compaction = undefined;
762
+ } else if (system.subtype === 'compact_boundary') {
763
+ const meta = system.compact_metadata ?? {};
764
+ this.emit('item/completed', {
765
+ threadId: this.threadId, turnId: this.activeTurn,
766
+ item: { type: 'contextCompaction', id: this.compaction ?? randomUUID(), status: 'completed', trigger: meta.trigger, preTokens: meta.pre_tokens, postTokens: meta.post_tokens },
767
+ });
768
+ this.compaction = undefined;
769
+ }
770
+ return;
771
+ }
772
+ // How full the context is, reported as Codex does: the tokens of the latest model call against the window.
773
+ // Until Claude has said what its window is, ALP does not guess one (ALPD §57).
774
+ const usage = message.type === 'assistant' && !message.parent_tool_use_id ? (message.message as any).usage : undefined;
775
+ if (usage) {
776
+ const totalTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.output_tokens ?? 0);
777
+ const last = { totalTokens, inputTokens: usage.input_tokens ?? 0, cachedInputTokens: usage.cache_read_input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0 };
778
+ this.emit('thread/tokenUsage/updated', {
779
+ threadId: this.threadId, turnId: this.activeTurn,
780
+ tokenUsage: { last, total: last, modelContextWindow: this.contextWindow ?? null, ...(this.compactAt ? { autoCompactTokens: this.compactAt } : {}) },
781
+ });
782
+ }
783
+ if (message.type === 'result') {
784
+ // The session's cost so far, as Claude counts it; a viewer shows it with the context meter.
785
+ const cost = (message as any).total_cost_usd;
786
+ if (typeof cost === 'number' && Number.isFinite(cost) && cost >= 0) this.emit('thread/tokenUsage/updated', { threadId: this.threadId, turnId: this.activeTurn, tokenUsage: { totalCostUsd: cost } });
787
+ const windows = Object.values((message as any).modelUsage ?? {}).map((entry: any) => entry?.contextWindow).filter((value): value is number => typeof value === 'number' && value > 0);
788
+ // Only when it was not measured: the measured window may be the setting's, smaller than the model's.
789
+ if (windows.length && !this.compactAt) this.contextWindow = Math.max(...windows);
790
+ }
791
+ if (message.type === 'assistant' && !message.parent_tool_use_id) {
792
+ const id = message.message.id;
793
+ const state = this.assistantState(id);
794
+ if (message.uuid && state.seen.has(message.uuid)) return;
795
+ if (message.uuid) state.seen.add(message.uuid);
796
+ for (const block of message.message.content as any[]) {
797
+ if (block.type === 'text') {
798
+ // SDK 0.3.292 delivers one authoritative assistant event per completed
799
+ // block, sharing message.id. Replace that block's draft, never append
800
+ // the final text to its streamed draft or replace earlier final blocks.
801
+ const pending = [...state.blocks.entries()].sort(([a], [b]) => a - b).find(([, value]) => !value.final);
802
+ const index = pending?.[0] ?? Math.max(-1, ...state.blocks.keys()) + 1;
803
+ state.blocks.set(index, { text: block.text, final: true });
804
+ this.emit('item/completed', {
805
+ threadId: this.threadId,
806
+ item: { type: 'agentMessage', id, text: [...state.blocks.entries()].sort(([a], [b]) => a - b).map(([, block]) => block.text).join('') },
807
+ });
808
+ } else if (block.type === 'tool_use') {
809
+ const command = block.name === 'Bash'
810
+ ? String(block.input?.command ?? '')
811
+ : `${block.name} ${JSON.stringify(block.input ?? {})}`;
812
+ this.toolCalls.set(block.id, { name: block.name, command, input: block.input });
813
+ // The command stays what skill use, copy escapes and the journal read; the typed detail rides along.
814
+ const detail = claudeToolDetail(block.name, block.input);
815
+ this.emit('item/started', {
816
+ threadId: this.threadId,
817
+ item: {
818
+ type: 'commandExecution',
819
+ id: block.id,
820
+ command,
821
+ cwd: this.cwd,
822
+ status: 'inProgress',
823
+ aggregatedOutput: '',
824
+ ...(detail ? { tool: block.name, detail } : {}),
825
+ },
826
+ });
827
+ }
828
+ }
829
+ return;
830
+ }
831
+
832
+ if (message.type === 'user') {
833
+ const content = Array.isArray(message.message.content)
834
+ ? message.message.content as any[]
835
+ : [];
836
+ // The SDK's structured output belongs to the message's one tool result.
837
+ const results = content.filter(block => block.type === 'tool_result');
838
+ const structured = results.length === 1 ? (message as any).tool_use_result : undefined;
839
+ for (const block of results) {
840
+ const call = this.toolCalls.get(block.tool_use_id);
841
+ if (!call) continue;
842
+ const output = typeof block.content === 'string'
843
+ ? block.content
844
+ : JSON.stringify(block.content ?? '');
845
+ const text = resultText(block.content);
846
+ const detail = claudeToolDetail(call.name, call.input, { text, structured, error: Boolean(block.is_error) });
847
+ this.emit('item/completed', {
848
+ threadId: this.threadId,
849
+ item: {
850
+ type: 'commandExecution',
851
+ id: block.tool_use_id,
852
+ command: call.command,
853
+ cwd: this.cwd,
854
+ status: block.is_error ? 'failed' : 'completed',
855
+ exitCode: block.is_error ? 1 : 0,
856
+ aggregatedOutput: output,
857
+ ...(detail ? { tool: call.name, detail, ...(block.is_error ? { error: text } : {}) } : {}),
858
+ },
859
+ });
860
+ this.toolCalls.delete(block.tool_use_id);
861
+ }
862
+ return;
863
+ }
864
+
865
+ // Before any turn, an error result is Claude refusing to start the session (a resume it rejected).
866
+ // The SDK then ends the stream with this error, which fails the transport as before.
867
+ if (message.type === 'result' && message.is_error && !this.activeTurn && !this.turnsStarted) {
868
+ this.refuseBoot(new Error(message.errors?.length ? message.errors.join('; ') : 'Claude refused to start the session'));
869
+ return;
870
+ }
871
+ if (message.type === 'result') this.clearAssistantText();
872
+ if (message.type === 'result' && this.activeTurn) {
873
+ const turnId = this.activeTurn;
874
+ this.activeTurn = undefined;
875
+ // A usage limit ends the turn with an error message or a failed result; report it as Codex does.
876
+ const limited = this.limitedTurn || (message.is_error && claudeLimited(this.rateLimit));
877
+ this.limitedTurn = false;
878
+ const failed = message.is_error || limited;
879
+ this.emit('turn/completed', {
880
+ threadId: this.threadId,
881
+ turn: {
882
+ id: turnId,
883
+ status: failed ? 'failed' : 'completed',
884
+ ...(failed
885
+ ? { error: {
886
+ message: limited ? `Claude usage limit reached${this.rateLimit?.resetsAt ? `; resets ${new Date(this.rateLimit.resetsAt * 1000).toISOString()}` : ''}` : 'errors' in message && message.errors?.length ? message.errors.join('; ') : 'Claude turn failed',
887
+ ...(limited ? { codexErrorInfo: 'usageLimitExceeded', ...(this.rateLimit?.resetsAt ? { resetsAt: this.rateLimit.resetsAt } : {}) } : {}),
888
+ } }
889
+ : {}),
890
+ },
891
+ });
892
+ }
893
+ }
894
+
895
+ private refuseBoot(error: Error) {
896
+ this.bootError ??= error;
897
+ this.rejectBoot?.(error);
898
+ }
899
+
900
+ private emit(method: string, params: any) {
901
+ for (const listener of this.listeners) listener(method, params);
902
+ }
903
+
904
+ private assistantState(id: string) {
905
+ let state = this.assistantText.get(id);
906
+ if (!state) this.assistantText.set(id, state = { blocks: new Map(), seen: new Set() });
907
+ return state;
908
+ }
909
+
910
+ private clearAssistantText() {
911
+ this.streamingMessage = undefined;
912
+ this.assistantText.clear();
913
+ }
914
+
915
+ private fail(value: unknown) {
916
+ if (this.closed) return;
917
+ this.clearAssistantText();
918
+ const error = value instanceof Error ? value : new Error(String(value));
919
+ this.refuseBoot(error);
920
+ for (const listener of this.failures) listener(error);
921
+ }
922
+
923
+ async close() {
924
+ if (this.closed) return;
925
+ this.closed = true;
926
+ this.clearAssistantText();
927
+ this.input.close();
928
+ this.query?.close();
929
+ await this.pump?.catch(() => {});
930
+ this.listeners.clear();
931
+ this.failures.clear();
932
+ if (this.skillDirectory) rmSync(this.skillDirectory, { recursive: true, force: true });
933
+ // Claude's sandbox leaves an empty .claude/.cc-writes in the workspace; remove what it created.
934
+ if (this.claudeDirectory === false && this.config) {
935
+ for (const directory of ['.claude/.cc-writes', '.claude']) { try { rmdirSync(path.join(this.config.cwd, directory)); } catch {} }
936
+ }
937
+ }
938
+ }