@anhlp/alp-runtime 0.0.0-beta.1

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