@parall/codex-agent 1.44.0 → 1.45.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.
package/dist/workspace.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { parse as parseToml } from 'smol-toml';
4
3
  import { BRIDGE_WORKSPACE_INSTRUCTIONS, PRLL_BEHAVIOR, PRLL_REFERENCE_GUIDE, buildIdentity, buildSkillReferences, writeSkillFiles, } from '@parall/agent-core';
4
+ import { runLegacyWorkspaceConfigMigration } from './legacy-workspace-config-migration.js';
5
5
  /**
6
6
  * Config-lock timings, exported for tests.
7
7
  *
@@ -25,10 +25,10 @@ function sleepSync(ms) {
25
25
  }
26
26
  /**
27
27
  * Advisory cross-process lock serializing `<codexHome>/config.toml`
28
- * read-modify-write. On a local daemon machine every runtime_auth codex
29
- * bridge child shares the operator's CODEX_HOME, and the daemon starts them
30
- * together — without a lock, a whole-file rewrite working from a stale read
31
- * can erase a trust entry another agent appended in between.
28
+ * read-modify-write. Several bridge children can share one CODEX_HOME (an
29
+ * operator-set PRLL_CODEX_HOME reaches every child on the machine), and the
30
+ * daemon starts them together — without a lock, concurrent whole-file
31
+ * rewrites working from stale reads lose updates.
32
32
  *
33
33
  * Queue design (why not wx-create + steal): any scheme that renames or
34
34
  * unlinks the SHARED lock path can, between its staleness check and the
@@ -71,11 +71,12 @@ function sleepSync(ms) {
71
71
  * eviction and the process re-enqueues. On `waitMs` timeout
72
72
  * the mutation proceeds without the lock (warn) — blocking would wedge
73
73
  * bridge startup. The lock coordinates bridge processes only; codex itself
74
- * does not observe it, which is why bridge writes to a shared config are
75
- * additionally kept rare (the trust write is a no-op after the first boot
76
- * per workspace, and runtime_auth agents never write the provider block)
77
- * and whole-file writes are atomic (temp + rename) so codex never reads a
78
- * truncated file.
74
+ * does not observe it, which is why bridge writes to the global config are
75
+ * additionally kept rare the only remaining mutation is the parall
76
+ * provider block (runtime_auth agents, which share the operator's ~/.codex,
77
+ * write nothing at all: platform instructions ride the app-server
78
+ * `developerInstructions` param) — and whole-file writes are atomic
79
+ * (temp + rename) so codex never reads a truncated file.
79
80
  *
80
81
  * Exported for tests.
81
82
  */
