klaus-agent 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/approval/approval.d.ts +8 -3
  2. package/dist/approval/approval.js +23 -23
  3. package/dist/approval/approval.js.map +1 -1
  4. package/dist/approval/types.d.ts +1 -0
  5. package/dist/background/task-manager.d.ts +2 -0
  6. package/dist/background/task-manager.js +14 -0
  7. package/dist/background/task-manager.js.map +1 -1
  8. package/dist/compaction/compaction.d.ts +0 -1
  9. package/dist/compaction/compaction.js +21 -35
  10. package/dist/compaction/compaction.js.map +1 -1
  11. package/dist/compaction/summarizer.js +2 -7
  12. package/dist/compaction/summarizer.js.map +1 -1
  13. package/dist/core/agent-loop.d.ts +2 -1
  14. package/dist/core/agent-loop.js +14 -9
  15. package/dist/core/agent-loop.js.map +1 -1
  16. package/dist/core/agent.d.ts +1 -0
  17. package/dist/core/agent.js +11 -3
  18. package/dist/core/agent.js.map +1 -1
  19. package/dist/injection/history-normalizer.js +20 -10
  20. package/dist/injection/history-normalizer.js.map +1 -1
  21. package/dist/llm/types.d.ts +2 -0
  22. package/dist/multi-agent/task-executor.d.ts +2 -1
  23. package/dist/multi-agent/types.d.ts +3 -0
  24. package/dist/planning/planning-manager.d.ts +2 -0
  25. package/dist/planning/planning-manager.js +6 -0
  26. package/dist/planning/planning-manager.js.map +1 -1
  27. package/dist/providers/anthropic.js +14 -6
  28. package/dist/providers/anthropic.js.map +1 -1
  29. package/dist/providers/google.js.map +1 -1
  30. package/dist/providers/openai-codex.js +5 -8
  31. package/dist/providers/openai-codex.js.map +1 -1
  32. package/dist/providers/openai.js +3 -2
  33. package/dist/providers/openai.js.map +1 -1
  34. package/dist/providers/shared.d.ts +2 -2
  35. package/dist/providers/shared.js +11 -6
  36. package/dist/providers/shared.js.map +1 -1
  37. package/dist/session/session-manager.d.ts +1 -0
  38. package/dist/session/session-manager.js +11 -1
  39. package/dist/session/session-manager.js.map +1 -1
  40. package/dist/task-graph/task-graph.d.ts +3 -0
  41. package/dist/task-graph/task-graph.js +38 -13
  42. package/dist/task-graph/task-graph.js.map +1 -1
  43. package/dist/tools/executor.d.ts +3 -2
  44. package/dist/tools/mcp-adapter.js +22 -5
  45. package/dist/tools/mcp-adapter.js.map +1 -1
  46. package/dist/utils/id.js +2 -6
  47. package/dist/utils/id.js.map +1 -1
  48. package/dist/wire/wire.d.ts +2 -1
  49. package/package.json +1 -1
  50. package/src/approval/approval.ts +29 -23
  51. package/src/approval/types.ts +1 -0
  52. package/src/background/task-manager.ts +17 -0
  53. package/src/compaction/compaction.ts +23 -36
  54. package/src/compaction/summarizer.ts +2 -7
  55. package/src/core/agent-loop.ts +17 -10
  56. package/src/core/agent.ts +12 -3
  57. package/src/injection/history-normalizer.ts +22 -12
  58. package/src/llm/types.ts +2 -0
  59. package/src/multi-agent/task-executor.ts +1 -1
  60. package/src/multi-agent/types.ts +3 -0
  61. package/src/planning/planning-manager.ts +8 -0
  62. package/src/providers/anthropic.ts +70 -57
  63. package/src/providers/google.ts +1 -1
  64. package/src/providers/openai-codex.ts +7 -2
  65. package/src/providers/openai.ts +8 -3
  66. package/src/providers/shared.ts +11 -6
  67. package/src/session/session-manager.ts +15 -4
  68. package/src/task-graph/task-graph.ts +41 -13
  69. package/src/tools/executor.ts +2 -2
  70. package/src/tools/mcp-adapter.ts +23 -7
  71. package/src/utils/id.ts +3 -6
  72. package/src/wire/wire.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  // History normalizer — merge adjacent user messages
2
2
 
3
- import type { AgentMessage, Message } from "../types.js";
3
+ import type { AgentMessage, Message, ContentBlock } from "../types.js";
4
4
 
