@anhlp/alp-runtime 0.0.0-beta.2 → 0.0.1-beta.3

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.
@@ -392,6 +392,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
392
392
  type: string;
393
393
  description: string;
394
394
  };
395
+ requestId: {
396
+ type: string;
397
+ description: string;
398
+ };
395
399
  };
396
400
  required: string[];
397
401
  additionalProperties: boolean;
@@ -427,6 +431,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
427
431
  type: string;
428
432
  description: string;
429
433
  };
434
+ requestId: {
435
+ type: string;
436
+ description: string;
437
+ };
430
438
  labels: {
431
439
  type: string;
432
440
  items: {
@@ -1034,6 +1042,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
1034
1042
  type: string;
1035
1043
  description: string;
1036
1044
  };
1045
+ requestId: {
1046
+ type: string;
1047
+ description: string;
1048
+ };
1037
1049
  };
1038
1050
  required: string[];
1039
1051
  additionalProperties: boolean;
@@ -1069,6 +1081,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
1069
1081
  type: string;
1070
1082
  description: string;
1071
1083
  };
1084
+ requestId: {
1085
+ type: string;
1086
+ description: string;
1087
+ };
1072
1088
  labels: {
1073
1089
  type: string;
1074
1090
  items: {
@@ -1678,6 +1694,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
1678
1694
  type: string;
1679
1695
  description: string;
1680
1696
  };
1697
+ requestId: {
1698
+ type: string;
1699
+ description: string;
1700
+ };
1681
1701
  };
1682
1702
  required: string[];
1683
1703
  additionalProperties: boolean;
@@ -1713,6 +1733,10 @@ export declare function nativeSessionConfig(runtimeKind: RuntimeKind, mapping: R
1713
1733
  type: string;
1714
1734
  description: string;
1715
1735
  };
1736
+ requestId: {
1737
+ type: string;
1738
+ description: string;
1739
+ };
1716
1740
  labels: {
1717
1741
  type: string;
1718
1742
  items: {
@@ -97,7 +97,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
97
97
  dynamicTools: [SEND_TOOL, BOARD_TOOL, taskTool(taskActions(mapping, parentAgent, role))]
98
98
  };
99
99
  }
