@anhlp/alp-protocol 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 (47) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +16 -2
  4. package/conformance/cases.json +951 -0
  5. package/conformance/run.d.mts +32 -0
  6. package/conformance/run.mjs +403 -0
  7. package/dist/connection.d.ts +13 -0
  8. package/dist/connection.js +0 -0
  9. package/dist/contract-v1.d.ts +8783 -0
  10. package/dist/contract-v1.js +5 -0
  11. package/dist/contract.d.ts +25 -0
  12. package/dist/contract.js +28 -0
  13. package/dist/dto.d.ts +104 -0
  14. package/dist/dto.js +0 -0
  15. package/dist/events.d.ts +354 -0
  16. package/dist/events.js +0 -0
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.js +2 -0
  19. package/dist/public.d.ts +1 -0
  20. package/dist/public.js +2 -0
  21. package/dist/rpc.d.ts +1210 -0
  22. package/dist/rpc.js +0 -0
  23. package/dist/schedule.d.ts +43 -0
  24. package/dist/schedule.js +0 -0
  25. package/dist/session.d.ts +285 -0
  26. package/dist/session.js +0 -0
  27. package/dist/validate.d.ts +12 -0
  28. package/dist/validate.js +151 -0
  29. package/dist/wire.d.ts +192 -0
  30. package/dist/wire.js +137 -0
  31. package/fixtures/ipc-v1.jsonl +4 -0
  32. package/fixtures/v1/cases.json +7166 -0
  33. package/package.json +127 -4
  34. package/schema/ipc-v1.json +3271 -0
  35. package/schema/methods-v1.json +8678 -0
  36. package/src/connection.ts +13 -0
  37. package/src/contract-v1.js +2 -0
  38. package/src/contract.js +44 -0
  39. package/src/dto.ts +78 -0
  40. package/src/events.ts +207 -0
  41. package/src/index.ts +7 -0
  42. package/src/public.js +2 -0
  43. package/src/rpc.ts +272 -0
  44. package/src/schedule.ts +43 -0
  45. package/src/session.ts +213 -0
  46. package/src/validate.js +115 -0
  47. package/src/wire.js +213 -0