5
5
  export function normalizeHistory(messages: AgentMessage[]): AgentMessage[] {
6
6
  if (messages.length <= 1) return messages;
@@ -23,17 +23,27 @@ export function normalizeHistory(messages: AgentMessage[]): AgentMessage[] {
23
23
  (prev as Message).role === "user"
24
24
  ) {
25
25
  const prevMsg = prev as Message & { role: "user" };
26
- const prevText = typeof prevMsg.content === "string"
27
- ? prevMsg.content
28
- : prevMsg.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n");
29
- const currentText = typeof current.content === "string"
30
- ? current.content
31
- : current.content.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("\n");
32
-
33
- result[result.length - 1] = {
34
- role: "user",
35
- content: prevText + "\n" + currentText,
36
- };
26
+ const prevBlocks = typeof prevMsg.content === "string"
27
+ ? [{ type: "text" as const, text: prevMsg.content }]
28
+ : prevMsg.content;
29
+ const currentBlocks = typeof current.content === "string"
30
+ ? [{ type: "text" as const, text: current.content }]
31
+ : current.content;
32
+
33
+ const merged: ContentBlock[] = [...prevBlocks, ...currentBlocks];
34
+
35
+ // Optimize: if all blocks are text, collapse to a single string
36
+ if (merged.every((b) => b.type === "text")) {
37
+ result[result.length - 1] = {
38
+ role: "user",
39
+ content: merged.map((b) => (b as { text: string }).text).join("\n"),
40
+ };
41
+ } else {
42
+ result[result.length - 1] = {
43
+ role: "user",
44
+ content: merged,
45
+ };
46
+ }
37
47
  continue;
38
48
  }
39
49
 
package/src/llm/types.ts CHANGED
@@ -76,6 +76,8 @@ export interface TextBlock {
76
76
  export interface ThinkingBlock {
77
77
  type: "thinking";
78
78
  thinking: string;
79
+ /** Opaque signature returned by the provider; must be echoed back in subsequent requests. */
80
+ signature?: string;
79
81
  }
80
82
 
81
83
  export type AssistantContentBlock = TextBlock | ToolCallBlock | ThinkingBlock;
@@ -4,7 +4,7 @@ import type { Agent } from "../core/agent.js";
4
4
  import type { LaborMarket } from "./labor-market.js";
5
5
  import type { AgentMessage, AgentEvent, AssistantMessage } from "../types.js";
6
6
 
7
- export interface TaskResult {
7
+ interface TaskResult {
8
8
  messages: AgentMessage[];
9
9
  lastAssistantMessage?: AssistantMessage;
10
10
  }
@@ -1,10 +1,13 @@
1
1
  // Multi-agent types
2
2
 
3
3
  import type { AgentTool } from "../tools/types.js";
4
+ import type { ModelConfig } from "../llm/types.js";
4
5
 
5
6
  export interface SubagentConfig {
6
7
  name: string;
7
8
  systemPrompt: string | (() => string | Promise<string>);
8
9
  tools?: AgentTool[];
9
10
  description: string;
11
+ /** Override the parent agent's model config. If omitted, inherits the parent's model. */
12
+ model?: ModelConfig;
10
13
  }
@@ -4,6 +4,9 @@ import type { TodoItem, TodoStatus, PlanPhase, PlanningState, PlanningConfig } f
4
4
  import { PLANNING_TOOL_NAMES } from "./types.js";
5
5
  import { generateId } from "../utils/id.js";
6
6
 
7
+ /** Number of built-in tools always added to allowedInPlanning (todo + plan_mode). */
8
+ const BUILT_IN_PLANNING_TOOL_COUNT = Object.keys(PLANNING_TOOL_NAMES).length;
9
+
7
10
  export class PlanningManager {
8
11
  private _state: PlanningState;
9
12
  private _config: PlanningConfig;
@@ -43,6 +46,11 @@ export class PlanningManager {
43
46
  return this._allowedInPlanning;
44
47
  }
45
48
 
49
+ /** Whether user configured read-only tools beyond the built-in planning tools. */
50
+ get hasConfiguredReadOnlyTools(): boolean {
51
+ return this._allowedInPlanning.size > BUILT_IN_PLANNING_TOOL_COUNT;
52
+ }
53
+
46
54
  // --- Phase control ---
47
55
 
48
56
  startExecution(): string {
@@ -11,6 +11,12 @@ import type {
11
11
  } from "../llm/types.js";
12
12
  import { withRetry, RETRYABLE_PATTERNS, mapThinkingBudget } from "./shared.js";
13
13
 
14
+ // Anthropic SDK type extension not yet in published typings
15
+ interface ContentBlockDeltaSignature {
16
+ type: "signature_delta";
17
+ signature: string;
18
+ }
19
+
14
20
  export class AnthropicProvider implements LLMProvider {
15
21
  private client: Anthropic;
16
22
 
@@ -72,70 +78,77 @@ export class AnthropicProvider implements LLMProvider {
72
78
  const stream = this.client.messages.stream(params, { signal });
73
79
 
74
80
  for await (const event of stream) {
75
- if (event.type === "content_block_start") {
76
- const block = event.content_block;
77
- if (block.type === "text") {
78
- contentBlocks.push({ type: "text", text: "" });
79
- } else if (block.type === "tool_use") {
80
- contentBlocks.push({ type: "tool_call", id: block.id, name: block.name, input: {} });
81
- toolInputBuffers.set(event.index, "");
82
- yield { type: "tool_call_start", id: block.id, name: block.name };
83
- } else if (block.type === "thinking") {
84
- contentBlocks.push({ type: "thinking", thinking: "" });
85
- }
86
- } else if (event.type === "content_block_delta") {
87
- const delta = event.delta;
88
- if (delta.type === "text_delta") {
89
- const block = contentBlocks[event.index];
90
- if (block && block.type === "text") {
91
- block.text += delta.text;
92
- }
93
- yield { type: "text", text: delta.text };
94
- } else if (delta.type === "input_json_delta") {
95
- const buf = (toolInputBuffers.get(event.index) ?? "") + delta.partial_json;
96
- toolInputBuffers.set(event.index, buf);
97
- const block = contentBlocks[event.index];
98
- if (block && block.type === "tool_call") {
99
- yield { type: "tool_call_delta", id: block.id, input: delta.partial_json };
100
- }
101
- } else if (delta.type === "thinking_delta") {
102
- const block = contentBlocks[event.index];
103
- if (block && block.type === "thinking") {
104
- block.thinking += delta.thinking;
105
- }
106
- yield { type: "thinking", thinking: delta.thinking };
81
+ if (event.type === "content_block_start") {
82
+ const block = event.content_block;
83
+ if (block.type === "text") {
84
+ contentBlocks.push({ type: "text", text: "" });
85
+ } else if (block.type === "tool_use") {
86
+ contentBlocks.push({ type: "tool_call", id: block.id, name: block.name, input: {} });
87
+ toolInputBuffers.set(event.index, "");
88
+ yield { type: "tool_call_start", id: block.id, name: block.name };
89
+ } else if (block.type === "thinking") {
90
+ contentBlocks.push({ type: "thinking", thinking: "" });
91
+ }
92
+ } else if (event.type === "content_block_delta") {
93
+ const delta = event.delta;
94
+ if (delta.type === "text_delta") {
95
+ const block = contentBlocks[event.index];
96
+ if (block && block.type === "text") {
97
+ block.text += delta.text;
107
98
  }
108
- } else if (event.type === "content_block_stop") {
99
+ yield { type: "text", text: delta.text };
100
+ } else if (delta.type === "input_json_delta") {
101
+ const buf = (toolInputBuffers.get(event.index) ?? "") + delta.partial_json;
102
+ toolInputBuffers.set(event.index, buf);
109
103
  const block = contentBlocks[event.index];
110
104
  if (block && block.type === "tool_call") {
111
- const buf = toolInputBuffers.get(event.index) ?? "{}";
112
- try {
113
- block.input = JSON.parse(buf || "{}");
114
- } catch {
115
- block.input = {};
116
- }
117
- toolInputBuffers.delete(event.index);
105
+ yield { type: "tool_call_delta", id: block.id, input: delta.partial_json };
106
+ }
107
+ } else if (delta.type === "thinking_delta") {
108
+ const block = contentBlocks[event.index];
109
+ if (block && block.type === "thinking") {
110
+ block.thinking += delta.thinking;
118
111
  }
119
- } else if (event.type === "message_delta") {
120
- if (event.usage) {
121
- usage = {
122
- inputTokens: usage.inputTokens,
123
- outputTokens: event.usage.output_tokens,
124
- totalTokens: usage.inputTokens + event.usage.output_tokens,
125
- };
112
+ yield { type: "thinking", thinking: delta.thinking };
113
+ } else if ((delta as unknown as ContentBlockDeltaSignature).type === "signature_delta") {
114
+ const sigDelta = delta as unknown as ContentBlockDeltaSignature;
115
+ const block = contentBlocks[event.index];
116
+ if (block && block.type === "thinking") {
117
+ block.signature = (block.signature ?? "") + sigDelta.signature;
126
118
  }
127
- } else if (event.type === "message_start") {
128
- if (event.message.usage) {
129
- usage = {
130
- inputTokens: event.message.usage.input_tokens,
131
- outputTokens: event.message.usage.output_tokens,
132
- totalTokens: event.message.usage.input_tokens + event.message.usage.output_tokens,
133
- cacheReadTokens: (event.message.usage as any).cache_read_input_tokens,
134
- cacheWriteTokens: (event.message.usage as any).cache_creation_input_tokens,
135
- };
119
+ }
120
+ } else if (event.type === "content_block_stop") {
121
+ const block = contentBlocks[event.index];
122
+ if (block && block.type === "tool_call") {
123
+ const buf = toolInputBuffers.get(event.index) ?? "{}";
124
+ try {
125
+ block.input = JSON.parse(buf || "{}");
126
+ } catch {
127
+ block.input = {};
136
128
  }
129
+ toolInputBuffers.delete(event.index);
130
+ }
131
+ } else if (event.type === "message_delta") {
132
+ if (event.usage) {
133
+ usage = {
134
+ inputTokens: usage.inputTokens,
135
+ outputTokens: event.usage.output_tokens,
136
+ totalTokens: usage.inputTokens + event.usage.output_tokens,
137
+ };
138
+ }
139
+ } else if (event.type === "message_start") {
140
+ if (event.message.usage) {
141
+ const u = event.message.usage;
142
+ usage = {
143
+ inputTokens: u.input_tokens,
144
+ outputTokens: u.output_tokens,
145
+ totalTokens: u.input_tokens + u.output_tokens,
146
+ cacheReadTokens: u.cache_read_input_tokens ?? undefined,
147
+ cacheWriteTokens: u.cache_creation_input_tokens ?? undefined,
148
+ };
137
149
  }
138
150
  }
151
+ }
139
152
 
140
153
  const message: AssistantMessage = { role: "assistant", content: contentBlocks };
141
154
  yield { type: "done", message, usage };
@@ -179,7 +192,7 @@ function mapAssistantBlock(block: AssistantContentBlock): Anthropic.ContentBlock
179
192
  return { type: "tool_use", id: block.id, name: block.name, input: block.input };
180
193
  }
181
194
  if (block.type === "thinking") {
182
- return { type: "thinking", thinking: block.thinking, signature: "" };
195
+ return { type: "thinking", thinking: block.thinking, signature: block.signature ?? "" };
183
196
  }
184
197
  return { type: "text", text: JSON.stringify(block) };
185
198
  }
@@ -49,7 +49,7 @@ export class GeminiProvider implements LLMProvider {
49
49
  maxOutputTokens: maxTokens ?? 8192,
50
50
  ...(thinkingBudget ? {
51
51
  thinkingConfig: { thinkingBudget },
52
- } as any : {}),
52
+ } as Record<string, unknown> : {}),
53
53
  },
54
54
  },
55
55
  this.baseUrl ? { baseUrl: this.baseUrl } : undefined,
@@ -291,6 +291,8 @@ function mapReasoningEffort(modelId: string, level?: ThinkingLevel): string | un
291
291
 
292
292
  // --- SSE parsing ---
293
293
 
294
+ const MAX_SSE_BUFFER_BYTES = 1024 * 1024; // 1MB cap to prevent unbounded accumulation
295
+
294
296
  async function* parseSSE(response: Response): AsyncGenerator<Record<string, unknown>> {
295
297
  if (!response.body) return;
296
298
 
@@ -304,6 +306,10 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
304
306
  if (done) break;
305
307
  buffer += decoder.decode(value, { stream: true });
306
308
 
309
+ if (buffer.length > MAX_SSE_BUFFER_BYTES) {
310
+ throw new Error(`SSE buffer exceeded ${MAX_SSE_BUFFER_BYTES} bytes — aborting to prevent unbounded memory growth`);
311
+ }
312
+
307
313
  let idx = buffer.indexOf("\n\n");
308
314
  while (idx !== -1) {
309
315
  const chunk = buffer.slice(0, idx);
@@ -326,8 +332,7 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
326
332
  }
327
333
  }
328
334
  } finally {
329
- try { await reader.cancel(); } catch { /* ignore */ }
330
- try { reader.releaseLock(); } catch { /* ignore */ }
335
+ reader.cancel().catch(() => {});
331
336
  }
332
337
  }
333
338
 
@@ -8,11 +8,15 @@ import type {
8
8
  AssistantMessage,
9
9
  AssistantContentBlock,
10
10
  TokenUsage,
11
- ThinkingLevel,
12
11
  Message,
13
12
  } from "../llm/types.js";
14
13
  import { withRetry, RETRYABLE_PATTERNS, mapReasoningEffort } from "./shared.js";
15
14
 
15
+ // OpenAI o1/o3 reasoning content not yet in published typings
16
+ interface DeltaWithReasoning {
17
+ reasoning_content?: string;
18
+ }
19
+
16
20
  export class OpenAIProvider implements LLMProvider {
17
21
  private client: OpenAI;
18
22
 
@@ -75,8 +79,9 @@ export class OpenAIProvider implements LLMProvider {
75
79
  }
76
80
 
77
81
  // Reasoning/thinking content (o1/o3 series)
78
- if ((delta as any).reasoning_content) {
79
- const thinking = (delta as any).reasoning_content as string;
82
+ const reasoningContent = (delta as unknown as DeltaWithReasoning).reasoning_content;
83
+ if (reasoningContent) {
84
+ const thinking = reasoningContent;
80
85
  if (contentBlocks.length === 0 || contentBlocks[contentBlocks.length - 1].type !== "thinking") {
81
86
  contentBlocks.push({ type: "thinking", thinking: "" });
82
87
  }
@@ -12,13 +12,14 @@ export const RETRYABLE_PATTERNS: Record<string, string[]> = {
12
12
  codex: [...COMMON_RETRYABLE, "rate_limit", "usage_limit", "overloaded"],
13
13
  };
14
14
 
15
- export function isRetryableError(error: Error, patterns: string[]): boolean {
15
+ function isRetryableError(error: Error, patterns: string[]): boolean {
16
16
  return patterns.some((p) => error.message.includes(p));
17
17
  }
18
18
 
19
19
  /**
20
20
  * Wraps a streaming generator with exponential backoff retry logic.
21
- * Only retries on connection-level failures before streaming starts.
21
+ * Only retries on connection-level failures before any events have been yielded.
22
+ * Once streaming starts (first event yielded), errors are not retried to avoid duplicate output.
22
23
  */
23
24
  export async function* withRetry(
24
25
  streamOnce: () => AsyncIterable<AssistantMessageEvent>,
@@ -33,15 +34,19 @@ export async function* withRetry(
33
34
  await new Promise((r) => setTimeout(r, delay));
34
35
  }
35
36
 
37
+ let hasYielded = false;
36
38
  try {
37
- yield* streamOnce();
39
+ for await (const event of streamOnce()) {
40
+ hasYielded = true;
41
+ yield event;
42
+ }
38
43
  return;
39
44
  } catch (err) {
40
45
  lastError = err instanceof Error ? err : new Error(String(err));
41
46
 
42
- if (!isRetryableError(lastError, retryablePatterns) || attempt === maxRetries) {
43
- yield { type: "error", error: lastError };
44
- return;
47
+ // If we already yielded events, don't retry caller has consumed partial output
48
+ if (hasYielded || !isRetryableError(lastError, retryablePatterns) || attempt === maxRetries) {
49
+ throw lastError;
45
50
  }
46
51
  }
47
52
  }
@@ -247,12 +247,23 @@ export class SessionManager {
247
247
  this._sessionId = (header as SessionHeader).id;
248
248
  }
249
249
 
250
- // Rest are entries
250
+ // Rest are entries — validate required fields before accepting
251
251
  for (let i = 1; i < records.length; i++) {
252
- const entry = records[i] as SessionEntry;
253
- this._entries.push(entry);
254
- this._entriesById.set(entry.id, entry);
252
+ const entry = records[i];
253
+ if (!this._isValidEntry(entry)) continue;
254
+ this._entries.push(entry as SessionEntry);
255
+ this._entriesById.set(entry.id, entry as SessionEntry);
255
256
  this._leafId = entry.id;
256
257
  }
257
258
  }
259
+
260
+ private _isValidEntry(record: unknown): record is SessionEntry {
261
+ if (!record || typeof record !== "object") return false;
262
+ const r = record as Record<string, unknown>;
263
+ return (
264
+ typeof r.type === "string" &&
265
+ typeof r.id === "string" &&
266
+ typeof r.timestamp === "string"
267
+ );
268
+ }
258
269
  }
@@ -1,6 +1,7 @@
1
1
  // Task graph — dependency-aware DAG with background execution and auto-unlock
2
2
 
3
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from "fs";
3
+ import { readFileSync, mkdirSync, existsSync } from "fs";
4
+ import { writeFile, rename } from "fs/promises";
4
5
  import { join } from "path";
5
6
  import { generateId } from "../utils/id.js";
6
7
  import type { TaskNode, TaskStatus, TaskGraphConfig, CompletedTaskResult } from "./types.js";
@@ -22,11 +23,15 @@ export class TaskGraph {
22
23
 
23
24
  get(id: string): TaskNode | undefined {
24
25
  const task = this._tasks.get(id);
25
- return task ? { ...task } : undefined;
26
+ return task ? { ...task, blockedBy: [...task.blockedBy], blocks: [...task.blocks] } : undefined;
26
27
  }
27
28
 
28
29
  listAll(): TaskNode[] {
29
- return [...this._tasks.values()];
30
+ return [...this._tasks.values()].map((t) => ({
31
+ ...t,
32
+ blockedBy: [...t.blockedBy],
33
+ blocks: [...t.blocks],
34
+ }));
30
35
  }
31
36
 
32
37
  /** Tasks that are pending with no unfinished blockers. */
@@ -63,7 +68,7 @@ export class TaskGraph {
63
68
  };
64
69
  this._tasks.set(node.id, node);
65
70
  this._persist();
66
- return { ...node };
71
+ return { ...node, blockedBy: [...node.blockedBy], blocks: [...node.blocks] };
67
72
  }
68
73
 
69
74
  /** Add a dependency: `taskId` is blocked by `blockedById`. */
@@ -115,7 +120,7 @@ export class TaskGraph {
115
120
  }
116
121
  task.updatedAt = Date.now();
117
122
  this._persist();
118
- return { ...task };
123
+ return { ...task, blockedBy: [...task.blockedBy], blocks: [...task.blocks] };
119
124
  }
120
125
 
121
126
 
@@ -242,13 +247,20 @@ export class TaskGraph {
242
247
  return false;
243
248
  }
244
249
 
250
+ private _persistPromise: Promise<void> = Promise.resolve();
251
+
245
252
  private _persist(): void {
246
253
  if (!this._config.persistDir) return;
247
- const data = JSON.stringify([...this._tasks.values()], null, 2);
248
- const target = join(this._config.persistDir, "tasks.json");
249
- const tmp = target + ".tmp";
250
- writeFileSync(tmp, data, "utf-8");
251
- renameSync(tmp, target);
254
+ const dir = this._config.persistDir;
255
+ const tasks = [...this._tasks.values()];
256
+ this._persistPromise = this._persistPromise
257
+ .then(() => {
258
+ const data = JSON.stringify(tasks, null, 2);
259
+ const target = join(dir, "tasks.json");
260
+ const tmp = target + ".tmp";
261
+ return writeFile(tmp, data, "utf-8").then(() => rename(tmp, target));
262
+ })
263
+ .catch(() => { /* persist failure is non-fatal */ });
252
264
  }
253
265
 
254
266
  private _loadFromDisk(): void {
@@ -259,12 +271,28 @@ export class TaskGraph {
259
271
  const raw = JSON.parse(readFileSync(filePath, "utf-8"));
260
272
  if (!Array.isArray(raw)) return;
261
273
  for (const entry of raw) {
262
- if (entry && typeof entry === "object" && typeof entry.id === "string" && typeof entry.subject === "string") {
263
- this._tasks.set(entry.id, entry as TaskNode);
264
- }
274
+ if (!this._isValidTaskNode(entry)) continue;
275
+ this._tasks.set(entry.id, entry as TaskNode);
265
276
  }
266
277
  } catch {
267
278
  // Corrupted file — start fresh
268
279
  }
269
280
  }
281
+
282
+ private static readonly _validStatuses = new Set(["pending", "in_progress", "completed", "failed"]);
283
+
284
+ private _isValidTaskNode(entry: unknown): entry is TaskNode {
285
+ if (!entry || typeof entry !== "object") return false;
286
+ const e = entry as Record<string, unknown>;
287
+ return (
288
+ typeof e.id === "string" &&
289
+ typeof e.subject === "string" &&
290
+ typeof e.status === "string" &&
291
+ TaskGraph._validStatuses.has(e.status as string) &&
292
+ Array.isArray(e.blockedBy) &&
293
+ Array.isArray(e.blocks) &&
294
+ typeof e.createdAt === "number" &&
295
+ typeof e.updatedAt === "number"
296
+ );
297
+ }
270
298
  }
@@ -5,7 +5,7 @@ import type { ToolCallBlock, TextContent } from "../llm/types.js";
5
5
  import type { Approval } from "../approval/types.js";
6
6
  import { Value } from "@sinclair/typebox/value";
7
7
 
8
- export interface ToolExecutorConfig {
8
+ interface ToolExecutorConfig {
9
9
  tools: AgentTool[];
10
10
  mode: "sequential" | "parallel";
11
11
  approval: Approval;
@@ -16,7 +16,7 @@ export interface ToolExecutorConfig {
16
16
  onEvent: (event: ToolExecutorEvent) => void;
17
17
  }
18
18
 
19
- export type ToolExecutorEvent =
19
+ type ToolExecutorEvent =
20
20
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown }
21
21
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; partialResult: AgentToolResult }
22
22
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: AgentToolResult; isError: boolean };
@@ -156,17 +156,33 @@ export class MCPAdapter {
156
156
  // Approval is handled by the executor via approvalAction field
157
157
  // No need to request approval here
158
158
 
159
- // Call with timeout
159
+ // Call with timeout and abort signal support
160
160
  const callPromise = client.callTool(def.name, params);
161
161
  let result: MCPToolResult;
162
162
 
163
+ let timer: ReturnType<typeof setTimeout> | undefined;
164
+ let abortHandler: (() => void) | undefined;
165
+ const racers: Promise<MCPToolResult>[] = [callPromise];
166
+
163
167
  if (timeout) {
164
- const timeoutPromise = new Promise<never>((_, reject) =>
165
- setTimeout(() => reject(new Error(`MCP tool ${def.name} timed out after ${timeout}ms`)), timeout),
166
- );
167
- result = await Promise.race([callPromise, timeoutPromise]);
168
- } else {
169
- result = await callPromise;
168
+ racers.push(new Promise<never>((_, reject) => {
169
+ timer = setTimeout(() => reject(new Error(`MCP tool ${def.name} timed out after ${timeout}ms`)), timeout);
170
+ }));
171
+ }
172
+
173
+ if (context.signal.aborted) throw new Error("Aborted");
174
+ racers.push(new Promise<never>((_, reject) => {
175
+ abortHandler = () => reject(new Error("Aborted"));
176
+ context.signal.addEventListener("abort", abortHandler, { once: true });
177
+ }));
178
+
179
+ try {
180
+ result = await Promise.race(racers);
181
+ } finally {
182
+ if (timer !== undefined) clearTimeout(timer);
183
+ if (abortHandler) {
184
+ context.signal.removeEventListener("abort", abortHandler);
185
+ }
170
186
  }
171
187
 
172
188
  // Convert MCP result to AgentToolResult
package/src/utils/id.ts CHANGED
@@ -1,8 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+
1
3
  export function generateId(): string {
2
- const chars = "0123456789abcdefghijklmnopqrstuvwxyz";
3
- let id = "";
4
- for (let i = 0; i < 12; i++) {
5
- id += chars[Math.floor(Math.random() * chars.length)];
6
- }
7
- return id;
4
+ return randomBytes(9).toString("base64url").slice(0, 12);
8
5
  }
package/src/wire/wire.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { generateId } from "../utils/id.js";
4
4
  import type { WireMessage, WireSubscriber, WireSubscription } from "./types.js";
5
5
 
6
- export interface WireOptions {
6
+ interface WireOptions {
7
7
  /** Max messages to buffer for replay to late subscribers. 0 = no buffering. */
8
8
  bufferSize?: number;
9
9
  }