@@ -314,9 +315,9 @@ function bakeryEnqueue(queueDir) {
314
315
  /**
315
316
  * Whole-file config writes go through temp + rename so a concurrent reader
316
317
  * (including codex itself, which does not observe the advisory lock) never
317
- * sees a truncated file. The trust-append path intentionally keeps
318
- * appendFileSync O_APPEND is atomic for these small writes and cannot
319
- * clobber a concurrent append.
318
+ * sees a truncated file. This is now the only way the bridge writes the global
319
+ * config: the append-based trust path it used to share this file with is gone
320
+ * with `ensureWorkspaceTrusted`, and the provider block is a whole-file rewrite.
320
321
  *
321
322
  * The rename targets the file's REAL path: `config.toml` managed by a
322
323
  * dotfiles setup is often a symlink, and renaming onto the link path would
@@ -402,108 +403,6 @@ function resolveWriteTarget(filePath) {
402
403
  }
403
404
  throw new Error(`symlink chain deeper than 40 while resolving ${filePath}`);
404
405
  }
405
- /**
406
- * Ensure the workspace directory is marked as trusted in the global Codex
407
- * config so that project-level `developer_instructions` are loaded at
408
- * app-server startup.
409
- *
410
- * Uses smol-toml for structured reads (avoids substring false-positives),
411
- * but writes via raw text manipulation (preserves comments and formatting).
412
- * Failures are swallowed (warn-only) so a trust write issue never blocks
413
- * the bridge from starting.
414
- */
415
- export function ensureWorkspaceTrusted(codexHome, workspaceDir, log) {
416
- withConfigLock(codexHome, log, () => ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log));
417
- }
418
- function ensureWorkspaceTrustedLocked(codexHome, workspaceDir, log) {
419
- const configPath = path.join(codexHome, 'config.toml');
420
- const normalizedPath = path.resolve(workspaceDir);
421
- try {
422
- let content = '';
423
- try {
424
- content = fs.readFileSync(configPath, 'utf8');
425
- }
426
- catch (err) {
427
- if (err.code !== 'ENOENT')
428
- throw err;
429
- }
430
- // Structured read — know exactly what state we're in.
431
- let parsed;
432
- if (content) {
433
- try {
434
- parsed = parseToml(content);
435
- }
436
- catch {
437
- // File is already broken TOML — don't make it worse.
438
- log?.warn(`Codex config.toml is not valid TOML; skipping trust write for ${normalizedPath}`);
439
- return;
440
- }
441
- }
442
- const projects = parsed?.projects;
443
- const existingTrust = projects?.[normalizedPath]?.trust_level;
444
- if (existingTrust === 'trusted')
445
- return;
446
- if (existingTrust !== undefined) {
447
- // An explicit non-trusted value is a human decision — on a local
448
- // runtime_auth daemon this file IS the operator's own ~/.codex config,
449
- // and a workspace they deliberately marked untrusted must never be
450
- // silently flipped by an agent. Leave it and surface the consequence.
451
- log?.warn(`Codex config marks ${normalizedPath} as trust_level=${JSON.stringify(existingTrust)}; respecting the explicit decision — project-level developer_instructions will not load for this workspace`);
452
- return;
453
- }
454
- // TOML basic-string keys require backslash and double-quote escaping.
455
- const escapedPath = normalizedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
456
- const sectionHeader = `[projects."${escapedPath}"]`;
457
- const trustLine = 'trust_level = "trusted"';
458
- const headerIdx = content.indexOf(sectionHeader);
459
- if (headerIdx !== -1) {
460
- // Section exists but trust_level isn't "trusted". Scan all lines
461
- // within the section (up to the next `[` header or EOF) for an
462
- // existing trust_level key — it may not be the first line after
463
- // the header if the user added comments or other keys.
464
- const headerLineEnd = content.indexOf('\n', headerIdx);
465
- if (headerLineEnd === -1) {
466
- content = `${content}\n${trustLine}\n`;
467
- }
468
- else {
469
- const afterHeader = headerLineEnd + 1;
470
- const sectionEnd = findSectionEnd(content, afterHeader);
471
- const sectionBody = content.substring(afterHeader, sectionEnd);
472
- const trustMatch = sectionBody.match(/^[ \t]*trust_level[ \t]*=.*$/m);
473
- if (trustMatch) {
474
- const matchStart = afterHeader + trustMatch.index;
475
- const matchEnd = matchStart + trustMatch[0].length;
476
- content = content.substring(0, matchStart) + trustLine + content.substring(matchEnd);
477
- }
478
- else {
479
- content =
480
- content.substring(0, afterHeader) + trustLine + '\n' + content.substring(afterHeader);
481
- }
482
- }
483
- writeConfigAtomic(configPath, content);
484
- }
485
- else if (projects?.[normalizedPath] !== undefined) {
486
- // smol-toml found the section but indexOf missed it — the header
487
- // uses non-canonical TOML formatting. Appending would create a
488
- // duplicate table. Skip rather than corrupt the file.
489
- log?.warn(`Codex config.toml has non-canonical header for ${normalizedPath}; skipping trust write`);
490
- }
491
- else if (projects && !content.includes('[projects.')) {
492
- // `projects` exists in parsed output but no `[projects.` table
493
- // headers in the raw text — it's an inline table. Appending a
494
- // standard table header would produce invalid TOML.
495
- log?.warn(`Codex config.toml uses inline table for projects; skipping trust write for ${normalizedPath}`);
496
- }
497
- else {
498
- // Section doesn't exist — append.
499
- fs.mkdirSync(codexHome, { recursive: true });
500
- fs.appendFileSync(configPath, `\n${sectionHeader}\n${trustLine}\n`, 'utf8');
501
- }
502
- }
503
- catch (err) {
504
- log?.warn(`failed to write Codex project trust for ${normalizedPath}: ${String(err)}`);
505
- }
506
- }
507
406
  /**
508
407
  * Returns true when the agent is using the Parall LLM proxy (llm_source=parall)
509
408
  * rather than a BYO custom provider or runtime_auth. Detected by checking
@@ -530,10 +429,61 @@ export function isParallProxyMode(env = process.env) {
530
429
  *
531
430
  * Provider-managed: overwritten on every boot (env vars are the SSOT).
532
431
  */
