@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/dist/rpc.js ADDED
File without changes
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A scheduled continuation: a one-shot job that opens a new turn on a root session at a
3
+ * given time, without a user message. The protocol owns the shape so every
4
+ * client (the CLI, a viewer, an agent tool) reads the same state.
5
+ */
6
+ /** Where a job stands: waiting for its time, delivered, failed, or cancelled before it fired. */
7
+ export type ScheduleStatus = 'pending' | 'delivered' | 'failed' | 'cancelled';
8
+ /** Why a job failed or still waits; failed may mean execution is unknown. */
9
+ export type ScheduleError = {
10
+ message: string;
11
+ code?: string;
12
+ };
13
+ /** One job as `schedule.list` and `schedule.get` answer it. */
14
+ export type ScheduleJob = {
15
+ /** The job's own id, `sch_` and sixteen hex digits. */
16
+ id: string;
17
+ /** The project whose root session the job continues. */
18
+ projectRoot: string;
19
+ /** The root session the job opens a turn on. */
20
+ sessionId: string;
21
+ /** The continuation the job delivers. */
22
+ prompt: string;
23
+ /** When the job is due, in UTC (ISO 8601). */
24
+ dueAt: string;
25
+ status: ScheduleStatus;
26
+ /**
27
+ * How many times alpd tried to deliver the job. A job delivered once may have been
28
+ * tried twice: a retry after a crash reads the receipt of the first delivery.
29
+ */
30
+ attempts: number;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ /** When the job was delivered, failed or cancelled. */
34
+ deliveredAt?: string;
35
+ failedAt?: string;
36
+ cancelledAt?: string;
37
+ /** When the first delivery was tried. */
38
+ attemptedAt?: string;
39
+ /** How late the delivery was, in milliseconds; 0 when it was on time. */
40
+ lateByMs?: number;
41
+ /** The last error of the job: why it failed, or why it still waits. */
42
+ lastError?: ScheduleError;
43
+ };
File without changes
@@ -0,0 +1,285 @@
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';
6
+ command: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ } | {
10
+ type: 'http' | 'sse';
11
+ url: string;
12
+ headers?: Record<string, string>;
13
+ };
14
+ /** What a client asks for; resolveSession turns it into a launchable session. */
15
+ export type SessionSpec = {
16
+ cwd: string;
17
+ agent?: string;
18
+ /** Workflow selected for a new session; omitted uses settings or the restored snapshot. */
19
+ workflow?: string;
20
+ /** Native model, or runtime-prefixed (`codex:`/`claude:`, or `acp:<provider>[/<model>]`) to also choose the runtime. */
21
+ model?: string;
22
+ mode?: string;
23
+ thinking?: string;
24
+ systemPrompt?: string;
25
+ env?: Record<string, string>;
26
+ mcpServers?: Record<string, HostMcpServer>;
27
+ /** Keep the native thread so the session can be resumed. */
28
+ persist?: boolean;
29
+ /** Keep the native thread on disk though the session cannot be resumed: an assignment, which alp_recall may question later. */
30
+ keepThread?: boolean;
31
+ restore?: {
32
+ agent: string;
33
+ threadId: string;
34
+ runtime?: string;
35
+ model?: string;
36
+ workflow?: {
37
+ mode: string;
38
+ maxPeers: number;
39
+ supervisor?: boolean;
40
+ };
41
+ /** Claude: `threadId` does not exist yet and is made from this conversation on its first prompt (a fork or rewind, §12.7). */
42
+ base?: NativeBase;
43
+ };
44
+ /** Where the native harness works, when not the project root: an assignment's git worktree. ALP files are still read from cwd. */
45
+ workdir?: string;
46
+ /** Explicit shared-assignment write scope; workdir points here while ALP uses cwd for its project. */
47
+ writeRoot?: string;
48
+ /** The workdir is a disposable copy the session may write, though its mode is read-only for the requester's tree. */
49
+ copy?: boolean;
50
+ /** The requester's directory the copy mirrors. */
51
+ copyOf?: string;
52
+ };
53
+ export type OpenOptions = {
54
+ history?: 'replay' | 'skip';
55
+ /** False when the client cannot show child sessions; delegation is then refused in this tree. */
56
+ delegation?: boolean;
57
+ };
58
+ /** An image inline in a prompt (capability `prompt.images`): base64 data, never a path or URL; limits in PROMPT_IMAGES. */
59
+ export type PromptImage = {
60
+ type: 'image';
61
+ mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
62
+ data: string;
63
+ name?: string;
64
+ };
65
+ /** A stored attachment in a prompt (capability `attachments`, §12.6): its opaque id only, never a path or URL. */
66
+ export type PromptAttachment = {
67
+ type: 'attachment';
68
+ attachmentId: string;
69
+ };
70
+ /**
71
+ * What alpd hands a runtime for an attachment part once it validated and pinned it (§12.6, the
72
+ * runtime seam of issue #121 branch J): ids, display metadata and digest, never a host path. The
73
+ * runtime reads the bytes through the AttachmentReader alpd binds, and only for a session whose
74
+ * runtime declared it takes attachments.
75
+ */
76
+ export type RuntimeAttachment = {
77
+ type: 'attachment';
78
+ attachmentId: string;
79
+ name: string;
80
+ mimeType: string;
81
+ detectedMimeType: string;
82
+ size: number;
83
+ sha256: string;
84
+ };
85
+ /** Text parts carry `text`; image parts (`type: 'image'`) carry PromptImage's fields; attachment parts (`type: 'attachment'`) `attachmentId`. */
86
+ export type PromptContent = Array<{
87
+ type: string;
88
+ text?: string;
89
+ mimeType?: string;
90
+ data?: string;
91
+ name?: string;
92
+ attachmentId?: string;
93
+ }>;
94
+ export type PromptInput = {
95
+ clientMessageId: string;
96
+ /** auto starts a turn; steer adds to the running one. */
97
+ delivery: 'auto' | 'steer';
98
+ content: PromptContent;
99
+ };
100
+ /** A root before it opens: the team and main it would run with, for viewers that let the user choose first (ALPD §60). */
101
+ export type SessionPreview = {
102
+ teams: Array<{
103
+ id: string;
104
+ label: string;
105
+ description?: string;
106
+ }>;
107
+ /** The team the spec selects, else settings' workflow.mode, else Phở. */
108
+ team: string;
109
+ teamLabel?: string;
110
+ agent: string;
111
+ runtime: RuntimeKind;
112
+ model: string;
113
+ mode: string;
114
+ thinking: string;
115
+ };
116
+ /** What became of one assignment an earlier alpd left running. */
117
+ export type RecoveryOutcome = {
118
+ assignmentId: string;
119
+ agent: string;
120
+ outcome: 'resumed' | 'parked' | 'failed';
121
+ error?: string;
122
+ };
123
+ export type Pause = {
124
+ since: string;
125
+ by: string;
126
+ reason: string;
127
+ resetsAt?: string; /** When alpd last asked the runtime about the limit (ALPD §58). */
128
+ checkedAt?: string;
129
+ };
130
+ export type PauseState = {
131
+ all?: Pause;
132
+ runtimes: Partial<Record<RuntimeKind, Pause>>;
133
+ parked: Array<{
134
+ assignmentId: string;
135
+ agent: string;
136
+ runtime: RuntimeKind;
137
+ rootId: string;
138
+ reason: string;
139
+ since: string;
140
+ }>;
141
+ };
142
+ export type RecallAnswer = {
143
+ assignmentId: string;
144
+ agent: string;
145
+ taskId?: string;
146
+ finishedAt: string;
147
+ answer: string;
148
+ };
149
+ /** A session's lifecycle as alpd records it; neutral, so viewers and integrations share one union. */
150
+ export type SessionStatus = 'initializing' | 'idle' | 'running' | 'error' | 'closed';
151
+ /** The last failure recorded for a session (daemon store `SessionRecord.lastError`). */
152
+ export type SessionError = {
153
+ code?: string;
154
+ message: string;
155
+ };
156
+ /**
157
+ * A session as clients list it: its last snapshot plus lifecycle (ALPD §18). This is the portable
158
+ * shape served by `session.list`; the daemon and every viewer agree on it, so it is the same type
159
+ * the daemon's store and server use rather than a lossy summary.
160
+ */
161
+ export type SessionSummary = SessionSnapshot & {
162
+ status: SessionStatus;
163
+ title?: string;
164
+ archived?: boolean;
165
+ /** A root's turn that ended unseen, until a client acknowledges it (capability `session.attention`). */
166
+ attention?: TurnAttention;
167
+ lastError?: SessionError;
168
+ createdAt?: string;
169
+ updatedAt?: string;
170
+ /**
171
+ * Whether alpd can fork or rewind this session's conversation now, and why not (capabilities
172
+ * `session.fork` and `session.rewind`, §12.7). A single turn may still be refused: one in
173
+ * progress, the first one, or one alpd cannot map to the runtime's own history.
174
+ */
175
+ conversation?: {
176
+ fork: boolean;
177
+ rewind: boolean;
178
+ /** Whether session.reload can restart this root's native process (capability `session.reload`, §12.8); the root must also be open and idle. */
179
+ reload?: boolean;
180
+ /** 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. */
181
+ configure?: boolean;
182
+ reason?: 'runtime_unsupported' | 'not_persistent' | 'not_root';
183
+ };
184
+ /** The session this root was forked from (§12.7). */
185
+ forkedFrom?: ForkProvenance;
186
+ /** Conversation branches rewinds set aside, oldest first; each stays recoverable with session.fork { branchId } (§12.7). */
187
+ branches?: ConversationBranch[];
188
+ };
189
+ /**
190
+ * A Claude conversation ALP has bound a session to but the runtime has not written yet (§12.7): the
191
+ * first prompt resumes `threadId` as a new session, keeping its history through the entry `at` (all of
192
+ * it when absent). Internal to alpd and its runtime; never accepted from a remote client.
193
+ */
194
+ export type NativeBase = {
195
+ threadId: string;
196
+ at?: string;
197
+ };
198
+ /** Where a fork came from (§12.7): the source root, and the turn its conversation was cut at. */
199
+ export type ForkProvenance = {
200
+ sessionId: string;
201
+ /** The cut: through this turn, or before it; absent when the whole conversation was taken. */
202
+ turnId?: string;
203
+ boundary: 'through' | 'before' | 'whole';
204
+ /** Set when the fork restored a branch a rewind set aside. */
205
+ branchId?: string;
206
+ at: string;
207
+ };
208
+ /** The later turns of a conversation a rewind set aside (§12.7): kept, never deleted, recoverable as a new session. */
209
+ export type ConversationBranch = {
210
+ branchId: string;
211
+ /** The turn the rewind went back to before; it and every later turn are in the branch. */
212
+ beforeTurnId: string;
213
+ /** How many turns the branch holds past the kept conversation. */
214
+ turns: number;
215
+ at: string;
216
+ };
217
+ /**
218
+ * The ALP CLI command that continues a session in a terminal (session.resumeCommand, §12.8): `argv`
219
+ * and the same command quoted for a POSIX shell and for Windows (cmd.exe). `display` is the one for
220
+ * the daemon host's own platform. Never a native runtime's command, credential, env or path.
221
+ */
222
+ export type ResumeCommand = {
223
+ argv: string[];
224
+ posix: string;
225
+ windows: string;
226
+ display: string;
227
+ platform: 'posix' | 'windows';
228
+ };
229
+ /**
230
+ * The configuration a root runs with, or would after a change, as alpd resolved it (§12.9): its team,
231
+ * agent, runtime, model, effort and mode, and what they bring (delegation targets, ALP tools, MCP
232
+ * servers by name and kind, hook count, an instructions digest). Never a path, command or credential.
233
+ */
234
+ export type EffectiveConfig = {
235
+ team: string;
236
+ teamLabel?: string;
237
+ agent: string;
238
+ runtime: RuntimeKind;
239
+ model: string;
240
+ thinking: string;
241
+ mode: string;
242
+ /** The permission profile capping the agent's mode, when one does. */
243
+ permissionProfile?: string;
244
+ supervisor: boolean;
245
+ maxPeers?: number;
246
+ delegates: string[];
247
+ tools: string[];
248
+ mcpServers: Array<{
249
+ name: string;
250
+ kind: 'stdio' | 'http';
251
+ }>;
252
+ hooks: number;
253
+ instructionsSha?: string;
254
+ };
255
+ export type ConfigureChange = 'model' | 'thinking' | 'team' | 'mode';
256
+ export type ConfigureRefusal = 'session_busy' | 'session_closed' | 'not_root' | 'runtime_unsupported' | 'runtime_mismatch' | 'agent_mismatch' | 'permission_escalation' | 'team_not_found' | 'invalid_params' | 'not_persistent';
257
+ /** session.configure.preview (§12.9): the current and proposed configuration, and whether alpd would apply it now. */
258
+ export type ConfigurePreview = {
259
+ current: EffectiveConfig;
260
+ proposed: EffectiveConfig;
261
+ changes: ConfigureChange[];
262
+ /** Applying it restarts the root's native process on its own conversation. */
263
+ restart: boolean;
264
+ /** session.configure would take it now; a tree that gets busy meanwhile still refuses it. */
265
+ allowed: boolean;
266
+ reason?: ConfigureRefusal;
267
+ message?: string;
268
+ teams: Array<{
269
+ id: string;
270
+ label: string;
271
+ description?: string;
272
+ }>;
273
+ };
274
+ /** What a session.configure with model, thinking or team did (§12.9): what was asked, and what the root runs with now. */
275
+ export type ConfigureOutcome = {
276
+ status: 'applied' | 'unchanged';
277
+ requested: {
278
+ model?: string;
279
+ thinking?: string;
280
+ team?: string;
281
+ mode?: string;
282
+ };
283
+ effective: EffectiveConfig;
284
+ restarted: boolean;
285
+ };
File without changes
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Checks `value` against `schema`, resolving `#/$defs/...` against `root`.
3
+ * @param {any} schema
4
+ * @param {unknown} value
5
+ * @param {any} [root]
6
+ * @returns {SchemaIssue[]} empty when the value conforms
7
+ */
8
+ export function validateSchema(schema: any, value: unknown, root?: any): SchemaIssue[];
9
+ export type SchemaIssue = {
10
+ path: string;
11
+ message: string;
12
+ };
@@ -0,0 +1,151 @@
1
+ // packages/protocol/src/validate.js
2
+ var MAX_ISSUES = 8;
3
+ function typeOf(value) {
4
+ if (value === null) return "null";
5
+ if (Array.isArray(value)) return "array";
6
+ if (typeof value === "number") return !Number.isFinite(value) ? "non-finite number" : Number.isInteger(value) ? "integer" : "number";
7
+ return typeof value;
8
+ }
9
+ var typeMatches = (want, have) => want === have || want === "number" && have === "integer";
10
+ function equal(a, b) {
11
+ if (a === b) return true;
12
+ if (typeOf(a) !== typeOf(b) || a === null || typeof a !== "object") return false;
13
+ if (Array.isArray(a)) return a.length === /** @type {unknown[]} */
14
+ b.length && a.every((item, index) => equal(
15
+ item,
16
+ /** @type {unknown[]} */
17
+ b[index]
18
+ ));
19
+ const left = Object.keys(a).filter((key) => (
20
+ /** @type {any} */
21
+ a[key] !== void 0
22
+ ));
23
+ const right = Object.keys(
24
+ /** @type {object} */
25
+ b
26
+ ).filter((key) => (
27
+ /** @type {any} */
28
+ b[key] !== void 0
29
+ ));
30
+ return left.length === right.length && left.every((key) => equal(
31
+ /** @type {any} */
32
+ a[key],
33
+ /** @type {any} */
34
+ b[key]
35
+ ));
36
+ }
37
+ function resolve(root, ref) {
38
+ if (!ref.startsWith("#/")) throw new Error(`Unsupported schema reference ${ref}`);
39
+ let target = root;
40
+ for (const part of ref.slice(2).split("/")) target = target?.[part.replaceAll("~1", "/").replaceAll("~0", "~")];
41
+ if (target === void 0) throw new Error(`Unresolved schema reference ${ref}`);
42
+ return target;
43
+ }
44
+ function check(schema, value, at, root, issues) {
45
+ if (issues.length >= MAX_ISSUES || schema === true || schema === void 0) return;
46
+ if (schema === false) {
47
+ issues.push({ path: at, message: "is not allowed" });
48
+ return;
49
+ }
50
+ if (schema.$ref) {
51
+ check(resolve(root, schema.$ref), value, at, root, issues);
52
+ if (Object.keys(schema).length === 1) return;
53
+ }
54
+ const kind = typeOf(value);
55
+ if (schema.type !== void 0) {
56
+ const wanted = Array.isArray(schema.type) ? schema.type : [schema.type];
57
+ if (!wanted.some(
58
+ /** @param {string} want */
59
+ (want) => typeMatches(want, kind)
60
+ )) {
61
+ issues.push({ path: at, message: `must be ${wanted.join(" or ")}` });
62
+ return;
63
+ }
64
+ }
65
+ if ("const" in schema && !equal(schema.const, value)) {
66
+ issues.push({ path: at, message: `must be ${JSON.stringify(schema.const)}` });
67
+ return;
68
+ }
69
+ if (schema.enum && !schema.enum.some(
70
+ /** @param {unknown} option */
71
+ (option) => equal(option, value)
72
+ )) {
73
+ issues.push({ path: at, message: `must be one of ${schema.enum.map(
74
+ /** @param {unknown} option */
75
+ (option) => JSON.stringify(option)
76
+ ).join(", ")}` });
77
+ return;
78
+ }
79
+ if (kind === "integer" || kind === "number") {
80
+ const number = (
81
+ /** @type {number} */
82
+ value
83
+ );
84
+ if (schema.minimum !== void 0 && number < schema.minimum) issues.push({ path: at, message: `must be at least ${schema.minimum}` });
85
+ if (schema.maximum !== void 0 && number > schema.maximum) issues.push({ path: at, message: `must be at most ${schema.maximum}` });
86
+ }
87
+ if (kind === "string") {
88
+ const text = (
89
+ /** @type {string} */
90
+ value
91
+ );
92
+ let length = 0;
93
+ if (schema.minLength !== void 0 || schema.maxLength !== void 0) for (const _point of text) length++;
94
+ if (schema.minLength !== void 0 && length < schema.minLength) issues.push({ path: at, message: `must have at least ${schema.minLength} characters` });
95
+ if (schema.maxLength !== void 0 && length > schema.maxLength) issues.push({ path: at, message: `must have at most ${schema.maxLength} characters` });
96
+ if (schema.pattern !== void 0 && !new RegExp(schema.pattern, "u").test(text)) issues.push({ path: at, message: `must match ${schema.pattern}` });
97
+ }
98
+ if (kind === "array" && schema.minItems !== void 0 && /** @type {unknown[]} */
99
+ value.length < schema.minItems) issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
100
+ if (kind === "array" && schema.maxItems !== void 0 && /** @type {unknown[]} */
101
+ value.length > schema.maxItems) issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
102
+ if (kind === "array" && schema.items !== void 0) {
103
+ const array = (
104
+ /** @type {unknown[]} */
105
+ value
106
+ );
107
+ for (let index = 0; index < array.length && issues.length < MAX_ISSUES; index++) check(schema.items, array[index] === void 0 ? null : array[index], `${at}/${index}`, root, issues);
108
+ }
109
+ if (kind === "object") {
110
+ const object = (
111
+ /** @type {Record<string, unknown>} */
112
+ value
113
+ );
114
+ const properties = schema.properties ?? {};
115
+ for (const name of schema.required ?? []) if (object[name] === void 0) issues.push({ path: `${at}/${name}`, message: "is required" });
116
+ for (const [name, child] of Object.entries(object)) {
117
+ if (child === void 0) continue;
118
+ if (Object.hasOwn(properties, name)) check(properties[name], child, `${at}/${name}`, root, issues);
119
+ else if (schema.additionalProperties !== void 0) check(schema.additionalProperties, child, `${at}/${name}`, root, issues);
120
+ }
121
+ }
122
+ if (schema.not !== void 0) {
123
+ const inner = [];
124
+ check(schema.not, value, at, root, inner);
125
+ if (!inner.length) issues.push({ path: at, message: "matches a schema it must not match" });
126
+ }
127
+ for (
128
+ const [keyword, exactlyOne] of
129
+ /** @type {const} */
130
+ [["anyOf", false], ["oneOf", true]]
131
+ ) {
132
+ if (!schema[keyword]) continue;
133
+ const passing = schema[keyword].filter(
134
+ /** @param {any} option */
135
+ (option) => {
136
+ const inner = [];
137
+ check(option, value, at, root, inner);
138
+ return !inner.length;
139
+ }
140
+ ).length;
141
+ 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" });
142
+ }
143
+ }
144
+ function validateSchema(schema, value, root = schema) {
145
+ const issues = [];
146
+ check(schema, value, "", root, issues);
147
+ return issues;
148
+ }
149
+ export {
150
+ validateSchema
151
+ };