@parall/codex-agent 1.18.0

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.
@@ -0,0 +1,559 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { execSync } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import type {
7
+ CleanupForkOpts,
8
+ DispatchAdapter,
9
+ DispatchOpts,
10
+ ForkOpts,
11
+ ForkSessionHandle,
12
+ GatewayLogger,
13
+ RuntimeEvent,
14
+ } from "@parall/agent-core";
15
+ import type { CodexAgentConfig } from "./config.js";
16
+ import { normalizeApprovalPolicy, normalizeSandbox } from "./config.js";
17
+ import { EventMapper } from "./event-mapping.js";
18
+ import { JsonRpcStdioClient } from "./jsonrpc-client.js";
19
+ import type { CodexSessionManager } from "./session-manager.js";
20
+
21
+ type CodexAppServerAdapterOptions = Pick<
22
+ CodexAgentConfig,
23
+ | "approvalPolicy"
24
+ | "codexBin"
25
+ | "codexHome"
26
+ | "model"
27
+ | "reasoningEffort"
28
+ | "sandbox"
29
+ | "workspaceDir"
30
+ > & {
31
+ sessionManager: CodexSessionManager;
32
+ log?: GatewayLogger;
33
+ };
34
+
35
+ type TurnEventEnvelope =
36
+ | { kind: "runtime"; event: RuntimeEvent }
37
+ | { kind: "turn_end"; threadId?: string }
38
+ | { kind: "error"; message: string };
39
+
40
+ /**
41
+ * Bridge driver backed by `codex app-server --listen stdio://`.
42
+ *
43
+ * Lifecycle:
44
+ * - bridge startup: spawn one `codex app-server` subprocess, do the
45
+ * `initialize` / `initialized` handshake once, keep the pipe open
46
+ * - per Parall dispatch: `thread/start` (first time) or `thread/resume`,
47
+ * then `turn/start`; forward server notifications until `turn/completed`
48
+ * - fork-on-busy: `thread/fork` creates a disposable sibling thread
49
+ *
50
+ * Concurrency: agent-core routes one dispatch per sessionKey at a time, so
51
+ * main + fork can interleave turns on the same stdio pipe. We route
52
+ * notifications by threadId the server stamps on every item/turn event.
53
+ */
54
+ export class CodexAppServerAdapter implements DispatchAdapter {
55
+ private client: JsonRpcStdioClient | null = null;
56
+ private proc: ChildProcessWithoutNullStreams | null = null;
57
+ private initialized = false;
58
+ private startPromise: Promise<void> | null = null;
59
+ private readonly activeTurns = new Map<string, TurnSink>();
60
+ private readonly resumedThreadIds = new Set<string>();
61
+ private stopping = false;
62
+
63
+ /**
64
+ * Store an active turn sink keyed by threadId. If a sink already exists for
65
+ * the same threadId, log a warning and fail the existing sink — this
66
+ * shouldn't happen in practice (agent-core serialises per sessionKey, and
67
+ * thread/fork returns a distinct id) but the failure mode of a silent
68
+ * replacement would be a perpetually hung dispatch generator, which is
69
+ * hard to debug.
70
+ */
71
+ private setActiveTurn(threadId: string, sink: TurnSink, log?: GatewayLogger): void {
72
+ const existing = this.activeTurns.get(threadId);
73
+ if (existing) {
74
+ (log ?? this.opts.log)?.warn?.(
75
+ `codex-agent: thread ${threadId} already had an active turn; failing the previous dispatch`,
76
+ );
77
+ existing.push({ kind: "error", message: `thread ${threadId} replaced by concurrent turn` });
78
+ existing.close();
79
+ }
80
+ this.activeTurns.set(threadId, sink);
81
+ }
82
+
83
+ constructor(private readonly opts: CodexAppServerAdapterOptions) {}
84
+
85
+ async *dispatch({ bodyForAgent, sessionKey, context }: DispatchOpts): AsyncIterable<RuntimeEvent> {
86
+ await this.ensureStarted(context.log);
87
+ // After ensureStarted resolves the subprocess could still die before we
88
+ // capture the client (handleSubprocessClose nulls this.client). Yield a
89
+ // clean error event instead of relying on a non-null assertion that would
90
+ // throw a TypeError on the next sendRequest call.
91
+ const client = this.client;
92
+ if (!client) {
93
+ yield { type: "error", message: "Codex app-server not available (subprocess died during dispatch start)" };
94
+ return;
95
+ }
96
+ const log = this.opts.log ?? context.log;
97
+ const isMainSession = this.opts.sessionManager.isMain(sessionKey);
98
+
99
+ let threadId = this.opts.sessionManager.getThreadId(sessionKey);
100
+ if (!threadId) {
101
+ try {
102
+ threadId = await this.openThread(client, { resumeId: undefined });
103
+ this.opts.sessionManager.recordThreadId(sessionKey, threadId);
104
+ // Mark this freshly-started thread as already live in the current
105
+ // app-server process. Without this, the second dispatch after a cold
106
+ // start would enter the thread/resume branch below for a thread this
107
+ // process just created — and if the server treats resume-of-just-
108
+ // created-thread as an attach-to-detached-thread operation, it could
109
+ // fail the resume path and replace the thread, losing the first turn.
110
+ this.resumedThreadIds.add(threadId);
111
+ } catch (err) {
112
+ yield { type: "error", message: `Codex thread/start failed: ${errToString(err)}` };
113
+ return;
114
+ }
115
+ } else if (isMainSession && !this.resumedThreadIds.has(threadId)) {
116
+ // We have a persisted threadId from a previous bridge run — resume it.
117
+ // Mirror the deferred-clear pattern from the turn/start retry below: only
118
+ // discard the persisted id once a fresh-thread start has actually
119
+ // succeeded, so a transient resume failure (network/timeout/upstream
120
+ // hiccup) doesn't permanently throw away the prior conversation context.
121
+ try {
122
+ threadId = await this.openThread(client, { resumeId: threadId });
123
+ this.opts.sessionManager.recordThreadId(sessionKey, threadId);
124
+ this.resumedThreadIds.add(threadId);
125
+ } catch (err) {
126
+ log?.warn?.(`codex-agent: thread/resume failed (${errToString(err)}); attempting one-shot fresh-thread start`);
127
+ let freshThreadId: string;
128
+ try {
129
+ freshThreadId = await this.openThread(client, { resumeId: undefined });
130
+ } catch (innerErr) {
131
+ yield {
132
+ type: "error",
133
+ message: `Codex thread/start failed after resume error (persisted thread retained): ${errToString(innerErr)}`,
134
+ };
135
+ return;
136
+ }
137
+ this.opts.sessionManager.clearMainThread();
138
+ this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
139
+ this.resumedThreadIds.add(freshThreadId);
140
+ threadId = freshThreadId;
141
+ }
142
+ }
143
+
144
+ const sink = new TurnSink();
145
+ this.setActiveTurn(threadId, sink, log);
146
+ const groupKey = randomUUID();
147
+ let sawTurnEnd = false;
148
+
149
+ try {
150
+ // Start the turn. If the stored threadId is dead, we retry once with a fresh thread.
151
+ let turnStartResult: unknown;
152
+ try {
153
+ turnStartResult = await client.sendRequest("turn/start", {
154
+ threadId,
155
+ input: buildTurnInput(bodyForAgent),
156
+ });
157
+ } catch (err) {
158
+ const message = errToString(err);
159
+ // For the main session we attempt one fresh-thread retry. Codex CLI's
160
+ // wording for stale threads shifts across versions (not found / unknown
161
+ // / invalid / expired / gone / ...), so we don't gate the retry on a
162
+ // regex. But we only commit to discarding the persisted threadId after
163
+ // the retry succeeds — if the failure was transient (network, timeout,
164
+ // upstream model hiccup), keeping the old id lets the next dispatch
165
+ // resume normally instead of permanently losing conversation context.
166
+ // Fork sessions don't retry: agent-core spawns a fresh fork next trigger.
167
+ if (!isMainSession) {
168
+ yield { type: "error", message: `Codex turn/start failed: ${message}` };
169
+ return;
170
+ }
171
+ log?.warn?.(`codex-agent: turn/start on thread ${threadId} failed (${message}); attempting one-shot fresh-thread retry`);
172
+ this.activeTurns.delete(threadId);
173
+ let freshThreadId: string;
174
+ try {
175
+ freshThreadId = await this.openThread(client, { resumeId: undefined });
176
+ } catch (createErr) {
177
+ yield { type: "error", message: `Codex turn/start failed; could not create replacement thread: ${errToString(createErr)}` };
178
+ return;
179
+ }
180
+ this.setActiveTurn(freshThreadId, sink, log);
181
+ try {
182
+ turnStartResult = await client.sendRequest("turn/start", {
183
+ threadId: freshThreadId,
184
+ input: buildTurnInput(bodyForAgent),
185
+ });
186
+ } catch (retryErr) {
187
+ // Retry also failed — likely transient or systemic, not a stale-thread
188
+ // issue. Don't clobber the persisted threadId; next dispatch will try
189
+ // resume again.
190
+ this.activeTurns.delete(freshThreadId);
191
+ yield { type: "error", message: `Codex turn/start failed after retry: ${errToString(retryErr)}` };
192
+ return;
193
+ }
194
+ // Retry accepted — the original thread really was unusable. Now safe
195
+ // to discard the old persisted id and persist the fresh one.
196
+ this.opts.sessionManager.clearMainThread();
197
+ this.opts.sessionManager.recordThreadId(sessionKey, freshThreadId);
198
+ this.resumedThreadIds.add(freshThreadId);
199
+ threadId = freshThreadId;
200
+ }
201
+
202
+ void turnStartResult; // turnId comes back here but isn't needed — we key by threadId
203
+
204
+ while (true) {
205
+ const envelope = await sink.next();
206
+ if (envelope.kind === "turn_end") {
207
+ sawTurnEnd = true;
208
+ break;
209
+ }
210
+ if (envelope.kind === "error") {
211
+ yield { type: "error", message: envelope.message };
212
+ continue;
213
+ }
214
+ const event = envelope.event;
215
+ if (event.type === "error") {
216
+ yield event;
217
+ continue;
218
+ }
219
+ if (event.type === "text") {
220
+ // Layer 0 symmetric output contract: Codex's plain text is never
221
+ // projected as a chat message. Outbound messages must come from the
222
+ // agent explicitly invoking `@parall/cli messages send` / `dm` via
223
+ // the shell/exec tool. Text events are still yielded so agent-core
224
+ // records them as suppressed session steps for audit.
225
+ yield { ...event, project: false, groupKey };
226
+ continue;
227
+ }
228
+ yield { ...event, groupKey };
229
+ }
230
+ } finally {
231
+ this.activeTurns.delete(threadId!);
232
+ if (!sawTurnEnd) {
233
+ // Defensive: if we bailed early, make sure we leave no dangling sink.
234
+ sink.close();
235
+ }
236
+ }
237
+ }
238
+
239
+ async forkSession({ sessionKey: parentSessionKey }: ForkOpts): Promise<ForkSessionHandle | null> {
240
+ const client = this.client;
241
+ if (!client) return null;
242
+ const parentThreadId = this.opts.sessionManager.getThreadId(parentSessionKey);
243
+ if (!parentThreadId) return null;
244
+ const handle = this.opts.sessionManager.createForkSessionKey();
245
+ try {
246
+ const result = await client.sendRequest("thread/fork", {
247
+ threadId: parentThreadId,
248
+ ephemeral: true,
249
+ });
250
+ const forkedThreadId = extractThreadId(result);
251
+ if (!forkedThreadId) {
252
+ this.opts.log?.warn?.("codex-agent: thread/fork returned no thread id");
253
+ return null;
254
+ }
255
+ this.opts.sessionManager.recordThreadId(handle.sessionKey, forkedThreadId);
256
+ return handle;
257
+ } catch (err) {
258
+ return this.logForkFailure(err);
259
+ }
260
+ }
261
+
262
+ cleanupFork({ fork }: CleanupForkOpts) {
263
+ this.opts.sessionManager.cleanupFork(fork.sessionKey);
264
+ }
265
+
266
+ private logForkFailure(err: unknown): null {
267
+ this.opts.log?.warn?.(`codex-agent: thread/fork failed: ${errToString(err)}`);
268
+ return null;
269
+ }
270
+
271
+ async stop() {
272
+ this.stopping = true;
273
+ const proc = this.proc;
274
+ const client = this.client;
275
+ this.proc = null;
276
+ this.client = null;
277
+ this.initialized = false;
278
+ if (client) client.dispose(new Error("adapter stopped"));
279
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
280
+ proc.kill("SIGTERM");
281
+ }
282
+ }
283
+
284
+ private async ensureStarted(log?: GatewayLogger): Promise<void> {
285
+ if (this.initialized && this.client) return;
286
+ if (this.startPromise) return this.startPromise;
287
+ this.startPromise = this.doStart(log);
288
+ try {
289
+ await this.startPromise;
290
+ } finally {
291
+ // Keep startPromise set only while actively starting; reset once resolved
292
+ // (or rejected) so the next ensureStarted() after a subprocess death can
293
+ // restart cleanly.
294
+ this.startPromise = null;
295
+ }
296
+ }
297
+
298
+ private async doStart(log?: GatewayLogger): Promise<void> {
299
+ // Reset graceful-stop flag in case the adapter is being restarted after a
300
+ // previous stop() — otherwise handleSubprocessClose would mislabel the next
301
+ // unexpected exit as "during graceful stop".
302
+ this.stopping = false;
303
+ ensureGitRepo(this.opts.workspaceDir);
304
+
305
+ // Only steer Codex's own state via CODEX_HOME. Leaving HOME untouched
306
+ // preserves the user's real dotfiles for any subprocess Codex spawns
307
+ // (git/ssh/npm/etc.). Earlier versions also rewrote HOME, which broke
308
+ // tooling for users who set a custom CODEX_HOME pointing somewhere
309
+ // other than their shell home.
310
+ const env = {
311
+ ...process.env,
312
+ CODEX_HOME: this.opts.codexHome,
313
+ FORCE_COLOR: "0",
314
+ NO_COLOR: "1",
315
+ };
316
+ const args = ["app-server", "--listen", "stdio://"];
317
+ (log ?? this.opts.log)?.info?.(`codex-agent: spawning ${this.opts.codexBin} ${args.join(" ")}`);
318
+ const proc = spawn(this.opts.codexBin, args, {
319
+ cwd: this.opts.workspaceDir,
320
+ env,
321
+ stdio: ["pipe", "pipe", "pipe"],
322
+ }) as ChildProcessWithoutNullStreams;
323
+
324
+ proc.stderr.setEncoding("utf8");
325
+ proc.stderr.on("data", (chunk: string) => {
326
+ (log ?? this.opts.log)?.warn?.(`codex-agent[stderr]: ${chunk.trim()}`);
327
+ });
328
+
329
+ // Don't publish proc/client until the initialize handshake succeeds. If
330
+ // initialize rejects (timeout, malformed handshake, app-server crash on
331
+ // start), we kill the half-spawned subprocess and clear local refs here
332
+ // — leaving them on `this` would leak the orphan process and cause the
333
+ // next ensureStarted() to spawn a second app-server on top of it.
334
+ const client = new JsonRpcStdioClient(proc);
335
+ client.setNotificationHandler((method, params) => this.routeNotification(method, params));
336
+
337
+ proc.once("close", (code, signal) => this.handleSubprocessClose(proc, code, signal, log));
338
+ proc.once("error", (err) => this.handleSubprocessClose(proc, null, null, log, err));
339
+
340
+ try {
341
+ await client.sendRequest("initialize", {
342
+ clientInfo: { name: "parall-codex-agent", version: "1" },
343
+ capabilities: { experimentalApi: false },
344
+ });
345
+ client.sendNotification("initialized", {});
346
+ } catch (err) {
347
+ client.dispose(err instanceof Error ? err : new Error(String(err)));
348
+ if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGTERM");
349
+ throw err;
350
+ }
351
+
352
+ this.proc = proc;
353
+ this.client = client;
354
+ this.initialized = true;
355
+ }
356
+
357
+ private handleSubprocessClose(
358
+ proc: ChildProcessWithoutNullStreams,
359
+ code: number | null,
360
+ signal: NodeJS.Signals | null,
361
+ log?: GatewayLogger,
362
+ err?: Error,
363
+ ): void {
364
+ // Ignore close/error callbacks from a stale subprocess instance: the
365
+ // adapter may already have spawned a fresh app-server (e.g. after a
366
+ // recovered handshake failure), and we don't want a dying old child to
367
+ // tear down the new healthy one.
368
+ if (this.proc !== null && this.proc !== proc) return;
369
+ const reason = err
370
+ ? `spawn error: ${err.message}`
371
+ : `exited (code=${code ?? "null"}${signal ? `, signal=${signal}` : ""})`;
372
+ const logger = log ?? this.opts.log;
373
+ if (this.stopping) {
374
+ logger?.info?.(`codex-agent: app-server subprocess ${reason} during graceful stop`);
375
+ } else {
376
+ logger?.warn?.(`codex-agent: app-server subprocess ${reason}; resetting adapter`);
377
+ }
378
+ this.client?.dispose(err ?? new Error(`app-server ${reason}`));
379
+ for (const activeSink of this.activeTurns.values()) {
380
+ activeSink.push({ kind: "error", message: `Codex app-server ${reason}` });
381
+ activeSink.close();
382
+ }
383
+ this.activeTurns.clear();
384
+ this.resumedThreadIds.clear();
385
+ this.client = null;
386
+ this.proc = null;
387
+ this.initialized = false;
388
+ }
389
+
390
+ private async openThread(
391
+ client: JsonRpcStdioClient,
392
+ opts: { resumeId: string | undefined },
393
+ ): Promise<string> {
394
+ // Per the app-server protocol, `thread/resume` accepts the same
395
+ // configuration overrides as `thread/start` (sandbox, approvalPolicy,
396
+ // model, reasoningEffort). Passing them on resume lets the user change
397
+ // PRLL_CODEX_* env vars and have the bridge pick them up on the next
398
+ // restart instead of being stuck on the values baked into the persisted
399
+ // thread. Sandbox / approval values are normalised to the camelCase enum
400
+ // the protocol expects (`workspaceWrite`, `onRequest`, etc.), so users
401
+ // can supply either CLI-style or protocol-style env values.
402
+ const commonParams: Record<string, unknown> = {
403
+ approvalPolicy: normalizeApprovalPolicy(this.opts.approvalPolicy),
404
+ sandbox: normalizeSandbox(this.opts.sandbox),
405
+ };
406
+ if (this.opts.model) commonParams.model = this.opts.model;
407
+ if (this.opts.reasoningEffort) {
408
+ // The app-server JSON-RPC surface uses camelCase for overrides —
409
+ // `modelReasoningEffort` parallels `approvalPolicy` / `sandbox` on the
410
+ // top-level params. The corresponding config.toml key is
411
+ // `model_reasoning_effort` (snake_case), but the nested `config` on
412
+ // `thread/start` / `thread/resume` takes the camelCase form.
413
+ commonParams.config = { modelReasoningEffort: this.opts.reasoningEffort };
414
+ }
415
+
416
+ const method = opts.resumeId ? "thread/resume" : "thread/start";
417
+ const params: Record<string, unknown> = opts.resumeId
418
+ ? { threadId: opts.resumeId, ...commonParams }
419
+ : { cwd: this.opts.workspaceDir, ...commonParams };
420
+
421
+ const result = await client.sendRequest(method, params);
422
+ const threadId = extractThreadId(result) ?? opts.resumeId;
423
+ if (!threadId) {
424
+ throw new Error(`${method} returned no thread id`);
425
+ }
426
+ return threadId;
427
+ }
428
+
429
+ private routeNotification(method: string, params: unknown) {
430
+ const threadId = extractThreadIdFromNotification(params);
431
+ if (!threadId) {
432
+ // Surface server-initiated generic errors to every active turn.
433
+ if (method === "error") {
434
+ const msg = (params as { message?: unknown })?.message ?? "Codex app-server error";
435
+ for (const sink of this.activeTurns.values()) {
436
+ sink.push({ kind: "error", message: String(msg) });
437
+ }
438
+ }
439
+ return;
440
+ }
441
+
442
+ const sink = this.activeTurns.get(threadId);
443
+ if (!sink) return;
444
+
445
+ // For turn/completed we still run the mapper first — it emits a
446
+ // RuntimeEvent error if `turn.status === "failed"`. Enqueue those events
447
+ // before the turn_end sentinel so the dispatch loop can yield them.
448
+ for (const event of sink.mapper.map(method, params)) {
449
+ sink.push({ kind: "runtime", event });
450
+ }
451
+ if (method === "turn/completed") {
452
+ sink.push({ kind: "turn_end", threadId });
453
+ }
454
+ }
455
+
456
+ }
457
+
458
+ /** Per-turn buffered sink backed by an unbounded promise queue. */
459
+ class TurnSink {
460
+ readonly mapper = new EventMapper();
461
+ private readonly queue: TurnEventEnvelope[] = [];
462
+ private resolver: ((value: TurnEventEnvelope) => void) | null = null;
463
+ private closed = false;
464
+
465
+ push(envelope: TurnEventEnvelope) {
466
+ if (this.closed) return;
467
+ if (this.resolver) {
468
+ const r = this.resolver;
469
+ this.resolver = null;
470
+ r(envelope);
471
+ return;
472
+ }
473
+ this.queue.push(envelope);
474
+ }
475
+
476
+ next(): Promise<TurnEventEnvelope> {
477
+ // Drain any queued envelopes first, even after close(). Otherwise a final
478
+ // error envelope enqueued right before close() (e.g. by
479
+ // handleSubprocessClose) is silently dropped because the consumer would
480
+ // see turn_end before it.
481
+ const pending = this.queue.shift();
482
+ if (pending) return Promise.resolve(pending);
483
+ if (this.closed) {
484
+ return Promise.resolve({ kind: "turn_end" });
485
+ }
486
+ return new Promise((resolve) => {
487
+ this.resolver = resolve;
488
+ });
489
+ }
490
+
491
+ close() {
492
+ this.closed = true;
493
+ const r = this.resolver;
494
+ this.resolver = null;
495
+ r?.({ kind: "turn_end" });
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Codex app-server's `turn/start` expects `input` as an array of content
501
+ * items (each `{ type: "text", text: "..." }` or similar), not a raw string.
502
+ * Wrap the event body so the protocol contract is honoured — sending a bare
503
+ * string has worked historically via undocumented coercion but isn't stable.
504
+ */
505
+ function buildTurnInput(body: string): Array<{ type: "text"; text: string }> {
506
+ return [{ type: "text", text: body }];
507
+ }
508
+
509
+ function ensureGitRepo(workingDirectory: string): void {
510
+ fs.mkdirSync(workingDirectory, { recursive: true });
511
+ // Only `git init` if the workspace isn't already inside any git repo. A
512
+ // bare existsSync(.git) check would miss the common case of a user pointing
513
+ // PRLL_CODEX_WORKSPACE_DIR at a subdirectory of their existing project,
514
+ // and silently creating a nested repo there would mangle their layout.
515
+ try {
516
+ execSync("git rev-parse --is-inside-work-tree", { cwd: workingDirectory, stdio: "pipe" });
517
+ return;
518
+ } catch {
519
+ // Not inside a repo — fall through to init.
520
+ }
521
+ const env = {
522
+ ...process.env,
523
+ GIT_AUTHOR_NAME: "parall-codex-agent",
524
+ GIT_AUTHOR_EMAIL: "agent@parall.local",
525
+ GIT_COMMITTER_NAME: "parall-codex-agent",
526
+ GIT_COMMITTER_EMAIL: "agent@parall.local",
527
+ };
528
+ try {
529
+ execSync("git init", { cwd: workingDirectory, stdio: "pipe", env });
530
+ execSync("git commit --allow-empty -m init", { cwd: workingDirectory, stdio: "pipe", env });
531
+ } catch {
532
+ // Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
533
+ }
534
+ }
535
+
536
+ function extractThreadId(result: unknown): string | undefined {
537
+ if (!result || typeof result !== "object") return undefined;
538
+ const r = result as Record<string, unknown>;
539
+ if (typeof r.threadId === "string") return r.threadId;
540
+ const thread = r.thread as Record<string, unknown> | undefined;
541
+ if (thread && typeof thread.id === "string") return thread.id;
542
+ return undefined;
543
+ }
544
+
545
+ function extractThreadIdFromNotification(params: unknown): string | undefined {
546
+ if (!params || typeof params !== "object") return undefined;
547
+ const p = params as Record<string, unknown>;
548
+ if (typeof p.threadId === "string") return p.threadId;
549
+ const thread = p.thread as Record<string, unknown> | undefined;
550
+ if (thread && typeof thread.id === "string") return thread.id;
551
+ const meta = (p._meta ?? p.meta) as Record<string, unknown> | undefined;
552
+ if (meta && typeof meta.threadId === "string") return meta.threadId;
553
+ return undefined;
554
+ }
555
+
556
+ function errToString(err: unknown): string {
557
+ if (err instanceof Error) return err.message;
558
+ return String(err);
559
+ }