533
- export function ensureParallProvider(codexHome, apiUrl, log) {
534
- withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl));
432
+ /**
433
+ * Escape a value as a TOML basic (double-quoted) string. Needed for Windows
434
+ * paths (backslashes) in the auth command; harmless hardening everywhere else.
435
+ */
436
+ export function tomlBasicString(value) {
437
+ let out = '';
438
+ for (const ch of value) {
439
+ const code = ch.codePointAt(0) ?? 0;
440
+ if (ch === '\\')
441
+ out += '\\\\';
442
+ else if (ch === '"')
443
+ out += '\\"';
444
+ else if (ch === '\b')
445
+ out += '\\b';
446
+ else if (ch === '\t')
447
+ out += '\\t';
448
+ else if (ch === '\n')
449
+ out += '\\n';
450
+ else if (ch === '\f')
451
+ out += '\\f';
452
+ else if (ch === '\r')
453
+ out += '\\r';
454
+ else if (code < 0x20 || code === 0x7f)
455
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
456
+ else
457
+ out += ch;
458
+ }
459
+ return `"${out}"`;
460
+ }
461
+ /**
462
+ * The `[model_providers.parall.auth]` body. auth.command is the only path
463
+ * that actually sends the Bearer header on custom providers (see
464
+ * codex-responses-api-proxy.md), so both platforms stay in that shape:
465
+ * - POSIX keeps the empirically validated `printenv OPENAI_API_KEY`,
466
+ * byte-for-byte — do not touch it.
467
+ * - Windows has no printenv; reuse the bridge's own Node runtime
468
+ * (process.execPath — on disk by construction, and the config is
469
+ * provider-managed / rewritten every boot, so a moved Node self-heals).
470
+ * The -e script mirrors printenv exactly: missing var → exit 1, value →
471
+ * stdout with no trailing newline. Keep the script free of `"` and `\`
472
+ * so it stays inert inside a TOML basic string.
473
+ */
474
+ function parallAuthLines(opts) {
475
+ const platform = opts?.platform ?? process.platform;
476
+ if (platform !== 'win32') {
477
+ return ['command = "printenv"', 'args = ["OPENAI_API_KEY"]'];
478
+ }
479
+ const nodeBin = opts?.nodeBin ?? process.execPath;
480
+ const script = 'const v=process.env.OPENAI_API_KEY;if(v===undefined)process.exit(1);process.stdout.write(v)';
481
+ return [`command = ${tomlBasicString(nodeBin)}`, `args = ["-e", ${tomlBasicString(script)}]`];
482
+ }
483
+ export function ensureParallProvider(codexHome, apiUrl, log, opts) {
484
+ withConfigLock(codexHome, log, () => ensureParallProviderLocked(codexHome, apiUrl, opts));
535
485
  }