100
- const delegationInstruction = targets.length ? `Use alp_delegate to assign bounded work to: ${targets.join(", ")}. Work runs in the background: by default alp_delegate starts the assignment and returns its assignmentId at once, and its result, questions and stall reports come to you as mail. Pass wait: true only when your very next step cannot go on without the result; it then returns the result, or early with the child's first question. A result carries the child's structured handoff (null if it filed none) and output, its final message. While assignments run, go on with work that does not depend on them; when only their results are left, alp_wait for them (mail from your requester or the user ends the wait early), or end your turn and ALP wakes you with their results and questions. Answer questions with alp_send kind answer and replyTo; use kind steer to change an instruction, note for information. To stop an assignment, for example one parked on a usage limit whose work should go on now, use alp_cancel and delegate the rest, on another runtime (pass a model of claude: or codex:); its changes stay in the checkout or its worktree. At most ${mapping.workflow.maxPeers} peers may run concurrently. Concurrent peers must be read-only or isolated: pass isolation "worktree" to give a writing peer its own git worktree. A worktree result lists its branch and changed files; apply it with alp_merge (uncommitted, conflicts left as markers) or drop it with alp_discard, then verify. Writers in this shared checkout run one at a time. To learn why a finished assignment did something, ask it with alp_recall rather than guessing from its handoff. When the project configures verify commands, ALP runs them before alp_merge applies a change, and after a writer in your checkout finishes; a failed check applies nothing. To fix a failed worktree change, delegate again with continueFrom set to its assignmentId. Do not run shell/file mutations in parallel with delegation. Include scope, constraints, verification, and required handoff in task. Child inherits your mode unless you request read-only. Review its returned evidence before answering. Do not use native spawn tools or shell-launched agents to bypass this route.` : "No delegation targets are authorized. Do not spawn agents or use shell-launched agents. Complete your assigned scope and return evidence.";
100
+ const delegationInstruction = targets.length ? `Use alp_delegate to assign bounded work to: ${targets.join(", ")}. Delegation has two finite budgets shared by the entire root session tree: 16 implementation starts (lead/peer) and separately 16 read-only advisor starts (reviewer/oracle). Only a new accepted user prompt in root, including steering, resets both; child turns, mail wakes and approval answers do not. Started assignments count even when they fail. Work runs in the background: by default alp_delegate starts the assignment and returns its assignmentId at once, and its result, questions and stall reports come to you as mail. Pass wait: true only when your very next step cannot go on without the result; it then returns the result, or early with the child's first question. A result carries the child's structured handoff (null if it filed none) and output, its final message. While assignments run, go on with work that does not depend on them; when only their results are left, alp_wait for them (mail from your requester or the user ends the wait early), or end your turn and ALP wakes you with their results and questions. Answer questions with alp_send kind answer and replyTo; use kind steer to change an instruction, note for information. To stop an assignment, for example one parked on a usage limit whose work should go on now, use alp_cancel and delegate the rest, on another runtime (pass a model of claude: or codex:); its changes stay in the checkout or its worktree. At most ${mapping.workflow.maxPeers} peers may run concurrently. Concurrent peers must be read-only or isolated: pass isolation "worktree" to give a writing peer its own git worktree. A worktree result lists its branch and changed files; apply it with alp_merge (uncommitted, conflicts left as markers) or drop it with alp_discard, then verify. Writers in this shared checkout run one at a time. To learn why a finished assignment did something, ask it with alp_recall rather than guessing from its handoff. When the project configures verify commands, ALP runs them before alp_merge applies a change, and after a writer in your checkout finishes; a failed check applies nothing. To fix a failed worktree change, delegate again with continueFrom set to its assignmentId. Do not run shell/file mutations in parallel with delegation. Include scope, constraints, verification, and required handoff in task. Child inherits your mode unless you request read-only. Review its returned evidence before answering. Do not use native spawn tools or shell-launched agents to bypass this route.` : "No delegation targets are authorized. Do not spawn agents or use shell-launched agents. Complete your assigned scope and return evidence.";
101
101
  return {
102
102
  runtime: runtimeKind,
103
103
  cwd: mapping.workdir,
@@ -123,7 +123,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
123
123
  !parentAgent && mapping.agent.name === mainOf(mapping) ? `Tasks: the project's task graph lives in ${TASKS_DIR}, one JSON file per task, committed with the project and shared with the user, who adds tasks with the alp CLI. Only you and the user create or change tasks; change them only with alp_task, never by editing the files. Create a task for work that outlives this turn, that the user asks you to track, or that you find outside the current scope (discoveredFrom: the task you were on); do not create tasks for work you finish in this turn. Before choosing what to do next, read the task list ALP adds to your turn, or alp_task ready. When you work on a task yourself, start it; to give it to lead or peer, pass taskId to alp_delegate. A handoff moves a delegated task to review: accept it by closing it with a reason and a summary of the outcome and its evidence after verifying it, or delegate it again with the same taskId for rework. Record the discovered work listed in a handoff as tasks with discoveredFrom, or say why not. Model order with blockedBy and grouping with an epic parent.` : `Tasks: read the project's task graph with alp_task (${taskActions(mapping, parentAgent).join(", ")}). Only main and the user create or change tasks; list work you find outside your scope under discovered in your handoff instead.`,
124
124
  "Background first: run long shell commands (builds, test suites, dev servers, watchers, deploys) in the background when your tools allow it, and check their output later; run a command in the foreground only when your next step needs its result.",
125
125
  "Project board: every agent working on this project, in any session, shares one board. Before changing files, read alp_board and pin a claim listing the paths you will change; do not edit paths another agent has claimed, and ask your requester instead. Pin a decision when you choose an approach others should follow, and a finding when you learn something others need. Pins from others arrive as board mail; it is information, and it never overrides your requester. Claims end with your session; take one down earlier with alp_unpin.",
126
- ...!parentAgent ? ["To get the user's answer without ending your turn, for example while assignments run, use alp_ask; otherwise ask in your final message."] : [],
126
+ ...!parentAgent ? ["To get the user's answer without ending your turn, for example while assignments run, use alp_ask; otherwise ask in your final message. Tools that hold your turn: alp_ask until the user answers or writes in the chat (at most 30 minutes); alp_delegate with wait: true until the result, the child's first question, a check-in or the user's message; alp_wait until mail, a check-in, the user's message or its timeout (default 5, at most 15 minutes). Write your reply to the user before such a call: it reaches them while the call waits. Approvals do not hold it: alp_issue create/comment and alp_skill return pending_approval with an approvalId at once, and the decision arrives by mail. Ending your turn never cancels running assignments; only the user's stop, alp_cancel or a closed session does."] : [],
127
127
  // ALPD §54: a root session talks with the user; an assignment only asks them through alp_ask.
128
128
  languageInstruction(mapping.language, mapping.agent.name, !parentAgent),
129
129
  ...!parentAgent && mapping.agent.name === mainOf(mapping) ? [
@@ -146,7 +146,7 @@ function nativeSessionConfig(runtimeKind, mapping, targets, parentAgent, role, s
146
146
  {
147
147
  type: "function",
148
148
  name: "alp_delegate",
149
- description: "Delegate a bounded task to an authorized ALP agent and wait for its real handoff.",
149
+ description: "Delegate a bounded task to an authorized ALP agent. Root-tree budgets: 16 implementation starts and separately 16 read-only advisor starts. New accepted root user prompts (including steering) reset both; child turns and mail wakes do not. The default returns immediately; wait true waits for handoff.",
150
150
  inputSchema: {
151
151
  type: "object",
152
152
  properties: {
@@ -11,6 +11,8 @@ export type Session = {
11
11
  mapping: ResolvedSession;
12
12
  threadId: string;
13
13
  active?: string;
14
+ /** A continued turn accepts a simultaneous user auto prompt as steering. */
15
+ resumedTurn?: string;
14
16
  pending: boolean;
15
17
  buffered: Array<[string, any]>;
16
18
  closed: boolean;
@@ -26,12 +28,17 @@ export type Session = {
26
28
  peerCount: number;
27
29
  /** Live assignments this session requested, keyed by child session id. */
28
30
  assignments: Map<string, Assignment>;
31
+ /** Root-tree implementation starts since the last new root user turn. */
29
32
  calls: number;
33
+ /** Separate finite budget for mandatory read-only reviewer/oracle starts. */
34
+ advisorCalls: number;
30
35
  mail: MailEvent[];
31
36
  waiters: Waiter[];
32
37
  lastActivity: number;
33
- /** Auto-wakes since the last user prompt; capped to stop runaway loops. Check-ins do not count. */
38
+ /** Auto-wakes since the last user prompt or new result/question; check-ins do not count. */
34
39
  wakes: number;
40
+ /** Failed wake starts since fresh result/question/check-in mail or a user prompt. */
41
+ wakeFailures: number;
35
42
  /** When it last got a check-in, or when its assignments started running. */
36
43
  checkInAt?: number;
37
44
  /** Set by interrupt: pending mail waits for the next user prompt. */
@@ -257,7 +257,10 @@ function antigravityToolDetail(info) {
257
257
  return { type: "unknown", input: tool?.parameters ?? null, output: tool?.error?.message ?? output ?? null };
258
258
  }
259
259
  function withHeaders(path, diff) {
260
- return /^(--- |diff --git )/.test(diff) ? diff : `--- a/${path}
260
+ if (/^(--- |diff --git )/.test(diff)) return diff;
261
+ if (diff.startsWith("+++ ")) return `--- a/${path}
262
+ ${diff}`;
263
+ return `--- a/${path}
261
264
  +++ b/${path}
262
265
  ${diff}`;
263
266
  }
package/dist/tools.d.ts CHANGED
@@ -418,6 +418,10 @@ export declare const SKILL_TOOL: {
418
418
  type: string;
419
419
  description: string;
420
420
  };
421
+ requestId: {
422
+ type: string;
423
+ description: string;
424
+ };
421
425
  };
422
426
  required: string[];
423
427
  additionalProperties: boolean;
@@ -454,6 +458,10 @@ export declare const ISSUE_TOOL: {
454
458
  type: string;
455
459
  description: string;
456
460
  };
461
+ requestId: {
462
+ type: string;
463
+ description: string;
464
+ };
457
465
  labels: {
458
466
  type: string;
459
467
  items: {
package/dist/tools.js CHANGED
@@ -64,7 +64,7 @@ var HANDOFF_TOOL = {
64
64
  var WAIT_TOOL = {
65
65
  type: "function",
66
66
  name: "alp_wait",
67
- description: "Wait for mail from your assignments: results, questions, notes, and stall reports. Returns as soon as any arrives, or with an empty list and a status snapshot on timeout.",
67
+ description: "Wait for mail from your assignments: results, questions, notes, and stall reports. Returns as soon as any arrives, a check-in comes or the user writes to you; otherwise with an empty list and a status snapshot after timeoutMs (default 5 minutes, at most 15). Ending your turn instead never cancels your assignments.",
68
68
  inputSchema: {
69
69
  type: "object",
70
70
  properties: {
@@ -196,7 +196,7 @@ var UNPIN_TOOL = {
196
196
  var ASK_TOOL = {
197
197
  type: "function",
198
198
  name: "alp_ask",
199
- description: "Ask a question and wait for the answer: your requester, or the user. Only main talks to the user, unless the user has written to you. Returns unanswered after the ask timeout.",
199
+ description: "Ask a question and wait for the answer: your requester, or the user. Only main talks to the user, unless the user has written to you. Holds your turn until answered; returns unanswered after the ask timeout (15 minutes for a requester, 30 for the user), and user_wrote when the user writes in the chat instead of answering.",
200
200
  inputSchema: {
201
201
  type: "object",
202
202
  properties: {
@@ -225,7 +225,7 @@ var LESSON_TOOL = {
225
225
  var SKILL_TOOL = {
226
226
  type: "function",
227
227
  name: "alp_skill",
228
- description: "Propose a skill distilled from your lessons. ALP shows the whole skill to the user, and saves it to the user's skill library and gives it to the roles only when the user approves; any other answer comes back as feedback.",
228
+ description: "Propose a skill distilled from your lessons. ALP shows the whole skill to the user, and saves it to the user's skill library and gives it to the roles only when the user approves. Returns pending_approval with approvalId immediately; the decision (saved, or not saved with the user's feedback) arrives once by mail, even after your turn ends. Continue other work. A retry of the same draft returns its existing pending/result in this session; use a fresh requestId only to propose an identical draft again.",
229
229
  inputSchema: {
230
230
  type: "object",
231
231
  properties: {
@@ -234,7 +234,8 @@ var SKILL_TOOL = {
234
234
  body: { type: "string", description: "The SKILL.md body in Markdown, without frontmatter: when to use it, the method as steps, and the checks." },
235
235
  roles: { type: "array", items: { type: "string" }, description: "The roles whose work the skill guides, and only those: any of main (yourself), lead, peer, oracle, reviewer, supervisor, or a custom agent of this project." },
236
236
  lessons: { type: "array", items: { type: "string" }, description: "The exact text of the lessons this skill replaces; they leave the lessons files once it is saved." },
237
- replace: { type: "boolean", description: "Propose a new version of an existing skill of this name." }
237
+ replace: { type: "boolean", description: "Propose a new version of an existing skill of this name." },
238
+ requestId: { type: "string", description: "Optional session-scoped idempotency key (1-100 characters). Reuse with the same draft on retries; a fresh key explicitly proposes a new operation." }
238
239
  },
239
240
  required: ["name", "description", "body", "roles"],
240
241
  additionalProperties: false
@@ -243,7 +244,7 @@ var SKILL_TOOL = {
243
244
  var ISSUE_TOOL = {
244
245
  type: "function",
245
246
  name: "alp_issue",
246
- description: "GitHub issues of this project (target project, from its origin remote) or of ALP itself (target alp). search needs no approval. create and comment show the whole draft to the user and post it only when the user approves; any other answer comes back as feedback.",
247
+ description: "GitHub issues of this project (target project, from its origin remote) or of ALP itself (target alp). search needs no approval. create and comment show the whole draft to the user and post it only when the user approves; create/comment return pending_approval with approvalId immediately, then send one decision/posting result by mail, even after your turn ends. Continue other work. Draft retries return the existing pending/result in this session. Use a fresh requestId only to intentionally propose an identical draft again; reuse one requestId with the exact draft to replay safely.",
247
248
  inputSchema: {
248
249
  type: "object",
249
250
  properties: {
@@ -253,6 +254,7 @@ var ISSUE_TOOL = {
253
254
  title: { type: "string", description: "For create." },
254
255
  body: { type: "string", description: "For create and comment: Markdown. Facts, reproduction and evidence; no secrets." },
255
256
  issue: { type: "integer", description: "For comment: the issue number." },
257
+ requestId: { type: "string", description: "Optional session-scoped idempotency key (1-100 characters). Reuse with the same draft on retries; a fresh key explicitly proposes a new operation." },
256
258
  labels: { type: "array", items: { type: "string" }, description: "For create: existing labels of the repository." }
257
259
  },
258
260
  required: ["action", "target"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anhlp/alp-runtime",
3
- "version": "0.0.0-beta.2",
3
+ "version": "0.0.1-beta.3",
4
4
  "type": "module",
5
5
  "description": "ALP runtime module",
6
6
  "files": [
@@ -222,8 +222,8 @@
222
222
  "node": ">=22.12"
223
223
  },
224
224
  "dependencies": {
225
- "@anhlp/alp-core": "0.0.0-beta.2",
226
- "@anhlp/alp-protocol": "0.0.0-beta.2",
225
+ "@anhlp/alp-core": "0.0.1-beta.3",
226
+ "@anhlp/alp-protocol": "0.0.1-beta.3",
227
227
  "@anthropic-ai/claude-agent-sdk": "^0.3.292",
228
228
  "@modelcontextprotocol/sdk": "^1.32.1",
229
229
  "@agentclientprotocol/sdk": "1.7.0",
@@ -68,11 +68,13 @@ export const toolShapes: Record<string, Record<string, z.ZodType>> = {
68
68
  roles: z.array(z.string().min(1)).min(1).max(10),
69
69
  lessons: z.array(z.string().min(1).max(600)).max(50).optional(),
70
70
  replace: z.boolean().optional(),
71
+ requestId: z.string().min(1).max(100).optional(),
71
72
  },
72
73
  alp_issue: {
73
74
  action: z.enum(['search', 'create', 'comment']),
74
75
  target: z.enum(['project', 'alp']),
75
76
  query: z.string().min(1).max(200).optional(),
77
+ requestId: z.string().min(1).max(100).optional(),
76
78
  title: z.string().min(1).max(200).optional(),
77
79
  body: z.string().min(1).max(20_000).optional(),
78
80
  issue: z.number().int().positive().optional(),
@@ -344,7 +346,8 @@ export class ClaudeTransport {
344
346
  private threadId = '';
345
347
  private activeTurn?: string;
346
348
  private closed = false;
347
- private assistantText = new Map<string, string>();
349
+ private assistantText = new Map<string, { blocks: Map<number, { text: string; final: boolean }>; seen: Set<string> }>();
350
+ private streamingMessage?: string;
348
351
  private toolCalls = new Map<string, { name: string; command: string; input: unknown }>();
349
352
  /** The last rate limit Claude reported, and whether the running turn hit one. */
350
353
  private rateLimit?: { status: string; resetsAt?: number; overageStatus?: string; rateLimitType?: string; utilization?: number };
@@ -433,6 +436,7 @@ export class ClaudeTransport {
433
436
  }
434
437
 
435
438
  if (method === 'thread/start' || method === 'thread/resume' || method === 'thread/fork') {
439
+ this.clearAssistantText();
436
440
  this.config = params as NativeConfig;
437
441
  this.threadId =
438
442
  method === 'thread/resume' ? params.threadId : randomUUID();
@@ -451,7 +455,10 @@ export class ClaudeTransport {
451
455
  const turnId = method === 'turn/steer'
452
456
  ? params.expectedTurnId
453
457
  : randomUUID();
454
- if (method === 'turn/start') this.activeTurn = turnId;
458
+ if (method === 'turn/start') {
459
+ this.clearAssistantText();
460
+ this.activeTurn = turnId;
461
+ }
455
462
  this.input.push({
456
463
  type: 'user',
457
464
  message: {
@@ -466,6 +473,7 @@ export class ClaudeTransport {
466
473
  }
467
474
 
468
475
  if (method === 'turn/interrupt') {
476
+ this.streamingMessage = undefined;
469
477
  await this.query?.interrupt();
470
478
  return {};
471
479
  }
@@ -574,7 +582,7 @@ export class ClaudeTransport {
574
582
  ...(plugins.length ? { plugins } : {}),
575
583
  persistSession: !config.ephemeral,
576
584
  promptSuggestions: false,
577
- includePartialMessages: false,
585
+ includePartialMessages: true,
578
586
  ...(from ? { resume: from.resume, ...(from.fork ? { forkSession: true, sessionId: this.threadId } : {}) } : { sessionId: this.threadId }),
579
587
  // Explicit native path avoids SDK optional binaries inside Electron app.asar.
580
588
  pathToClaudeCodeExecutable: this.command,
@@ -620,6 +628,7 @@ export class ClaudeTransport {
620
628
  }
621
629
 
622
630
  private handle(message: SDKMessage) {
631
+ if (this.closed) return;
623
632
  if (message.type === 'rate_limit_event') {
624
633
  this.rateLimit = message.rate_limit_info;
625
634
  this.emit('account/rateLimits/updated', { claude: message.rate_limit_info });
@@ -627,10 +636,36 @@ export class ClaudeTransport {
627
636
  }
628
637
  // Claude starts a turn by itself when a background task it ran ends (ALPD §56). Adopt it, so
629
638
  // ALP's tools work in it and its end is reported like any other turn's.
630
- if (message.type === 'assistant' && !message.parent_tool_use_id && !this.activeTurn && !this.closed) {
639
+ const streamStart = message.type === 'stream_event' && message.event?.type === 'message_start';
640
+ if ((message.type === 'assistant' || streamStart) && !message.parent_tool_use_id && !this.activeTurn) {
631
641
  this.activeTurn = randomUUID();
632
642
  this.emit('turn/started', { threadId: this.threadId, turn: { id: this.activeTurn } });
633
643
  }
644
+ if (message.type === 'stream_event') {
645
+ if (message.parent_tool_use_id) return;
646
+ const event = message.event;
647
+ if (event.type === 'message_start') {
648
+ this.streamingMessage = event.message.id;
649
+ } else if (event.type === 'message_stop') {
650
+ this.streamingMessage = undefined;
651
+ } else if (this.streamingMessage) {
652
+ const id = this.streamingMessage;
653
+ const state = this.assistantState(id);
654
+ let delta: string | undefined;
655
+ if (event.type === 'content_block_start' && event.content_block.type === 'text') {
656
+ state.blocks.set(event.index, { text: event.content_block.text, final: false });
657
+ delta = event.content_block.text;
658
+ } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
659
+ const block = state.blocks.get(event.index);
660
+ if (block && !block.final) {
661
+ block.text += event.delta.text;
662
+ delta = event.delta.text;
663
+ }
664
+ }
665
+ if (delta) this.emit('item/agentMessage/delta', { threadId: this.threadId, turnId: this.activeTurn, itemId: id, delta });
666
+ }
667
+ return;
668
+ }
634
669
  if (message.type === 'assistant' && message.error === 'rate_limit') this.limitedTurn = true;
635
670
  // A compaction, reported as Codex reports one: a contextCompaction item from start to end (ALPD §57).
636
671
  if (message.type === 'system' && (message.subtype === 'status' || message.subtype === 'compact_boundary')) {
@@ -671,14 +706,21 @@ export class ClaudeTransport {
671
706
  if (windows.length && !this.compactAt) this.contextWindow = Math.max(...windows);
672
707
  }
673
708
  if (message.type === 'assistant' && !message.parent_tool_use_id) {
709
+ const id = message.message.id;
710
+ const state = this.assistantState(id);
711
+ if (message.uuid && state.seen.has(message.uuid)) return;
712
+ if (message.uuid) state.seen.add(message.uuid);
674
713
  for (const block of message.message.content as any[]) {
675
714
  if (block.type === 'text') {
676
- const id = message.message.id;
677
- const text = (this.assistantText.get(id) ?? '') + block.text;
678
- this.assistantText.set(id, text);
715
+ // SDK 0.3.292 delivers one authoritative assistant event per completed
716
+ // block, sharing message.id. Replace that block's draft, never append
717
+ // the final text to its streamed draft or replace earlier final blocks.
718
+ const pending = [...state.blocks.entries()].sort(([a], [b]) => a - b).find(([, value]) => !value.final);
719
+ const index = pending?.[0] ?? Math.max(-1, ...state.blocks.keys()) + 1;
720
+ state.blocks.set(index, { text: block.text, final: true });
679
721
  this.emit('item/completed', {
680
722
  threadId: this.threadId,
681
- item: { type: 'agentMessage', id, text },
723
+ item: { type: 'agentMessage', id, text: [...state.blocks.entries()].sort(([a], [b]) => a - b).map(([, block]) => block.text).join('') },
682
724
  });
683
725
  } else if (block.type === 'tool_use') {
684
726
  const command = block.name === 'Bash'
@@ -737,6 +779,7 @@ export class ClaudeTransport {
737
779
  return;
738
780
  }
739
781
 
782
+ if (message.type === 'result') this.clearAssistantText();
740
783
  if (message.type === 'result' && this.activeTurn) {
741
784
  const turnId = this.activeTurn;
742
785
  this.activeTurn = undefined;
@@ -764,8 +807,20 @@ export class ClaudeTransport {
764
807
  for (const listener of this.listeners) listener(method, params);
765
808
  }
766
809
 
810
+ private assistantState(id: string) {
811
+ let state = this.assistantText.get(id);
812
+ if (!state) this.assistantText.set(id, state = { blocks: new Map(), seen: new Set() });
813
+ return state;
814
+ }
815
+
816
+ private clearAssistantText() {
817
+ this.streamingMessage = undefined;
818
+ this.assistantText.clear();
819
+ }
820
+
767
821
  private fail(value: unknown) {
768
822
  if (this.closed) return;
823
+ this.clearAssistantText();
769
824
  const error = value instanceof Error ? value : new Error(String(value));
770
825
  for (const listener of this.failures) listener(error);
771
826
  }
@@ -773,6 +828,7 @@ export class ClaudeTransport {
773
828
  async close() {
774
829
  if (this.closed) return;
775
830
  this.closed = true;
831
+ this.clearAssistantText();
776
832
  this.input.close();
777
833
  this.query?.close();
778
834
  await this.pump?.catch(() => {});
package/src/contracts.ts CHANGED
@@ -147,8 +147,8 @@ export type AlpRuntime = {
147
147
  recall(target: { assignmentId?: string; taskId?: string; projectRoot?: string }, question: string): Promise<RecallAnswer>;
148
148
  /** Assignments an earlier alpd left running, which recover() can continue once their root is open again. */
149
149
  recoverable(): LiveEntry[];
150
- /** Continues the assignments an earlier alpd left running in an open root's tree; with continueRoot, the root's own turn too. */
151
- recover(rootId: string, options?: { continueRoot?: boolean }): Promise<RecoveryOutcome[]>;
150
+ /** Continues work in a reopened tree. continueRoot resumes its running turn; parkedRoot restores its held continuation intent. */
151
+ recover(rootId: string, options?: { continueRoot?: boolean; parkedRoot?: string }): Promise<RecoveryOutcome[]>;
152
152
  /** Gives up the assignments of a tree whose root cannot be opened again: their tasks go back to open. */
153
153
  abandon(rootId: string, reason: string): Promise<RecoveryOutcome[]>;
154
154
  /** Closes everything; assignments still running stay in the live file, so the next alpd continues them. */
@@ -35,13 +35,13 @@ export function claudeUsage(result: any) {
35
35
 
36
36
  /**
37
37
  * Whether a runtime's usage report (codexUsage or claudeUsage) lets it work again (ALPD §58):
38
- * true when every window has room, false when one is used up or spending is capped, and
38
+ * true when every window has room, false on explicit provider denial, a used-up window or spending cap, and
39
39
  * undefined when the report says nothing either way.
40
40
  */
41
41
  export function usageAllows(usage: any): boolean | undefined {
42
42
  if (!usage?.available) return undefined;
43
43
  const limits: any[] = Array.isArray(usage.limits) ? usage.limits : [];
44
- if (limits.some(limit => limit?.spendControlReached === true)) return false;
44
+ if (usage.ordinaryUsageAllowed === false || limits.some(limit => limit?.spendControlReached === true)) return false;
45
45
  const windows: any[] = limits.length ? limits.flatMap(limit => limit?.windows ?? []) : usage.windows ?? [];
46
46
  const known = windows.filter(window => typeof window?.usedPercent === 'number');
47
47
  if (!known.length) return undefined;