@anhlp/alp-runtime 0.0.1-beta.3 → 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.
package/src/contracts.ts CHANGED
@@ -7,6 +7,7 @@ import type { ProviderUsage } from './usage.js';
7
7
  import type { AcpProvider } from './acp-transport.js';
8
8
  import type { AntigravityProvider } from './antigravity-transport.js';
9
9
  import type { GitHubRunner } from './github.js';
10
+ import type { AttachmentReader } from './attachments.js';
10
11
 
11
12
  export type RuntimeTransport = {
12
13
  request(method: string, params: any): Promise<any>;
@@ -67,6 +68,12 @@ export type RuntimeOptions = {
67
68
  /** Where assignments whose profile has workdir copy get their disposable copies. Default: a directory in the temp directory. */
68
69
  copyDir?: string;
69
70
 
71
+ /**
72
+ * Where the runtime stages read-only copies of attached files for its sessions' tools (§12.6):
73
+ * alpd's `<ALP_HOME>/state/staged`. Omitted, no session takes attachment parts.
74
+ */
75
+ attachmentStageDir?: string;
76
+
70
77
  /** Where project boards are kept (JSONL per project). Omitted keeps them in memory only. */
71
78
  boardDir?: string;
72
79
 
@@ -78,6 +85,8 @@ export type RuntimeOptions = {
78
85
 
79
86
  /** Where finished assignments' native threads are recorded for alp_recall. Omitted keeps the record in memory. */
80
87
  recallFile?: string;
88
+ /** Permanent assignment ownership; unlike recall, results never expire. */
89
+ assignmentResultFile?: string;
81
90
 
82
91
  /** Where pauses are kept across restarts. Omitted keeps them in memory. */
83
92
  pauseFile?: string;
@@ -106,21 +115,44 @@ export type RuntimeOptions = {
106
115
  github?: GitHubRunner;
107
116
  };
108
117
 
109
- import type { OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
110
- export type { OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
118
+ import type { NativeBase, OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
119
+ export type { NativeBase, OpenOptions, PromptContent, PromptInput } from '@anhlp/alp-protocol/session';
120
+
121
+ export type ScheduleHandler = (action: 'create' | 'list' | 'get' | 'cancel', params: Record<string, unknown>) => Promise<unknown>;
111
122
 
112
123
  export type AlpRuntime = {
124
+ /** The daemon supplies its persistent schedule operations; standalone runtimes have none. */
125
+ bindSchedules?: (handler: ScheduleHandler | undefined) => void;
126
+ /** alpd hands over its attachment reader (§12.6) once, at start; with a staging directory, Codex and Claude sessions then take attachment parts. */
127
+ bindAttachments?: (reader: AttachmentReader | undefined) => void;
128
+ /** Whether this live session's runtime takes `{ type: 'attachment' }` parts (RuntimeAttachment) now. */
129
+ takesAttachments?: (sessionId: string) => boolean;
130
+ /** Removes the staged copy of an attachment alpd no longer keeps. */
131
+ dropAttachment?: (attachmentId: string) => Promise<void>;
113
132
  onEvent(listener: (envelope: Envelope) => void): () => void;
133
+ /** Certifies a brand-new generated checkout; never use to adopt an existing workspace. */
134
+ registerWorkspace(directory: string): Promise<void>;
135
+ /** Reserves runtime admission while an external checkout mutation runs. */
136
+ withWorkspaceMutation<T>(directory: string, run: () => Promise<T>): Promise<T>;
114
137
  /** Opens a root session under a client-chosen id. */
115
138
  open(sessionId: string, spec: SessionSpec, options?: OpenOptions): Promise<SessionSnapshot>;
116
139
  /** What a new root would run as, before it opens, and the teams it may choose (ALPD §60). */
117
140
  preview(spec: SessionSpec): Promise<SessionPreview>;
118
141
  /** Failures are reported as prompt.failed, once per clientMessageId. */
119
142
  prompt(sessionId: string, input: PromptInput): Promise<void>;
143
+ /**
144
+ * Atomically admits an idle scheduled prompt in the runtime queue. Busy leaves all preparation
145
+ * untouched. The callback may persist its attempt/receipt, then use its single-use sender;
146
+ * the sender is valid only during the callback. Do not enqueue runtime operations in the callback.
147
+ */
148
+ withIdlePrompt?<T>(sessionId: string, run: (send: (input: PromptInput) => Promise<void>) => Promise<T>): Promise<{ busy: true } | { busy: false; value: T }>;
120
149
  /** Cancels the running turn and closes the whole subtree. */
121
150
  interrupt(sessionId: string): Promise<void>;
122
- /** Changes permission mode while idle; undefined keeps the current mode. */
123
- configure(sessionId: string, changes: { mode?: string }): Promise<SessionSnapshot>;
151
+ /** Atomically cancels only this active turn; false leaves the session and subtree untouched. */
152
+ interruptTurn?(sessionId: string, turnId: string): Promise<boolean>;
153
+ /** Changes permission mode while idle; undefined keeps the current mode.
154
+ * onApplied is awaited after apply and before session.updated; failure does not roll back permissions. */
155
+ configure(sessionId: string, changes: { mode?: string }, options?: { onApplied?: (session: SessionSnapshot) => Promise<void> }): Promise<SessionSnapshot>;
124
156
  close(sessionId: string): Promise<void>;
125
157
  snapshot(sessionId: string): SessionSnapshot | undefined;
126
158
  /** Live sessions, roots before their children. */
@@ -153,8 +185,122 @@ export type AlpRuntime = {
153
185
  abandon(rootId: string, reason: string): Promise<RecoveryOutcome[]>;
154
186
  /** Closes everything; assignments still running stay in the live file, so the next alpd continues them. */
155
187
  shutdown(): Promise<void>;
188
+ /**
189
+ * Makes the native conversation a fork or rewind continues (docs/protocol/alp-json-rpc-v1.md §12.7): the
190
+ * source's conversation cut at a completed turn, persisted by the runtime and leaving the source
191
+ * unchanged. Touches no live session. Codex forks natively now (thread/fork); Claude validates the
192
+ * cut against the transcript and answers a binding the first prompt forks (NativeBinding.base).
193
+ * Refusals throw a ConversationError; one the runtime may or may not have acted on carries
194
+ * `outcomeUnknown: true`. Absent: the host cannot fork or rewind.
195
+ */
196
+ branch?(request: BranchRequest): Promise<NativeBinding>;
197
+ /**
198
+ * Moves an idle live root to another native conversation (a rewind), or reopens its own (a reload):
199
+ * its native process restarts and resumes `binding`, keeping the session id, its configuration and
200
+ * attached viewers. Refused (ConversationError session_busy) unless the whole tree is idle: no turn,
201
+ * pending prompt, child, assignment, question, mail or parked work. Emits session.updated.
202
+ * When the new binding cannot be opened, the old one is reopened and the error thrown; when neither
203
+ * opens, the session fails as a provider failure does.
204
+ */
205
+ rebind?(sessionId: string, binding: NativeBinding): Promise<SessionSnapshot>;
206
+ /**
207
+ * Restarts an idle live root's native process on the conversation it runs now (a reload, §12.8):
208
+ * the same binding, the session's own configuration, id and viewers. Refused as rebind is. The new
209
+ * process must resume that very conversation (a Claude one is refused unless its transcript, or its
210
+ * base, exists), never an empty one in its place; when it cannot, the session closes with its
211
+ * conversation kept (a later open resumes it) and a ConversationError native_refused is thrown.
212
+ * Emits session.updated.
213
+ */
214
+ reload?(sessionId: string): Promise<SessionSnapshot>;
215
+ /**
216
+ * Holds the turns the runtime would start by itself on this session (mail wakes) while a lifecycle
217
+ * operation changes its conversation (a rewind, later a reload or live configure): mail stays queued
218
+ * and is delivered when the last hold is released, to whatever conversation the session runs then.
219
+ * rebind does not refuse for mail a hold keeps. Client prompts are not held here; alpd holds them.
220
+ * Undefined when the session is not open.
221
+ */
222
+ hold?(sessionId: string): (() => void) | undefined;
223
+ /**
224
+ * Whether an open session may begin a turn it has no id for yet: its turn/start (a prompt or a mail
225
+ * wake) is reserved, queued or on its way to the native runtime, or its process is opening or restarting. Such a
226
+ * turn shows in `activeTurnId` only once the runtime answers, and a hold taken meanwhile does not
227
+ * stop it. False when the session is not open.
228
+ */
229
+ starting?(sessionId: string): boolean;
230
+ /**
231
+ * What a live root would run with after a change of model, thinking, team or mode (§12.9), and whether
232
+ * reconfigure would take it now; reads only. `teams` are the teams the host defines for its project.
233
+ */
234
+ previewConfigure?(sessionId: string, changes: ConfigureChanges): Promise<ConfigurePreview>;
235
+ /**
236
+ * Changes an idle live root's model, thinking, team or mode (§12.9): resolves the new configuration
237
+ * as session.create would (same runtime, agent and conversation; never more permissions or new
238
+ * executable resources), then restarts its native process with it on the same conversation (`fresh`:
239
+ * a root with no turn yet starts a new, empty one). The session takes the new configuration only
240
+ * once the runtime took it (Codex echoes the model it resumed with); otherwise the old one is
241
+ * restarted and a ConversationError thrown (`outcomeUnknown` when the runtime may have acted). A
242
+ * new team also replaces the root's supervisor. Refused as rebind is while the tree is not idle.
243
+ * `onApplied` lets a host persist the configuration the runtime took before session.updated is
244
+ * emitted. It is awaited only for an applied change; its failure leaves the runtime configured
245
+ * and reports outcomeUnknown (never a rollback or a known native refusal).
246
+ * Emits session.updated after that persistence succeeds.
247
+ */
248
+ reconfigure?(sessionId: string, changes: ConfigureChanges, options?: { fresh?: boolean; onApplied?: (result: ConfigureResult) => Promise<void> }): Promise<ConfigureResult>;
249
+ };
250
+
251
+ import type { ConfigureChanges, EffectiveConfig } from './configure.js';
252
+ export type { ConfigureChanges, EffectiveConfig } from './configure.js';
253
+ import type { ConfigurePreview } from '@anhlp/alp-protocol/session';
254
+ export type { ConfigurePreview } from '@anhlp/alp-protocol/session';
255
+ /** What reconfigure did: the session as it runs now, its effective configuration, and whether its process restarted. */
256
+ export type ConfigureResult = {
257
+ session: SessionSnapshot;
258
+ effective: EffectiveConfig;
259
+ status: 'applied' | 'unchanged';
260
+ restarted: boolean;
261
+ /** What the native runtime answered it runs with (Codex: the resumed thread's model and effort). */
262
+ native?: { model?: string; reasoningEffort?: string };
156
263
  };
157
264
 
265
+ /** The native conversation a session continues: its thread, or for Claude one its first prompt makes from `base`. */
266
+ export type NativeBinding = { threadId: string; base?: NativeBase };
267
+
268
+ export type BranchRequest = {
269
+ runtime: RuntimeKind;
270
+ /** The conversation to branch: the source session's binding. */
271
+ source: NativeBinding;
272
+ /** Where the source session runs (Claude keeps its transcripts by this directory). */
273
+ cwd: string;
274
+ /** Where the branch will run; the source's cwd when absent. */
275
+ targetCwd?: string;
276
+ /** The configuration the branch keeps: Codex otherwise forks with its default model and settings. */
277
+ model: string;
278
+ thinking: string;
279
+ mode: string;
280
+ /**
281
+ * The cut, by ALP turn id (a Codex turn id; a Claude prompt uuid): keep through a completed turn, or
282
+ * everything before a turn. Absent: the whole conversation.
283
+ */
284
+ cutoff?: { throughTurnId: string } | { beforeTurnId: string };
285
+ /** The source root's turns in order, as ALP's timeline has them, for the cut's validation. */
286
+ turns: string[];
287
+ };
288
+
289
+ export type ConversationErrorReason = 'runtime_unsupported' | 'boundary_unmapped' | 'empty_prefix' | 'native_refused' | 'session_busy'
290
+ // A live configure (§12.9).
291
+ | 'runtime_mismatch' | 'agent_mismatch' | 'permission_escalation' | 'team_not_found' | 'invalid_params' | 'not_root';
292
+ /** A fork, rewind or rebind the runtime refused; `outcomeUnknown` when the native runtime may have acted. */
293
+ export class ConversationError extends Error {
294
+ readonly data: { reason: ConversationErrorReason };
295
+ readonly outcomeUnknown: boolean;
296
+ constructor(reason: ConversationErrorReason, message: string, { outcomeUnknown = false } = {}) {
297
+ super(message);
298
+ this.name = 'ConversationError';
299
+ this.data = { reason };
300
+ this.outcomeUnknown = outcomeUnknown;
301
+ }
302
+ }
303
+
158
304
  import type { SessionPreview } from '@anhlp/alp-protocol/session';
159
305
  export type { SessionPreview } from '@anhlp/alp-protocol/session';
160
306
 
@@ -0,0 +1,81 @@
1
+ import { ConversationError, type BranchRequest } from './contracts.js';
2
+
3
+ /**
4
+ * Conversation cuts for fork and rewind (docs/protocol/alp-json-rpc-v1.md §12.7). Pure helpers of
5
+ * runtime.ts's branch(): where a Claude transcript is cut, which Codex turn a fork must end at, and
6
+ * what a Codex refusal means.
7
+ */
8
+
9
+ /** One entry of a Claude session's main chain, as the Agent SDK reads it from the transcript. */
10
+ export type ChainEntry = { uuid: string; type: 'user' | 'assistant'; toolResult?: boolean };
11
+
12
+ const refuse = (reason: ConstructorParameters<typeof ConversationError>[0], message: string, outcomeUnknown = false) =>
13
+ new ConversationError(reason, message, { outcomeUnknown });
14
+
15
+ /** The turn a Codex fork must end at, after ALP's own validation of the cut against its timeline. */
16
+ export function codexForkEnd(request: Pick<BranchRequest, 'cutoff' | 'turns'>) {
17
+ const { cutoff, turns } = request;
18
+ if (!cutoff) {
19
+ if (!turns.length) throw refuse('empty_prefix', 'The conversation has no turn to fork');
20
+ return { last: turns.at(-1)!, params: {} };
21
+ }
22
+ const id = 'beforeTurnId' in cutoff ? cutoff.beforeTurnId : cutoff.throughTurnId;
23
+ const index = turns.indexOf(id);
24
+ if (index < 0) throw refuse('boundary_unmapped', `Turn ${id} is not a turn of this conversation`);
25
+ if ('beforeTurnId' in cutoff) {
26
+ if (index === 0) throw refuse('empty_prefix', 'Nothing comes before the first turn');
27
+ return { last: turns[index - 1], params: { beforeTurnId: id } };
28
+ }
29
+ return { last: id, params: { lastTurnId: id } };
30
+ }
31
+
32
+ /** What a refused thread/fork means; one written but unanswered may have forked. */
33
+ export function codexForkRefusal(error: unknown) {
34
+ if (error instanceof ConversationError) return error;
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ if ((error as { outcomeUnknown?: boolean })?.outcomeUnknown) return refuse('native_refused', `Codex did not answer the fork: ${message}`, true);
37
+ if (/^turn not found\b/i.test(message)) return refuse('boundary_unmapped', `Codex does not know that turn: ${message}`);
38
+ if (/identifies an in-progress turn/i.test(message)) return refuse('session_busy', `The turn is still running: ${message}`);
39
+ return refuse('native_refused', `Codex refused the fork: ${message}`);
40
+ }
41
+
42
+ /**
43
+ * The chain uuid a Claude fork resumes at (`resumeSessionAt`): the last entry kept. ALP turn ids are
44
+ * the prompt uuids it gave Claude, so a turn starts at its prompt entry and runs to the next ALP turn's
45
+ * prompt; entries of a turn Claude started itself belong to the turn before it. Always a pinned entry,
46
+ * never "everything", so turns the source adds later never reach a branch made before them.
47
+ */
48
+ export function claudeCut(chain: ChainEntry[], request: Pick<BranchRequest, 'cutoff' | 'turns'>): string {
49
+ const { cutoff, turns } = request;
50
+ const position = new Map(chain.map((entry, index) => [entry.uuid, index]));
51
+ const prompt = (id: string) => {
52
+ const index = position.get(id);
53
+ return index !== undefined && chain[index].type === 'user' && !chain[index].toolResult ? index : undefined;
54
+ };
55
+ if (!cutoff) {
56
+ if (!chain.length) throw refuse('empty_prefix', 'The conversation has no turn to fork');
57
+ return chain.at(-1)!.uuid;
58
+ }
59
+ const id = 'beforeTurnId' in cutoff ? cutoff.beforeTurnId : cutoff.throughTurnId;
60
+ const turn = turns.indexOf(id);
61
+ const at = prompt(id);
62
+ if (turn < 0 || at === undefined) throw refuse('boundary_unmapped', `Turn ${id} is not a turn of this conversation's transcript`);
63
+ // ALP's turns found in the transcript must come in ALP's order, or the cut would not be what ALP shows.
64
+ const found = turns.map(prompt).filter((index): index is number => index !== undefined);
65
+ if (found.some((index, i) => i > 0 && index <= found[i - 1])) throw refuse('boundary_unmapped', 'The transcript orders turns differently from ALP');
66
+ if ('beforeTurnId' in cutoff) {
67
+ if (turn === 0 || at === 0) throw refuse('empty_prefix', 'Nothing comes before the first turn');
68
+ return chain[at - 1].uuid;
69
+ }
70
+ // Through a turn: up to the prompt of the next ALP turn the transcript has, else its end.
71
+ const next = turns.slice(turn + 1).map(prompt).find(index => index !== undefined);
72
+ return chain[(next ?? chain.length) - 1].uuid;
73
+ }
74
+
75
+ /** The chain a binding stands for: a pending fork is its base cut at `at`, until its first prompt makes it. */
76
+ export function pendingChain(base: { at?: string }, chain: ChainEntry[]) {
77
+ if (!base.at) return chain;
78
+ const index = chain.findIndex(entry => entry.uuid === base.at);
79
+ if (index < 0) throw refuse('native_refused', `The conversation no longer has the entry ${base.at} it was cut at`);
80
+ return chain.slice(0, index + 1);
81
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  // The ALP runtime: native harnesses, sessions, delegation and mail. Viewer-neutral; never imports a viewer SDK.
2
- export { createAlpRuntime, DEFAULT_TEAM } from './runtime.js';
3
- export type { AlpRuntime, RuntimeOptions, OpenOptions, RuntimeTransport, PromptInput, PromptContent, SessionPreview } from './runtime.js';
2
+ export { createAlpRuntime, DEFAULT_TEAM, WorkspaceMutationError } from './runtime.js';
3
+ export type { WorkspaceMutationReason, AlpRuntime, RuntimeOptions, OpenOptions, RuntimeTransport, PromptInput, PromptContent, SessionPreview } from './runtime.js';
4
+ export { ConversationError } from './contracts.js';
5
+ export type { BranchRequest, ConversationErrorReason, NativeBase, NativeBinding, ConfigureChanges, ConfigureResult, ConfigurePreview, EffectiveConfig } from './contracts.js';
4
6
  export type { AlpEvent, AlpError, Envelope, SessionSnapshot, TimelineItem, TurnOrigin, AssignmentSnapshot, UserQuestion, TreeStatus, SessionState } from './events.js';
5
7
  export { resolveSession, InstructionsAdapter, skillDescription } from './resolve.js';
6
8
  export type { SessionSpec, HostMcpServer, ResolvedSession, RuntimeKind } from './resolve.js';
7
9
  export { DEFAULT_MODEL, DEFAULT_CLAUDE_MODEL, models, modes, thinkingOptions, thinkingOptionsFor } from './catalog.js';
8
10
  export { CodexTransport } from './transport.js';
11
+ export { ATTACHMENT_HEADER, ATTACHMENT_ROUTES, attachmentStageDir, createAttachmentStager, stagingExposure } from './attachments.js';
12
+ export type { AttachmentReader, AttachmentRoute } from './attachments.js';
9
13
  export { usageAllows } from './runtime-context.js';
10
14
  export { AcpTransport, acpDecision, acpEnv, acpPermission } from './acp-transport.js';
11
15
  export type { AcpProvider } from './acp-transport.js';
package/src/mailbox.ts CHANGED
@@ -27,8 +27,11 @@ export function takeBatch(mail: MailEvent[], accept: (event: MailEvent) => boole
27
27
  return batch;
28
28
  }
29
29
 
30
+ /** How rendered mail begins, so a replayed prompt can tell ALP's mail from the user's words. */
31
+ export const MAIL_HEADER = 'ALP mail from other agents.';
32
+
30
33
  export function renderMail(events: MailEvent[], requester?: string) {
31
- const header = 'ALP mail from other agents. ' +
34
+ const header = `${MAIL_HEADER} ` +
32
35
  (events.some(event => event.from === USER)
33
36
  ? 'Mail sent by "user" is the user writing to you directly: follow it as a user instruction. ' +
34
37
  (requester ? `ALP has told ${requester} about it. You may now ask the user with alp_ask to: "user"; your handoff must say what the user asked and what you did. ` : '')