@zvada/agent-server 0.2.2 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +20 -4
  3. package/docs/consuming.md +269 -0
  4. package/docs/deploy.md +80 -0
  5. package/docs/harnesses.md +64 -0
  6. package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
  7. package/package.json +23 -3
  8. package/src/client/client.ts +143 -50
  9. package/src/core/agents/acp/acp-agent.ts +9 -0
  10. package/src/core/agents/acp/mappings.ts +3 -3
  11. package/src/core/agents/base.ts +9 -1
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +31 -3
  14. package/src/core/agents/claude-code/generator-session.ts +16 -5
  15. package/src/core/agents/claude-code/options.ts +13 -3
  16. package/src/core/agents/claude-code/session-manager.ts +9 -4
  17. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
  18. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
  19. package/src/core/agents/types.ts +1 -1
  20. package/src/core/diagnostics.ts +59 -0
  21. package/src/core/index.ts +3 -1
  22. package/src/core/presets.ts +15 -2
  23. package/src/core/provision/pins.ts +5 -1
  24. package/src/core/proxy/anthropic-proxy.ts +43 -2
  25. package/src/core/proxy/api-key-store.ts +37 -5
  26. package/src/core/proxy/index.ts +8 -1
  27. package/src/core/runtime/agent-runtime.ts +66 -18
  28. package/src/core/runtime/event-processor.ts +51 -26
  29. package/src/protocol/config.ts +8 -6
  30. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  31. package/src/protocol/factories.ts +106 -10
  32. package/src/protocol/guards.ts +53 -0
  33. package/src/protocol/index.ts +10 -0
  34. package/src/protocol/lifecycle.ts +289 -112
  35. package/src/protocol/meta.ts +14 -0
  36. package/src/protocol/part-input.ts +56 -7
  37. package/src/protocol/parts.ts +125 -10
  38. package/src/protocol/reduce.ts +749 -0
  39. package/src/protocol/selectors.ts +162 -0
  40. package/src/protocol/seq-cursor.ts +87 -0
  41. package/src/protocol/stop-reasons.ts +45 -0
  42. package/src/protocol/time.ts +23 -0
  43. package/src/protocol/tokens.ts +23 -0
  44. package/src/protocol/tool-state.ts +85 -25
  45. package/src/protocol/verify.ts +440 -0
  46. package/src/protocol/vocabulary.ts +18 -0
  47. package/src/protocol/wire.ts +81 -13
  48. package/src/server/acp/binding.ts +23 -2
  49. package/src/server/acp/translate.ts +51 -14
  50. package/src/server/agent-server.ts +85 -6
  51. package/AGENTS.md +0 -21
@@ -27,7 +27,11 @@ export type CliTool = (typeof CLI_TOOLS)[number];
27
27
  * set `provision.pins.codex` to their SDK's vendored version.
28
28
  */