536
- function ensureParallProviderLocked(codexHome, apiUrl) {
486
+ function ensureParallProviderLocked(codexHome, apiUrl, opts) {
537
487
  const configPath = path.join(codexHome, 'config.toml');
538
488
  const baseUrl = apiUrl.replace(/\/$/, '') + '/api/llm/v1';
539
489
  try {
@@ -550,14 +500,13 @@ function ensureParallProviderLocked(codexHome, apiUrl) {
550
500
  const providerBlock = [
551
501
  sectionHeader,
552
502
  'name = "Parall Proxy"',
553
- `base_url = "${baseUrl}"`,
503
+ `base_url = ${tomlBasicString(baseUrl)}`,
554
504
  'wire_api = "responses"',
555
505
  'supports_websockets = false',
556
506
  'requires_openai_auth = false',
557
507
  '',
558
508
  authHeader,
559
- 'command = "printenv"',
560
- 'args = ["OPENAI_API_KEY"]',
509
+ ...parallAuthLines(opts),
561
510
  ].join('\n');
562
511
  const headerIdx = content.indexOf(sectionHeader);
563
512
  if (headerIdx !== -1) {
@@ -585,18 +534,28 @@ function findSectionEnd(content, fromIndex) {
585
534
  const nextHeader = content.indexOf('\n[', fromIndex);
586
535
  return nextHeader === -1 ? content.length : nextHeader;
587
536
  }
588
- // writeCodexSystemPrompt (re)writes the standing platform prompt surfaces:
589
- // .parall/system-prompt.md (human-inspectable copy) and the workspace
590
- // .codex/config.toml developer_instructions (what the CLI actually loads).
537
+ /** Human-inspectable copy of the prompt; also the migration's authorship proof. */
538
+ function systemPromptCopyPath(workspaceDir) {
539
+ return path.join(workspaceDir, '.parall', 'system-prompt.md');
540
+ }
541
+ // writeCodexSystemPrompt (re)builds the platform prompt and refreshes its
542
+ // human-inspectable reference copy (.parall/system-prompt.md). Delivery to
543
+ // the CLI is the app-server per-thread `developerInstructions` param — NOT
544
+ // a workspace config file.
591
545
  // capabilityFragments are platform-derived capability declarations
592
546
  // (agents.capabilities[].fragment) — placed after the platform reference
593
547
  // guide, before skill references; empty/absent = no capability section.
594
- // Split out from ensureCodexWorkspace so the bridge can rewrite on
595
- // platform-config heat-update; the app-server reads workspace config at
596
- // process start, so the caller pairs a CHANGED fragment set with
597
- // adapter.requestProcessRestart(). Throws on failure BY DESIGN at boot the
598
- // prompt is mandatory, so a write failure must fail startup loudly; the
599
- // refresh hot path wraps this in try/catch and retries next refresh.
548
+ // Split out from ensureCodexWorkspace so the bridge can rebuild the prompt on
549
+ // a platform-config heat-update and hand the new value to
550
+ // adapter.updateConfig, which carries it into the NEXT thread/start. A thread
551
+ // that is already open keeps the instructions it was started with — codex
552
+ // bakes them into the thread at start and neither thread/resume nor
553
+ // thread/fork replaces them (verified against a live 0.144.1 app-server; the
554
+ // retired workspace-config channel behaved identically). See
555
+ // docs/tech-debt/codex-persisted-thread-prompt-refresh.md.
556
+ // Throws on failure BY DESIGN — at boot the prompt is mandatory, so a write
557
+ // failure must fail startup loudly; the refresh hot path wraps this in
558
+ // try/catch and retries next refresh.
600
559
  export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments) {
601
560
  const parts = [
602
561
  buildIdentity(agentIdentity),
@@ -609,18 +568,60 @@ export function writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFr
609
568
  }
610
569
  parts.push(buildSkillReferences(workspaceDir));
611
570
  const systemPrompt = parts.join('\n\n');
612
- const parallDir = path.join(workspaceDir, '.parall');
613
- fs.mkdirSync(parallDir, { recursive: true });
614
- fs.writeFileSync(path.join(parallDir, 'system-prompt.md'), systemPrompt, 'utf8');
615
- const codexConfigDir = path.join(workspaceDir, '.codex');
616
- fs.mkdirSync(codexConfigDir, { recursive: true });
617
- const toml = `developer_instructions = """\n${systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '\\"""')}\n"""\n`;
618
- fs.writeFileSync(path.join(codexConfigDir, 'config.toml'), toml, 'utf8');
571
+ // Reference copy the agent can read as a file. The prompt itself is
572
+ // delivered per-thread via the app-server `developerInstructions` param —
573
+ // deliberately NOT via workspace `.codex/config.toml`, which codex only
574
+ // loads for workspaces marked trusted in the operator's global config:
575
+ // that coupled the platform prompt to codex's interactive workspace-trust
576
+ // concept and forced the bridge to write trust entries into the
577
+ // operator's own ~/.codex on shared homes.
578
+ fs.mkdirSync(path.join(workspaceDir, '.parall'), { recursive: true });
579
+ fs.writeFileSync(systemPromptCopyPath(workspaceDir), systemPrompt, 'utf8');
580
+ return systemPrompt;
619
581
  }
620
582
  export function ensureCodexWorkspace(workspaceDir, log, agentIdentity, capabilityFragments) {
621
583
  fs.mkdirSync(workspaceDir, { recursive: true });
584
+ // The ONE ordering rule this bootstrap has to get right.
585
+ //
586
+ // `.parall/system-prompt.md` is the migration's authorship proof, and
587
+ // writeCodexSystemPrompt below overwrites it. It is therefore legacy-era
588
+ // evidence for exactly one boot, so the migration must take its one-way claim
589
+ // and consume the proof BEFORE that write. The migration refuses to delete
590
+ // anything once the claim exists, so no later boot can judge the legacy config
591
+ // against a prompt this bridge wrote itself. A claim that cannot be persisted
592
+ // throws from here — the boot dies with the evidence intact rather than
593
+ // manufacturing a prompt a later boot would mistake for it.
594
+ //
595
+ // The proof goes in as a THUNK: reading it warns when it exists but cannot be
596
+ // read, and a boot that already lost (or never had) the claim must stay
597
+ // silent. Handing over an evaluated value would fire that warning before the
598
+ // claim is even checked.
599
+ runLegacyWorkspaceConfigMigration(workspaceDir, () => readAuthorshipProof(workspaceDir, log), log);
622
600
  // Boot: let a write failure PROPAGATE (fatal) — startup must not proceed
623
601
  // without the platform prompt. The refresh path tolerates failure.
624
- writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
602
+ const systemPrompt = writeCodexSystemPrompt(workspaceDir, agentIdentity, capabilityFragments);
625
603
  writeSkillFiles(path.join(workspaceDir, '.parall', 'skills'));
604
+ return systemPrompt;
605
+ }
606
+ /**
607
+ * The previous boot's prompt copy — the migration's authorship proof.
608
+ *
609
+ * ENOENT is the ordinary first boot in a workspace: absent, no warning. Any
610
+ * other error means the proof EXISTS but could not be read, which is a
611
+ * different fact and must not be reported as "never bootstrapped here" — the
612
+ * migration would otherwise preserve a genuinely stale config while telling the
613
+ * operator the wrong reason. Both map to "don't delete"; only the diagnostic
614
+ * differs.
615
+ */
616
+ function readAuthorshipProof(workspaceDir, log) {
617
+ const proofPath = systemPromptCopyPath(workspaceDir);
618
+ try {
619
+ return { kind: 'present', prompt: fs.readFileSync(proofPath, 'utf8') };
620
+ }
621
+ catch (err) {
622
+ if (err.code === 'ENOENT')
623
+ return { kind: 'absent' };
624
+ log?.warn(`could not read the authorship proof ${proofPath}: ${String(err)}`);
625
+ return { kind: 'unreadable' };
626
+ }
626
627
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/codex-agent",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "Codex CLI bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,13 +25,13 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "smol-toml": "^1.6.1",
29
- "@parall/agent-core": "1.44.0",
30
- "@parall/cli": "1.44.0",
31
- "@parall/sdk": "1.44.0"
28
+ "@parall/agent-core": "1.45.0",
29
+ "@parall/cli": "1.45.0",
30
+ "@parall/sdk": "1.45.0"
32
31
  },
33
32
  "devDependencies": {
34
33
  "@types/node": "^22.0.0",
34
+ "smol-toml": "^1.6.1",
35
35
  "typescript": "^5.7.0"
36
36
  },
37
37
  "scripts": {
@@ -0,0 +1,59 @@
1
+ import { execSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import { ensureLocalAttachmentGitExclude } from '@parall/agent-core/internal/attachment-input';
4
+
5
+ /**
6
+ * OS-level plumbing for the `codex app-server` child process: the cwd invariant
7
+ * it requires (a git repo), and the Windows spawn/kill quirks.
8
+ */
9
+
10
+ export const IS_WIN32 = process.platform === 'win32';
11
+
12
+ export function quoteWin32Arg(arg: string): string {
13
+ if (!/[\s"&|^<>()]/.test(arg)) return arg;
14
+ return `"${arg.replace(/"/g, '""')}"`;
15
+ }
16
+
17
+ export function killWin32Tree(pid: number): boolean {
18
+ try {
19
+ execSync(`taskkill /T /F /PID ${pid}`, { windowsHide: true, stdio: 'ignore' });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ export function ensureGitRepo(workingDirectory: string): void {
27
+ fs.mkdirSync(workingDirectory, { recursive: true });
28
+ // Only `git init` if the workspace isn't already inside any git repo. A
29
+ // bare existsSync(.git) check would miss the common case of a user pointing
30
+ // PRLL_WORKSPACE_DIR at a subdirectory of their existing project,
31
+ // and silently creating a nested repo there would mangle their layout.
32
+ //
33
+ // The exclude write sits inside this try, so in principle its failure would
34
+ // read as "not a repo" and fall through to `git init` in the user's existing
35
+ // repository. It cannot: the helper swallows its own errors. Deliberately left
36
+ // as-is rather than fixed under review — latent, not live, and out of this
37
+ // PR's scope: docs/tech-debt/codex-ensure-git-repo-exclude-coupling.md.
38
+ try {
39
+ execSync('git rev-parse --is-inside-work-tree', { cwd: workingDirectory, stdio: 'pipe' });
40
+ ensureLocalAttachmentGitExclude(workingDirectory);
41
+ return;
42
+ } catch {
43
+ // Not inside a repo — fall through to init.
44
+ }
45
+ const env = {
46
+ ...process.env,
47
+ GIT_AUTHOR_NAME: 'parall-codex-agent',
48
+ GIT_AUTHOR_EMAIL: 'agent@parall.local',
49
+ GIT_COMMITTER_NAME: 'parall-codex-agent',
50
+ GIT_COMMITTER_EMAIL: 'agent@parall.local',
51
+ };
52
+ try {
53
+ execSync('git init', { cwd: workingDirectory, stdio: 'pipe', env });
54
+ execSync('git commit --allow-empty -m init', { cwd: workingDirectory, stdio: 'pipe', env });
55
+ ensureLocalAttachmentGitExclude(workingDirectory);
56
+ } catch {
57
+ // Non-fatal: codex app-server may still accept a bare directory. Let it raise at turn time.
58
+ }
59
+ }
@@ -0,0 +1,46 @@
1
+ import type { PreparedLocalImage } from '@parall/agent-core/internal/attachment-input';
2
+
3
+ /**
4
+ * Wire shapes of the `codex app-server` JSON-RPC payloads: what we send as turn
5
+ * input, and how we read ids back out of responses and notifications. The
6
+ * protocol has shifted between CLI versions, so the tolerant field probing lives
7
+ * here rather than being spread through the adapter.
8
+ */
9
+
10
+ export type CodexTurnInput = { type: 'text'; text: string } | { type: 'localImage'; path: string };
11
+
12
+ export function buildTurnInput(body: string, images: PreparedLocalImage[]): CodexTurnInput[] {
13
+ return [
14
+ { type: 'text', text: body },
15
+ ...images.map((image) => ({ type: 'localImage' as const, path: image.localPath })),
16
+ ];
17
+ }
18
+
19
+ export function extractThreadId(result: unknown): string | undefined {
20
+ if (!result || typeof result !== 'object') return undefined;
21
+ const r = result as Record<string, unknown>;
22
+ if (typeof r.threadId === 'string') return r.threadId;
23
+ const thread = r.thread as Record<string, unknown> | undefined;
24
+ if (thread && typeof thread.id === 'string') return thread.id;
25
+ return undefined;
26
+ }
27
+
28
+ export function extractTurnId(result: unknown): string | undefined {
29
+ if (!result || typeof result !== 'object') return undefined;
30
+ const r = result as Record<string, unknown>;
31
+ if (typeof r.turnId === 'string') return r.turnId;
32
+ const turn = r.turn as Record<string, unknown> | undefined;
33
+ if (turn && typeof turn.id === 'string') return turn.id;
34
+ return undefined;
35
+ }
36
+
37
+ export function extractThreadIdFromNotification(params: unknown): string | undefined {
38
+ if (!params || typeof params !== 'object') return undefined;
39
+ const p = params as Record<string, unknown>;
40
+ if (typeof p.threadId === 'string') return p.threadId;
41
+ const thread = p.thread as Record<string, unknown> | undefined;
42
+ if (thread && typeof thread.id === 'string') return thread.id;
43
+ const meta = (p._meta ?? p.meta) as Record<string, unknown> | undefined;
44
+ if (meta && typeof meta.threadId === 'string') return meta.threadId;
45
+ return undefined;
46
+ }