package/src/session.ts ADDED
@@ -0,0 +1,213 @@
1
+ import type { RuntimeKind } from './dto.js';
2
+ import type { SessionSnapshot, TurnAttention } from './events.js';
3
+ /** MCP servers a client adds to the agent's own; the agent's names win no collisions. */
4
+ export type HostMcpServer =
5
+ | { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
6
+ | { type: 'http' | 'sse'; url: string; headers?: Record<string, string> };
7
+
8
+ /** What a client asks for; resolveSession turns it into a launchable session. */
9
+ export type SessionSpec = {
10
+ cwd: string;
11
+ agent?: string;
12
+ /** Workflow selected for a new session; omitted uses settings or the restored snapshot. */
13
+ workflow?: string;
14
+ /** Native model, or runtime-prefixed (`codex:`/`claude:`, or `acp:<provider>[/<model>]`) to also choose the runtime. */
15
+ model?: string;
16
+ mode?: string;
17
+ thinking?: string;
18
+ systemPrompt?: string;
19
+ env?: Record<string, string>;
20
+ mcpServers?: Record<string, HostMcpServer>;
21
+ /** Keep the native thread so the session can be resumed. */
22
+ persist?: boolean;
23
+ /** Keep the native thread on disk though the session cannot be resumed: an assignment, which alp_recall may question later. */
24
+ keepThread?: boolean;
25
+ restore?: {
26
+ agent: string; threadId: string; runtime?: string; model?: string; workflow?: { mode: string; maxPeers: number; supervisor?: boolean };
27
+ /** Claude: `threadId` does not exist yet and is made from this conversation on its first prompt (a fork or rewind, §12.7). */
28
+ base?: NativeBase;
29
+ };
30
+ /** Where the native harness works, when not the project root: an assignment's git worktree. ALP files are still read from cwd. */
31
+ workdir?: string;
32
+ /** Explicit shared-assignment write scope; workdir points here while ALP uses cwd for its project. */
33
+ writeRoot?: string;
34
+ /** The workdir is a disposable copy the session may write, though its mode is read-only for the requester's tree. */
35
+ copy?: boolean;
36
+ /** The requester's directory the copy mirrors. */
37
+ copyOf?: string;
38
+ };
39
+
40
+ export type OpenOptions = {
41
+ history?: 'replay' | 'skip';
42
+ /** False when the client cannot show child sessions; delegation is then refused in this tree. */
43
+ delegation?: boolean;
44
+ };
45
+
46
+ /** An image inline in a prompt (capability `prompt.images`): base64 data, never a path or URL; limits in PROMPT_IMAGES. */
47
+ export type PromptImage = { type: 'image'; mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; data: string; name?: string };
48
+ /** A stored attachment in a prompt (capability `attachments`, §12.6): its opaque id only, never a path or URL. */
49
+ export type PromptAttachment = { type: 'attachment'; attachmentId: string };
50
+ /**
51
+ * What alpd hands a runtime for an attachment part once it validated and pinned it (§12.6, the
52
+ * runtime seam of issue #121 branch J): ids, display metadata and digest, never a host path. The
53
+ * runtime reads the bytes through the AttachmentReader alpd binds, and only for a session whose
54
+ * runtime declared it takes attachments.
55
+ */
56
+ export type RuntimeAttachment = { type: 'attachment'; attachmentId: string; name: string; mimeType: string; detectedMimeType: string; size: number; sha256: string };
57
+ /** Text parts carry `text`; image parts (`type: 'image'`) carry PromptImage's fields; attachment parts (`type: 'attachment'`) `attachmentId`. */
58
+ export type PromptContent = Array<{ type: string; text?: string; mimeType?: string; data?: string; name?: string; attachmentId?: string }>;
59
+
60
+ export type PromptInput = {
61
+ clientMessageId: string;
62
+ /** auto starts a turn; steer adds to the running one. */
63
+ delivery: 'auto' | 'steer';
64
+ content: PromptContent;
65
+ };
66
+
67
+ /** A root before it opens: the team and main it would run with, for viewers that let the user choose first (ALPD §60). */
68
+ export type SessionPreview = {
69
+ teams: Array<{ id: string; label: string; description?: string }>;
70
+ /** The team the spec selects, else settings' workflow.mode, else Phở. */
71
+ team: string;
72
+ teamLabel?: string;
73
+ agent: string;
74
+ runtime: RuntimeKind;
75
+ model: string;
76
+ mode: string;
77
+ thinking: string;
78
+ };
79
+
80
+ /** What became of one assignment an earlier alpd left running. */
81
+ export type RecoveryOutcome = { assignmentId: string; agent: string; outcome: 'resumed' | 'parked' | 'failed'; error?: string };
82
+
83
+ export type Pause = { since: string; by: string; reason: string; resetsAt?: string; /** When alpd last asked the runtime about the limit (ALPD §58). */ checkedAt?: string };
84
+ export type PauseState = {
85
+ all?: Pause;
86
+ runtimes: Partial<Record<RuntimeKind, Pause>>;
87
+ parked: Array<{ assignmentId: string; agent: string; runtime: RuntimeKind; rootId: string; reason: string; since: string }>;
88
+ };
89
+
90
+ export type RecallAnswer = { assignmentId: string; agent: string; taskId?: string; finishedAt: string; answer: string };
91
+
92
+ /** A session's lifecycle as alpd records it; neutral, so viewers and integrations share one union. */
93
+ export type SessionStatus = 'initializing' | 'idle' | 'running' | 'error' | 'closed';
94
+
95
+ /** The last failure recorded for a session (daemon store `SessionRecord.lastError`). */
96
+ export type SessionError = { code?: string; message: string };
97
+
98
+ /**
99
+ * A session as clients list it: its last snapshot plus lifecycle (ALPD §18). This is the portable
100
+ * shape served by `session.list`; the daemon and every viewer agree on it, so it is the same type
101
+ * the daemon's store and server use rather than a lossy summary.
102
+ */
103
+ export type SessionSummary = SessionSnapshot & {
104
+ status: SessionStatus;
105
+ title?: string;
106
+ archived?: boolean;
107
+ /** A root's turn that ended unseen, until a client acknowledges it (capability `session.attention`). */
108
+ attention?: TurnAttention;
109
+ lastError?: SessionError;
110
+ createdAt?: string;
111
+ updatedAt?: string;
112
+ /**
113
+ * Whether alpd can fork or rewind this session's conversation now, and why not (capabilities
114
+ * `session.fork` and `session.rewind`, §12.7). A single turn may still be refused: one in
115
+ * progress, the first one, or one alpd cannot map to the runtime's own history.
116
+ */
117
+ conversation?: {
118
+ fork: boolean; rewind: boolean;
119
+ /** Whether session.reload can restart this root's native process (capability `session.reload`, §12.8); the root must also be open and idle. */
120
+ reload?: boolean;
121
+ /** Whether session.configure can change this root's model, effort or team (capability `session.configure.live`, §12.9); the root must also be open and idle. */
122
+ configure?: boolean;
123
+ reason?: 'runtime_unsupported' | 'not_persistent' | 'not_root';
124
+ };
125
+ /** The session this root was forked from (§12.7). */
126
+ forkedFrom?: ForkProvenance;
127
+ /** Conversation branches rewinds set aside, oldest first; each stays recoverable with session.fork { branchId } (§12.7). */
128
+ branches?: ConversationBranch[];
129
+ };
130
+
131
+ /**
132
+ * A Claude conversation ALP has bound a session to but the runtime has not written yet (§12.7): the
133
+ * first prompt resumes `threadId` as a new session, keeping its history through the entry `at` (all of
134
+ * it when absent). Internal to alpd and its runtime; never accepted from a remote client.
135
+ */
136
+ export type NativeBase = { threadId: string; at?: string };
137
+
138
+ /** Where a fork came from (§12.7): the source root, and the turn its conversation was cut at. */
139
+ export type ForkProvenance = {
140
+ sessionId: string;
141
+ /** The cut: through this turn, or before it; absent when the whole conversation was taken. */
142
+ turnId?: string;
143
+ boundary: 'through' | 'before' | 'whole';
144
+ /** Set when the fork restored a branch a rewind set aside. */
145
+ branchId?: string;
146
+ at: string;
147
+ };
148
+
149
+ /** The later turns of a conversation a rewind set aside (§12.7): kept, never deleted, recoverable as a new session. */
150
+ export type ConversationBranch = {
151
+ branchId: string;
152
+ /** The turn the rewind went back to before; it and every later turn are in the branch. */
153
+ beforeTurnId: string;
154
+ /** How many turns the branch holds past the kept conversation. */
155
+ turns: number;
156
+ at: string;
157
+ };
158
+
159
+ /**
160
+ * The ALP CLI command that continues a session in a terminal (session.resumeCommand, §12.8): `argv`
161
+ * and the same command quoted for a POSIX shell and for Windows (cmd.exe). `display` is the one for
162
+ * the daemon host's own platform. Never a native runtime's command, credential, env or path.
163
+ */
164
+ export type ResumeCommand = { argv: string[]; posix: string; windows: string; display: string; platform: 'posix' | 'windows' };
165
+
166
+ /**
167
+ * The configuration a root runs with, or would after a change, as alpd resolved it (§12.9): its team,
168
+ * agent, runtime, model, effort and mode, and what they bring (delegation targets, ALP tools, MCP
169
+ * servers by name and kind, hook count, an instructions digest). Never a path, command or credential.
170
+ */
171
+ export type EffectiveConfig = {
172
+ team: string;
173
+ teamLabel?: string;
174
+ agent: string;
175
+ runtime: RuntimeKind;
176
+ model: string;
177
+ thinking: string;
178
+ mode: string;
179
+ /** The permission profile capping the agent's mode, when one does. */
180
+ permissionProfile?: string;
181
+ supervisor: boolean;
182
+ maxPeers?: number;
183
+ delegates: string[];
184
+ tools: string[];
185
+ mcpServers: Array<{ name: string; kind: 'stdio' | 'http' }>;
186
+ hooks: number;
187
+ instructionsSha?: string;
188
+ };
189
+
190
+ export type ConfigureChange = 'model' | 'thinking' | 'team' | 'mode';
191
+ export type ConfigureRefusal = 'session_busy' | 'session_closed' | 'not_root' | 'runtime_unsupported' | 'runtime_mismatch' | 'agent_mismatch' | 'permission_escalation' | 'team_not_found' | 'invalid_params' | 'not_persistent';
192
+
193
+ /** session.configure.preview (§12.9): the current and proposed configuration, and whether alpd would apply it now. */
194
+ export type ConfigurePreview = {
195
+ current: EffectiveConfig;
196
+ proposed: EffectiveConfig;
197
+ changes: ConfigureChange[];
198
+ /** Applying it restarts the root's native process on its own conversation. */
199
+ restart: boolean;
200
+ /** session.configure would take it now; a tree that gets busy meanwhile still refuses it. */
201
+ allowed: boolean;
202
+ reason?: ConfigureRefusal;
203
+ message?: string;
204
+ teams: Array<{ id: string; label: string; description?: string }>;
205
+ };
206
+
207
+ /** What a session.configure with model, thinking or team did (§12.9): what was asked, and what the root runs with now. */
208
+ export type ConfigureOutcome = {
209
+ status: 'applied' | 'unchanged';
210
+ requested: { model?: string; thinking?: string; team?: string; mode?: string };
211
+ effective: EffectiveConfig;
212
+ restarted: boolean;
213
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * A dependency-free checker for the JSON Schema subset the protocol schemas use (draft-07:
3
+ * type, const, enum, properties, required, additionalProperties, items, minItems, maxItems, minimum, maximum,
4
+ * minLength, maxLength, pattern, oneOf, anyOf, not and local `#/$defs/...` references).
5
+ * Lengths count Unicode code points, as JSON Schema does. A property whose value is
6
+ * `undefined` counts as absent, so in-process callers behave as their JSON would.
7
+ */
8
+
9
+ /** @typedef {{ path: string, message: string }} SchemaIssue */
10
+
11
+ const MAX_ISSUES = 8;
12
+
13
+ /** @param {unknown} value */
14
+ function typeOf(value) {
15
+ if (value === null) return 'null';
16
+ if (Array.isArray(value)) return 'array';
17
+ if (typeof value === 'number') return !Number.isFinite(value) ? 'non-finite number' : Number.isInteger(value) ? 'integer' : 'number';
18
+ return typeof value;
19
+ }
20
+
21
+ /** @param {string} want @param {string} have */
22
+ const typeMatches = (want, have) => want === have || (want === 'number' && have === 'integer');
23
+
24
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
25
+ function equal(a, b) {
26
+ if (a === b) return true;
27
+ if (typeOf(a) !== typeOf(b) || a === null || typeof a !== 'object') return false;
28
+ if (Array.isArray(a)) return a.length === /** @type {unknown[]} */ (b).length && a.every((item, index) => equal(item, /** @type {unknown[]} */ (b)[index]));
29
+ const left = Object.keys(a).filter(key => /** @type {any} */ (a)[key] !== undefined);
30
+ const right = Object.keys(/** @type {object} */ (b)).filter(key => /** @type {any} */ (b)[key] !== undefined);
31
+ return left.length === right.length && left.every(key => equal(/** @type {any} */ (a)[key], /** @type {any} */ (b)[key]));
32
+ }
33
+
34
+ /** @param {any} root @param {string} ref */
35
+ function resolve(root, ref) {
36
+ if (!ref.startsWith('#/')) throw new Error(`Unsupported schema reference ${ref}`);
37
+ let target = root;
38
+ for (const part of ref.slice(2).split('/')) target = target?.[part.replaceAll('~1', '/').replaceAll('~0', '~')];
39
+ if (target === undefined) throw new Error(`Unresolved schema reference ${ref}`);
40
+ return target;
41
+ }
42
+
43
+ /**
44
+ * @param {any} schema
45
+ * @param {unknown} value
46
+ * @param {string} at
47
+ * @param {any} root
48
+ * @param {SchemaIssue[]} issues
49
+ */
50
+ function check(schema, value, at, root, issues) {
51
+ if (issues.length >= MAX_ISSUES || schema === true || schema === undefined) return;
52
+ if (schema === false) { issues.push({ path: at, message: 'is not allowed' }); return; }
53
+ if (schema.$ref) { check(resolve(root, schema.$ref), value, at, root, issues); if (Object.keys(schema).length === 1) return; }
54
+ const kind = typeOf(value);
55
+ if (schema.type !== undefined) {
56
+ const wanted = Array.isArray(schema.type) ? schema.type : [schema.type];
57
+ if (!wanted.some(/** @param {string} want */ want => typeMatches(want, kind))) { issues.push({ path: at, message: `must be ${wanted.join(' or ')}` }); return; }
58
+ }
59
+ if ('const' in schema && !equal(schema.const, value)) { issues.push({ path: at, message: `must be ${JSON.stringify(schema.const)}` }); return; }
60
+ if (schema.enum && !schema.enum.some(/** @param {unknown} option */ option => equal(option, value))) { issues.push({ path: at, message: `must be one of ${schema.enum.map(/** @param {unknown} option */ option => JSON.stringify(option)).join(', ')}` }); return; }
61
+ if (kind === 'integer' || kind === 'number') {
62
+ const number = /** @type {number} */ (value);
63
+ if (schema.minimum !== undefined && number < schema.minimum) issues.push({ path: at, message: `must be at least ${schema.minimum}` });
64
+ if (schema.maximum !== undefined && number > schema.maximum) issues.push({ path: at, message: `must be at most ${schema.maximum}` });
65
+ }
66
+ if (kind === 'string') {
67
+ const text = /** @type {string} */ (value);
68
+ let length = 0;
69
+ if (schema.minLength !== undefined || schema.maxLength !== undefined) for (const _point of text) length++;
70
+ if (schema.minLength !== undefined && length < schema.minLength) issues.push({ path: at, message: `must have at least ${schema.minLength} characters` });
71
+ if (schema.maxLength !== undefined && length > schema.maxLength) issues.push({ path: at, message: `must have at most ${schema.maxLength} characters` });
72
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(text)) issues.push({ path: at, message: `must match ${schema.pattern}` });
73
+ }
74
+ if (kind === 'array' && schema.minItems !== undefined && /** @type {unknown[]} */ (value).length < schema.minItems) issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
75
+ if (kind === 'array' && schema.maxItems !== undefined && /** @type {unknown[]} */ (value).length > schema.maxItems) issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
76
+ if (kind === 'array' && schema.items !== undefined) {
77
+ const array = /** @type {unknown[]} */ (value);
78
+ for (let index = 0; index < array.length && issues.length < MAX_ISSUES; index++) check(schema.items, array[index] === undefined ? null : array[index], `${at}/${index}`, root, issues);
79
+ }
80
+ if (kind === 'object') {
81
+ const object = /** @type {Record<string, unknown>} */ (value);
82
+ const properties = schema.properties ?? {};
83
+ for (const name of schema.required ?? []) if (object[name] === undefined) issues.push({ path: `${at}/${name}`, message: 'is required' });
84
+ for (const [name, child] of Object.entries(object)) {
85
+ if (child === undefined) continue;
86
+ if (Object.hasOwn(properties, name)) check(properties[name], child, `${at}/${name}`, root, issues);
87
+ else if (schema.additionalProperties !== undefined) check(schema.additionalProperties, child, `${at}/${name}`, root, issues);
88
+ }
89
+ }
90
+ if (schema.not !== undefined) {
91
+ /** @type {SchemaIssue[]} */
92
+ const inner = [];
93
+ check(schema.not, value, at, root, inner);
94
+ if (!inner.length) issues.push({ path: at, message: 'matches a schema it must not match' });
95
+ }
96
+ for (const [keyword, exactlyOne] of /** @type {const} */ ([['anyOf', false], ['oneOf', true]])) {
97
+ if (!schema[keyword]) continue;
98
+ const passing = schema[keyword].filter(/** @param {any} option */ option => { /** @type {SchemaIssue[]} */ const inner = []; check(option, value, at, root, inner); return !inner.length; }).length;
99
+ if (exactlyOne ? passing !== 1 : passing === 0) issues.push({ path: at, message: exactlyOne && passing > 1 ? 'matches more than one allowed shape' : 'matches none of the allowed shapes' });
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Checks `value` against `schema`, resolving `#/$defs/...` against `root`.
105
+ * @param {any} schema
106
+ * @param {unknown} value
107
+ * @param {any} [root]
108
+ * @returns {SchemaIssue[]} empty when the value conforms
109
+ */
110
+ export function validateSchema(schema, value, root = schema) {
111
+ /** @type {SchemaIssue[]} */
112
+ const issues = [];
113
+ check(schema, value, '', root, issues);
114
+ return issues;
115
+ }
package/src/wire.js ADDED
@@ -0,0 +1,213 @@
1
+ /** Wire protocol of alpd (plans/reference/ALPD.md §3). Bump only for incompatible changes. */
2
+ export const PROTOCOL_VERSION = 1;
3
+ export const MAX_FRAME = 16 * 1024 * 1024;
4
+
5
+ export class AlpRpcError extends Error {
6
+ constructor(error) {
7
+ super(error?.message ?? 'ALP daemon request failed');
8
+ this.code = error?.code;
9
+ this.data = error?.data;
10
+ }
11
+ }
12
+
13
+ export const ERROR = Object.freeze({ failed: 1000, notFound: 1001, forbidden: 1003, conflict: 1004, unavailable: 1005, protocol: 1006 });
14
+ /** The JSON-RPC 2.0 codes alpd answers with (docs/protocol/alp-json-rpc-v1.md §5). */
15
+ export const RPC_ERROR = Object.freeze({ parseError: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602 });
16
+ /**
17
+ * Features a daemon lists in its daemon.hello result. A client uses a feature only when listed;
18
+ * a daemon that lists none (one older than this list) has none of them. Unknown names are ignored.
19
+ */
20
+ export const CAPABILITY = Object.freeze({ sessionInterruptTurn: 'session.interrupt.turn', attachAfter: 'attach.after', sessionHistory: 'session.history', workspaceRegistry: 'workspace.registry', workspaceWorktree: 'workspace.worktree', workspaceGit: 'workspace.git', promptReceipts: 'prompt.receipts', extensionMethods: 'extension.methods', team: 'team', task: 'task', library: 'library', libraryProjectWrite: 'library.project.write', settingsLanguage: 'settings.language', sessionListPage: 'session.list.page', questionWatch: 'question.watch', questionWatchTurns: 'question.watch.turns', sessionAttention: 'session.attention', workspaceFiles: 'workspace.files', workspaceFilesRead: 'workspace.files.read', promptImages: 'prompt.images', schedule: 'schedule', attachments: 'attachments', sessionFork: 'session.fork', sessionRewind: 'session.rewind' , sessionReload: 'session.reload', sessionResumeCommand: 'session.resumeCommand', sessionConfigureLive: 'session.configure.live', terminal: 'terminal' });
21
+ /**
22
+ * What alpd takes as images in one session.prompt (capability `prompt.images`): inline base64 of
23
+ * these types only, never a path or URL; at most `maxCount` of them, each at most `maxBytes`
24
+ * decoded, and `maxTotalBytes` together, so their base64 fits one MAX_FRAME. A gateway may allow
25
+ * less (docs/alp/remote-gateway.md).
26
+ */
27
+ export const PROMPT_IMAGES = Object.freeze({ mimeTypes: Object.freeze(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), maxCount: 8, maxBytes: 5 * 1024 * 1024, maxTotalBytes: 11 * 1024 * 1024 });
28
+ /** Decoded bytes of base64 `data` (padding aware), without decoding it. @param {string} data */
29
+ export const base64Bytes = data => Math.floor(data.length * 3 / 4) - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
30
+ export const RPC_METHODS = Object.freeze(["assignment.recall", "attachment.abort", "attachment.begin", "attachment.chunk", "attachment.commit", "attachment.delete", "attachment.get", "board.list", "catalog.get", "daemon.hello", "daemon.pause", "daemon.pauses", "daemon.resume", "daemon.shutdown", "daemon.status", "daemon.usage", "extension.register", "extension.unregister", "library.delete", "library.duplicate", "library.get", "library.list", "library.models", "library.rename", "library.save", "library.skills.set", "project.list", "project.register", "project.remove", "question.answer", "question.list", "question.watch", "registry.owner", "registry.worktree", "schedule.cancel", "schedule.create", "schedule.get", "schedule.list", "session.archive", "session.attach", "session.attention.clear", "session.close", "session.configure", "session.configure.preview", "session.create", "session.fork", "session.get", "session.history", "session.interrupt", "session.list", "session.log", "session.message", "session.preview", "session.prompt", "session.release", "session.reload", "session.rename", "session.resumeCommand", "session.rewind", "session.status", "settings.language.get", "settings.language.set", "task.create", "task.gate.resolve", "task.list", "task.update", "team.list", "terminal.attach", "terminal.close", "terminal.create", "terminal.input", "terminal.list", "terminal.resize", "terminal.revoke", "workspace.files.list", "workspace.files.read", "workspace.files.search", "workspace.get", "workspace.git.diff", "workspace.git.status", "workspace.list", "workspace.update", "workspace.worktree.create", "workspace.worktree.options", "workspace.worktree.remove"]);
31
+
32
+ /**
33
+ * Limits of workspace.files.list and workspace.files.read (capability `workspace.files.read`,
34
+ * docs/protocol/alp-json-rpc-v1.md §12.4): a file of at most `maxReadBytes`, a listing of at most
35
+ * `maxListEntries` entries, a relative path of at most `maxPathLength` characters.
36
+ */
37
+ export const WORKSPACE_FILES = Object.freeze({ maxReadBytes: 1024 * 1024, maxListEntries: 1000, maxPathLength: 4096 });
38
+
39
+ /**
40
+ * Limits of workspace.git.status and workspace.git.diff (capability `workspace.git`,
41
+ * docs/protocol/alp-json-rpc-v1.md §12.5): one file's patch at most `maxFileBytes`, one page's
42
+ * patches at most `maxTotalBytes` (then `nextCursor`), at most `maxUntracked` untracked files with
43
+ * content, `maxStatusFiles` status rows, a base branch name of at most `maxRefLength` characters.
44
+ * A gateway may lower them; never raise them.
45
+ */
46
+ export const WORKSPACE_GIT = Object.freeze({ maxFileBytes: 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024, maxUntracked: 200, maxStatusFiles: 1000, maxRefLength: 255 });
47
+
48
+ /**
49
+ * Limits of non-image attachments (capability `attachments`, docs/protocol/alp-json-rpc-v1.md
50
+ * §12.6): an object of at most `maxObjectBytes`; a prompt of at most `maxPerPrompt` attachments and
51
+ * `maxPromptBytes` together; chunks of at most `maxChunkBytes` decoded (lowered further by
52
+ * attachmentChunkBytes to fit a frame); `maxActiveUploads` incomplete uploads and
53
+ * `maxPrincipalBytes` stored per principal, `maxDaemonBytes` per daemon, retained references
54
+ * included. An incomplete upload expires after `uploadTimeoutMs`; an unreferenced committed object
55
+ * is purged after `unreferencedRetentionMs`, one whose references were all released after
56
+ * `releasedGraceMs`. A display name is at most `maxNameBytes` UTF-8 bytes. An operator or a gateway
57
+ * may lower them, never raise them; daemon.hello advertises the effective ones (`attachmentLimits`).
58
+ */
59
+ export const ATTACHMENTS = Object.freeze({
60
+ maxObjectBytes: 20 * 1024 * 1024,
61
+ maxPerPrompt: 8,
62
+ maxPromptBytes: 40 * 1024 * 1024,
63
+ maxChunkBytes: 256 * 1024,
64
+ maxActiveUploads: 2,
65
+ maxPrincipalBytes: 200 * 1024 * 1024,
66
+ maxDaemonBytes: 1024 * 1024 * 1024,
67
+ uploadTimeoutMs: 60 * 60 * 1000,
68
+ unreferencedRetentionMs: 24 * 60 * 60 * 1000,
69
+ releasedGraceMs: 7 * 24 * 60 * 60 * 1000,
70
+ maxNameBytes: 255,
71
+ });
72
+ /**
73
+ * Limits of terminals (capability `terminal`, docs/protocol/alp-json-rpc-v1.md §12.10): at most
74
+ * `maxPerPrincipal` running terminals per principal and `maxPerDaemon` per daemon; a
75
+ * terminal.input of at most `maxInputBytes` UTF-8 bytes; output events of at most
76
+ * `maxOutputFrameBytes` bytes of data; `replayBytes` of output kept per terminal for attach; a
77
+ * terminal ends after `idleMs` without input or output, after `maxLifetimeMs` in any case, and
78
+ * `reconnectMs` after its stream detached unless attached again. Sizes are `minCols`..`maxCols`
79
+ * columns and `minRows`..`maxRows` rows. An operator or a gateway may lower the first eight, never
80
+ * raise them; daemon.hello advertises the effective ones (`terminalLimits`).
81
+ */
82
+ export const TERMINAL = Object.freeze({
83
+ maxPerPrincipal: 4,
84
+ maxPerDaemon: 16,
85
+ maxInputBytes: 64 * 1024,
86
+ maxOutputFrameBytes: 64 * 1024,
87
+ replayBytes: 1024 * 1024,
88
+ idleMs: 30 * 60 * 1000,
89
+ maxLifetimeMs: 8 * 60 * 60 * 1000,
90
+ reconnectMs: 60 * 1000,
91
+ minCols: 2,
92
+ maxCols: 500,
93
+ minRows: 1,
94
+ maxRows: 200,
95
+ });
96
+ /** The TERMINAL limits a daemon.hello `terminalLimits` carries (the size bounds are fixed). */
97
+ export const TERMINAL_LIMIT_KEYS = Object.freeze(['maxPerPrincipal', 'maxPerDaemon', 'maxInputBytes', 'maxOutputFrameBytes', 'replayBytes', 'idleMs', 'maxLifetimeMs', 'reconnectMs']);
98
+ /** An opaque terminal id (§12.10). @param {unknown} id */
99
+ export const isTerminalId = id => typeof id === 'string' && /^term_[0-9a-f]{32}$/.test(id);
100
+ /** Room an attachment.chunk request needs besides its base64 data. */
101
+ export const ATTACHMENT_CHUNK_OVERHEAD = 64 * 1024;
102
+ /**
103
+ * The decoded bytes one attachment.chunk may carry over frames of at most `maxFrame` bytes:
104
+ * `maxChunkBytes`, lowered so its base64 and ATTACHMENT_CHUNK_OVERHEAD fit. At least 1 byte.
105
+ * @param {number} [maxFrame] @param {number} [maxChunkBytes]
106
+ */
107
+ export function attachmentChunkBytes(maxFrame = MAX_FRAME, maxChunkBytes = ATTACHMENTS.maxChunkBytes) {
108
+ const fits = Math.floor((maxFrame - ATTACHMENT_CHUNK_OVERHEAD) * 3 / 4);
109
+ return Math.max(1, Math.min(maxChunkBytes, fits));
110
+ }
111
+
112
+ /** Directories whose whole content is refused: Git's, ALP's own configuration (its library overrides hold env values), and key and cloud-credential stores. */
113
+ const BLOCKED_DIRECTORIES = new Set(['.git', '.alp', '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker', 'gcloud']);
114
+ /** Files that hold credentials by convention. */
115
+ const BLOCKED_NAMES = new Set(['.netrc', '_netrc', '.npmrc', '.yarnrc.yml', '.pypirc', '.pgpass', '.htpasswd', '.git-credentials', '.dockercfg', '.boto', '.s3cfg', '.mcp.json', 'auth.json', 'kubeconfig', 'settings.local.json', '.vault-token', 'application_default_credentials.json', 'token.json']);
116
+ /** Credential files known by their directory too: GitHub CLI's token file. */
117
+ const BLOCKED_PAIRS = new Set(['gh/hosts.yml']);
118
+ /** Names of credential files before their first dot (a leading dot aside): credentials.json, .credentials.json, secrets.yaml, a secrets/ directory. */
119
+ const BLOCKED_STEMS = new Set(['credential', 'credentials', 'secret', 'secrets']);
120
+ /**
121
+ * Source code is not a credential store: src/credentials.ts, lib/secrets.py stay readable (the stem
122
+ * rule only; never a data or config format such as json, yaml, toml, ini, xml, txt). alpd's
123
+ * private-key check still applies to them.
124
+ */
125
+ const SOURCE_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'java', 'kt', 'kts', 'rb', 'php', 'cs', 'swift', 'c', 'cc', 'cpp', 'h', 'hpp', 'm', 'mm', 'scala', 'dart', 'vue', 'svelte']);
126
+ /** Key, certificate-store, keyring and Terraform-state extensions. */
127
+ const BLOCKED_EXTENSIONS = new Set(['pem', 'key', 'p12', 'pfx', 'p8', 'jks', 'keystore', 'ppk', 'der', 'gpg', 'pgp', 'asc', 'kdbx', 'ovpn', 'tfstate', 'tfvars']);
128
+ /** .env and every name starting with it (.env.local, .envrc); id_* (SSH keys: id_rsa, id_ed25519.pub, id_work); service-account keys; shell and REPL histories; Terraform state backups. */
129
+ const BLOCKED_PATTERN = /^(?:\.env|id_|service[-_]?account)|_history$|\.tfstate\.backup$/;
130
+
131
+ /**
132
+ * Whether a path relative to a workspace names something workspace.files.list and
133
+ * workspace.files.read never show, whatever the grant: a segment that is a blocked directory, or
134
+ * any segment (file or directory) named as a secret is (BLOCKED_* above). Compared without case,
135
+ * as macOS and Windows file systems name files. alpd and the gateway both check it; alpd also
136
+ * refuses a file whose content holds a private key, whatever its name.
137
+ * @param {string} relative a path relative to the workspace, '/'-separated
138
+ */
139
+ export function blockedFilePath(relative) {
140
+ let previous = '';
141
+ for (const segment of String(relative).toLowerCase().split(/[\\/]/)) {
142
+ if (!segment) continue;
143
+ if (BLOCKED_PAIRS.has(`${previous}/${segment}`)) return true;
144
+ previous = segment;
145
+ if (BLOCKED_DIRECTORIES.has(segment) || BLOCKED_NAMES.has(segment) || BLOCKED_PATTERN.test(segment)) return true;
146
+ const dot = segment.lastIndexOf('.');
147
+ const extension = dot >= 0 ? segment.slice(dot + 1) : '';
148
+ if (BLOCKED_EXTENSIONS.has(extension)) return true;
149
+ // Any dotted part of the name but its extension: secrets.yaml, prod.secrets.yaml, .credentials.json.
150
+ const parts = segment.replace(/^\.+/, '').split('.');
151
+ if (parts.length > 1) parts.pop();
152
+ if (parts.some(part => BLOCKED_STEMS.has(part)) && !SOURCE_EXTENSIONS.has(extension)) return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ /** UTF-8 socket writers use one newline-terminated JSON value per frame. @param {unknown} value */
158
+ export function encodeFrame(value) { return `${JSON.stringify(value)}\n`; }
159
+ /** Parse one complete frame. Buffering and failure handling belong to the socket owner. @param {string} line @returns {unknown} */
160
+ export function decodeFrame(line) { return JSON.parse(line); }
161
+
162
+ /**
163
+ * The command lines of session.resumeCommand (docs/protocol/alp-json-rpc-v1.md §12.8), built from argv
164
+ * so that every argument reaches the program as it is: for a POSIX shell (sh, bash, zsh) and for
165
+ * Windows cmd.exe, typed at its prompt and then read again by npm's `alp.cmd` shim (`%*`). An argument
166
+ * with a control character (a line break would run a pasted command early) is refused, and for cmd.exe
167
+ * one with `"`, `%` or `!` too: a quote inside an argument ends the shim's quoting and lets the rest
168
+ * run, and `%`/`!` expand variables in either parse, so no escaping keeps them (session ids have none).
169
+ */
170
+ const POSIX_SAFE = /^[A-Za-z0-9_@+:,./-]+$/;
171
+ const WINDOWS_SAFE = /^[A-Za-z0-9_@+:./\\-]+$/;
172
+ const assertArgument = (/** @type {unknown} */ arg) => {
173
+ if (typeof arg !== 'string') throw new TypeError('A command argument is a string');
174
+ if (/[\u0000-\u001f\u007f]/.test(arg)) throw new TypeError('A command argument has no control characters');
175
+ return arg;
176
+ };
177
+ /** One argument for a POSIX shell: as it is when nothing in it is special, else in single quotes. @param {string} arg */
178
+ export function quotePosix(arg) {
179
+ const text = assertArgument(arg);
180
+ return text && POSIX_SAFE.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`;
181
+ }
182
+ /**
183
+ * One argument for cmd.exe: as it is when nothing in it is special; else in double quotes for the C
184
+ * runtime's split (backslashes before the closing quote doubled), with every cmd.exe metacharacter,
185
+ * the quotes too, escaped with ^ for the interactive parse; the shim's second parse then sees one
186
+ * quoted argument, inside which `&|<>()^` are literal. `"`, `%` and `!` are refused (see above).
187
+ * @param {string} arg
188
+ */
189
+ export function quoteWindows(arg) {
190
+ const text = assertArgument(arg);
191
+ if (text && WINDOWS_SAFE.test(text)) return text;
192
+ if (/["%!]/.test(text)) throw new TypeError('A cmd.exe command argument has no ", % or !');
193
+ const quoted = `"${text.replace(/(\\*)$/, '$1$1')}"`;
194
+ return quoted.replace(/([()\][^"`<>&|;, *?])/g, '^$1');
195
+ }
196
+ const SESSION_ID = /^[\w.:-]{1,128}$/;
197
+ /** argv as one command line. @param {string[]} argv @param {'posix' | 'windows'} platform */
198
+ export function commandLine(argv, platform) {
199
+ return argv.map(platform === 'windows' ? quoteWindows : quotePosix).join(' ');
200
+ }
201
+ /**
202
+ * session.resumeCommand's answer for a session: `alp attach <sessionId>`, which follows the session in
203
+ * a terminal (a closed one says how to send it a message). ALP keeps the orchestration, so it is never
204
+ * a native runtime's own resume command.
205
+ * @param {string} sessionId @param {'posix' | 'windows'} platform the daemon host's
206
+ */
207
+ export function resumeCommandOf(sessionId, platform) {
208
+ if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) throw new TypeError('A session id is [A-Za-z0-9_.:-], at most 128 characters');
209
+ const argv = ['alp', 'attach', sessionId];
210
+ const posix = commandLine(argv, 'posix');
211
+ const windows = commandLine(argv, 'windows');
212
+ return { argv, posix, windows, display: platform === 'windows' ? windows : posix, platform };
213
+ }