29
29
  export const DEFAULT_PINS: Record<CliTool, string> = {
30
- claude: "0.3.168",
30
+ // Held at 0.3.220: the paired CLI (2.1.220) honors `--thinking-display
31
+ // summarized`, so reasoning parts carry text. 2.1.233 ignores the flag and
32
+ // streams token-estimate placeholders instead — bump only after verifying
33
+ // thinking summaries still flow (see options.ts extraArgs).
34
+ claude: "0.3.220",
31
35
  // 0.146.1 re-verified 2026-08-06: generate-ts snapshot carries our full
32
36
  // surface unchanged (thread/turn/item methods, tokenUsage w/ window,
33
37
  // approvals) and a live turn ran clean incl. the gpt-5.6-sol default.
@@ -1,8 +1,29 @@
1
+ import { type DiagnosticHandler, emitDiagnostic } from "../diagnostics.ts";
1
2
  import { type ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
2
3
 
3
4
  /** Placeholder key handed to the agent subprocess; swapped for the real one here. */
4
5
  export const PROXY_PLACEHOLDER_KEY = "sk-proxy-managed";
5
6
 
7
+ /**
8
+ * The `anthropic-beta` capability that must accompany OAuth bearer traffic.
9
+ * Anthropic's gateway guidance: proxies carrying subscription-OAuth requests
10
+ * must advertise this capability or the upstream rejects the bearer token.
11
+ */
12
+ export const ANTHROPIC_OAUTH_BETA = "oauth-2025-04-20";
13
+
14
+ /** Append a capability to `anthropic-beta` without clobbering existing ones. */
15
+ function appendAnthropicBeta(headers: Headers, capability: string): void {
16
+ const existing = headers.get("anthropic-beta");
17
+ const values = existing
18
+ ? existing
19
+ .split(",")
20
+ .map((value) => value.trim())
21
+ .filter(Boolean)
22
+ : [];
23
+ if (!values.includes(capability)) values.push(capability);
24
+ headers.set("anthropic-beta", values.join(","));
25
+ }
26
+
6
27
  /** Minimal fetch shape (`globalThis.fetch` qualifies; injectable for tests/platforms). */
7
28
  export type FetchLike = (
8
29
  input: string | URL | Request,
@@ -16,6 +37,8 @@ export interface AnthropicProxyOptions {
16
37
  pathPrefix?: string;
17
38
  /** Fetch implementation (test seam / platform override). Default `globalThis.fetch`. */
18
39
  fetch?: FetchLike;
40
+ /** Notified on upstream 401/403 — the STORED key is bad, not the placeholder. */
41
+ onDiagnostic?: DiagnosticHandler;
19
42
  }
20
43
 
21
44
  /**
@@ -65,10 +88,19 @@ export function createAnthropicProxy(
65
88
  }
66
89
 
67
90
  const headers = new Headers(request.headers);
68
- headers.set("x-api-key", entry.apiKey);
69
- headers.delete("authorization");
70
91
  headers.delete("host");
71
92
  headers.delete("content-length");
93
+ if (entry.kind === "oauth") {
94
+ // Subscription token: bearer auth + the OAuth capability. The
95
+ // x-api-key the CLI sent carries only the placeholder — it must go,
96
+ // or the upstream sees two conflicting credentials.
97
+ headers.delete("x-api-key");
98
+ headers.set("authorization", `Bearer ${entry.apiKey}`);
99
+ appendAnthropicBeta(headers, ANTHROPIC_OAUTH_BETA);
100
+ } else {
101
+ headers.set("x-api-key", entry.apiKey);
102
+ headers.delete("authorization");
103
+ }
72
104
 
73
105
  const init: RequestInit & { duplex?: "half" } = {
74
106
  method: request.method,
@@ -98,6 +130,15 @@ export function createAnthropicProxy(
98
130
  );
99
131
  }
100
132
 
133
+ if (upstream.status === 401 || upstream.status === 403) {
134
+ emitDiagnostic(options.onDiagnostic, {
135
+ type: "proxyUpstreamAuth",
136
+ sessionId,
137
+ message: `upstream rejected the stored key for session ${sessionId} (${upstream.status})`,
138
+ detail: { status: upstream.status },
139
+ });
140
+ }
141
+
101
142
  const contentType = upstream.headers.get("content-type") ?? "";
102
143
  // Media type only — parameters (charset) stripped, case-insensitive, so
103
144
  // `text/event-streamish` or a parameter VALUE never selects SSE handling.
@@ -1,20 +1,52 @@
1
- /** A real upstream key + base URL, keyed by an opaque proxy session id. */
1
+ /** How a stored credential is presented to the upstream API. */
2
+ export type ApiCredentialKind = "api_key" | "oauth";
3
+
4
+ /** A real upstream credential + base URL, keyed by an opaque proxy session id. */
2
5
  export interface ApiKeyEntry {
3
6
  apiKey: string;
4
7
  upstreamBaseUrl: string;
8
+ /**
9
+ * `api_key` → sent as `x-api-key` (Anthropic API keys).
10
+ * `oauth` → sent as `Authorization: Bearer` with the OAuth capability
11
+ * advertised in `anthropic-beta` (Claude subscription tokens from
12
+ * `claude setup-token`, `sk-ant-oat01-…`).
13
+ */
14
+ kind: ApiCredentialKind;
15
+ }
16
+
17
+ export interface ApiKeyStoreSetOptions {
18
+ upstreamBaseUrl?: string;
19
+ kind?: ApiCredentialKind;
5
20
  }
6
21
 
7
22
  /**
8
23
  * In-memory map of proxy-session → real credentials. The agent subprocess is
9
24
  * given a placeholder key and pointed at the local proxy; the proxy swaps in the
10
- * real key from this store before forwarding upstream, so the key never lives in
11
- * the subprocess environment. (BYOK pattern from the echo/agnt sidecars.)
25
+ * real credential from this store before forwarding upstream, so it never lives
26
+ * in the subprocess environment. (BYOK pattern from the echo/agnt sidecars.)
27
+ * That no-exfiltration property is exactly why subscription OAuth tokens ride
28
+ * the same store instead of the child env: the child keeps the placeholder
29
+ * either way, only the upstream header shape differs.
12
30
  */
13
31
  export class ApiKeyStore {
14
32
  private readonly entries = new Map<string, ApiKeyEntry>();
15
33
 
16
- set(sessionId: string, apiKey: string, upstreamBaseUrl = "https://api.anthropic.com"): void {
17
- this.entries.set(sessionId, { apiKey, upstreamBaseUrl });
34
+ set(
35
+ sessionId: string,
36
+ apiKey: string,
37
+ // A plain string third argument (the base URL) remains supported: the
38
+ // pre-kind signature every existing sidecar call site uses.
39
+ optionsOrBaseUrl: string | ApiKeyStoreSetOptions = {},
40
+ ): void {
41
+ const options =
42
+ typeof optionsOrBaseUrl === "string"
43
+ ? { upstreamBaseUrl: optionsOrBaseUrl }
44
+ : optionsOrBaseUrl;
45
+ this.entries.set(sessionId, {
46
+ apiKey,
47
+ upstreamBaseUrl: options.upstreamBaseUrl ?? "https://api.anthropic.com",
48
+ kind: options.kind ?? "api_key",
49
+ });
18
50
  }
19
51
 
20
52
  get(sessionId: string): ApiKeyEntry | undefined {
@@ -1,6 +1,13 @@
1
1
  // @zvada/agent-server/core/proxy — optional BYOK Anthropic proxy building block.
2
- export { type ApiKeyEntry, ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
3
2
  export {
3
+ type ApiCredentialKind,
4
+ type ApiKeyEntry,
5
+ ApiKeyStore,
6
+ type ApiKeyStoreSetOptions,
7
+ apiKeyStore,
8
+ } from "./api-key-store.ts";
9
+ export {
10
+ ANTHROPIC_OAUTH_BETA,
4
11
  type AnthropicProxyOptions,
5
12
  createAnthropicProxy,
6
13
  PROXY_PLACEHOLDER_KEY,
@@ -1,3 +1,9 @@
1
+ import {
2
+ type ErrorCategory,
3
+ classifyError,
4
+ isCancellation,
5
+ isRecoverable,
6
+ } from "../../protocol/errors.ts";
1
7
  import type {
2
8
  AgentCapabilities,
3
9
  AgentHarness,
@@ -11,13 +17,8 @@ import type {
11
17
  } from "../../protocol/index.ts";
12
18
  import { DEFAULT_TOKEN_USAGE, generateUUIDv7 } from "../../protocol/index.ts";
13
19
  import type { AgentExecuteOptions, CancelResult, PermissionDecision } from "../agents/base.ts";
14
- import {
15
- type ErrorCategory,
16
- classifyError,
17
- isCancellation,
18
- isRecoverable,
19
- } from "../agents/error-classifier.ts";
20
20
  import type { AgentRegistry } from "../agents/registry.ts";
21
+ import { type DiagnosticHandler, emitDiagnostic } from "../diagnostics.ts";
21
22
  import { TurnConflictError } from "../utils/errors.ts";
22
23
  import { EventProcessor } from "./event-processor.ts";
23
24
  import type { EventSink } from "./event-sink.ts";
@@ -69,10 +70,14 @@ const turnKeyOf = (sessionId: string, turnId: string) => `${sessionId}:${turnId}
69
70
  const inputKeyOf = (request: RunRequest) =>
70
71
  canonicalJson({ input: request.input, config: request.config });
71
72
 
72
- /** The standard options offered for every brokered permission request. */
73
+ /** The standard options offered for every brokered permission request (§6.4:
74
+ * the broker must actually offer the `*_always` kinds the schema advertises —
75
+ * a "don't ask again" answer becomes a standing per-session rule). */
73
76
  const PERMISSION_OPTIONS: PermissionOption[] = [
74
77
  { optionId: "allow", name: "Allow", kind: "allow_once" },
78
+ { optionId: "allow_always", name: "Always allow", kind: "allow_always" },
75
79
  { optionId: "reject", name: "Reject", kind: "reject_once" },
80
+ { optionId: "reject_always", name: "Always reject", kind: "reject_always" },
76
81
  ];
77
82
 
78
83
  interface PendingPermission {
@@ -91,6 +96,8 @@ interface PendingPermission {
91
96
  export class AgentRuntime {
92
97
  /** Permission requests awaiting `respondPermission`, across all live turns. */
93
98
  private readonly pendingPermissions = new Map<string, PendingPermission>();
99
+ /** Standing per-session `*_always` answers: sessionId -> toolName -> verdict. */
100
+ private readonly alwaysDecisions = new Map<string, Map<string, "allow" | "deny">>();
94
101
  private readonly activeRuns = new Set<Promise<RunSummary>>();
95
102
  /** In-flight turns by turn key — the convergence target for retried runs. */
96
103
  private readonly inflightTurns = new Map<
@@ -106,7 +113,10 @@ export class AgentRuntime {
106
113
  private static readonly COMPLETED_TURN_MEMORY = 16;
107
114
  private shutdownPromise?: Promise<void>;
108
115
 
109
- constructor(private readonly registry: AgentRegistry) {}
116
+ constructor(
117
+ private readonly registry: AgentRegistry,
118
+ private readonly options: { onDiagnostic?: DiagnosticHandler } = {},
119
+ ) {}
110
120
 
111
121
  get harnesses(): AgentHarness[] {
112
122
  return this.registry.list();
@@ -229,18 +239,22 @@ export class AgentRuntime {
229
239
  const { sessionId, turnId, input, config } = request;
230
240
  const agent = this.registry.getAgent(config.harness);
231
241
  const transformer = this.registry.getAdapter(config.harness)({ sessionId });
232
- const processor = new EventProcessor(sessionId, turnId, {
233
- harness: config.harness,
234
- model: config.model,
235
- });
242
+ const processor = new EventProcessor(sessionId, turnId, { model: config.model });
236
243
 
237
244
  // Isolate sink failures: a misbehaving transport must never break the
238
245
  // turn's lifecycle bracketing (turn.started ... turn.ended).
239
246
  const emit = async (e: LifecycleEvent) => {
240
247
  try {
241
248
  await sink.emit(e);
242
- } catch {
243
- // swallow — the sink owns its own reliability
249
+ } catch (err) {
250
+ // The turn must not fail on a misbehaving sink — but the host gets
251
+ // to know its sink dropped an event.
252
+ emitDiagnostic(this.options.onDiagnostic, {
253
+ type: "sinkError",
254
+ sessionId,
255
+ message: `sink emit failed for ${e.type}: ${err instanceof Error ? err.message : String(err)}`,
256
+ detail: err,
257
+ });
244
258
  }
245
259
  };
246
260
 
@@ -274,6 +288,16 @@ export class AgentRuntime {
274
288
  permOpts?: { signal?: AbortSignal },
275
289
  ): Promise<PermissionDecision> => {
276
290
  if (permOpts?.signal?.aborted) return { decision: "cancel" };
291
+ // A standing `*_always` rule from an earlier prompt in this session
292
+ // answers without a round-trip (and without fake prompt events). The
293
+ // rule carries the verdict only — an `updatedInput` edit belongs to the
294
+ // call it was made for, never to every later call to the same tool.
295
+ const standing = this.alwaysDecisions.get(sessionId)?.get(toolCall.toolName);
296
+ if (standing) {
297
+ return standing === "allow"
298
+ ? { decision: "allow" }
299
+ : { decision: "deny", reason: "Denied by a standing rule" };
300
+ }
277
301
  const requestId = generateUUIDv7();
278
302
  // Register BEFORE emitting so even a sink that answers synchronously
279
303
  // from its emit() finds the pending entry.
@@ -317,8 +341,26 @@ export class AgentRuntime {
317
341
  const outcome = await outcomePromise;
318
342
  if (outcome.outcome === "cancelled") return { decision: "cancel" };
319
343
  const selected = PERMISSION_OPTIONS.find((o) => o.optionId === outcome.optionId);
320
- return selected?.kind === "allow_once" || selected?.kind === "allow_always"
321
- ? { decision: "allow" }
344
+ const allowed = selected?.kind === "allow_once" || selected?.kind === "allow_always";
345
+ // An `*_always` answer becomes a standing per-session rule: later calls
346
+ // to the same tool skip the round-trip entirely (the user already gave
347
+ // a durable answer — re-asking would be noise, re-emitting events would
348
+ // fake a prompt that never happened). Cleared on session close.
349
+ if (selected?.kind === "allow_always" || selected?.kind === "reject_always") {
350
+ let rules = this.alwaysDecisions.get(sessionId);
351
+ if (!rules) {
352
+ rules = new Map();
353
+ this.alwaysDecisions.set(sessionId, rules);
354
+ }
355
+ rules.set(toolCall.toolName, allowed ? "allow" : "deny");
356
+ }
357
+ return allowed
358
+ ? {
359
+ decision: "allow",
360
+ // What was approved is what runs (C1): an edited input replaces
361
+ // the original all the way into the harness.
362
+ ...(outcome.updatedInput !== undefined && { updatedInput: outcome.updatedInput }),
363
+ }
322
364
  : { decision: "deny", reason: "Denied by user" };
323
365
  };
324
366
  /** Cancellation / turn end resolves everything still pending as cancelled.
@@ -334,6 +376,11 @@ export class AgentRuntime {
334
376
 
335
377
  await emit({ type: "turn.started", turnId, sessionId, timestamp: Date.now() });
336
378
 
379
+ // The user echo (spec §7.2): the submitted input goes back onto the stream
380
+ // as a complete user message (outputIndex 0) before any harness output, so
381
+ // the stream is a complete transcript and multi-client attach works.
382
+ for (const le of processor.echoUserMessage(input)) await emit(le);
383
+
337
384
  const options: AgentExecuteOptions = {
338
385
  sessionId,
339
386
  turnId,
@@ -395,9 +442,9 @@ export class AgentRuntime {
395
442
  type: "error",
396
443
  turnId,
397
444
  sessionId,
398
- error: message,
445
+ category,
446
+ message,
399
447
  recoverable: isRecoverable(category),
400
- code: category,
401
448
  stack: err instanceof Error ? err.stack : undefined,
402
449
  timestamp: Date.now(),
403
450
  });
@@ -453,6 +500,7 @@ export class AgentRuntime {
453
500
  /** Dispose one idle logical session and its harness-native resources. */
454
501
  async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
455
502
  this.completedTurns.delete(sessionId);
503
+ this.alwaysDecisions.delete(sessionId);
456
504
  await Promise.all(
457
505
  [...this.pendingPermissions.values()]
458
506
  .filter((pending) => pending.sessionId === sessionId)
@@ -1,5 +1,10 @@
1
- import { generateUUIDv7 } from "../../protocol/index.ts";
2
- import type { Delta, LifecycleEvent, Part, StopReason } from "../../protocol/index.ts";
1
+ import {
2
+ classifyError,
3
+ createUserEchoParts,
4
+ echoMessageId,
5
+ generateUUIDv7,
6
+ } from "../../protocol/index.ts";
7
+ import type { AgentInput, Delta, LifecycleEvent, Part, StopReason } from "../../protocol/index.ts";
3
8
  import type { AdapterEvent, TransformResult } from "../agents/types.ts";
4
9
 
5
10
  /** Where a part lives on the wire — fixed at first emission for the whole turn. */
@@ -21,7 +26,7 @@ interface PartAddress {
21
26
  */
22
27
  export class EventProcessor {
23
28
  private openMessageId?: string;
24
- /** `parentToolUseId` of the open message (undefined for top-level messages). */
29
+ /** `parentToolCallId` of the open message (undefined for top-level messages). */
25
30
  private openMessageParent?: string;
26
31
  private currentOutputIndex = -1;
27
32
  private nextOutputIndex = 0;
@@ -31,9 +36,24 @@ export class EventProcessor {
31
36
  constructor(
32
37
  private readonly sessionId: string,
33
38
  private readonly turnId: string,
34
- private readonly meta: { harness?: string; model?: string } = {},
39
+ private readonly meta: { model?: string } = {},
35
40
  ) {}
36
41
 
42
+ /**
43
+ * The user echo: emits the submitted input back onto the stream as a
44
+ * complete user message at outputIndex 0, before any harness output — the
45
+ * stream is a complete transcript. Its messageId is DERIVED from the turn
46
+ * id (`echoMessageId`), so a consumer that minted the turnId can predict the
47
+ * whole echo instead of reconciling a look-alike against it.
48
+ */
49
+ *echoUserMessage(input: AgentInput): Generator<LifecycleEvent> {
50
+ yield* this.openMessage("user", undefined, echoMessageId(this.turnId));
51
+ for (const part of createUserEchoParts(input, this.turnId)) {
52
+ yield* this.emitPart(part);
53
+ }
54
+ yield* this.closeMessage();
55
+ }
56
+
37
57
  *handle(ev: AdapterEvent): Generator<LifecycleEvent> {
38
58
  switch (ev.kind) {
39
59
  case "message-start":
@@ -47,18 +67,18 @@ export class EventProcessor {
47
67
  yield* this.emitPart(ev.part);
48
68
  return;
49
69
  case "text-delta": {
50
- const e = this.delta(ev.partId, { type: "text-delta", text: ev.text });
70
+ const e = this.delta(ev.partId, { type: "text", text: ev.text });
51
71
  if (e) yield e;
52
72
  return;
53
73
  }
54
74
  case "reasoning-delta": {
55
- const e = this.delta(ev.partId, { type: "reasoning-delta", text: ev.text });
75
+ const e = this.delta(ev.partId, { type: "reasoning", text: ev.text });
56
76
  if (e) yield e;
57
77
  return;
58
78
  }
59
79
  case "tool-input-delta": {
60
80
  const e = this.delta(ev.partId, {
61
- type: "tool-input-delta",
81
+ type: "tool_input",
62
82
  toolCallId: ev.toolCallId,
63
83
  toolName: ev.toolName,
64
84
  input: ev.input,
@@ -78,10 +98,14 @@ export class EventProcessor {
78
98
  };
79
99
  return;
80
100
  case "compacted":
101
+ // Harnesses report compaction after the fact — a single completed
102
+ // upsert of the positional entity (its first appearance anchors it).
81
103
  yield {
82
- type: "session.compacted",
104
+ type: "session.compaction",
83
105
  sessionId: this.sessionId,
84
106
  turnId: this.turnId,
107
+ compactionId: generateUUIDv7(),
108
+ status: "completed",
85
109
  ...(ev.trigger && { trigger: ev.trigger }),
86
110
  ...(ev.preTokens !== undefined && { preTokens: ev.preTokens }),
87
111
  ...(ev.postTokens !== undefined && { postTokens: ev.postTokens }),
@@ -103,7 +127,7 @@ export class EventProcessor {
103
127
  cost: result.cost,
104
128
  error:
105
129
  result.error && !result.cancelled
106
- ? { name: "AgentError", message: result.error }
130
+ ? { category: classifyError(result.error), message: result.error }
107
131
  : undefined,
108
132
  timestamp: Date.now(),
109
133
  };
@@ -111,29 +135,28 @@ export class EventProcessor {
111
135
 
112
136
  private *openMessage(
113
137
  role: "assistant" | "user",
114
- parentToolUseId?: string,
138
+ parentToolCallId?: string,
139
+ /** Derived id for the user echo; harness messages get a fresh UUIDv7. */
140
+ id?: string,
115
141
  ): Generator<LifecycleEvent> {
116
142
  if (this.openMessageId) yield* this.closeMessage();
117
- const messageId = generateUUIDv7();
143
+ const messageId = id ?? generateUUIDv7();
118
144
  this.openMessageId = messageId;
119
- this.openMessageParent = parentToolUseId;
145
+ this.openMessageParent = parentToolCallId;
120
146
  this.currentOutputIndex = this.nextOutputIndex++;
121
147
  this.nextPartIndex = 0;
122
148
  yield {
123
149
  type: "message.started",
150
+ sessionId: this.sessionId,
124
151
  turnId: this.turnId,
125
152
  messageId,
126
153
  outputIndex: this.currentOutputIndex,
127
154
  role,
128
155
  // A parented message is a sub-agent's output — nests under its tool call,
129
156
  // not a top-level model message (see DESIGN.md D5).
130
- ...(parentToolUseId && { parentToolUseId }),
157
+ ...(parentToolCallId && { parentToolCallId }),
158
+ ...(this.meta.model && { model: this.meta.model }),
131
159
  timestamp: Date.now(),
132
- metadata: {
133
- sessionId: this.sessionId,
134
- ...(this.meta.harness && { harness: this.meta.harness }),
135
- ...(this.meta.model && { model: this.meta.model }),
136
- },
137
160
  };
138
161
  }
139
162
 
@@ -141,6 +164,7 @@ export class EventProcessor {
141
164
  if (!this.openMessageId) return;
142
165
  yield {
143
166
  type: "message.ended",
167
+ sessionId: this.sessionId,
144
168
  turnId: this.turnId,
145
169
  messageId: this.openMessageId,
146
170
  timestamp: Date.now(),
@@ -150,25 +174,25 @@ export class EventProcessor {
150
174
  }
151
175
 
152
176
  /** Ensure an open message whose parent matches the incoming part. A part
153
- * whose `parentToolUseId` differs from the open message (main↔sub-agent, or
177
+ * whose `parentToolCallId` differs from the open message (main↔sub-agent, or
154
178
  * between sibling sub-agents) starts a new message so sub-agent output is
155
179
  * grouped under its own parented message rather than mixed into another. */
156
- private *ensureMessage(parentToolUseId?: string): Generator<LifecycleEvent> {
157
- if (this.openMessageId && this.openMessageParent !== parentToolUseId) {
180
+ private *ensureMessage(parentToolCallId?: string): Generator<LifecycleEvent> {
181
+ if (this.openMessageId && this.openMessageParent !== parentToolCallId) {
158
182
  yield* this.closeMessage();
159
183
  }
160
- if (!this.openMessageId) yield* this.openMessage("assistant", parentToolUseId);
184
+ if (!this.openMessageId) yield* this.openMessage("assistant", parentToolCallId);
161
185
  }
162
186
 
163
187
  /** Resolve (or assign) the wire address of a part. Yields a message.started
164
188
  * first when the part is new and no matching message is open. */
165
189
  private *addressFor(
166
190
  partId: string,
167
- parentToolUseId?: string,
191
+ parentToolCallId?: string,
168
192
  ): Generator<LifecycleEvent, PartAddress> {
169
193
  const existing = this.partAddressById.get(partId);
170
194
  if (existing) return existing;
171
- yield* this.ensureMessage(parentToolUseId);
195
+ yield* this.ensureMessage(parentToolCallId);
172
196
  const address: PartAddress = {
173
197
  messageId: this.openMessageId as string,
174
198
  outputIndex: this.currentOutputIndex,
@@ -179,7 +203,7 @@ export class EventProcessor {
179
203
  }
180
204
 
181
205
  private *emitPart(part: Part): Generator<LifecycleEvent> {
182
- const address = yield* this.addressFor(part.id, part.parentToolUseId);
206
+ const address = yield* this.addressFor(part.id, part.parentToolCallId);
183
207
  // Snapshot: adapters mutate their part objects in place across
184
208
  // open/update, so we must clone at emit time or buffered consumers would
185
209
  // all observe the final state. Stamp ownership so the part is
@@ -189,12 +213,12 @@ export class EventProcessor {
189
213
  snapshot.messageId = address.messageId;
190
214
  yield {
191
215
  type: "message.part",
216
+ sessionId: this.sessionId,
192
217
  turnId: this.turnId,
193
218
  messageId: address.messageId,
194
219
  outputIndex: address.outputIndex,
195
220
  partIndex: address.partIndex,
196
221
  part: snapshot,
197
- ...(snapshot.parentToolUseId && { parentToolUseId: snapshot.parentToolUseId }),
198
222
  timestamp: Date.now(),
199
223
  };
200
224
  }
@@ -206,6 +230,7 @@ export class EventProcessor {
206
230
  if (!address) return null;
207
231
  return {
208
232
  type: "message.part.delta",
233
+ sessionId: this.sessionId,
209
234
  turnId: this.turnId,
210
235
  messageId: address.messageId,
211
236
  outputIndex: address.outputIndex,
@@ -4,17 +4,19 @@ import { AgentInputSchema } from "./part-input.ts";
4
4
  import { ThinkingLevelSchema } from "./thinking.ts";
5
5
 
6
6
  /**
7
- * Permission posture for a turn. `bypassPermissions`/`yolo` auto-approve every
7
+ * Permission posture for a turn. `bypass_permissions` auto-approves every
8
8
  * tool (the right default inside a sandbox); `plan` is read-only planning;
9
- * `dontAsk` never prompts — unapproved tools are denied, sandboxes stay at
10
- * their normal (non-dangerous) level. Harnesses map this onto their own modes.
9
+ * `dont_ask` never prompts — unapproved tools are denied, sandboxes stay at
10
+ * their normal (non-dangerous) level. Values follow the casing law (snake);
11
+ * adapters map to provider vocabulary at the edge (Claude SDK: acceptEdits,
12
+ * bypassPermissions).
11
13
  */
12
14
  export const PermissionModeSchema = z.enum([
13
15
  "default",
14
16
  "plan",
15
- "acceptEdits",
16
- "dontAsk",
17
- "bypassPermissions",
17
+ "accept_edits",
18
+ "dont_ask",
19
+ "bypass_permissions",
18
20
  ]);
19
21
  export type PermissionMode = z.infer<typeof PermissionModeSchema>;
20
22
 
@@ -1,20 +1,42 @@
1
+ import { z } from "zod";
2
+ import { openVocabulary } from "./vocabulary.ts";
3
+
1
4
  /**
2
- * Maps a raw harness error into a stable category, so callers can react
3
- * (retry on `network`/`rate_limit`, surface `auth` prominently, treat `abort`
4
- * as success). Distilled from deus-machine's lifecycle classifier.
5
+ * The one error vocabulary (Law: one shape per concept). Used by
6
+ * `turn.ended.error`, the standalone `error` event, and `RunSummary.error`.
7
+ *
8
+ * OPEN (Law 3): products may extend with their own categories (deus adds
9
+ * "db_write"); unknown non-`_` values are reserved for this protocol.
10
+ * `usage_limit` (plan/quota exhausted) and `rate_limit` (429/backoff) are
11
+ * deliberately distinct — they need different user guidance; collapsing them
12
+ * is a display choice, never a wire merge.
5
13
  */
6
14
  export const ERROR_CATEGORIES = [
7
15
  "auth",
8
16
  "rate_limit",
17
+ "usage_limit",
9
18
  "context_limit",
10
19
  "network",
11
20
  "abort",
21
+ "invalid_request",
12
22
  "process_exit",
13
- "usage_limit",
14
- "unknown",
23
+ "internal",
15
24
  ] as const;
16
- export type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
25
+ export type ErrorCategory = (typeof ERROR_CATEGORIES)[number] | (string & {});
26
+ export const ErrorCategorySchema = openVocabulary<ErrorCategory>();
27
+
28
+ export const ErrorInfoSchema = z.object({
29
+ category: ErrorCategorySchema,
30
+ message: z.string(),
31
+ });
32
+ export type ErrorInfo = z.infer<typeof ErrorInfoSchema>;
17
33
 
34
+ /**
35
+ * Maps a raw harness error into a stable category, so callers can react
36
+ * (retry on `network`/`rate_limit`, surface `auth` prominently, treat `abort`
37
+ * as success). Rule order matters. Distilled from deus-machine's lifecycle
38
+ * classifier; lives in /protocol because categories are vocabulary.
39
+ */
18
40
  const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
19
41
  ["abort", ["aborted", "aborterror", "cancelled", "canceled", "interrupted", "sigint"]],
20
42
  [
@@ -46,6 +68,10 @@ const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
46
68
  "socket hang up",
47
69
  ],
48
70
  ],
71
+ [
72
+ "invalid_request",
73
+ ["invalid request", "invalid_request_error", "status 400", "http 400", "bad request"],
74
+ ],
49
75
  ["process_exit", ["exited with code", "process exited", "spawn", "enoent", "killed"]],
50
76
  ];
51
77
 
@@ -54,7 +80,7 @@ export function classifyError(error: unknown): ErrorCategory {
54
80
  for (const [category, needles] of RULES) {
55
81
  if (needles.some((n) => message.includes(n))) return category;
56
82
  }
57
- return "unknown";
83
+ return "internal";
58
84
  }
59
85
 
60
86
  /** Whether a turn can plausibly be retried after this error. */