@zvada/agent-server 0.3.2 → 0.3.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.
@@ -12,12 +12,38 @@ export interface StoredSession {
12
12
  */
13
13
  export class SessionStore<S extends StoredSession> {
14
14
  private readonly sessions = new Map<string, S>();
15
+ private readonly pending = new Map<string, Set<S["client"]>>();
16
+ private closed = false;
17
+
18
+ /** Own a client before its initialization RPCs have completed. */
19
+ trackPending(sessionId: string, client: S["client"]): () => void {
20
+ if (this.closed) {
21
+ client.close();
22
+ throw new Error("session store is closed");
23
+ }
24
+ let clients = this.pending.get(sessionId);
25
+ if (!clients) {
26
+ clients = new Set();
27
+ this.pending.set(sessionId, clients);
28
+ }
29
+ clients.add(client);
30
+ return () => {
31
+ clients.delete(client);
32
+ if (!clients.size && this.pending.get(sessionId) === clients) {
33
+ this.pending.delete(sessionId);
34
+ }
35
+ };
36
+ }
15
37
 
16
38
  get(sessionId: string): S | undefined {
17
39
  return this.sessions.get(sessionId);
18
40
  }
19
41
 
20
42
  set(sessionId: string, session: S): void {
43
+ if (this.closed || session.client.closed) {
44
+ session.client.close();
45
+ throw new Error("cannot register a closed session");
46
+ }
21
47
  this.sessions.set(sessionId, session);
22
48
  }
23
49
 
@@ -36,20 +62,21 @@ export class SessionStore<S extends StoredSession> {
36
62
 
37
63
  /** Tear down one session: cancel its idle timer, unmap it, close its client. */
38
64
  close(sessionId: string): void {
65
+ const pending = this.pending.get(sessionId);
66
+ this.pending.delete(sessionId);
39
67
  const session = this.sessions.get(sessionId);
40
- if (!session) return;
41
- this.clearIdle(session);
42
68
  this.sessions.delete(sessionId);
43
- session.client.close();
69
+ if (session) this.clearIdle(session);
70
+ for (const client of pending ?? []) client.close();
71
+ session?.client.close();
44
72
  }
45
73
 
46
- /** Tear down every session (process shutdown). */
74
+ /** Tear down ready and initializing clients; refuse subsequent registrations. */
47
75
  closeAll(): void {
48
- for (const session of this.sessions.values()) {
49
- this.clearIdle(session);
50
- session.client.close();
76
+ this.closed = true;
77
+ for (const id of new Set([...this.sessions.keys(), ...this.pending.keys()])) {
78
+ this.close(id);
51
79
  }
52
- this.sessions.clear();
53
80
  }
54
81
 
55
82
  /** Shut the session down after `idleMs` unless another turn claims it first. */
package/src/core/index.ts CHANGED
@@ -75,6 +75,9 @@ export { CodexSdkTransformer, createCodexSdkTransformer } from "./agents/codex-s
75
75
  export {
76
76
  CodexAppServerAgent,
77
77
  type CodexAppServerAgentOptions,
78
+ type CodexAppServerAuth,
79
+ type CodexChatGptAuth,
80
+ type CodexChatGptAuthTokens,
78
81
  } from "./agents/codex-app-server/codex-app-server-agent.ts";
79
82
  export {
80
83
  CodexAppServerTransformer,
@@ -4,7 +4,10 @@ import { createAcpTransformer } from "./agents/acp/adapter.ts";
4
4
  import { createClaudeCodeTransformer } from "./agents/claude-code/adapter.ts";
5
5
  import { ClaudeCodeAgent, type ClaudeCodeAgentOptions } from "./agents/claude-code/claude-agent.ts";
6
6
  import { createCodexAppServerTransformer } from "./agents/codex-app-server/adapter.ts";
7
- import { CodexAppServerAgent } from "./agents/codex-app-server/codex-app-server-agent.ts";
7
+ import {
8
+ CodexAppServerAgent,
9
+ type CodexAppServerAgentOptions,
10
+ } from "./agents/codex-app-server/codex-app-server-agent.ts";
8
11
  import { createCodexSdkTransformer } from "./agents/codex-sdk/adapter.ts";
9
12
  import { CodexSdkAgent } from "./agents/codex-sdk/codex-sdk-agent.ts";
10
13
  import { AgentRegistry } from "./agents/registry.ts";
@@ -33,6 +36,8 @@ export interface CreateRegistryOptions {
33
36
  * `resolveCliPath` stays provisioner-wired here.
34
37
  */
35
38
  claudeCode?: Omit<ClaudeCodeAgentOptions, "resolveCliPath">;
39
+ /** Codex embed-tier authentication and process options; CLI resolution stays provisioner-owned. */
40
+ codexAppServer?: Omit<CodexAppServerAgentOptions, "resolveCliPath">;
36
41
  /**
37
42
  * Operational diagnostics port (see `EngineDiagnostic`): interrupt
38
43
  * timeouts, resume fallbacks, sink failures — signals the engine must not
@@ -72,7 +77,7 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
72
77
  }
73
78
  if (enabled.has("codex-app-server")) {
74
79
  registry.register(
75
- new CodexAppServerAgent({ resolveCliPath: codexCli }),
80
+ new CodexAppServerAgent({ ...opts.codexAppServer, resolveCliPath: codexCli }),
76
81
  createCodexAppServerTransformer,
77
82
  );
78
83
  }
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import {
3
+ type Stats,
3
4
  chmodSync,
4
5
  existsSync,
5
6
  lstatSync,
@@ -10,7 +11,7 @@ import {
10
11
  } from "node:fs";
11
12
  import { chmod, mkdir, readdir, rename, rm } from "node:fs/promises";
12
13
  import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
14
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
14
15
  import { CliNotFoundError, CliProvisionError } from "../utils/errors.ts";
15
16
  import { extractTarGz } from "./extract.ts";
16
17
  import { DEFAULT_NPM_REGISTRY, downloadWithIntegrity, fetchDist } from "./npm.ts";
@@ -147,7 +148,7 @@ function moduleDir(): string {
147
148
  */
148
149
  export class CliProvisioner {
149
150
  private readonly mode: ProvisionMode;
150
- private readonly cacheDir: string;
151
+ private cacheDir: string;
151
152
  private readonly registry: string;
152
153
  /** Explicit pins only — see `versionFor` for the per-tool default chain. */
153
154
  private readonly pins: Partial<Record<CliTool, string>>;
@@ -159,15 +160,15 @@ export class CliProvisioner {
159
160
  private readonly extract: (tgzPath: string, destDir: string) => Promise<void>;
160
161
  private readonly hostResolve: (tool: CliTool) => ResolvedCli | undefined;
161
162
  private readonly memo = new Map<CliTool, Promise<ResolvedCli | undefined>>();
162
- private cacheRootTrusted = false;
163
163
 
164
164
  constructor(options: ProvisionOptions = {}, deps: ProvisionerDeps = {}) {
165
165
  this.env = deps.env ?? process.env;
166
166
  this.mode = options.mode ?? this.modeFromEnv();
167
- this.cacheDir =
167
+ this.cacheDir = resolve(
168
168
  options.cacheDir ??
169
- this.env.AGENT_SERVER_CACHE_DIR ??
170
- join(this.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "agent-server", "cli");
169
+ this.env.AGENT_SERVER_CACHE_DIR ??
170
+ join(this.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "agent-server", "cli"),
171
+ );
171
172
  this.registry = options.registry ?? this.env.AGENT_SERVER_NPM_REGISTRY ?? DEFAULT_NPM_REGISTRY;
172
173
  this.pins = options.pins ?? {};
173
174
  this.overrides = options.overrides ?? {};
@@ -184,17 +185,30 @@ export class CliProvisioner {
184
185
  * finds its sibling platform package and has the clearest failure message.
185
186
  */
186
187
  resolve(tool: CliTool): Promise<ResolvedCli | undefined> {
187
- const memoized = this.memo.get(tool);
188
- if (memoized) return memoized;
189
- const pending = this.doResolve(tool).then((resolved) => {
190
- if (resolved) {
191
- this.log(`${tool} CLI: ${resolved.path} (${resolved.source}${withVersion(resolved)})`);
192
- }
193
- return resolved;
194
- });
195
- this.memo.set(tool, pending);
196
- pending.catch(() => this.memo.delete(tool));
197
- return pending;
188
+ let pending = this.memo.get(tool);
189
+ if (!pending) {
190
+ pending = this.doResolve(tool).then((resolved) => {
191
+ if (resolved)
192
+ this.log(`${tool} CLI: ${resolved.path} (${resolved.source}${withVersion(resolved)})`);
193
+ return resolved;
194
+ });
195
+ this.memo.set(tool, pending);
196
+ }
197
+ return pending
198
+ .then((resolved) => {
199
+ if (resolved?.source === "cache" || resolved?.source === "downloaded") {
200
+ const root = this.ensureTrustedCacheRoot();
201
+ if (!managedDirectory(root, dirname(resolved.path), false)) {
202
+ throw new CliProvisionError(`Cached CLI disappeared: ${resolved.path}`);
203
+ }
204
+ trustedBinary(resolved.path, lstatSync(resolved.path));
205
+ }
206
+ return resolved;
207
+ })
208
+ .catch((error) => {
209
+ if (this.memo.get(tool) === pending) this.memo.delete(tool);
210
+ throw error;
211
+ });
198
212
  }
199
213
 
200
214
  /** Warm the managed cache with the pinned build (the `install` command). */
@@ -355,48 +369,30 @@ export class CliProvisioner {
355
369
  * (lstat): a symlink here could redirect the spawn — and the post-install
356
370
  * `chmod` — at a path outside the tree we verified.
357
371
  */
358
- private binaryIn(tool: CliTool, dir: string): string | undefined {
372
+ private binaryIn(tool: CliTool, dir: string, managedRoot?: string): string | undefined {
359
373
  const candidates =
360
374
  tool === "claude"
361
375
  ? ["claude", "claude.exe"]
362
376
  : [join("bin", "codex"), join("bin", "codex.exe")];
363
377
  for (const candidate of candidates) {
364
378
  const path = join(dir, candidate);
379
+ if (managedRoot && !managedDirectory(managedRoot, dirname(path), false)) continue;
380
+ let stat: Stats;
365
381
  try {
366
- if (lstatSync(path).isFile()) return path;
382
+ stat = lstatSync(path);
367
383
  } catch {
368
- // not here — try the next candidate
384
+ continue;
369
385
  }
386
+ if (managedRoot) trustedBinary(path, stat);
387
+ if (stat.isFile()) return path;
370
388
  }
371
389
  return undefined;
372
390
  }
373
391
 
374
- /**
375
- * Trust gate on the cache root, mirroring the Claude SDK's own temp-dir
376
- * defense: install paths are predictable (public versions + platform), so
377
- * under a shared/pre-created directory another local user could plant the
378
- * binary we spawn. Create `0o700`, require our own uid, and tighten a
379
- * looser mode (earlier runs created it with the umask). POSIX-only — on
380
- * Windows the default cache locations are already per-user.
381
- */
382
- private ensureTrustedCacheRoot(): void {
383
- if (this.cacheRootTrusted) return;
384
- mkdirSync(this.cacheDir, { recursive: true, mode: 0o700 });
385
- if (typeof process.getuid === "function") {
386
- const stat = lstatSync(this.cacheDir);
387
- if (!stat.isDirectory()) {
388
- throw new CliProvisionError(
389
- `Cache dir ${this.cacheDir} is not a directory (planted symlink?) — refusing to use it`,
390
- );
391
- }
392
- if (stat.uid !== process.getuid()) {
393
- throw new CliProvisionError(
394
- `Cache dir ${this.cacheDir} is owned by uid ${stat.uid}, expected ${process.getuid()} — refusing to use it`,
395
- );
396
- }
397
- if ((stat.mode & 0o777) !== 0o700) chmodSync(this.cacheDir, 0o700);
398
- }
399
- this.cacheRootTrusted = true;
392
+ /** Pin trusted aliases to their physical location and recheck on every use. */
393
+ private ensureTrustedCacheRoot(): string {
394
+ this.cacheDir = trustedCacheDirectory(this.cacheDir, true);
395
+ return this.cacheDir;
400
396
  }
401
397
 
402
398
  /** The directory inside an extracted tarball that becomes the install. */
@@ -418,38 +414,40 @@ export class CliProvisioner {
418
414
  }
419
415
 
420
416
  private async ensureInstalled(tool: CliTool): Promise<ResolvedCli> {
421
- this.ensureTrustedCacheRoot();
417
+ const root = this.ensureTrustedCacheRoot();
422
418
  const pkg = this.packageFor(tool);
423
- const dir = join(this.cacheDir, tool, `${pkg.version}-${pkg.suffix}`);
424
- const cached = this.binaryIn(tool, dir);
419
+ const dir = join(root, tool, `${pkg.version}-${pkg.suffix}`);
420
+ const cached = this.binaryIn(tool, dir, root);
425
421
  if (cached) return { path: cached, source: "cache", version: pkg.version };
426
422
 
427
423
  this.log(`downloading ${tool} ${pkg.version} (${pkg.suffix}) from ${this.registry}…`);
428
424
  const startedAt = Date.now();
429
425
  const staging = join(
430
- this.cacheDir,
426
+ root,
431
427
  ".tmp",
432
428
  `${tool}-${pkg.version}-${process.pid}-${randomBytes(4).toString("hex")}`,
433
429
  );
434
- await mkdir(staging, { recursive: true });
430
+ managedDirectory(root, dirname(staging), true);
431
+ await mkdir(staging, { mode: 0o700 });
435
432
  try {
436
433
  const dist = await fetchDist(this.registry, pkg.name, pkg.registryVersion, this.fetch);
437
434
  const tgz = join(staging, "package.tgz");
438
435
  const bytes = await downloadWithIntegrity(dist.tarball, tgz, dist.integrity, this.fetch);
439
436
  const extracted = join(staging, "x");
440
- await mkdir(extracted);
437
+ await mkdir(extracted, { mode: 0o700 });
441
438
  await this.extract(tgz, extracted);
442
439
  const payload = await this.payloadDir(tool, extracted);
443
440
 
444
- await mkdir(dirname(dir), { recursive: true });
441
+ this.ensureTrustedCacheRoot();
442
+ managedDirectory(root, dirname(dir), true);
445
443
  try {
446
444
  await rename(payload, dir);
447
445
  } catch (error) {
448
446
  // Lost an install race with another process. The directory is
449
447
  // version-keyed, so the winner's tree is what we would have written.
450
- if (!this.binaryIn(tool, dir)) throw error;
448
+ if (!this.binaryIn(tool, dir, root)) throw error;
451
449
  }
452
- const path = this.binaryIn(tool, dir);
450
+ const path = this.binaryIn(tool, dir, root);
453
451
  if (!path) {
454
452
  throw new CliProvisionError(`downloaded ${pkg.name}@${pkg.registryVersion} has no binary`);
455
453
  }
@@ -476,3 +474,90 @@ export class CliProvisioner {
476
474
  function withVersion(resolved: ResolvedCli): string {
477
475
  return resolved.version ? ` ${resolved.version}` : "";
478
476
  }
477
+
478
+ function refuseCache(path: string, reason: string): never {
479
+ throw new CliProvisionError(`Untrusted CLI cache path ${path}: ${reason} — refusing to use it`);
480
+ }
481
+
482
+ function trustedOwner(path: string, stat: Stats, ownOnly = false): void {
483
+ if (typeof process.getuid !== "function") return;
484
+ const uid = process.getuid();
485
+ if (stat.uid !== uid && (ownOnly || stat.uid !== 0))
486
+ refuseCache(path, `owned by uid ${stat.uid}, expected ${uid}${ownOnly ? "" : " or root"}`);
487
+ }
488
+
489
+ function privateDirectory(path: string, stat: Stats, allowSticky = false): void {
490
+ if (!stat.isDirectory()) refuseCache(path, "not a directory (planted symlink?)");
491
+ trustedOwner(path, stat);
492
+ if (
493
+ typeof process.getuid === "function" &&
494
+ stat.mode & 0o022 &&
495
+ !(allowSticky && stat.mode & 0o1000)
496
+ ) {
497
+ refuseCache(path, "writable by other users");
498
+ }
499
+ }
500
+
501
+ function createDirectory(path: string): void {
502
+ try {
503
+ mkdirSync(path, { mode: 0o700 });
504
+ } catch (error) {
505
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
506
+ }
507
+ }
508
+
509
+ /** Check each parent before creating its child; trusted sticky parents permit /tmp. */
510
+ function trustedCacheDirectory(path: string, ownLeaf: boolean): string {
511
+ const root = parse(path).root;
512
+ let current = root;
513
+ privateDirectory(root, lstatSync(root), true);
514
+ const components = relative(root, path).split(sep).filter(Boolean);
515
+ if (ownLeaf && !components.length) refuseCache(path, "filesystem root cannot be a CLI cache");
516
+ for (const [index, component] of components.entries()) {
517
+ const candidate = join(current, component);
518
+ createDirectory(candidate);
519
+ const stat = lstatSync(candidate);
520
+ const leaf = ownLeaf && index === components.length - 1;
521
+ if (stat.isSymbolicLink() && !leaf) {
522
+ trustedOwner(candidate, stat);
523
+ current = trustedCacheDirectory(realpathSync(candidate), false);
524
+ continue;
525
+ }
526
+ if (leaf) {
527
+ if (!stat.isDirectory()) refuseCache(candidate, "not a directory (planted symlink?)");
528
+ trustedOwner(candidate, stat, true);
529
+ if (typeof process.getuid === "function" && (stat.mode & 0o777) !== 0o700)
530
+ chmodSync(candidate, 0o700);
531
+ } else privateDirectory(candidate, stat, true);
532
+ current = candidate;
533
+ }
534
+ return current;
535
+ }
536
+
537
+ /** Managed descendants cannot be aliases or outsider-writable, even under a repaired root. */
538
+ function managedDirectory(root: string, path: string, create: boolean): boolean {
539
+ const rel = relative(root, path);
540
+ if (isAbsolute(rel) || rel.split(sep).includes(".."))
541
+ refuseCache(path, "outside the managed root");
542
+ let current = root;
543
+ for (const component of rel.split(sep).filter(Boolean)) {
544
+ current = join(current, component);
545
+ if (create) createDirectory(current);
546
+ let stat: Stats;
547
+ try {
548
+ stat = lstatSync(current);
549
+ } catch (error) {
550
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
551
+ throw error;
552
+ }
553
+ privateDirectory(current, stat);
554
+ }
555
+ return true;
556
+ }
557
+
558
+ function trustedBinary(path: string, stat: Stats): void {
559
+ if (!stat.isFile()) refuseCache(path, "not a regular executable file");
560
+ trustedOwner(path, stat);
561
+ if (typeof process.getuid === "function" && stat.mode & 0o022)
562
+ refuseCache(path, "executable writable by other users");
563
+ }
@@ -155,7 +155,12 @@ export class EventProcessor {
155
155
  // A parented message is a sub-agent's output — nests under its tool call,
156
156
  // not a top-level model message (see DESIGN.md D5).
157
157
  ...(parentToolCallId && { parentToolCallId }),
158
- ...(this.meta.model && { model: this.meta.model }),
158
+ // `model` is assistant/model-message metadata (the turn's configured
159
+ // model from `RunConfig.model`) — never on the user echo, which is
160
+ // user-authored. Gating here keeps it off every user message; every
161
+ // assistant message (top-level AND parented sub-agent output) keeps it,
162
+ // since `config.model` is the only per-turn model signal available.
163
+ ...(role === "assistant" && this.meta.model && { model: this.meta.model }),
159
164
  timestamp: Date.now(),
160
165
  };
161
166
  }
@@ -331,7 +331,11 @@ function openMessage(
331
331
  outputIndex: init.outputIndex,
332
332
  role: init.role,
333
333
  ...(init.parentToolCallId !== undefined && { parentToolCallId: init.parentToolCallId }),
334
- ...(init.model !== undefined && { model: init.model }),
334
+ // `model` is assistant/model-message metadata; the user echo is
335
+ // user-authored, so never persist it on a user-role message. Defense in
336
+ // depth alongside the producer gate: a hand-built or replayed
337
+ // `message.started{role:"user", model}` does not leak into the fold.
338
+ ...(init.role === "assistant" && init.model !== undefined && { model: init.model }),
335
339
  parts: [],
336
340
  startedAt: init.timestamp,
337
341
  };
@@ -59,10 +59,21 @@ export function verifyStreamContract(
59
59
  // Via the Law-6 decoder, not the closed union: an unknown event type (or an
60
60
  // unknown part inside a known event) is forward-compat, not a violation —
61
61
  // only a KNOWN type with a malformed body is.
62
+ //
63
+ // Indices that fail to decode are recorded and skipped by the later passes
64
+ // that re-touch raw event bodies (the bookkeeping loop and the projection-
65
+ // prep loop below): a malformed event's nested fields (message.part's
66
+ // `event.part.id`, `event.part.messageId`, `"raw" in part`) cannot be safely
67
+ // dereferenced a second time, so without the skip the bookkeeping forEach
68
+ // would throw a TypeError and discard the `violations` array — including the
69
+ // schema-valid report just pushed for it. The schema-valid report is
70
+ // authoritative for the index; the raw-body passes leave the index alone.
71
+ const schemaBad = new Set<number>();
62
72
  events.forEach((event, i) => {
63
73
  try {
64
74
  decodeLifecycleEvent(event);
65
75
  } catch (err) {
76
+ schemaBad.add(i);
66
77
  report(
67
78
  "schema-valid",
68
79
  i,
@@ -113,6 +124,13 @@ export function verifyStreamContract(
113
124
  // Unknown types carry no contract obligations we can check — they are
114
125
  // preserved, not interpreted.
115
126
  if (isUnknownEvent(event)) return;
127
+ // The validity loop already reported this index as schema-invalid. The
128
+ // bookkeeping below dereferences a malformed event's nested body —
129
+ // `message.part` reads `event.part.id`/`event.part.messageId` and runs
130
+ // `"raw" in part` — which a malformed `part` (null/undefined/non-object)
131
+ // cannot safely provide, so the index is left to the schema-valid report
132
+ // instead of crashing this forEach out from under the violations array.
133
+ if (schemaBad.has(i)) return;
116
134
  switch (event.type) {
117
135
  case "session.created":
118
136
  sessionCreatedInTurn++;
@@ -168,6 +186,18 @@ export function verifyStreamContract(
168
186
  turn.lastOutputIndex = Math.max(turn.lastOutputIndex, event.outputIndex);
169
187
  turn.openMessages.set(event.messageId, i);
170
188
  messageRole.set(event.messageId, event.role);
189
+ // `model` is assistant/model-message metadata; the user echo (and any
190
+ // user-role message) is user-authored, so a `model` field on it
191
+ // misattributes the configured model to the user's prompt. This catches
192
+ // leaks the producer gate prevents but a hand-built or replayed stream
193
+ // could still carry.
194
+ if (event.model !== undefined && event.role !== "assistant") {
195
+ report(
196
+ "model-assistant-only",
197
+ i,
198
+ `message.started ${event.messageId} carries model ${JSON.stringify(event.model)} on a ${event.role} message; model is assistant-only metadata`,
199
+ );
200
+ }
171
201
  return;
172
202
  }
173
203
  case "message.part": {
@@ -306,6 +336,12 @@ export function verifyStreamContract(
306
336
  const lastTextDelta = new Map<string, number>();
307
337
  events.forEach((event, i) => {
308
338
  if (isUnknownEvent(event)) return;
339
+ // Reported above as schema-invalid; this pass dereferences the nested
340
+ // `event.part.id` and `event.delta.type`, which a malformed body cannot
341
+ // safely provide. At HEAD the bookkeeping loop throws first, so this
342
+ // deref is not separately reachable — once that loop is guarded, this
343
+ // one becomes the next reachable crash, so both passes check the set.
344
+ if (schemaBad.has(i)) return;
309
345
  if (event.type === "message.part") lastSnapshot.set(event.part.id, { event, index: i });
310
346
  else if (event.type === "message.part.delta" && event.delta.type !== "tool_input") {
311
347
  lastTextDelta.set(event.partId, i);
@@ -267,7 +267,8 @@ export type WireMessage =
267
267
  | { kind: "notification"; method: string; params?: unknown }
268
268
  /** `id` is null on protocol-level errors answering unparseable requests. */
269
269
  | { kind: "response"; id: JsonRpcId | null; result?: unknown; error?: JsonRpcError }
270
- | { kind: "invalid"; error: string };
270
+ /** The decoder sets parseError; optional for existing callers constructing invalid messages. */
271
+ | { kind: "invalid"; error: string; parseError?: boolean };
271
272
 
272
273
  /** Decode one NDJSON line into a JSON-RPC message (never throws). */
273
274
  export function decodeWireMessage(line: string): WireMessage {
@@ -275,13 +276,14 @@ export function decodeWireMessage(line: string): WireMessage {
275
276
  try {
276
277
  raw = JSON.parse(line);
277
278
  } catch {
278
- return { kind: "invalid", error: "not valid JSON" };
279
+ return { kind: "invalid", error: "not valid JSON", parseError: true };
279
280
  }
280
281
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
281
- return { kind: "invalid", error: "not a JSON-RPC object" };
282
+ return { kind: "invalid", error: "not a JSON-RPC object", parseError: false };
282
283
  }
283
284
  const msg = raw as Record<string, unknown>;
284
- if (msg.jsonrpc !== "2.0") return { kind: "invalid", error: "missing jsonrpc: '2.0'" };
285
+ if (msg.jsonrpc !== "2.0")
286
+ return { kind: "invalid", error: "missing jsonrpc: '2.0'", parseError: false };
285
287
  const id = JsonRpcIdSchema.safeParse(msg.id);
286
288
  if (typeof msg.method === "string") {
287
289
  return id.success
@@ -297,7 +299,11 @@ export function decodeWireMessage(line: string): WireMessage {
297
299
  error: error.success ? error.data : undefined,
298
300
  };
299
301
  }
300
- return { kind: "invalid", error: "neither request, notification, nor response" };
302
+ return {
303
+ kind: "invalid",
304
+ error: "neither request, notification, nor response",
305
+ parseError: false,
306
+ };
301
307
  }
302
308
 
303
309
  export function encodeRequest(id: JsonRpcId, method: string, params?: unknown): string {
@@ -115,20 +115,31 @@ export class AcpUpdateTranslator {
115
115
  const kind = part.kind
116
116
  ? ((acpKinds.has(part.kind) ? part.kind : "other") as "other")
117
117
  : undefined;
118
+ const updates: SessionUpdate[] = [];
118
119
  if (!this.openedTools.has(part.toolCallId)) {
119
120
  this.openedTools.add(part.toolCallId);
120
- return [
121
- {
122
- sessionUpdate: "tool_call",
123
- toolCallId: part.toolCallId,
124
- title: part.title ?? part.toolName,
125
- name: part.toolName,
126
- ...(kind && { kind }),
127
- status,
128
- ...(part.locations?.length && { locations: part.locations }),
129
- ...("input" in part.state && { rawInput: part.state.input }),
130
- },
131
- ];
121
+ // A terminal-first snapshot — a tool already `completed`/`failed` on
122
+ // its first (and maybe only) `message.part` (e.g. a conforming ACP
123
+ // agent that reports a tool solely via a bare terminal
124
+ // `tool_call_update`, which the `acp` harness preserves as a single
125
+ // terminal part) — must still deliver its result body. Emit the
126
+ // spec-canonical pair: announce with a NON-terminal `in_progress`
127
+ // status, then a `tool_call_update` carrying the real terminal
128
+ // status and the output/error `content` (the only update a result
129
+ // can ride on). `cancelled` has no body to drop and stays on the
130
+ // single-event announce path with its mapped `failed` status.
131
+ const terminalFirst = part.state.status === "completed" || part.state.status === "failed";
132
+ updates.push({
133
+ sessionUpdate: "tool_call",
134
+ toolCallId: part.toolCallId,
135
+ title: part.title ?? part.toolName,
136
+ name: part.toolName,
137
+ ...(kind && { kind }),
138
+ status: terminalFirst ? "in_progress" : status,
139
+ ...(part.locations?.length && { locations: part.locations }),
140
+ ...("input" in part.state && { rawInput: part.state.input }),
141
+ });
142
+ if (!terminalFirst) return updates;
132
143
  }
133
144
  const output =
134
145
  part.state.status === "failed"
@@ -136,16 +147,15 @@ export class AcpUpdateTranslator {
136
147
  : part.state.status === "completed"
137
148
  ? part.state.output
138
149
  : undefined;
139
- return [
140
- {
141
- sessionUpdate: "tool_call_update",
142
- toolCallId: part.toolCallId,
143
- status,
144
- ...(output && {
145
- content: [{ type: "content", content: { type: "text", text: output } }],
146
- }),
147
- },
148
- ];
150
+ updates.push({
151
+ sessionUpdate: "tool_call_update",
152
+ toolCallId: part.toolCallId,
153
+ status,
154
+ ...(output && {
155
+ content: [{ type: "content", content: { type: "text", text: output } }],
156
+ }),
157
+ });
158
+ return updates;
149
159
  }
150
160
  }
151
161
 
@@ -111,16 +111,31 @@ export class AgentServer {
111
111
  if (this.shuttingDown) return;
112
112
  this.shuttingDown = true;
113
113
  // Drain first so cancelled turns still broadcast their turn.ended.
114
- await this.runtime.shutdown();
115
- for (const transport of [...this.transports]) transport.close();
116
- this.options.onShutdown?.();
114
+ // The drain (and each transport's close) is a library extension point
115
+ // that may throw; the finally still closes every transport and runs the
116
+ // host hook even then — every other external call in this file is
117
+ // defended the same way (broadcast/observers/sink/handleLine).
118
+ try {
119
+ await this.runtime.shutdown();
120
+ } finally {
121
+ for (const transport of [...this.transports]) {
122
+ try {
123
+ transport.close();
124
+ } catch {
125
+ // One faulty transport's close must not skip the others or the
126
+ // host's shutdown hook — same isolation broadcast uses for send.
127
+ }
128
+ }
129
+ this.options.onShutdown?.();
130
+ }
117
131
  }
118
132
 
119
133
  private async handleLine(transport: WireTransport, line: string): Promise<void> {
120
134
  if (!line.trim()) return;
121
135
  const msg = decodeWireMessage(line);
122
136
  if (msg.kind === "invalid") {
123
- transport.send(encodeErrorResponse(null, WIRE_ERROR_CODES.parseError, msg.error));
137
+ const code = msg.parseError ? WIRE_ERROR_CODES.parseError : WIRE_ERROR_CODES.invalidRequest;
138
+ transport.send(encodeErrorResponse(null, code, msg.error));
124
139
  return;
125
140
  }
126
141
  // Clients only send requests on this wire; stray notifications/responses
@@ -375,7 +390,10 @@ export class AgentServer {
375
390
 
376
391
  case WIRE_METHODS.shutdown: {
377
392
  transport.send(encodeResponse(id, {}));
378
- await this.shutdown();
393
+ // The success ack is already committed on the wire; a throw out of
394
+ // shutdown() must not reach handleLine's catch and emit a second
395
+ // response for this id (a JSON-RPC 2.0 violation).
396
+ await this.shutdown().catch(() => {});
379
397
  return;
380
398
  }
381
399