tandem-editor 0.14.3 → 0.16.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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/cli/skill-content.ts","../../src/shared/constants.ts","../../src/server/platform.ts","../../src/server/integrations/acl-win.ts","../../src/server/integrations/backup.ts","../../src/server/integrations/apply.ts","../../src/cli/win-path-guard.ts","../../src/cli/uninstall-scrub.ts","../../src/cli/setup.ts","../../src/shared/cli-runtime.ts","../../src/cli/preflight.ts","../../src/cli/mcp-stdio.ts","../../src/shared/api-paths.ts","../../src/shared/fetch-with-timeout.ts","../../src/shared/utils.ts","../../src/shared/events/types.ts","../../src/shared/positions/types.ts","../../src/shared/types.ts","../../src/shared/sse-consumer.ts","../../src/channel/event-bridge.ts","../../src/channel/run.ts","../../src/cli/channel.ts","../../src/server/annotations/lockfile.ts","../../src/cli/doctor.ts","../../src/shared/auth/token-file.ts","../../src/cli/rotate-token.ts","../../src/server/license/gate-flag.ts","../../src/shared/offsets.ts","../../src/server/file-io/mdast-ydoc.ts","../../src/server/file-io/markdown.ts","../../src/server/mcp/document-model.ts","../../src/server/file-io/docx-html.ts","../../src/server/file-io/docx.ts","../../src/shared/origins.ts","../../src/server/annotations/migration-log.ts","../../src/server/annotations/migrations/v1_to_v2.ts","../../src/server/annotations/migrations/runner.ts","../../src/server/annotations/migrations/index.ts","../../src/server/annotations/schema.ts","../../src/shared/positions/index.ts","../../src/server/positions.ts","../../src/server/file-io/docx-walker.ts","../../src/server/file-io/docx-comments.ts","../../src/shared/sanitize.ts","../../src/server/file-io/docx-comment-export.ts","../../src/server/file-io/docx-export.ts","../../src/server/file-io/docx-footnotes.ts","../../src/server/file-io/docx-apply.ts","../../src/server/file-io/index.ts","../../src/server/license/paths.ts","../../src/server/license/public-key.ts","../../src/server/license/verifier.ts","../../src/server/license/license-state.ts","../../src/cli/license.ts","../../src/cli/start.ts","../../src/cli/node-version.ts","../../src/cli/index.ts"],"sourcesContent":["/**\n * SKILL.md content installed to ~/.claude/skills/tandem/ by `tandem setup`.\n * Single source of truth lives at `skills/tandem/SKILL.md`. This module\n * reads that file at module load so the plugin install path and the\n * `tandem setup` install path always deliver byte-identical content.\n *\n * The file is shipped via package.json `files: [\"skills/\", ...]`, and the\n * CLI entry (dist/cli/index.js) is not self-contained — so at runtime the\n * relative path `../../skills/tandem/SKILL.md` resolves from either\n * dist/cli/ (tsx dev) or dist/cli/ (npm install) to the package-root\n * `skills/tandem/SKILL.md`.\n */\nimport { readFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SKILL_PATH = resolve(__dirname, \"../../skills/tandem/SKILL.md\");\n\nexport const SKILL_CONTENT = readFileSync(SKILL_PATH, \"utf-8\");\n","export const DEFAULT_WS_PORT = 3478;\nexport const DEFAULT_MCP_PORT = 3479;\n\nexport const TANDEM_REPO_URL = \"https://github.com/bloknayrb/tandem\";\nexport const TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;\n\n/**\n * Feature flag for the in-app \"bring your own API key\" Models registry UI\n * (#1018/#1022). The registry stores provider keys in the OS keychain, but no\n * server-side LLM client consumes them yet (see `src/server/models/api-routes.ts`\n * — \"a future LLM client\"). Until that lands, offering the picker is a dead end:\n * users configure a key, AI still doesn't work, and they conclude the app is\n * broken. So the entire BYO-models surface (first-run picker, Settings → Models\n * tab, titlebar default-model chip) is gated OFF behind this flag. AI today is\n * the external Claude Code integration (MCP). Flip to `true` — or wire to an\n * env/build define — once the outbound LLM client exists.\n */\nexport const BYO_MODELS_ENABLED = false;\n\n/** File extensions the server accepts for opening. */\nexport const SUPPORTED_EXTENSIONS = new Set([\".md\", \".txt\", \".html\", \".htm\", \".docx\"]);\n\n/**\n * Inline marks the `.docx` import path (`docx-html.ts#htmlToYDoc`) can emit onto\n * Y.XmlText. The client Tiptap schema MUST register a mark for every name here:\n * y-prosemirror's sync (`createTextNodesFromYText`) calls `schema.mark(name)` for\n * each delta attribute and, on an UNREGISTERED mark, its catch deletes the whole\n * offending Y.XmlText and propagates the deletion to disk — silent content loss,\n * not a crash. So this list is the contract the editor schema is tested against\n * (`tests/client/editor-schema-marks.test.ts`). Lives in shared (not docx-html)\n * so the client guard imports the real source rather than a drift-prone copy.\n * The markdown path (`mdast-ydoc.ts`) emits a different set (its core + the\n * `rawMarkdown` mark, already client-registered).\n */\nexport const DOCX_INLINE_MARKS = [\n \"bold\",\n \"italic\",\n \"strike\",\n \"code\",\n \"link\",\n \"underline\",\n \"superscript\",\n \"subscript\",\n // Footnote reference marker (#1123 Tier-A #3). Carries `{ id, kind }` on the\n // verbatim `[N]` text mammoth renders, so export can emit a real\n // `<w:footnoteReference>`. The literal \"footnote-ref\" MUST byte-match the\n // delta-attribute key `docx-html.ts` writes AND the client `Mark.create`\n // name (`footnote-ref.ts`) — a one-char drift is silent XmlText deletion.\n \"footnote-ref\",\n] as const;\n\n// DEFAULT_FONT_BY_EXTENSION removed as a #887 follow-up: seeded defaults\n// silently overrode the user's global editor-font choice for un-customized\n// formats (changing Settings > Editor Font did nothing for .docx / .html /\n// .txt until the user clicked every per-format radio group). The global\n// setting is now the true default; `fontByExtension` is a sparse\n// user-overrides map.\n\nexport const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB\nexport const SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days\nexport const TYPING_DEBOUNCE = 3000; // 3 seconds\nexport const DISCONNECT_DEBOUNCE_MS = 3000; // 3 seconds before showing \"server not reachable\"\nexport const PROLONGED_DISCONNECT_MS = 30_000; // 30 seconds before showing App-level disconnect banner\n\nimport type { HighlightColor } from \"./types.js\";\n\nexport const HIGHLIGHT_COLORS: Record<HighlightColor, string> = {\n yellow: \"rgba(255, 235, 59, 0.3)\",\n green: \"rgba(76, 175, 80, 0.3)\",\n blue: \"rgba(33, 150, 243, 0.3)\",\n pink: \"rgba(236, 72, 153, 0.3)\",\n};\n\nexport const HIGHLIGHT_COLOR_VARS: Record<HighlightColor, string> = {\n yellow: \"var(--tandem-highlight-yellow)\",\n green: \"var(--tandem-highlight-green)\",\n blue: \"var(--tandem-highlight-blue)\",\n pink: \"var(--tandem-highlight-pink)\",\n};\n\nexport function normalizeHighlightColor(color: string | null | undefined): HighlightColor {\n return color && color in HIGHLIGHT_COLORS ? (color as HighlightColor) : \"yellow\";\n}\n\nexport const TANDEM_MODE_DEFAULT = \"tandem\" as const;\nexport const TANDEM_MODE_KEY = \"tandem:mode\";\nexport const TANDEM_SETTINGS_KEY = \"tandem:settings\";\n// Panel-width localStorage keys.\n//\n// NOTE: these use legacy hyphen naming (vs the neighboring colon convention\n// `tandem:mode`/`tandem:settings`) because they predate the colon scheme and\n// changing the strings would invalidate every existing user's saved widths.\n// Do not \"fix\" the style — the key string is the persistence contract.\n//\n// Right-side panel width is shared between the tabbed layout and any\n// future left-panel variant. The left key only applies when the left\n// panel is visible.\nexport const PANEL_WIDTH_KEY = \"tandem-panel-width\";\nexport const LEFT_PANEL_WIDTH_KEY = \"tandem-left-panel-width\";\n\nexport type PanelSide = \"left\" | \"right\";\n\n/**\n * Maps a panel side to its localStorage key. Using a Record instead of two\n * bare constants makes the \"both handles write to the same key\" regression\n * (#228) structurally impossible — you can't accidentally map both sides to\n * the same value at a callsite.\n *\n * Uses `as const satisfies Record<PanelSide, string>` so the value type stays\n * as the literal strings rather than widening to `string` — this preserves\n * the persistence-key identity at every callsite while still enforcing\n * exhaustive coverage of `PanelSide`.\n */\nexport const PANEL_WIDTH_KEYS = {\n left: LEFT_PANEL_WIDTH_KEY,\n right: PANEL_WIDTH_KEY,\n} as const satisfies Record<PanelSide, string>;\nexport const SELECTION_DWELL_DEFAULT_MS = 1000;\nexport const SELECTION_DWELL_MIN_MS = 500;\nexport const SELECTION_DWELL_MAX_MS = 3000;\n\n// Large file thresholds\nexport const CHARS_PER_PAGE = 3_000;\nexport const LARGE_FILE_PAGE_THRESHOLD = 50;\nexport const VERY_LARGE_FILE_PAGE_THRESHOLD = 100;\n\nexport const CTRL_ROOM = \"__tandem_ctrl__\";\n\n/** Y.Map key constants — centralized to prevent silent bugs from string typos. */\nexport const Y_MAP_ANNOTATIONS = \"annotations\";\nexport const Y_MAP_AWARENESS = \"awareness\";\nexport const Y_MAP_USER_AWARENESS = \"userAwareness\";\nexport const Y_MAP_MODE = \"mode\";\nexport const Y_MAP_DWELL_MS = \"selectionDwellMs\";\nexport const Y_MAP_CHAT = \"chat\";\nexport const Y_MAP_DOCUMENT_META = \"documentMeta\";\nexport const Y_MAP_ANNOTATION_REPLIES = \"annotationReplies\";\nexport const Y_MAP_SAVED_AT_VERSION = \"savedAtVersion\";\nexport const Y_MAP_AUTHORSHIP = \"authorship\";\n// Y.Map sub-keys: userAwareness\nexport const Y_MAP_SELECTION = \"selection\";\nexport const Y_MAP_ACTIVITY = \"activity\";\n// Y.Map sub-keys: awareness (Claude focus)\nexport const Y_MAP_CLAUDE = \"claude\";\n// Y.Map sub-keys: documentMeta\nexport const Y_MAP_OPEN_DOCUMENTS = \"openDocuments\";\nexport const Y_MAP_ACTIVE_DOCUMENT_ID = \"activeDocumentId\";\nexport const Y_MAP_ACTIVE_DOCUMENT_EPOCH = \"activeDocumentEpoch\";\nexport const Y_MAP_READ_ONLY = \"readOnly\";\nexport const Y_MAP_STORE_READ_ONLY = \"storeReadOnly\";\n/**\n * Per-document external-conflict state (#1069, `.docx` only). Holds an\n * `ExternalConflictState` (see shared/types.ts) while the document's unsaved\n * Y.Doc edits diverge from the on-disk source; absent otherwise. Written via\n * `withInternal` (server metadata); cleared on resolve / reload / explicit save.\n */\nexport const Y_MAP_EXTERNAL_CONFLICT = \"externalConflict\";\n/**\n * Per-document docx fidelity report (#1145, `.docx` only). Holds a\n * `FidelityReport` (see shared/types.ts): Word features mammoth dropped on\n * import (`importLosses`, set at open / force-reload / file-watcher reload) and\n * what the export downgraded on the most recent save (`exportDowngrades`). The\n * client renders a calm, self-erasing notice while either list is non-empty.\n * No observer is attached to the per-document `documentMeta` map (durable-sync\n * watches only annotations/replies; the only `documentMeta` channel observer is\n * `ctrl-meta` on CTRL_ROOM), so the write is inert at any origin — server\n * write-only, client read-only.\n */\nexport const Y_MAP_FIDELITY_REPORT = \"fidelityReport\";\n/**\n * Per-document reconstructed Word footnote bodies (#1123 Tier-A #3 PR 2, `.docx`\n * only). Holds `Record<string, FootnoteBody>` (see shared/types.ts) keyed by the\n * OOXML footnote id, written off-fragment under Y_MAP_DOCUMENT_META so the inline\n * `[N]` marker stays offset-neutral (a footnote body is not document body text).\n * Written as a WHOLE-VALUE replace at import (so a force-reload of a doc with\n * fewer footnotes can't leave stale ids) and read by the exporter to emit real\n * `<w:footnote>` parts. Same inertness as Y_MAP_FIDELITY_REPORT: no observer on\n * per-document documentMeta, so the write is server-only, client/Claude-invisible.\n */\nexport const Y_MAP_FOOTNOTE_BODIES = \"footnoteBodies\";\n\nexport const AUTHORSHIP_TOGGLE_KEY = \"tandem:showAuthorship\";\n/**\n * Per-type annotation decoration visibility, mirrored from settings so the\n * ProseMirror plugin can read it at init before any Svelte effect runs.\n * Value is `JSON.stringify({ comment, highlight, note })` carrying the\n * *effective* booleans (master mute already folded in). Replaces the v8-era\n * single `tandem:showAnnotationDecorations` flag (#596 → 1.13 per-type split).\n */\nexport const DECORATION_VISIBILITY_KEY = \"tandem:decorationVisibility\";\n\nexport const RECENT_FILES_KEY = \"tandem:recentFiles\";\nexport const RECENT_FILES_CAP = 20;\n\nexport const USER_NAME_KEY = \"tandem:userName\";\nexport const USER_NAME_DEFAULT = \"You\";\nexport const USER_NAME_EVENT = \"tandem:user-name-changed\";\nexport const USER_NAME_MAX_LEN = 40;\n\n// Toast notifications\nexport const TOAST_DISMISS_MS = { error: 8000, warning: 6000, info: 4000 } as const;\nexport const MAX_VISIBLE_TOASTS = 5;\nexport const NOTIFICATION_BUFFER_SIZE = 50;\n\n// Activity center — persistent notification tray (sub-PR 1.10).\n// NOTE: NOT \"tandem:activity\" — that would shadow the Y_MAP_ACTIVITY = \"activity\"\n// Y.Map sub-key above. This is a localStorage key for the client-side tray history.\nexport const ACTIVITY_HISTORY_KEY = \"tandem:activityHistory\";\nexport const ACTIVITY_HISTORY_CAP = 50;\n// Tray-side TTL for info-severity items. Deliberately decoupled from\n// TOAST_DISMISS_MS.info: tied together, an info item left the tray the moment\n// its toast vanished, so the tray never answered \"I missed the toast — what\n// just happened?\". Warnings/errors persist until dismissed; coalescing + the\n// cap keep ambient SSE info from flooding the tray in the meantime.\nexport const ACTIVITY_INFO_TTL_MS = 5 * 60_000;\n\n// Onboarding tutorial\nexport const TUTORIAL_COMPLETED_KEY = \"tandem:tutorialCompleted\";\n// Load-bearing: useTutorial.svelte.ts uses this prefix to exclude tutorial\n// SEEDS from \"user-authored annotation\" detection. Tutorial NOTES carry\n// author=\"user\" (ADR-027: only the user can author notes), so the prefix\n// is the ONLY thing distinguishing a seed from a real user note. Renaming\n// this constant without updating useTutorial.svelte.ts would silently\n// re-introduce the step-1 auto-advance bug from PR #621 PR-A2b.\nexport const TUTORIAL_ANNOTATION_PREFIX = \"tutorial-\";\n/** Persists \"user skipped the Cowork onboarding step\" across sessions. */\nexport const COWORK_ONBOARDING_SKIPPED_KEY = \"tandem:coworkOnboardingSkipped\";\n/** Polling interval for `cowork_get_status` while the consumer is active. */\nexport const COWORK_STATUS_POLL_MS = 30_000;\n/** Debounce interval for the \"Re-scan workspaces\" button. */\nexport const COWORK_RESCAN_DEBOUNCE_MS = 2_000;\n\n// Channel / event queue\nexport const CHANNEL_EVENT_BUFFER_SIZE = 200;\nexport const CHANNEL_EVENT_BUFFER_AGE_MS = 60_000; // 60 seconds\nexport const CHANNEL_SSE_KEEPALIVE_MS = 15_000; // 15 seconds\nexport const CHANNEL_MAX_RETRIES = 5;\nexport const CHANNEL_RETRY_DELAY_MS = 2_000;\n\n// Channel shim per-request timeouts. Mirror the monitor pattern (#364) so a\n// half-open Tandem server can't wedge `tandem_reply`, the permission relay,\n// or the event-bridge SSE handshake / awareness / mode / error-report POSTs.\n// Intentionally separate constants per endpoint so a slow endpoint doesn't\n// hold up a faster one — and so log lines name a meaningful threshold.\nexport const CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 10_000; // /api/events handshake\nexport const CHANNEL_SSE_INACTIVITY_TIMEOUT_MS = 60_000; // No-bytes watchdog on SSE body\nexport const CHANNEL_MODE_FETCH_TIMEOUT_MS = 2_000; // /api/mode cache refresh\nexport const CHANNEL_AWARENESS_FETCH_TIMEOUT_MS = 5_000; // /api/channel-awareness POST\nexport const CHANNEL_ERROR_REPORT_TIMEOUT_MS = 3_000; // /api/channel-error POST on exit\nexport const CHANNEL_REPLY_FETCH_TIMEOUT_MS = 5_000; // /api/channel-reply (tandem_reply)\nexport const CHANNEL_PERMISSION_FETCH_TIMEOUT_MS = 5_000; // /api/channel-permission relay\n// Bound the SSE buffer so a misbehaving server that never emits frame\n// boundaries can't wedge the bridge with unbounded string growth.\nexport const CHANNEL_MAX_SSE_BUFFER_BYTES = 1_000_000;\n\n/** Auth token filename inside the app-data directory. */\nexport const TOKEN_FILE_NAME = \"auth-token\";\n\n/** Default MCP bind host — loopback only by default. */\nexport const DEFAULT_BIND_HOST = \"127.0.0.1\";\n\n/** Env var name to opt in to unauthenticated LAN binding. */\nexport const TANDEM_ALLOW_UNAUTHENTICATED_LAN_ENV = \"TANDEM_ALLOW_UNAUTHENTICATED_LAN\";\n\n/**\n * Env var name to suppress the integration-wizard first-run auto-open.\n * Used by the E2E harness (Playwright) so wizard modals don't cover\n * unrelated editor surfaces. The integration-wizard.spec.ts test itself\n * does NOT set this — it exercises the manual-reopen affordance.\n */\nexport const TANDEM_DISABLE_FIRST_RUN_WIZARD_ENV = \"TANDEM_DISABLE_FIRST_RUN_WIZARD\";\n\n/** Tauri WebView origin hostname — must be accepted alongside localhost. */\nexport const TAURI_HOSTNAME = \"tauri.localhost\";\n\n/**\n * Linux Tauri WebView origin. On Linux the WebView serves content from the\n * custom `tauri://` scheme (origin `tauri://localhost`), not `http://tauri.localhost`\n * as on Windows. The `tauri://` scheme is non-network and served only by the\n * Tauri runtime's own WebView, so no remote page can forge this origin — it is\n * the unforgeable Linux analog of the trusted Windows origin. Match the exact\n * string only (never a `tauri://*` wildcard).\n */\nexport const TAURI_LINUX_ORIGIN = \"tauri://localhost\";\n\n// Zoom persistence (Tauri desktop)\nexport const ZOOM_STORAGE_KEY = \"tandem:zoomLevel\";\nexport const ZOOM_MIN = 0.5;\nexport const ZOOM_MAX = 2.0;\nexport const ZOOM_DEFAULT = 1.0;\n","import { execFileSync, execSync } from \"child_process\";\nimport envPaths from \"env-paths\";\nimport net from \"net\";\nimport path from \"path\";\n\n/**\n * Resolve the Tandem app-data root directory. `TANDEM_APP_DATA_DIR` overrides\n * the `env-paths` default. Not memoised so tests can swap tempdirs mid-run.\n */\nexport function resolveAppDataDir(): string {\n const envOverride = process.env.TANDEM_APP_DATA_DIR;\n if (envOverride && envOverride.length > 0) return envOverride;\n return envPaths(\"tandem\", { suffix: \"\" }).data;\n}\n\nconst APP_DATA_DIR = resolveAppDataDir();\n\n/** Platform-appropriate session storage directory. */\nexport const SESSION_DIR = path.join(APP_DATA_DIR, \"sessions\");\n\n/** Path to the file tracking the last version the user ran. */\nexport const LAST_SEEN_VERSION_FILE = path.join(APP_DATA_DIR, \"last-seen-version\");\n\n/**\n * Kill any process currently listening on the given TCP port.\n * Best-effort — swallows all errors so startup always proceeds.\n */\nexport function freePort(port: number): void {\n try {\n if (process.platform === \"win32\") {\n freePortWindows(port);\n } else {\n freePortUnix(port);\n }\n } catch (err) {\n console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);\n }\n}\n\n/**\n * Poll until a TCP port is available for binding.\n * Replaces the fixed 300ms sleep after freePort() — the OS may need\n * longer to release a killed process's socket (especially on Windows).\n */\nexport async function waitForPort(port: number, timeoutMs = 5000): Promise<void> {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await tryBind(port)) return;\n await new Promise((r) => setTimeout(r, 200));\n }\n throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);\n}\n\n/** Attempt to bind a port and immediately release it. Returns true if available. */\nfunction tryBind(port: number): Promise<boolean> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer();\n srv.once(\"error\", (err: NodeJS.ErrnoException) => {\n srv.close(() => {\n if (err.code === \"EADDRINUSE\") {\n resolve(false);\n } else {\n reject(err); // EACCES, etc. — don't mask as \"port in use\"\n }\n });\n });\n srv.listen(port, \"127.0.0.1\", () => {\n srv.close(() => resolve(true));\n });\n });\n}\n\n/** Parse PIDs from lsof output (one PID per line). */\nexport function parseLsofPids(output: string): number[] {\n return output\n .trim()\n .split(\"\\n\")\n .map((line) => parseInt(line.trim(), 10))\n .filter((pid) => Number.isFinite(pid) && pid > 0);\n}\n\n/** Parse a PID from ss output (e.g. `pid=1234`). */\nexport function parseSsPid(output: string): number | null {\n const match = output.match(/pid=(\\d+)/);\n return match ? parseInt(match[1], 10) : null;\n}\n\nfunction freePortWindows(port: number): void {\n const out = execSync(`netstat -ano | findstr \":${port}.*LISTENING\"`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n const pid = out.trim().split(/\\s+/).at(-1);\n if (pid && /^\\d+$/.test(pid)) {\n try {\n const killOut = execFileSync(\"taskkill\", [\"/PID\", pid, \"/F\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n // Some taskkill paths exit 0 with empty stdout — avoid a dangling\n // \": \" in the log when there's no message to append.\n const trimmed = killOut.trim();\n console.error(\n `[Tandem] Killed stale PID ${pid} holding port ${port}${trimmed ? `: ${trimmed}` : \"\"}`,\n );\n } catch (err) {\n // Surface taskkill failure (permission denied, cross-session, race) so a\n // subsequent port-bind EADDRINUSE is diagnosable. The prior `stdio: \"ignore\"`\n // masked these and left the user with a silent \"Disconnected\" state.\n const stderr =\n err && typeof err === \"object\" && \"stderr\" in err\n ? String((err as { stderr: unknown }).stderr ?? \"\")\n : \"\";\n const message = err instanceof Error ? err.message : String(err);\n console.error(\n `[Tandem] taskkill failed for PID ${pid} on port ${port}: ${message}${\n stderr ? ` — stderr: ${stderr.trim()}` : \"\"\n }`,\n );\n }\n }\n}\n\nfunction freePortUnix(port: number): void {\n let pids: number[] = [];\n\n try {\n const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n pids = parseLsofPids(out);\n } catch {\n // lsof not available — try ss (Linux)\n try {\n const out = execSync(`ss -tlnp sport = :${port}`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n const pid = parseSsPid(out);\n if (pid) pids = [pid];\n } catch {\n // ss also unavailable — give up\n }\n }\n\n for (const pid of pids) {\n try {\n process.kill(pid, \"SIGKILL\");\n console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);\n } catch {\n // Process already gone\n }\n }\n}\n","/**\n * Windows DACL hardening for files that contain bearer tokens (`~/.claude.json`\n * and similar). Every function in this module is a no-op on non-Windows\n * platforms — POSIX paths use `chmod 0o600` instead.\n *\n * Security contract:\n * - `icacls`, `whoami`, and `powershell` are always invoked via `execFile`\n * with an argv array, never via a shell. Paths flow from `homedir()`\n * which is user-controlled via `HOME`/`USERPROFILE`; any shell-style\n * invocation is a command-injection bug.\n * - Broad-principal detection (`assertNoBroadAce`) reads the file's SDDL\n * via PowerShell `(Get-Acl).Sddl`. SDDL uses locale-independent 2-letter\n * shortcuts for well-known SIDs (`WD`=Everyone, `AU`=Authenticated\n * Users, `BU`=BUILTIN\\Users). Parsing `icacls` default output instead\n * would false-negative on non-English Windows (where the resolver\n * returns \"Utilisateurs\", \"Benutzer\", etc.) and on partial-success\n * exit-0 cases where icacls's \"Failed processing N files\" summary is\n * also localised.\n * - `setRestrictiveAcl` grants by **SID** (resolved once per process via\n * `whoami /user`), not by `process.env.USERNAME`. USERNAME is\n * in-process spoofable; a poisoned USERNAME could direct the grant at\n * a different local user whose name happens to collide, or contain\n * characters icacls parses unexpectedly (`:`, `/`, embedded UPNs).\n * - `setRestrictiveAcl` calls `/inheritance:r` THEN `/grant:r`. Without\n * `:r` on both flags, inherited ACEs from the parent dir survive and\n * `/grant` adds the user as an additional principal instead of\n * replacing the ACL.\n * - The tempfile self-verify (`assertNoBroadAce` at the end of\n * `setRestrictiveAcl`) is load-bearing — icacls is documented to exit\n * 0 on partial failure (silent no-op). The SDDL re-read is the only\n * trustworthy signal that the DACL is actually correct.\n * - PowerShell paths are passed via the `TANDEM_ACL_PATH` env var, not\n * interpolated into the script string. This avoids every quoting /\n * escaping pitfall for paths that contain spaces, apostrophes, or\n * backticks.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { join } from \"node:path\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\n\n/**\n * Resolve a Windows system binary by absolute path under `%SystemRoot%`.\n * Bypasses `PATH` so a git-bash / MSYS / Cygwin shadow (e.g. their own\n * `whoami` that doesn't understand Windows flags) can't intercept us.\n * `icacls` and `whoami` both live in `System32`.\n */\nfunction systemBin(name: string): string {\n return join(process.env.SystemRoot ?? \"C:\\\\Windows\", \"System32\", name);\n}\n\n/**\n * Run a PowerShell script, preferring PowerShell 7 (`pwsh.exe`) and falling\n * back to Windows PowerShell 5.1 (`powershell.exe`). pwsh is more reliable —\n * 5.1's auto-module-load has been observed to fail intermittently for\n * `Microsoft.PowerShell.Security` in CI sandboxes and on some user setups.\n *\n * Fallback is gated on `ENOENT` only. A spawn error of `EACCES` or `EPERM`\n * almost always reflects an AppLocker / WDAC policy denial — silently\n * routing around it via the legacy shell is the wrong defence posture\n * (admin's policy is a real security boundary). The original error is\n * preserved as `cause` on the fallback's failure for log forensics.\n */\nasync function runPowerShell(script: string, env: NodeJS.ProcessEnv): Promise<{ stdout: string }> {\n const args = [\"-NoProfile\", \"-NonInteractive\", \"-Command\", script];\n try {\n return await execFileAsync(\"pwsh.exe\", args, { env });\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code !== \"ENOENT\") throw err;\n try {\n return await execFileAsync(\"powershell.exe\", args, { env });\n } catch (fallbackErr) {\n throw new Error(\n `runPowerShell: both pwsh.exe and powershell.exe failed (pwsh: ${(err as Error).message})`,\n { cause: fallbackErr },\n );\n }\n }\n}\n\n/**\n * SDDL ACE-string fragments that flag a broad-principal grant. These\n * shortcuts are locale-independent — they appear verbatim in the SDDL\n * regardless of Windows display language.\n *\n * Each pattern matches the principal-SID position of an SDDL ACE,\n * which is the last token before the closing `)`. Example ACE:\n * `(A;;FA;;;WD)` ← Everyone has Full Access\n *\n * Reference: https://learn.microsoft.com/windows/win32/secauthz/sid-strings\n */\nconst BROAD_SDDL_FRAGMENTS = [\n \";WD)\", // Everyone (S-1-1-0)\n \";AU)\", // Authenticated Users (S-1-5-11)\n \";BU)\", // BUILTIN\\Users (S-1-5-32-545)\n] as const;\n\n/**\n * Cached SID of the current Windows user. `whoami /user` is the cheapest\n * locale-independent way to get this. Resolved once per process; cleared\n * to `null` only on test reset.\n */\nlet cachedCurrentUserSid: string | null = null;\n\n/** Reset for tests only. Production callers MUST NOT depend on resetting. */\nexport function _resetCurrentUserSidForTests(): void {\n cachedCurrentUserSid = null;\n}\n\n/**\n * Resolve the SID of the current user via `whoami /user /fo csv /nh`. CSV\n * output is locale-independent and parses cleanly without PowerShell.\n */\nasync function getCurrentUserSid(): Promise<string> {\n if (cachedCurrentUserSid !== null) return cachedCurrentUserSid;\n const { stdout } = await execFileAsync(systemBin(\"whoami.exe\"), [\"/user\", \"/fo\", \"csv\", \"/nh\"]);\n // CSV row format: `\"DOMAIN\\user\",\"S-1-5-21-...\"` — extract the second column.\n const match = stdout.match(/\"(S-[\\d-]+)\"\\s*$/m);\n if (!match) {\n throw new Error(`getCurrentUserSid: could not parse SID from whoami output: ${stdout.trim()}`);\n }\n cachedCurrentUserSid = match[1];\n return cachedCurrentUserSid;\n}\n\n/**\n * Set a restrictive DACL on `path`: break inheritance, then grant Full\n * Control to the current user (by SID, not name) only. On non-Windows,\n * no-op.\n *\n * Includes a post-set SDDL re-verify on `path` because icacls is\n * documented to exit 0 even when \"Failed processing N files\" — the SDDL\n * read is the only trustworthy signal that the DACL is actually correct.\n *\n * @throws if any step (SID resolve, icacls, verify) fails.\n */\nexport async function setRestrictiveAcl(path: string): Promise<void> {\n if (process.platform !== \"win32\") return;\n\n const sid = await getCurrentUserSid();\n\n // icacls processes flags left-to-right in one invocation:\n // /inheritance:r — remove ALL inherited entries (`:d` would copy them\n // as explicit, defeating the purpose)\n // /grant:r — replace any existing explicit ACE for the user\n // rather than ADD a new one (no `:r` → duplicates\n // accumulate over repeated runs).\n // *<SID>:F — grant Full Control by SID. The `*` prefix tells\n // icacls to interpret the principal as a raw SID\n // rather than a name to look up.\n try {\n await execFileAsync(systemBin(\"icacls.exe\"), [path, \"/inheritance:r\", \"/grant:r\", `*${sid}:F`]);\n } catch (err) {\n throw new Error(`setRestrictiveAcl: icacls failed on ${path}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n await assertNoBroadAce(path);\n}\n\n/**\n * Read the SDDL of `path` and assert no well-known broad principal is\n * granted access. Uses PowerShell `Get-Acl` so the principal check works\n * regardless of Windows display language.\n *\n * @throws if any broad-principal SDDL shortcut appears in the file's ACL.\n */\nexport async function assertNoBroadAce(path: string): Promise<void> {\n if (process.platform !== \"win32\") return;\n\n // `-LiteralPath` ensures wildcards in the path are not expanded. The\n // explicit `Import-Module` is defensive — `Get-Acl` lives in\n // `Microsoft.PowerShell.Security` which usually auto-loads, but the\n // auto-load fails in some sandboxed shells (e.g. CI runners with\n // restricted module paths).\n const script =\n \"Import-Module Microsoft.PowerShell.Security; (Get-Acl -LiteralPath $env:TANDEM_ACL_PATH).Sddl\";\n\n const { stdout } = await runPowerShell(script, {\n ...process.env,\n TANDEM_ACL_PATH: path,\n });\n\n const sddl = stdout.trim();\n for (const fragment of BROAD_SDDL_FRAGMENTS) {\n if (sddl.includes(fragment)) {\n throw new Error(\n `assertNoBroadAce: ${path} has a broad-principal ACE (SDDL fragment ${fragment}). SDDL:\\n${sddl}`,\n );\n }\n }\n}\n","/**\n * Conditional backup of `~/.claude.json` before Tandem overwrites a\n * user-customised `mcpServers.tandem` entry.\n *\n * The threat we close: a user with a hand-crafted tandem entry pointing\n * at a non-default port, a custom URL, or extra fields runs `tandem\n * setup` (or the auto-launcher) and Tandem replaces the entry with the\n * default `http://127.0.0.1:3479/mcp` shape. The user's customisation\n * is silently destroyed.\n *\n * The backup gives them a recovery path. We only write a backup when\n * the existing entry is **non-default** — token-rotation and fresh-\n * install runs would otherwise generate backup churn that buries the\n * one backup the user actually needs.\n *\n * Storage layout: `${appDataDir}/.backups/claude-json-YYYYMMDD-HHMMSS-\n * ${UUID8}.json`. Dir mode 0o700, file mode 0o600. The UUID suffix\n * defeats predictable-path symlink attacks; `wx` (exclusive create)\n * is the second layer.\n *\n * Atomicity invariant: `writeBackup` MUST complete (or throw) before\n * the caller invokes `atomicWrite` on the destination. If the backup\n * write fails, the destination MUST NOT be touched. `applyConfig`\n * sequences this contract explicitly.\n *\n * Cross-platform note: on Windows, `wx` + `mode: 0o600` doesn't honour\n * POSIX modes, so we apply `setRestrictiveAcl` from `acl-win.ts` to the\n * backup file after write. The same SDDL contract that protects\n * `~/.claude.json` itself now also protects the backup copy.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { open, readdir, rm } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { setRestrictiveAcl } from \"./acl-win.js\";\n\n/** Directory name (under appDataDir) for non-default-config backups. */\nconst BACKUP_DIR_NAME = \".backups\";\n\n/** Filename prefix — present in every backup we create. */\nconst BACKUP_PREFIX = \"claude-json-\";\n\n/** Filename suffix — present in every backup we create. */\nconst BACKUP_SUFFIX = \".json\";\n\n/**\n * Keep at most this many backups. Older ones are pruned on every write\n * and on every server startup (covers crash-mid-prune cases).\n */\nexport const MAX_BACKUPS = 3;\n\n/**\n * Resolve the backup directory under `appDataDir`. Callers MUST pass the\n * dir as a parameter (not read `resolveAppDataDir()` internally) so tests\n * can inject a tmpdir without env-var stubbing.\n */\nexport function backupDir(appDataDir: string): string {\n return join(appDataDir, BACKUP_DIR_NAME);\n}\n\n/**\n * Format a timestamp as `YYYYMMDD-HHMMSS` in the local timezone. Local\n * time is what the user sees in their file manager — useful for\n * forensics. Exported for `file-io/doc-backup.ts`'s snapshot filenames.\n */\nexport function formatTimestamp(d: Date): string {\n const pad = (n: number): string => String(n).padStart(2, \"0\");\n return (\n `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-` +\n `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`\n );\n}\n\n/**\n * Build a backup filename. UUID suffix defeats predictable-path attacks\n * where an attacker pre-creates a symlink at the predicted name.\n */\nexport function backupFilename(now: Date = new Date()): string {\n const ts = formatTimestamp(now);\n const uuid8 = randomUUID().slice(0, 8);\n return `${BACKUP_PREFIX}${ts}-${uuid8}${BACKUP_SUFFIX}`;\n}\n\n/**\n * Write `content` as a backup under `backupDir(appDataDir)`. Caller\n * passes the directory (already validated via `assertPathSafe` and\n * created with mode 0o700) and the bytes to write.\n *\n * Returns the full path of the written backup.\n *\n * Atomicity contract: this function either completes successfully and\n * the backup is on disk, or it throws and the caller MUST NOT proceed\n * with the rewrite. The atomic-failure-leaves-original-intact guarantee\n * depends on this.\n */\nexport async function writeBackup(dir: string, content: Buffer): Promise<string> {\n const backupPath = join(dir, backupFilename());\n\n // `wx` is exclusive-create: fails if the path already exists (UUID\n // collision is astronomically rare but a symlinked predictable path\n // is the real concern). `0o600` is honoured on POSIX; on Windows\n // we apply setRestrictiveAcl below.\n const fd = await open(backupPath, \"wx\", 0o600);\n let writeFailed = false;\n try {\n try {\n await fd.write(content);\n } catch (writeErr) {\n writeFailed = true;\n throw writeErr;\n }\n } finally {\n await fd.close();\n if (writeFailed) {\n // Remove the partial/zero-byte file so it doesn't count against\n // MAX_BACKUPS or mislead a forensic read. Cleanup errors are\n // swallowed — the write failure is what the caller needs to see.\n await rm(backupPath, { force: true }).catch(() => {});\n }\n }\n\n if (process.platform === \"win32\") {\n try {\n await setRestrictiveAcl(backupPath);\n } catch (aclErr) {\n // ACL failure leaves a bearer-token-bearing file on disk at the\n // dir-inherited permissions. Remove it — the caller treats the\n // throw as \"abort\", so an orphan would be invisible.\n await rm(backupPath, { force: true }).catch(() => {});\n throw aclErr;\n }\n }\n\n return backupPath;\n}\n\n/**\n * List backups currently in `dir`, newest first. Files are matched by\n * the supplied prefix/suffix (defaults match the `.claude.json` backup\n * scheme) so stray non-backup files in the dir aren't touched.\n *\n * The prefix/suffix parameters let a second caller (the broken-\n * integrations.json backup sweep in `storage.ts`) share this\n * implementation. Both call sites filter by a constant pair.\n *\n * Sorting is by filename, which works because the timestamp segment is\n * lexicographically monotonic (`YYYYMMDD-HHMMSS` or `Date.now()`). The\n * UUID suffix is a tiebreaker within the same millisecond.\n */\nexport async function listBackups(\n dir: string,\n prefix: string = BACKUP_PREFIX,\n suffix: string = BACKUP_SUFFIX,\n): Promise<string[]> {\n let entries: string[];\n try {\n entries = await readdir(dir);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n throw err;\n }\n return entries\n .filter((e) => e.startsWith(prefix) && e.endsWith(suffix))\n .sort()\n .reverse();\n}\n\n/**\n * Delete backups beyond the `max` newest. Returns the list of paths\n * removed (best-effort — entries that failed to remove are still\n * reported in the return list and logged via `console.error`, but the\n * loop never throws).\n *\n * The aggregate-failure shape exists because this runs at server\n * startup: an AV-locked file on Windows or a transient EACCES MUST NOT\n * abandon the rest of the sweep. The previous shape threw on the first\n * `rm` failure and silently skipped the rest.\n *\n * Defaults match the `claude-json-...json` scheme; the broken-\n * integrations sweep passes its own constants.\n */\nexport async function pruneOldBackups(\n dir: string,\n prefix: string = BACKUP_PREFIX,\n suffix: string = BACKUP_SUFFIX,\n max: number = MAX_BACKUPS,\n): Promise<string[]> {\n const all = await listBackups(dir, prefix, suffix);\n const toRemove = all.slice(max);\n const failures: Array<{ path: string; err: unknown }> = [];\n for (const name of toRemove) {\n const fullPath = join(dir, name);\n try {\n await rm(fullPath, { force: true });\n } catch (err) {\n failures.push({ path: fullPath, err });\n }\n }\n if (failures.length > 0) {\n const summary = failures\n .map((f) => `${f.path}: ${f.err instanceof Error ? f.err.message : String(f.err)}`)\n .join(\"; \");\n console.error(\n `[tandem] backup sweep: ${failures.length} entries could not be removed (${summary})`,\n );\n }\n return toRemove.map((name) => join(dir, name));\n}\n\n/**\n * Server-startup hook. Idempotent. Bounded — sweeps only the backup dir.\n *\n * Callers pass `appDataDir` so tests can inject a tmpdir; the production\n * call site uses `resolveAppDataDir()`.\n */\nexport async function sweepBackupsOnStartup(appDataDir: string): Promise<void> {\n await pruneOldBackups(backupDir(appDataDir));\n}\n\n/**\n * Decide whether overwriting `existing` with `newEntry` could lose\n * information the user might care about. The check is content-based,\n * not shape-based: shape-only checks miss the case where a user\n * hand-crafted `headers.Authorization` with a custom Bearer token —\n * Tandem's overwrite would silently destroy it because the entry's\n * SHAPE matches the default (URL identical, only known keys present).\n *\n * Returns `true` when:\n * - existing entry exists, AND\n * - existing != newEntry under canonical-JSON equality.\n *\n * This trades off: token rotation now triggers a backup (the bytes\n * change). That's acceptable churn — `MAX_BACKUPS=3` caps disk usage\n * and the user gets a strict history of recent token-bearing configs\n * as a side benefit. The alternative (shape-only check) had a\n * security review-flagged silent-destruction gap that we cannot close\n * without state we don't have (Tandem can't distinguish \"token Tandem\n * itself wrote 5 minutes ago\" from \"token the user added by hand\").\n */\nexport function shouldBackup(existing: unknown, newEntry: unknown): boolean {\n if (existing == null) return false;\n return canonicalJson(existing) !== canonicalJson(newEntry);\n}\n\n/**\n * Deterministic JSON-string with sorted object keys. Defeats key-order\n * false-positives (`{a:1,b:2}` vs `{b:2,a:1}` would otherwise\n * stringify differently).\n */\nfunction canonicalJson(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(canonicalJson).join(\",\")}]`;\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const parts = keys.map(\n (k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`,\n );\n return `{${parts.join(\",\")}}`;\n}\n","/**\n * Library helpers for applying Tandem's MCP entries to a Claude (or other\n * MCP-capable) client's config file. Shared by the wizard's apply route,\n * `tandem setup`, and `tandem rotate-token`.\n *\n * Hardening invariants enforced here (callers MUST NOT bypass):\n * - `assertPathSafe()` rejects symlinks and any path whose realpath falls\n * outside `[homedir(), tmpdir()]`. Prevents a compromised account from\n * replacing `~/.claude.json` with a symlink to `/etc/shadow` or a\n * Windows junction redirecting `~/.claude` into a protected dir.\n * - MSIX detection is anchored to `/^Claude_[A-Za-z0-9]+$/` and the\n * `%LOCALAPPDATA%` realpath must resolve under home (defeats an\n * attacker who controls env).\n * - Malformed JSON backups land in `${appDataDir}/.broken-backups/` at\n * `0o600` rather than next to `~/.claude.json` (which may inherit\n * world-readable perms and would leak co-tenant API keys).\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport {\n chmodSync,\n existsSync,\n constants as fsConstants,\n lstatSync,\n mkdirSync,\n readdirSync,\n readFileSync,\n realpathSync,\n statSync,\n} from \"node:fs\";\nimport {\n chmod,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { basename, delimiter, dirname, join, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { SKILL_CONTENT } from \"../../cli/skill-content.js\";\nimport { DEFAULT_MCP_PORT } from \"../../shared/constants.js\";\nimport type { ClaudeCliPresence } from \"../../shared/integrations/contract.js\";\nimport { resolveAppDataDir } from \"../platform.js\";\nimport { setRestrictiveAcl } from \"./acl-win.js\";\nimport { backupDir, pruneOldBackups, shouldBackup, writeBackup } from \"./backup.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\n// Paths are anchored to the package root. The CLI bundle (`dist/cli/`) and\n// the server bundle (`dist/server/`) both sit one level below `dist/`, so\n// `../..` resolves to the package root in either bundle. In tsx dev,\n// `__dirname` is `src/server/integrations/`, so we need `../../..`.\nconst PACKAGE_ROOT = (() => {\n const fromBundle = resolve(__dirname, \"../..\");\n if (existsSync(join(fromBundle, \"package.json\"))) return fromBundle;\n return resolve(__dirname, \"../../..\");\n})();\n/**\n * Resolve the bundled channel-shim entry registered as Claude Code's push\n * transport.\n *\n * On a Tauri **desktop bundle** the `PACKAGE_ROOT` derivation above points at\n * the sidecar's own location, NOT the app's resource dir where `dist/channel/`\n * is bundled — so the computed path doesn't exist and `shouldRegisterChannelShim`\n * skips registration. The Tauri shell injects `TANDEM_CHANNEL_DIST` with the\n * resource-dir-resolved path on sidecar spawn (`src-tauri/src/lib.rs`); prefer\n * it when it points at an existing file. A bogus/missing injected path is\n * ignored so a broken injection degrades to the computed path rather than\n * registering an unresolvable MCP command. In npm-global / tsx-dev there is no\n * env var and the `PACKAGE_ROOT` derivation is correct.\n *\n * This replaces the old `/api/setup` startup round-trip, which used to be the\n * only path carrying the Tauri-resolved channel path (#477 PR 3c-ii-c).\n *\n * No UNC/traversal validation is applied to the injected path because the sole\n * setter is `resolve_channel_dist()` in `src-tauri/src/lib.rs` — trusted\n * same-user Rust code that emits `resource_dir/dist/channel/index.js` or\n * `cwd/dist/channel/index.js`. The path cannot arrive via any HTTP route.\n *\n * Exported (with `exists` injectable) so the env-precedence can be unit-tested\n * without re-importing the module to re-run the const initializer.\n */\nexport function resolveChannelDist(\n env: NodeJS.ProcessEnv = process.env,\n exists: (p: string) => boolean = existsSync,\n): string {\n const injected = env.TANDEM_CHANNEL_DIST;\n if (injected) {\n if (exists(injected)) return injected;\n // Set-but-missing: a broken desktop injection would otherwise degrade push\n // → polling with no trace. Behavior is unchanged (we still fall back); this\n // only leaves a diagnostic breadcrumb on stderr (Critical Rule #3-safe).\n console.error(\n `[Tandem] TANDEM_CHANNEL_DIST set to \"${injected}\" but no file there — ` +\n \"falling back to bundled path; real-time push may be unavailable.\",\n );\n }\n return resolve(PACKAGE_ROOT, \"dist/channel/index.js\");\n}\n\nconst CHANNEL_DIST = resolveChannelDist();\n\nconst MCP_URL = `http://127.0.0.1:${DEFAULT_MCP_PORT}`;\n\n/**\n * Refuse to read a config larger than 5 MiB. The realistic `.claude.json` is\n * single-digit kilobytes; anything beyond that is either accidental corruption\n * (log files dropped in) or a deliberate DoS aimed at making the wizard's\n * read-parse-rewrite path exhaust memory. The cap is generous enough that no\n * legitimate user hits it.\n */\nconst MAX_CONFIG_BYTES = 5 * 1024 * 1024;\n\nexport interface McpEntry {\n type?: \"http\";\n url?: string;\n command?: string;\n args?: string[];\n env?: Record<string, string>;\n headers?: Record<string, string>;\n}\n\nexport interface McpEntries {\n tandem: McpEntry;\n \"tandem-channel\"?: McpEntry;\n}\n\n/** MCP entry keys Tandem owns and may remove from an existing config. */\nexport type RemovableEntry = \"tandem\" | \"tandem-channel\";\n\n/**\n * Explicit apply intent. Callers state both `create` and `remove` so\n * absence of a key never implies removal — that distinction matters when\n * the wizard's user-confirmed diff differs from CLI's stale-cleanup\n * default. `applyOpsForCli` builds the CLI-shaped value.\n */\nexport interface ApplyOps {\n /** Entries to create or update in `mcpServers`. */\n create: McpEntries;\n /** Entries to remove if present in `mcpServers`. */\n remove: RemovableEntry[];\n /**\n * Optional callback invoked with the backup path when `applyConfig`\n * preserves a non-default existing `mcpServers.tandem` entry before\n * overwriting it. CLI callers pass a `console.error` printer; the\n * wizard pushes the path onto its structured apply response so the\n * user sees a recovery hint. Callback is NOT invoked on fresh-install\n * or pure token-rotation runs (those don't trigger a backup).\n */\n onBackup?: (backupPath: string) => void;\n}\n\n/**\n * Construct `ApplyOps` with the legacy \"remove tandem-channel unless using\n * the shim\" semantics. CLI callers (`tandem setup`, `tandem rotate-token`)\n * use this to preserve back-compat without re-implementing the diff. The\n * wizard never calls this — it builds `ApplyOps` from the user's diff\n * confirmation.\n *\n * Options object intentional: the boolean-flag `withChannelShim` is the\n * only state, but exposing it as a named field at every call site avoids\n * the boolean-trap antipattern (the previous positional signature read as\n * `applyOpsForCli(entries, true)` with no clue what the boolean meant).\n */\nexport function applyOpsForCli(create: McpEntries, opts: { withChannelShim: boolean }): ApplyOps {\n return {\n create,\n remove: opts.withChannelShim ? [] : [\"tandem-channel\"],\n };\n}\n\n/** Error thrown when a target path fails realpath/symlink validation. */\nexport class PathRejectedError extends Error {\n override readonly name = \"PathRejectedError\";\n constructor(\n public readonly path: string,\n public readonly reason: \"symlink\" | \"outside-home\" | \"unreadable\",\n message: string,\n ) {\n super(message);\n }\n}\n\nexport interface BuildMcpEntriesOptions {\n /** Include the legacy stdio channel shim. Defaults to false — the plugin\n * monitor handles event push for modern installs. Users on older setups\n * can run `tandem setup --with-channel-shim` to preserve the shim. */\n withChannelShim?: boolean;\n nodeBinary?: string;\n /** Auth token to embed in HTTP entry headers and stdio shim env.\n * When omitted (first-run before token provisioned), headers/env are omitted\n * and backward compatibility is preserved. */\n token?: string;\n /** Target kind controls entry shape. Claude Code uses HTTP (direct);\n * Claude Desktop uses stdio (npx bridge) because Cowork sessions can\n * only surface stdio MCP servers. */\n targetKind?: TargetKind;\n}\n\nexport function buildMcpEntries(\n channelPath: string,\n opts: BuildMcpEntriesOptions = {},\n): McpEntries {\n const isDesktop = opts.targetKind === \"claude-desktop\";\n\n let tandemEntry: McpEntry;\n if (isDesktop) {\n const env: Record<string, string> = { TANDEM_URL: MCP_URL };\n if (opts.token) {\n env.TANDEM_AUTH_TOKEN = opts.token;\n }\n tandemEntry = {\n command: \"npx\",\n args: [\"-y\", \"tandem-editor\", \"mcp-stdio\"],\n env,\n };\n } else {\n tandemEntry = { type: \"http\", url: `${MCP_URL}/mcp` };\n if (opts.token) {\n tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };\n }\n }\n const entries: McpEntries = { tandem: tandemEntry };\n\n if (opts.withChannelShim) {\n const shimEnv: Record<string, string> = { TANDEM_URL: MCP_URL };\n if (opts.token) {\n shimEnv.TANDEM_AUTH_TOKEN = opts.token;\n }\n entries[\"tandem-channel\"] = {\n command: opts.nodeBinary ?? \"node\",\n args: [channelPath],\n env: shimEnv,\n };\n }\n return entries;\n}\n\nexport type TargetKind = \"claude-code\" | \"claude-desktop\";\n\nexport interface DetectedTarget {\n label: string;\n configPath: string;\n kind: TargetKind;\n}\n\n// Realpath of OS roots changes only across process restart (and tmpdir is\n// stable for test fixtures). Memoize so per-integration apply loops and\n// MSIX detection don't re-syscall for every call.\n//\n// Lifetime: process. Tandem doesn't drop privileges and never mutates HOME\n// mid-process; if a future caller does either, the cache is stale and must\n// be invalidated.\nconst DEFAULT_ROOTS_CACHE = new Map<string, string>();\nfunction realpathCached(p: string): string {\n const cached = DEFAULT_ROOTS_CACHE.get(p);\n if (cached !== undefined) return cached;\n try {\n const r = realpathSync(p);\n DEFAULT_ROOTS_CACHE.set(p, r);\n return r;\n } catch {\n return p;\n }\n}\n\n/**\n * Verify the target directory (a) is not a symlink/junction and (b) its\n * realpath resolves under one of the allowed roots. Throws\n * `PathRejectedError` otherwise.\n *\n * Threat model: prior compromised account replaces `~/.claude.json` with a\n * symlink to `/etc/shadow`; on Windows, a junction at `~/.claude`\n * redirecting to `C:\\Windows\\System32\\config\\` could let `mkdir(...\n * recursive)` walk into a protected dir. We reject both cases before any\n * read or write. The ancestor check is the closed-set boundary — any path\n * outside the allowed roots is refused even if it's not a symlink.\n *\n * Residual TOCTOU: the lstat-then-write window admits a same-uid attacker\n * who swaps a regular file for a symlink between check and write. Node's\n * `fs` doesn't expose `O_NOFOLLOW` / `openat`-style per-component\n * traversal, so the window is unavoidable without native bindings. The\n * realpath check shrinks the practical attack but doesn't close it.\n *\n * Defaults to `[homedir(), tmpdir()]`. Callers may nominate alternate\n * roots (e.g., test fixtures that operate inside a specific tmpdir).\n */\nexport function assertPathSafe(targetPath: string, opts: { allowedRoots?: string[] } = {}): void {\n const allowedRoots = (opts.allowedRoots ?? [homedir(), tmpdir()]).map(realpathCached);\n\n // `lstat` a path that may not exist yet (e.g., apply will create the\n // parent dir). Walk up until we find an existing ancestor and validate\n // there — if any walked-through component is a symlink, fail.\n let cursor = targetPath;\n let existing: string | null = null;\n while (true) {\n if (existsSync(cursor)) {\n const st = lstatSync(cursor);\n if (st.isSymbolicLink()) {\n throw new PathRejectedError(\n targetPath,\n \"symlink\",\n `Refusing to operate on symlinked path: ${cursor}`,\n );\n }\n existing = cursor;\n break;\n }\n const parent = dirname(cursor);\n if (parent === cursor) break; // reached filesystem root\n cursor = parent;\n }\n\n const resolved = existing ? realpathSync(existing) : targetPath;\n const ok = allowedRoots.some((root) => {\n if (resolved === root) return true;\n const normRoot = root.endsWith(sep) ? root : `${root}${sep}`;\n return resolved.startsWith(normRoot);\n });\n if (!ok) {\n throw new PathRejectedError(\n targetPath,\n \"outside-home\",\n `Refusing path outside allowed roots: realpath=${resolved}`,\n );\n }\n}\n\n/** MSIX package families that match Claude's installer. Anchored on both ends. */\nexport const MSIX_PACKAGE_PATTERN = /^Claude_[A-Za-z0-9]+$/;\n\nexport interface DetectOptions {\n homeOverride?: string;\n localAppDataOverride?: string;\n force?: boolean;\n}\n\nexport function detectTargets(opts: DetectOptions = {}): DetectedTarget[] {\n const home = opts.homeOverride ?? homedir();\n const targets: DetectedTarget[] = [];\n\n // Claude Code — cross-platform.\n // MCP servers are configured in ~/.claude.json under the \"mcpServers\" key.\n // Detect if the file exists OR if ~/.claude directory exists (Claude Code is installed).\n // With --force, always include regardless.\n const claudeCodeConfig = join(home, \".claude.json\");\n const claudeCodeDir = join(home, \".claude\");\n if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {\n targets.push({ label: \"Claude Code\", configPath: claudeCodeConfig, kind: \"claude-code\" });\n }\n\n // Claude Desktop — platform-specific.\n // Only detect if the config file already exists (user has launched Desktop at least once).\n // With --force, always include.\n let desktopConfig: string | null = null;\n if (process.platform === \"win32\") {\n const appdata = process.env.APPDATA ?? join(home, \"AppData\", \"Roaming\");\n desktopConfig = join(appdata, \"Claude\", \"claude_desktop_config.json\");\n } else if (process.platform === \"darwin\") {\n desktopConfig = join(\n home,\n \"Library\",\n \"Application Support\",\n \"Claude\",\n \"claude_desktop_config.json\",\n );\n } else {\n desktopConfig = join(home, \".config\", \"claude\", \"claude_desktop_config.json\");\n }\n\n if (desktopConfig && (opts.force || existsSync(desktopConfig))) {\n targets.push({ label: \"Claude Desktop\", configPath: desktopConfig, kind: \"claude-desktop\" });\n }\n\n // Claude Desktop (MSIX) — Windows only.\n // MSIX-packaged installs (Microsoft Store) redirect %APPDATA% to a per-package\n // LocalCache dir. The config lives under %LOCALAPPDATA%\\Packages\\Claude_*\\\n // LocalCache\\Roaming\\Claude\\. Multiple package families may exist.\n // Package name is constrained to `Claude_[A-Za-z0-9]+` so an attacker can't\n // smuggle a write target through a hand-crafted package directory like\n // `Claude_../../Windows/System32` (the path constructor would reject such\n // a name on most platforms but we belt-and-suspender).\n if (process.platform === \"win32\") {\n const localAppData =\n opts.localAppDataOverride ?? process.env.LOCALAPPDATA ?? join(home, \"AppData\", \"Local\");\n // Verify LOCALAPPDATA resolves under home. An attacker who controls env\n // could otherwise redirect us to write under any directory.\n try {\n assertPathSafe(localAppData, { allowedRoots: [home] });\n } catch {\n return targets;\n }\n const packagesDir = join(localAppData, \"Packages\");\n try {\n const entries = readdirSync(packagesDir);\n const matching = entries.filter((n) => MSIX_PACKAGE_PATTERN.test(n));\n for (const pkg of matching) {\n const msixConfig = join(\n packagesDir,\n pkg,\n \"LocalCache\",\n \"Roaming\",\n \"Claude\",\n \"claude_desktop_config.json\",\n );\n if (opts.force || existsSync(msixConfig)) {\n const suffix = matching.length > 1 ? ` (${pkg.slice(0, 12)}…)` : \"\";\n targets.push({\n label: `Claude Desktop MSIX${suffix}`,\n configPath: msixConfig,\n kind: \"claude-desktop\",\n });\n }\n }\n } catch {\n // %LOCALAPPDATA%\\Packages doesn't exist or isn't readable — not an MSIX install\n }\n }\n\n return targets;\n}\n\nexport interface DetectClaudeCliOptions {\n /** Override `homedir()` — tests anchor the native-location probe under a tmpdir. */\n homeOverride?: string;\n /** Override `process.env.PATH` — tests inject a controlled PATH. */\n pathOverride?: string;\n /** Override `process.platform` — tests exercise the win32 `.exe` branch. */\n platformOverride?: NodeJS.Platform;\n}\n\n/**\n * Probe whether the `claude` CLI binary is present, independent of any config\n * file. This is the **binary** detector; {@link detectTargets} is the separate\n * **config-presence** detector (it answers \"has Claude ever written a config\n * here?\", which stays true after an uninstall and is true on a machine that\n * only has Claude Desktop).\n *\n * Pure filesystem probe — deliberately no `execFile`/spawn (no shell-injection\n * surface, no hang on a wedged binary). Returns:\n * - `INSTALLED_ON_PATH` — `claude[.exe]` found on the server process's PATH.\n * - `INSTALLED_NOT_ON_PATH` — found only in `~/.local/bin` (the native\n * installer's target, which is typically NOT on the server's PATH at\n * install time → the usual immediately-post-install state).\n * - `NOT_INSTALLED` — neither.\n *\n * PATH wins over `~/.local/bin`: if it's on PATH it's usable right now, which\n * is the more useful signal for the wizard.\n */\nexport function detectClaudeCli(opts: DetectClaudeCliOptions = {}): ClaudeCliPresence {\n const platform = opts.platformOverride ?? process.platform;\n const home = opts.homeOverride ?? homedir();\n const binName = platform === \"win32\" ? \"claude.exe\" : \"claude\";\n\n // `delimiter` is platform-specific (`;` on win32, `:` elsewhere). When a\n // platformOverride disagrees with the host, the override is for test\n // ergonomics only — real callers never pass it, so host `delimiter` is fine.\n const pathVar = opts.pathOverride ?? process.env.PATH ?? \"\";\n for (const dir of pathVar.split(delimiter)) {\n if (dir.length === 0) continue;\n if (existsSync(join(dir, binName))) return \"INSTALLED_ON_PATH\";\n }\n\n // Native install location — same `~/.local/bin` on every platform per the\n // official installer's documented uninstall paths (Windows included).\n if (existsSync(join(home, \".local\", \"bin\", binName))) return \"INSTALLED_NOT_ON_PATH\";\n\n return \"NOT_INSTALLED\";\n}\n\n/**\n * Atomic write: write to a temp file in the SAME directory as the destination,\n * tighten its mode/DACL, then rename. Same-directory tempfile avoids EXDEV\n * errors when `%TEMP%` and `%APPDATA%` are on different drives.\n *\n * Sequence (security-load-bearing):\n * 1. Write tempfile. On Windows the tempfile inherits its parent dir's\n * DACL (`homedir()` is OS-restricted by default to user + SYSTEM +\n * Administrators); on POSIX it lands at `0o666 & ~umask` (typically\n * `0o644` — world-readable, hence step 2's chmod).\n * 2. Tighten on the tempfile:\n * - Windows: `setRestrictiveAcl` breaks inheritance and grants Full\n * Control to the current user's SID only, then self-verifies via\n * SDDL read. The verify is load-bearing — icacls exits 0 even when\n * \"Failed processing N files\".\n * - POSIX: `chmod(tmp, 0o600)` — user-only read/write.\n * 3. Rename tempfile to dest. The kernel preserves mode (POSIX) and DACL\n * (Windows `MoveFileEx`) across the rename, so the tightened\n * permissions survive into the destination.\n * 4. On cleanup failure after a write/ACL/rename error, the original\n * failure propagates with the cleanup error attached via `cause` so\n * operators can diagnose a leaked tempfile or dest from a single log\n * line rather than chasing a stray `console.error`.\n */\nasync function atomicWrite(content: string, dest: string): Promise<void> {\n const tmp = join(dirname(dest), `.tandem-setup-${randomUUID()}.tmp`);\n // mode at create, not chmod-after: on POSIX a plain writeFile lands at\n // 0o666 & ~umask, leaving sibling vendors' tokens world-readable until the\n // tighten below. Windows ignores mode (the ACL step is the protection).\n await writeFile(tmp, content, { encoding: \"utf-8\", mode: 0o600 });\n\n try {\n if (process.platform === \"win32\") {\n await setRestrictiveAcl(tmp);\n } else {\n await chmod(tmp, 0o600);\n }\n } catch (tightenErr) {\n await unlinkOrLeak(tmp, tightenErr);\n throw tightenErr;\n }\n\n try {\n await rename(tmp, dest);\n } catch (err) {\n // EXDEV: cross-device link — fall back to copy + delete. The copy\n // preserves the source mode on POSIX (file is created via copyFile's\n // default which honors the source's mode bits); on Windows the\n // destination DACL is recomputed from the dest dir, so we re-run\n // setRestrictiveAcl on the dest after the cross-device copy.\n if ((err as NodeJS.ErrnoException).code === \"EXDEV\") {\n await copyFile(tmp, dest);\n await unlinkOrLeak(tmp, err);\n if (process.platform === \"win32\") await setRestrictiveAcl(dest);\n else await chmod(dest, 0o600);\n } else {\n await unlinkOrLeak(tmp, err);\n throw err;\n }\n }\n}\n\n/**\n * Attempt to delete `path` after a write/ACL/rename failure. On cleanup\n * success we just return; on cleanup failure we attach the cleanup error\n * as `cause` to the original failure (mutating `originalErr`) so operators\n * see a single composite log line rather than a free-floating\n * `console.error`. Bearer-token-bearing files that fail cleanup MUST be\n * surfaced, not silently logged.\n */\nasync function unlinkOrLeak(path: string, originalErr: unknown): Promise<void> {\n try {\n await unlink(path);\n } catch (cleanupErr) {\n if (originalErr instanceof Error && originalErr.cause === undefined) {\n (originalErr as { cause?: unknown }).cause = cleanupErr;\n }\n console.error(\n ` Warning: could not remove ${path} after a previous failure: ${\n (cleanupErr as Error).message\n }`,\n );\n }\n}\n\n/**\n * Apply explicit create/remove operations to a Claude config file.\n *\n * **Security gates (run before any read or write):**\n * - `assertPathSafe(configPath)` — symlink / outside-home rejection.\n * - Malformed JSON is backed up under Tandem's data dir with mode `0o600`\n * (avoids leaking other vendors' API keys via a world-readable\n * `~/.claude.json.broken-<ts>` sibling).\n *\n * `applyConfig` writes both `ops.create` entries (merging into the existing\n * `mcpServers` object) and removes any key listed in `ops.remove`. Removal\n * is silent if the key was already absent. Callers state both intents\n * explicitly — no implicit \"absent key implies remove\".\n */\nexport async function applyConfig(configPath: string, ops: ApplyOps): Promise<void> {\n // Security gate: refuse symlinks, refuse paths outside home/tmpdir.\n assertPathSafe(configPath);\n\n // Size guard runs before any read — fail closed on oversized files. ENOENT\n // falls through so fresh-install (no .claude.json yet) stays the common path.\n try {\n const { size } = statSync(configPath);\n if (size > MAX_CONFIG_BYTES) {\n throw new Error(\n `${configPath} is ${size} bytes; refusing to read (cap: ${MAX_CONFIG_BYTES}).`,\n );\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") throw err;\n }\n\n // Read existing config or start fresh — no existsSync guard needed.\n // ENOENT and malformed JSON start fresh; other errors (permissions, disk) propagate.\n let existing: { mcpServers?: Record<string, McpEntry> } = {};\n try {\n // Strip a leading UTF-8 BOM (``) before JSON.parse. Some editors\n // (legacy Windows tooling, certain VS Code configs) write `.claude.json`\n // with a BOM; without this strip, `JSON.parse` throws `SyntaxError`\n // and the file would be pushed into `.broken-backups/` as if it were\n // malformed. The BOM is encoded as the literal three bytes\n // `EF BB BF` which Node's \"utf-8\" decoder surfaces as a leading\n // U+FEFF code point.\n let raw = readFileSync(configPath, \"utf-8\");\n if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);\n const parsed: unknown = JSON.parse(raw);\n\n // Shape gate: the rewrite path spreads `existing.mcpServers` and\n // `existing` itself into the new config. If either is the wrong\n // shape, the spread produces a corrupted output (string-spread\n // yields `{0:'a',1:'b',...}`, array-spread yields numeric keys,\n // null-spread throws). Reject up-front so a legitimate-looking\n // config-shape mismatch never silently corrupts the user's file.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(`${configPath} root is not a JSON object — refusing to rewrite`);\n }\n const maybeServers = (parsed as Record<string, unknown>).mcpServers;\n if (\n maybeServers !== undefined &&\n (maybeServers === null || typeof maybeServers !== \"object\" || Array.isArray(maybeServers))\n ) {\n throw new Error(`${configPath} mcpServers is not an object — refusing to rewrite`);\n }\n existing = parsed as { mcpServers?: Record<string, McpEntry> };\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n // File doesn't exist yet — start fresh\n } else if (err instanceof SyntaxError) {\n // Don't silently wipe the user's other mcpServers. Copy the malformed\n // file under Tandem's data dir (NOT next to ~/.claude.json — that\n // location may inherit world-readable perms and leak co-tenant API\n // keys via the backup). Mode 0o600 hardens against the same.\n const brokenBackupDir = join(resolveAppDataDir(), \".broken-backups\");\n // Validate against default roots [homedir(), tmpdir()] rather than\n // scoping to resolveAppDataDir() — the latter is tautological, and\n // an XDG_DATA_HOME-poisoning attacker can otherwise redirect the\n // backup target outside the home tree.\n assertPathSafe(brokenBackupDir);\n // mode: 0o700 on dir creation — the file mode is 0o600, but a\n // world-readable parent dir lists sibling filenames (older backups\n // carry other vendors' keys). Mode applies only when the dir is\n // newly created; existing dirs retain their mode.\n mkdirSync(brokenBackupDir, { recursive: true, mode: 0o700 });\n // randomUUID() in the path defeats path prediction by an attacker\n // who might pre-create a file at the predicted location and have\n // our mode-at-open inherit world-readable bits. `wx` (exclusive\n // create) below is the second layer.\n const backupPath = join(\n brokenBackupDir,\n `${basename(configPath)}.broken-${Date.now()}-${randomUUID()}`,\n );\n try {\n if (process.platform === \"win32\") {\n // Windows ignores the POSIX `mode` arg on mkdir, so harden the\n // dir with an explicit DACL BEFORE writing the backup file.\n // Mirrors the ordering invariant in\n // `storage.ts#backupBrokenFile`: dir-level ACL closes the\n // TOCTOU window that a per-file ACL would otherwise open\n // between copyFile and the ACL set. Fail loud — orphaning a\n // half-hardened dir is worse than aborting the backup\n // outright. The inner `catch (copyErr)` below surfaces a\n // named error and refuses to overwrite the malformed config.\n try {\n await setRestrictiveAcl(brokenBackupDir);\n } catch (aclErr) {\n throw new Error(\n `failed to apply restrictive ACL to broken-backups dir ${brokenBackupDir}: ${\n aclErr instanceof Error ? aclErr.message : String(aclErr)\n }`,\n { cause: aclErr },\n );\n }\n // Windows doesn't honor POSIX modes — fall back to plain copy.\n // The randomUUID-suffixed path makes collisions effectively\n // impossible. COPYFILE_EXCL refuses to overwrite an existing\n // target, defeating any predictable-path symlink/pre-create\n // attack the UUID suffix might still leave reachable. NOTE:\n // `setRestrictiveAcl` (acl-win.ts) calls `icacls /grant:r\n // *<SID>:F` without (OI)(CI) inheritance flags, so the new\n // file does NOT inherit the parent dir's SID-only ACE.\n // Instead it receives the DACL synthesized from the process\n // token's default (typically user + SYSTEM + Administrators),\n // which is narrow enough to prevent cross-tenant leak in\n // standard contexts. If broader access is observed, the dir\n // ACE should be made inheritable in acl-win.ts (this would\n // also benefit storage.ts which uses the same helper).\n await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);\n } else {\n // Open with mode 0o600 + `wx` so the file is created exclusively\n // at the right mode (no copyFile + chmodSync race window where\n // the backup was briefly 0o644 with another vendor's API keys\n // inside).\n const data = await readFile(configPath);\n const fd = await open(backupPath, \"wx\", 0o600);\n try {\n await fd.write(data);\n } finally {\n await fd.close();\n }\n }\n console.error(\n ` Warning: ${configPath} contains malformed JSON — backed up to ${backupPath}, replacing with fresh config`,\n );\n } catch (copyErr) {\n console.error(\n ` Warning: ${configPath} contains malformed JSON and backup failed (${\n copyErr instanceof Error ? copyErr.message : copyErr\n }) — refusing to overwrite. Fix the JSON manually and rerun 'tandem setup'.`,\n );\n throw copyErr;\n }\n } else {\n throw err; // Permission errors, disk errors, etc. should not be silently swallowed\n }\n }\n\n // Backup the existing config IFF the existing `mcpServers.tandem` entry\n // is non-default (user-customised URL or extra keys). Token rotation and\n // fresh-install runs would otherwise generate backup churn that buries\n // the one backup the user actually needs. The write happens BEFORE the\n // rewrite — atomicity invariant: if backup throws, the original is\n // untouched.\n const backupPath = await maybeBackupExistingConfig(configPath, existing, ops);\n if (backupPath && ops.onBackup) {\n // The onBackup callback is observational — a wizard push, a CLI\n // print, a telemetry hop. If the consumer throws, log it but DO\n // NOT abort the rewrite: doing so would orphan the just-written\n // backup file and leave the user's config un-applied, with no\n // user-visible explanation.\n try {\n ops.onBackup(backupPath);\n } catch (cbErr) {\n console.error(\n ` Warning: onBackup callback threw — continuing with rewrite: ${\n cbErr instanceof Error ? cbErr.message : cbErr\n }`,\n );\n }\n }\n\n const merged: Record<string, McpEntry> = {\n ...(existing.mcpServers ?? {}),\n ...ops.create,\n };\n // Explicit removals — silent if the key was already absent.\n for (const key of ops.remove) {\n // \"create wins\": never remove a key we're also creating this run. The\n // wizard builds `ApplyOps` directly from a user-confirmed diff and can\n // legitimately list the same key in both `create` and `remove`; without\n // this guard the channel shim we just registered would be deleted again.\n // Structural invariant for every flow — see #985.\n if (key in ops.create) continue;\n if (merged[key]) {\n console.error(` Note: removed mcpServers.${key} from ${configPath}`);\n }\n delete merged[key];\n }\n const updated = { ...existing, mcpServers: merged };\n\n await mkdir(dirname(configPath), { recursive: true });\n await atomicWrite(JSON.stringify(updated, null, 2) + \"\\n\", configPath);\n}\n\nexport type RemoveEntriesResult =\n | { status: \"removed\"; removed: RemovableEntry[] }\n | { status: \"no-op\" }\n | { status: \"missing\" }\n | { status: \"skipped\"; reason: \"malformed-json\" | \"not-an-object\" | \"oversize\" };\n\n/**\n * Remove Tandem's `mcpServers` keys from a client config — the uninstall\n * counterpart of `applyConfig`, sharing the same gates (`assertPathSafe`,\n * size cap, BOM strip) and the same `atomicWrite` 0o600/ACL hardening, but\n * with scrub semantics `applyConfig` deliberately does NOT have:\n * - never creates the file (`applyConfig` starts fresh on ENOENT);\n * - never replaces malformed JSON (`applyConfig` backs it up and rewrites —\n * on an uninstall path that would wipe the user's whole config);\n * - never rewrites when nothing matched (no churn of a file other vendors'\n * tokens live in).\n *\n * `skipped` reasons are fixed strings — parse-error detail must never reach\n * the caller's log (V8 SyntaxError messages embed source snippets, and this\n * file holds bearer tokens).\n */\nexport async function removeConfigEntries(\n configPath: string,\n keys: RemovableEntry[],\n): Promise<RemoveEntriesResult> {\n assertPathSafe(configPath);\n\n let raw: string;\n try {\n const { size } = statSync(configPath);\n if (size > MAX_CONFIG_BYTES) return { status: \"skipped\", reason: \"oversize\" };\n raw = readFileSync(configPath, \"utf-8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return { status: \"missing\" };\n throw err;\n }\n if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return { status: \"skipped\", reason: \"malformed-json\" };\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return { status: \"skipped\", reason: \"not-an-object\" };\n }\n\n const servers = (parsed as Record<string, unknown>).mcpServers;\n if (servers === null || typeof servers !== \"object\" || Array.isArray(servers)) {\n return { status: \"no-op\" };\n }\n const map = servers as Record<string, unknown>;\n const removed = keys.filter((key) => key in map);\n if (removed.length === 0) return { status: \"no-op\" };\n for (const key of removed) {\n delete map[key];\n }\n\n await atomicWrite(JSON.stringify(parsed, null, 2) + \"\\n\", configPath);\n return { status: \"removed\", removed };\n}\n\n/**\n * Conditionally back up `configPath` before `applyConfig` overwrites a\n * user-customised tandem entry. Returns the backup path on write, or\n * `undefined` when no backup was needed (fresh install, token rotation,\n * non-tandem-key changes only).\n *\n * Atomicity contract: if this throws, the caller MUST abort before\n * touching the destination. The original config and any prior backup\n * remain intact.\n */\nasync function maybeBackupExistingConfig(\n configPath: string,\n existing: { mcpServers?: Record<string, McpEntry> },\n ops: ApplyOps,\n): Promise<string | undefined> {\n const existingTandem = existing.mcpServers?.tandem;\n if (!shouldBackup(existingTandem, ops.create.tandem)) return undefined;\n\n const dir = backupDir(resolveAppDataDir());\n // assertPathSafe defeats XDG_DATA_HOME poisoning — same hardening as\n // the broken-JSON backup path above.\n assertPathSafe(dir);\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n\n // mkdirSync's `mode` only applies on creation. If the dir already\n // existed at a more permissive mode (older umask, manual creation,\n // user fiddling), tighten it now. POSIX-only; on Windows the file's\n // own ACL — set by setRestrictiveAcl inside writeBackup — is the\n // protection. Filenames in a broader dir leak timestamps, not\n // bytes; failing closed here would block legit setups for that.\n if (process.platform !== \"win32\") {\n try {\n const dirStat = statSync(dir);\n if ((dirStat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700);\n } catch {\n // stat/chmod failure is non-fatal — proceeds with the wider\n // protection on the file itself.\n }\n }\n\n const content = readFileSync(configPath);\n const backupPath = await writeBackup(dir, content);\n // Prune AFTER successful write so we never delete the previous backup\n // before its replacement is on disk.\n await pruneOldBackups(dir);\n return backupPath;\n}\n\n/**\n * Install the Tandem skill to ~/.claude/skills/tandem/SKILL.md.\n * Claude Code auto-discovers skills in this directory and uses the description\n * field to trigger them when tandem_* tools are present.\n *\n * `homeOverride` is supported for tests only — the apply HTTP handler\n * MUST NOT thread this from the request body. Same symlink/realpath\n * hardening as `applyConfig`.\n */\nexport async function installSkill(opts: { homeOverride?: string } = {}): Promise<void> {\n const home = opts.homeOverride ?? homedir();\n const skillPath = join(home, \".claude\", \"skills\", \"tandem\", \"SKILL.md\");\n assertPathSafe(skillPath, { allowedRoots: opts.homeOverride ? [opts.homeOverride] : undefined });\n await mkdir(dirname(skillPath), { recursive: true });\n await atomicWrite(SKILL_CONTENT, skillPath);\n}\n\n/** Parse the integer `version:` from a skill front-matter block. Returns 0\n * if the file doesn't exist, has no version, or fails to parse — older\n * bundled skills predate the version stamp, so 0 means \"definitely upgrade\". */\nfunction readSkillVersion(skillContent: string): number {\n const match = skillContent.match(/^version:\\s*(\\d+)\\s*$/m);\n if (!match) return 0;\n const n = parseInt(match[1], 10);\n return Number.isFinite(n) ? n : 0;\n}\n\nconst BUNDLED_SKILL_VERSION = readSkillVersion(SKILL_CONTENT);\n\n/** Module-scoped last-failure record. Cleared on successful refresh; set on\n * read/write failure. Surfaced via `GET /api/launcher/status` (loopback only)\n * so the palette/settings UI can warn the user that the bundled skill is\n * out of date. `null` when last refresh succeeded or was a no-op. */\nlet lastSkillRefreshError: { code: \"write-failed\" | \"read-failed\"; message: string } | null = null;\nexport function getSkillRefreshError(): {\n code: \"write-failed\" | \"read-failed\";\n message: string;\n} | null {\n return lastSkillRefreshError;\n}\n/** Test-only — reset module state between tests. */\nexport function _resetSkillRefreshErrorForTests(): void {\n if (process.env.VITEST !== \"true\") return;\n lastSkillRefreshError = null;\n}\n\n/**\n * Idempotent skill refresh — called from supervisor startup so existing\n * users (who already ran `tandem setup` once) pick up bundled-skill\n * updates without re-running the wizard.\n *\n * Compares the bundled `version:` against the on-disk file. Writes only\n * if bundled > on-disk. Silently no-ops on any error (read failure,\n * write failure, missing parent dir) — this is a best-effort refresh,\n * not a critical path. The wizard-driven `installSkill()` remains the\n * authoritative installer.\n */\nexport async function refreshSkillIfStale(opts: { homeOverride?: string } = {}): Promise<void> {\n if (BUNDLED_SKILL_VERSION === 0) return; // Bundled skill has no version stamp — nothing to compare.\n const home = opts.homeOverride ?? homedir();\n const skillPath = join(home, \".claude\", \"skills\", \"tandem\", \"SKILL.md\");\n try {\n assertPathSafe(skillPath, {\n allowedRoots: opts.homeOverride ? [opts.homeOverride] : undefined,\n });\n } catch {\n return;\n }\n let onDiskVersion = -1; // -1 = file missing (treat as needing install)\n let readErr: unknown;\n try {\n const fs = await import(\"node:fs/promises\");\n const current = await fs.readFile(skillPath, \"utf8\");\n onDiskVersion = readSkillVersion(current);\n } catch (err) {\n // ENOENT is expected (first run); any other error is a real read failure.\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") readErr = err;\n }\n if (onDiskVersion >= BUNDLED_SKILL_VERSION) {\n // No-op path — clear any prior failure only if read succeeded.\n if (readErr === undefined) lastSkillRefreshError = null;\n else\n lastSkillRefreshError = {\n code: \"read-failed\",\n message: readErr instanceof Error ? readErr.message : String(readErr),\n };\n return;\n }\n try {\n await mkdir(dirname(skillPath), { recursive: true });\n await atomicWrite(SKILL_CONTENT, skillPath);\n lastSkillRefreshError = null;\n console.error(\n `[Tandem] Refreshed bundled skill at ${skillPath} (v${onDiskVersion} → v${BUNDLED_SKILL_VERSION}).`,\n );\n } catch (err) {\n lastSkillRefreshError = {\n code: \"write-failed\",\n message: err instanceof Error ? err.message : String(err),\n };\n console.error(\n `[Tandem] Skill refresh failed (non-fatal): ${err instanceof Error ? err.message : err}`,\n );\n }\n}\n\n/**\n * Returns true if the channel-shim build artifact exists at the given path.\n * Exported so the prereq check can be tested without spawning runSetup.\n */\nexport function validateChannelShimPrereq(channelPath: string): boolean {\n return existsSync(channelPath);\n}\n\n/**\n * Single source of truth for \"should this target get the stdio channel shim?\".\n *\n * The channel shim is Claude Code's real-time push transport (the plugin\n * monitor it was meant to replace cannot activate via any path Tandem can\n * use — see Spike B / #985). So the default for the Claude Code target is\n * ON, gated only by the build artifact actually existing.\n *\n * - `claude-desktop` → always false (Cowork stdio path; the node-process\n * shim does not apply there).\n * - `override` (the explicit `--with-channel-shim` / wizard opt-out) wins\n * when provided.\n * - Otherwise: default-on for Claude Code, but only if `channelPath` exists.\n * That `existsSync` guard does double duty — it degrades gracefully when\n * `tandem` runs from source without a build, AND it stops the CLI/wizard\n * from writing a wrong `CHANNEL_DIST` on a desktop bundle. On the desktop\n * bundle the correct resource-dir channel path is injected via the\n * `TANDEM_CHANNEL_DIST` env var (see `resolveChannelDist`), so `CHANNEL_DIST`\n * already resolves to an existing file there and the shim registers.\n */\nexport function shouldRegisterChannelShim(\n targetKind: TargetKind,\n channelPath: string,\n override?: boolean,\n): boolean {\n if (targetKind === \"claude-desktop\") return false;\n if (override !== undefined) return override;\n return validateChannelShimPrereq(channelPath);\n}\n\n/** Re-exported for `tandem setup` orchestration in `src/cli/setup.ts`. */\nexport { CHANNEL_DIST, PACKAGE_ROOT };\n\n/**\n * Write the given token into all detected Claude MCP config files.\n * Returns the number of configs successfully updated and any per-target errors.\n *\n * CLI-shaped semantics: when `withChannelShim` is false, any existing\n * `tandem-channel` entry is removed (legacy install artifact). The\n * wizard's apply endpoint uses an explicit-confirmation code path\n * instead; this helper is for `tandem rotate-token` / `tandem setup`\n * where the flag already captures user intent.\n */\nexport async function applyConfigWithToken(\n token: string | null,\n opts: { force?: boolean; withChannelShim?: boolean } = {},\n): Promise<{ updated: number; errors: string[] }> {\n const targets = detectTargets({ force: opts.force });\n\n let updated = 0;\n const errors: string[] = [];\n for (const t of targets) {\n // Resolve per-target: a token rotation should preserve/heal the channel\n // shim registration (default-on for Claude Code) rather than silently\n // strip it. `opts.withChannelShim` still wins as an explicit override.\n const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);\n const entries = buildMcpEntries(CHANNEL_DIST, {\n withChannelShim,\n token: token ?? undefined,\n targetKind: t.kind,\n });\n try {\n await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));\n updated++;\n } catch (err) {\n errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);\n }\n }\n return { updated, errors };\n}\n","/**\n * Windows workspace path guard — mirrors the Rust §3 invariant for TypeScript callers.\n *\n * Four steps (in order):\n * a. lstat each ancestor; reject any path whose chain contains a symlink.\n * b. fs.realpath() to canonicalize (safe: symlinks already rejected in (a)).\n * c. Reject UNC paths (\\\\server\\share or \\\\?\\UNC\\...; allow \\\\?\\C:\\...).\n * d. Component-wise containment check under realpath'd %LOCALAPPDATA%\n * (case-insensitive on Windows).\n *\n * Extracted into its own module so it can be unit-tested via vi.mock(\"node:fs\", ...).\n */\n\nimport { promises as fs } from \"node:fs\";\nimport path from \"node:path\";\n\ntype Logger = { warn: (msg: string) => void };\n\n/**\n * Validate that `candidate` is a safe workspace path contained within `realLocalAppData`.\n *\n * @returns the realpath'd canonical path string on success, or null if rejected.\n * Callers are responsible for supplying a realpath'd `realLocalAppData`.\n */\nexport async function assertSafeWorkspacePath(\n candidate: string,\n realLocalAppData: string,\n logger?: Logger,\n): Promise<string | null> {\n const warn = (msg: string) => logger?.warn(`[path-guard] ${msg}`);\n\n // (a) lstat-walk: reject any component that is a symlink.\n if (await hasSymlinkInChain(candidate, warn)) {\n warn(`symlink/reparse point in chain: ${candidate}`);\n return null;\n }\n\n // (b) Canonicalize via realpath (safe now — symlinks already rejected).\n let real: string;\n try {\n real = await fs.realpath(candidate);\n } catch (err) {\n warn(`realpath failed for ${candidate}: ${(err as Error).message}`);\n return null;\n }\n\n // (c) Reject UNC paths.\n if (isUncPath(real)) {\n warn(`UNC path rejected: ${real}`);\n return null;\n }\n\n // (d) Component-wise containment under realLocalAppData (case-insensitive).\n if (!isComponentWiseChild(real, realLocalAppData)) {\n warn(`path outside %LOCALAPPDATA%: ${real}`);\n return null;\n }\n\n return real;\n}\n\n/** Returns true if any ancestor (inclusive) of `p` is a symbolic link. */\nasync function hasSymlinkInChain(p: string, warn: (m: string) => void): Promise<boolean> {\n // Walk from candidate up through all ancestors.\n let current = path.resolve(p);\n const visited = new Set<string>();\n\n while (true) {\n if (visited.has(current)) break;\n visited.add(current);\n\n try {\n const stat = await fs.lstat(current);\n if (stat.isSymbolicLink()) {\n return true;\n }\n } catch (err) {\n // lstat failed — fail closed for safety.\n warn(`lstat failed for ${current}: ${(err as Error).message}`);\n return true;\n }\n\n const parent = path.dirname(current);\n if (parent === current) break; // reached root\n current = parent;\n }\n\n return false;\n}\n\n/** Returns true if the path is a UNC path (\\\\server\\share or \\\\?\\UNC\\...). */\nfunction isUncPath(p: string): boolean {\n // Allow extended-length local paths (\\\\?\\C:\\...) but reject:\n // \\\\?\\UNC\\server\\share — extended UNC\n // \\\\server\\share — classic UNC\n if (p.startsWith(\"\\\\\\\\?\\\\UNC\\\\\") || p.startsWith(\"//?/UNC/\")) return true;\n if (\n (p.startsWith(\"\\\\\\\\\") && !p.startsWith(\"\\\\\\\\?\\\\\")) ||\n (p.startsWith(\"//\") && !p.startsWith(\"//?/\"))\n )\n return true;\n return false;\n}\n\n/**\n * Returns true if `child` is strictly within `root` on a component-wise basis\n * (case-insensitive on Windows).\n */\nfunction isComponentWiseChild(child: string, root: string): boolean {\n // Normalize separators and split on path.sep.\n const normalize = (p: string) => p.replace(/[\\\\/]+/g, path.sep).replace(/[/\\\\]$/, \"\");\n\n const rootNorm = normalize(root);\n const childNorm = normalize(child);\n\n const rootParts = rootNorm.split(path.sep);\n const childParts = childNorm.split(path.sep);\n\n if (childParts.length <= rootParts.length) return false;\n\n for (let i = 0; i < rootParts.length; i++) {\n // Case-insensitive on Windows.\n if (rootParts[i].toLowerCase() !== childParts[i].toLowerCase()) return false;\n }\n return true;\n}\n","/**\n * Tandem `--uninstall-scrub` subcommand.\n *\n * Invoked by the Tauri NSIS installer hook during uninstall on Windows, and\n * manually invocable on every platform (run it *before* deleting the app /\n * `npm uninstall -g` — see docs/data-locations.md). Removes every reference\n * Tandem wrote into other programs' config:\n * - Cowork workspaces (Windows): `installed_plugins.json`\n * (`mcpServers.tandem`), `known_marketplaces.json`\n * (`marketplaces.tandem`), `cowork_settings.json` (`tandem@tandem` in\n * `enabledPlugins`)\n * - MCP configs (all platforms): `mcpServers.tandem` /\n * `mcpServers[\"tandem-channel\"]` from `~/.claude.json` and every\n * detected Claude Desktop config (incl. Windows MSIX)\n * - The bundled skill dir `~/.claude/skills/tandem/` (only when it\n * contains nothing Tandem didn't install)\n * - `Tandem Cowork*` Windows Firewall rules via `netsh`\n *\n * Deliberately does NOT touch app data (sessions, annotations, doc-backups,\n * keychain) — the scrub removes references to a binary about to disappear;\n * user data stays unless the user deletes it (docs/data-locations.md).\n *\n * **Security invariant §10 (ADR):** this runs INSIDE the already-signed\n * `tandem.exe` binary — NOT as a separate `uninstall_scrub.exe`. That\n * prevents binary-planting attacks during uninstall.\n *\n * **Failure policy:** logs every error, exits 0 on clean-or-not-installed,\n * non-zero only on unrecoverable I/O failures. NSIS logs the exit code but\n * does NOT block uninstall — Tandem must always uninstall even if a\n * workspace scrub partially fails. Each step is isolated: a failure in one\n * never skips the rest.\n *\n * **Token safety:** this scrub READS JSON to find Tandem entries but the\n * removed-entry contents (including the auth token) are NEVER logged, and\n * parse-error detail is never interpolated into log lines (V8 SyntaxError\n * messages embed source snippets; these files hold bearer tokens).\n */\n\nimport { execFile } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport { type Dirent, promises as fsPromises } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport path from \"node:path\";\nimport { promisify } from \"node:util\";\nimport {\n assertPathSafe,\n type DetectedTarget,\n detectTargets,\n PathRejectedError,\n removeConfigEntries,\n} from \"../server/integrations/apply.js\";\nimport { assertSafeWorkspacePath } from \"./win-path-guard.js\";\n\nconst execFileAsync = promisify(execFile);\n\nconst TANDEM_PLUGIN_ID = \"tandem\";\nconst TANDEM_ENABLED_KEY = \"tandem@tandem\";\nconst FIREWALL_ALLOW_RULE = \"Tandem Cowork\";\nconst FIREWALL_DENY_RULE = \"Tandem Cowork \\u2014 Deny (elevation refused)\";\n\ntype ScrubLogger = {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n error: (msg: string) => void;\n close: () => Promise<void>;\n};\n\nasync function openLogger(): Promise<ScrubLogger> {\n const localAppData = process.env.LOCALAPPDATA;\n if (!localAppData) {\n // No log file — just use stderr.\n const write = (level: string, msg: string): void => {\n process.stderr.write(`[tandem uninstall-scrub ${level}] ${msg}\\n`);\n };\n return {\n info: (m) => write(\"info\", m),\n warn: (m) => write(\"warn\", m),\n error: (m) => write(\"error\", m),\n close: async () => {},\n };\n }\n\n const logDir = path.join(localAppData, \"tandem\", \"Logs\");\n await fsPromises.mkdir(logDir, { recursive: true }).catch(() => {});\n const logPath = path.join(logDir, \"uninstall.log\");\n const stream = await fsPromises.open(logPath, \"a\").catch(() => null);\n\n const write = (level: string, msg: string): void => {\n const line = `[${new Date().toISOString()}] [${level}] ${msg}\\n`;\n process.stderr.write(line);\n if (stream) {\n stream.write(line).catch(() => {});\n }\n };\n\n return {\n info: (m) => write(\"info\", m),\n warn: (m) => write(\"warn\", m),\n error: (m) => write(\"error\", m),\n close: async () => {\n if (stream) await stream.close().catch(() => {});\n },\n };\n}\n\n/**\n * Find all Cowork workspace directories.\n *\n * Matches `%LOCALAPPDATA%\\Packages\\Claude_*\\LocalCache\\Roaming\\Claude\\\n * local-agent-mode-sessions\\<ws-id>\\<vm-id>\\`.\n *\n * Each candidate vm-path is validated by the 4-step Windows path guard before\n * being included — callers receive only safe, realpath'd paths.\n *\n * Returns an empty array on any error (e.g. Cowork not installed).\n */\nexport async function findCoworkWorkspaces(logger: ScrubLogger): Promise<string[]> {\n const localAppData = process.env.LOCALAPPDATA;\n if (!localAppData) {\n logger.info(\"%LOCALAPPDATA% not set — skipping workspace scan\");\n return [];\n }\n\n // Realpath %LOCALAPPDATA% once for use by the path guard.\n let realLad: string;\n try {\n realLad = await fsPromises.realpath(localAppData);\n } catch {\n realLad = localAppData; // best-effort fallback\n }\n\n const packagesDir = path.join(localAppData, \"Packages\");\n let packageEntries: string[];\n try {\n packageEntries = await fsPromises.readdir(packagesDir);\n } catch (err) {\n logger.info(`cannot read Packages dir: ${(err as Error).message}`);\n return [];\n }\n\n const claudePackages = packageEntries.filter((name) => name.startsWith(\"Claude_\"));\n if (claudePackages.length === 0) {\n logger.info(\"no Claude_* package directories found\");\n return [];\n }\n\n const workspaces: string[] = [];\n for (const pkg of claudePackages) {\n const sessionsRoot = path.join(\n packagesDir,\n pkg,\n \"LocalCache\",\n \"Roaming\",\n \"Claude\",\n \"local-agent-mode-sessions\",\n );\n\n let wsEntries: string[];\n try {\n wsEntries = await fsPromises.readdir(sessionsRoot);\n } catch (err) {\n logger.warn(`cannot read sessions root ${sessionsRoot}: ${(err as Error).message}`);\n continue;\n }\n\n for (const ws of wsEntries) {\n const wsPath = path.join(sessionsRoot, ws);\n let vmEntries: string[];\n try {\n vmEntries = await fsPromises.readdir(wsPath);\n } catch (err) {\n logger.warn(`cannot read workspace dir ${wsPath}: ${(err as Error).message}`);\n continue;\n }\n\n for (const vm of vmEntries) {\n const vmPath = path.join(wsPath, vm);\n try {\n const stat = await fsPromises.stat(vmPath);\n if (!stat.isDirectory()) continue;\n\n // 4-step path guard — only include validated, realpath'd paths.\n const safePath = await assertSafeWorkspacePath(vmPath, realLad, logger);\n if (safePath !== null) {\n workspaces.push(safePath);\n }\n } catch (err) {\n logger.warn(`cannot stat ${vmPath}: ${(err as Error).message}`);\n }\n }\n }\n }\n\n logger.info(`found ${workspaces.length} workspace(s)`);\n return workspaces;\n}\n\n/**\n * Atomically rewrite a JSON file, invoking `mutate` on the parsed object.\n *\n * Precondition: `filePath` has already been validated by the path guard.\n * This function trusts its callers (consistent with `with_locked_json` on the Rust side).\n *\n * Returns true if the mutation changed something and was written, false if\n * the file was absent or unchanged.\n */\nexport async function rewriteJson(\n filePath: string,\n mutate: (obj: Record<string, unknown>) => boolean,\n logger: ScrubLogger,\n): Promise<boolean> {\n let content: string;\n try {\n content = await fsPromises.readFile(filePath, \"utf8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return false;\n }\n logger.warn(`cannot read ${filePath}: ${(err as Error).message}`);\n return false;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(content);\n } catch {\n // Path only, never the parse error — V8 SyntaxError messages embed a\n // source snippet, and these files can hold bearer tokens that would\n // land in uninstall.log.\n logger.warn(`invalid JSON in ${filePath} — skipping`);\n return false;\n }\n\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n logger.warn(`${filePath} is not a JSON object — skipping`);\n return false;\n }\n\n const changed = mutate(parsed as Record<string, unknown>);\n if (!changed) {\n return false;\n }\n\n const dir = path.dirname(filePath);\n // randomUUID, not Math.random: same path-prediction rationale as\n // apply.ts#atomicWrite — these files can hold bearer tokens.\n const tmpPath = path.join(dir, `.tandem-scrub-tmp-${randomUUID()}`);\n\n try {\n await fsPromises.writeFile(tmpPath, JSON.stringify(parsed, null, 2), \"utf8\");\n await fsPromises.rename(tmpPath, filePath);\n } catch (err) {\n await fsPromises.unlink(tmpPath).catch(() => {});\n throw err;\n }\n return true;\n}\n\n/**\n * Remove the Tandem entry from `installed_plugins.json`.\n */\nexport function removeInstalledPlugins(obj: Record<string, unknown>): boolean {\n let changed = false;\n for (const key of [\"mcpServers\", \"servers\"]) {\n const servers = obj[key];\n if (typeof servers === \"object\" && servers !== null && !Array.isArray(servers)) {\n const map = servers as Record<string, unknown>;\n if (TANDEM_PLUGIN_ID in map) {\n delete map[TANDEM_PLUGIN_ID];\n changed = true;\n }\n }\n }\n return changed;\n}\n\n/**\n * Remove the Tandem marketplace entry from `known_marketplaces.json`.\n */\nexport function removeKnownMarketplaces(obj: Record<string, unknown>): boolean {\n const mp = obj.marketplaces;\n if (typeof mp === \"object\" && mp !== null && !Array.isArray(mp)) {\n const map = mp as Record<string, unknown>;\n if (TANDEM_PLUGIN_ID in map) {\n delete map[TANDEM_PLUGIN_ID];\n return true;\n }\n }\n return false;\n}\n\n/**\n * Remove `tandem@tandem` from `enabledPlugins` in `cowork_settings.json`.\n */\nexport function removeCoworkSettings(obj: Record<string, unknown>): boolean {\n const enabled = obj.enabledPlugins;\n if (Array.isArray(enabled)) {\n const before = enabled.length;\n obj.enabledPlugins = enabled.filter((v) => v !== TANDEM_ENABLED_KEY);\n return (obj.enabledPlugins as unknown[]).length < before;\n }\n if (typeof enabled === \"object\" && enabled !== null) {\n const map = enabled as Record<string, unknown>;\n if (TANDEM_ENABLED_KEY in map) {\n delete map[TANDEM_ENABLED_KEY];\n return true;\n }\n }\n return false;\n}\n\n/**\n * Remove `mcpServers.tandem` / `mcpServers[\"tandem-channel\"]` from every\n * detected Claude config (`~/.claude.json` + Claude Desktop incl. MSIX).\n * Runs on every platform. Returns the failure count; each target is\n * isolated so one failure never skips the rest.\n *\n * `detect` is injectable for tests only.\n */\nexport async function scrubMcpConfigs(\n logger: ScrubLogger,\n detect: () => DetectedTarget[] = detectTargets,\n): Promise<number> {\n let failures = 0;\n let targets: DetectedTarget[];\n try {\n targets = detect();\n } catch (err) {\n logger.error(`cannot detect MCP config targets: ${(err as Error).message}`);\n return 1;\n }\n\n for (const target of targets) {\n try {\n const result = await removeConfigEntries(target.configPath, [\"tandem\", \"tandem-channel\"]);\n switch (result.status) {\n case \"removed\":\n logger.info(`removed ${result.removed.join(\", \")} from ${target.configPath}`);\n break;\n case \"no-op\":\n logger.info(`no Tandem MCP entries in ${target.configPath}`);\n break;\n case \"missing\":\n break;\n case \"skipped\":\n logger.warn(`left ${target.configPath} untouched (${result.reason})`);\n break;\n }\n } catch (err) {\n const detail =\n err instanceof PathRejectedError ? `path rejected (${err.reason})` : (err as Error).message;\n logger.error(`MCP scrub failed for ${target.configPath}: ${detail}`);\n failures++;\n }\n }\n return failures;\n}\n\nconst SKILL_DIR_ALLOWLIST = [/^SKILL\\.md$/, /^\\.tandem-setup-[0-9a-f-]+\\.tmp$/];\n\n/**\n * Remove the bundled skill dir `~/.claude/skills/tandem/` — but only when\n * every entry is a regular file Tandem itself installs (`SKILL.md`, plus\n * orphaned `atomicWrite` temps from a crashed install). Anything else means\n * the user put files there; leave the whole dir intact. A dangling skill is\n * harmless to Claude Code but keeps advertising tandem_* tools that no\n * longer exist; if the install survives and runs again, `refreshSkillIfStale`\n * recreates it.\n *\n * `homeOverride` is for tests only.\n */\nexport async function removeSkillDir(logger: ScrubLogger, homeOverride?: string): Promise<number> {\n const skillDir = path.join(homeOverride ?? homedir(), \".claude\", \"skills\", \"tandem\");\n\n // Symmetric with installSkill: if install would refuse a symlinked\n // ~/.claude, uninstall refuses too.\n try {\n assertPathSafe(skillDir, { allowedRoots: homeOverride ? [homeOverride] : undefined });\n } catch (err) {\n const reason = err instanceof PathRejectedError ? err.reason : (err as Error).message;\n logger.warn(`leaving ${skillDir} — path rejected (${reason})`);\n return 0;\n }\n\n let entries: Dirent[];\n try {\n entries = await fsPromises.readdir(skillDir, { withFileTypes: true });\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return 0;\n logger.warn(`cannot read skill dir ${skillDir}: ${(err as Error).message}`);\n return 0;\n }\n\n const unexpected = entries.filter(\n (e) => !e.isFile() || !SKILL_DIR_ALLOWLIST.some((re) => re.test(e.name)),\n );\n if (unexpected.length > 0) {\n logger.info(`leaving ${skillDir} — contains entries Tandem didn't install`);\n return 0;\n }\n\n try {\n await fsPromises.rm(skillDir, { recursive: true, force: true });\n logger.info(`removed skill dir ${skillDir}`);\n return 0;\n } catch (err) {\n logger.error(`failed to remove skill dir ${skillDir}: ${(err as Error).message}`);\n return 1;\n }\n}\n\n/**\n * Delete a Windows Firewall rule by name. Non-existent rules are not an error.\n */\nasync function deleteFirewallRule(name: string, logger: ScrubLogger): Promise<void> {\n try {\n await execFileAsync(\"netsh\", [\"advfirewall\", \"firewall\", \"delete\", \"rule\", `name=${name}`]);\n logger.info(`deleted firewall rule: ${name}`);\n } catch (err) {\n const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string };\n // netsh returns exit code 1 when no rule matches — not fatal.\n const stdoutStr = e.stdout ?? \"\";\n if (stdoutStr.includes(\"No rules match\")) {\n logger.info(`no firewall rule to delete: ${name}`);\n return;\n }\n logger.warn(\n `failed to delete firewall rule ${name}: ${e.message ?? String(err)} ` +\n `(stdout: ${stdoutStr.trim().slice(0, 200)})`,\n );\n }\n}\n\n/**\n * Main entry. Cowork + firewall steps are Windows-only; the MCP config and\n * skill-dir steps run everywhere. Each step is isolated so a failure in one\n * never skips the rest (the firewall rules in particular must always be\n * attempted).\n */\nexport async function runUninstallScrub(): Promise<number> {\n const logger = await openLogger();\n\n logger.info(\"Tandem uninstall scrub starting\");\n\n const isWindows = process.platform === \"win32\";\n let failures = 0;\n\n try {\n if (isWindows) {\n const workspaces = await findCoworkWorkspaces(logger);\n for (const ws of workspaces) {\n const pluginsDir = path.join(ws, \"cowork_plugins\");\n try {\n await rewriteJson(\n path.join(pluginsDir, \"installed_plugins.json\"),\n removeInstalledPlugins,\n logger,\n );\n await rewriteJson(\n path.join(pluginsDir, \"known_marketplaces.json\"),\n removeKnownMarketplaces,\n logger,\n );\n await rewriteJson(\n path.join(pluginsDir, \"cowork_settings.json\"),\n removeCoworkSettings,\n logger,\n );\n } catch (err) {\n logger.error(`scrub failed for ${ws}: ${(err as Error).message}`);\n failures++;\n }\n }\n } else {\n logger.info(`platform ${process.platform}: Cowork + firewall scrub is Windows-only`);\n }\n\n failures += await scrubMcpConfigs(logger);\n failures += await removeSkillDir(logger);\n\n if (isWindows) {\n await deleteFirewallRule(FIREWALL_ALLOW_RULE, logger);\n await deleteFirewallRule(FIREWALL_DENY_RULE, logger);\n }\n\n logger.info(`scrub complete: ${failures} failure(s)`);\n } catch (err) {\n logger.error(`scrub fatal error: ${(err as Error).message}`);\n failures++;\n }\n\n await logger.close();\n\n // Exit 0 on clean-or-not-installed. Non-zero only on unrecoverable failures —\n // and even then, NSIS does NOT block uninstall; it just records the code.\n return failures > 0 ? 1 : 0;\n}\n","import { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport {\n applyConfig,\n applyOpsForCli,\n buildMcpEntries,\n CHANNEL_DIST,\n type DetectedTarget,\n detectTargets,\n installSkill,\n PACKAGE_ROOT,\n shouldRegisterChannelShim,\n type TargetKind,\n validateChannelShimPrereq,\n} from \"../server/integrations/apply.js\";\n\n/**\n * Parse repeatable `--target=<kind>` CLI args into valid target kinds plus the\n * unrecognized leftovers (so the caller can warn on typos). Only the\n * `--target=<value>` form is recognized — `--target foo` (space, no `=`) is\n * silently ignored, and `--target=` (empty value) lands in `unknown` and is\n * treated as a typo by the caller. Pure + side-effect-free for unit testing.\n */\nexport function parseTargetArgs(args: string[]): {\n targets: TargetKind[];\n unknown: string[];\n} {\n const raw = args.filter((a) => a.startsWith(\"--target=\")).map((a) => a.slice(\"--target=\".length));\n const targets = raw.filter((t): t is TargetKind => t === \"claude-code\" || t === \"claude-desktop\");\n const unknown = raw.filter((t) => t !== \"claude-code\" && t !== \"claude-desktop\");\n return { targets, unknown };\n}\n\nexport interface SetupOptions {\n /**\n * When false (the default `tandem setup` with no flags) we only print\n * guidance — first-run setup is wizard-driven now (ADR-038 §2b). `--apply`\n * opts into writing the MCP config non-interactively (scriptable path for\n * CI / dotfile users).\n */\n apply?: boolean;\n force?: boolean;\n withChannelShim?: boolean;\n /**\n * Restrict to specific target kinds (`--target=claude-code|claude-desktop`).\n * Empty/undefined = all detected targets.\n */\n targets?: TargetKind[];\n}\n\n/**\n * `tandem setup` entry point.\n *\n * Auto-configuration of Claude on Tauri startup and the old interactive\n * `tandem setup` flow were removed in #477 PR 3c-ii-c — setup runs through the\n * in-app wizard, with this CLI surviving only as a non-interactive\n * `--apply` escape hatch for scripts. The bare `tandem setup` prints guidance.\n */\nexport async function runSetup(opts: SetupOptions = {}): Promise<void> {\n if (!opts.apply) {\n printGuidance();\n return;\n }\n await applySetup(opts);\n}\n\nfunction printGuidance(): void {\n console.error(\n \"\\nTandem setup is wizard-driven.\\n\\n\" +\n \" • Run `tandem` to launch the editor; the first-run wizard connects\\n\" +\n \" Claude (Claude Code / Claude Desktop) for you.\\n\" +\n \" • Or run `tandem setup --apply` to write the default Claude MCP config\\n\" +\n \" non-interactively. Honors --force, --target=<kind>, --with-channel-shim.\\n\",\n );\n}\n\nasync function applySetup(opts: SetupOptions): Promise<void> {\n console.error(\"\\nTandem Setup (--apply)\\n\");\n\n if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {\n console.error(\n `Error: --with-channel-shim requires dist/channel/index.js at ${CHANNEL_DIST}\\n` +\n `Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor.`,\n );\n process.exit(1);\n }\n\n console.error(\"Detecting Claude installations...\");\n\n let targets = detectTargets({ force: opts.force });\n if (opts.targets && opts.targets.length > 0) {\n const wanted = new Set(opts.targets);\n targets = targets.filter((t) => wanted.has(t.kind));\n }\n\n let failures = 0;\n if (targets.length === 0) {\n console.error(\n \" No matching Claude installations detected.\\n\" +\n \" If Claude Code is installed, ensure ~/.claude exists.\\n\" +\n \" Force configuration to default paths with: tandem setup --apply --force\",\n );\n } else {\n for (const t of targets) {\n console.error(` Found: ${t.label} (${t.configPath})`);\n }\n\n console.error(\"\\nWriting MCP configuration...\");\n failures = await writeTargets(targets, opts);\n\n if (failures === targets.length) {\n console.error(\"\\nSetup failed — could not write any configuration. Check file permissions.\");\n } else if (failures > 0) {\n console.error(\n `\\nSetup partially complete (${failures} target(s) failed). Start Tandem with: tandem`,\n );\n } else {\n console.error(\"\\nSetup complete! Start Tandem with: tandem\");\n console.error(\"Then in Claude, your tandem_* tools will be available.\");\n }\n }\n\n // Skill install is per-user, not per-integration — run it on any --apply\n // invocation (contrarian review S5), even when no targets were written.\n console.error(\"\\nInstalling Claude Code skill...\");\n try {\n await installSkill();\n console.error(\" \\x1b[32m✓\\x1b[0m ~/.claude/skills/tandem/SKILL.md\");\n } catch (err) {\n console.error(\n ` \\x1b[33m⚠\\x1b[0m Could not install skill: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n if (targets.length > 0 && failures < targets.length) {\n printPushStatus();\n }\n\n // Non-zero exit only when we attempted writes and every one failed, so\n // `tandem setup --apply` stays scriptable (CI can branch on exit code).\n if (targets.length > 0 && failures === targets.length) {\n process.exit(1);\n }\n}\n\nasync function writeTargets(targets: DetectedTarget[], opts: SetupOptions): Promise<number> {\n let failures = 0;\n for (const t of targets) {\n // Default-on for Claude Code (channel shim is its push transport, #985);\n // `--with-channel-shim` is now an explicit override. The helper's\n // existence check degrades to \"tandem HTTP entry only\" when the build\n // artifact is absent (an explicit `--with-channel-shim` with a missing\n // file already hard-errored above).\n const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);\n const entries = buildMcpEntries(CHANNEL_DIST, {\n withChannelShim,\n targetKind: t.kind,\n });\n try {\n await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));\n console.error(` \\x1b[32m✓\\x1b[0m ${t.label}`);\n } catch (err) {\n failures++;\n console.error(\n ` \\x1b[31m✗\\x1b[0m ${t.label}: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n return failures;\n}\n\nfunction printPushStatus(): void {\n // Real-time push is delivered by the channel shim, registered by default\n // above; the plugin monitor it was meant to replace cannot activate via any\n // path Claude Code exposes today (Spike B / #985), so it's framed as\n // forward-looking, not the path.\n const channelRegistered = validateChannelShimPrereq(CHANNEL_DIST);\n const pluginManifest = join(PACKAGE_ROOT, \".claude-plugin\", \"plugin.json\");\n const devInstructions = existsSync(pluginManifest)\n ? ` For development, you can also load the package directly:\\n\\n` +\n ` claude --plugin-dir ${PACKAGE_ROOT}\\n\\n`\n : \"\";\n\n console.error(\n \"\\n\\x1b[1mReal-time push notifications:\\x1b[0m\\n\" +\n (channelRegistered\n ? \" \\x1b[32mEnabled\\x1b[0m — the channel shim is registered; Claude Code receives events in real time.\\n\" +\n \" Relaunch any Claude Code session you started manually so it picks up the new server.\\n\\n\"\n : \" \\x1b[33mUnavailable\\x1b[0m — dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\\n\" +\n \" Run 'npm run build' and re-run setup to enable push.\\n\\n\") +\n \" A Tandem plugin is also published (skill + MCP; the real-time monitor it carries is\\n\" +\n \" forward-looking, pending Claude Code support):\\n\\n\" +\n \" claude plugin marketplace add bloknayrb/tandem\\n\" +\n \" claude plugin install tandem@tandem-editor\\n\\n\" +\n devInstructions,\n );\n}\n","/**\n * Helpers shared by CLI stdio entry points (`mcp-stdio`, `channel`) and the\n * standalone server / monitor binaries that all speak MCP over stdout.\n */\n\nimport { DEFAULT_MCP_PORT } from \"./constants.js\";\n\n/**\n * In stdio MCP mode, stdout is the JSON-RPC wire — any stray library write\n * corrupts the protocol. Redirect `console.log/warn/info` to stderr so\n * incidental logging is safe. Callers that also run in-process tests (e.g.\n * `src/monitor/index.ts`) can gate this behind `!process.env.VITEST`.\n */\nexport function redirectConsoleToStderr(): void {\n console.log = console.error;\n console.warn = console.error;\n console.info = console.error;\n}\n\n/**\n * Resolve the Tandem HTTP base URL used by stdio subcommands. Precedence:\n * (1) explicit override (programmatic, e.g. from tests)\n * (2) CLAUDE_PLUGIN_OPTION_SERVER_URL — injected by plugin host from userConfig\n * (3) TANDEM_URL — explicit env override\n * (4) 127.0.0.1 default (apiMiddleware narrowed out bare 'localhost' in PR #477 PR 2)\n * Blank values are treated as absent so a blank plugin option does not mask an\n * explicit TANDEM_URL or the 127.0.0.1 default.\n * The returned string has no trailing slash so callers can concatenate\n * `/health`, `/mcp`, etc. without double-slash. One or more trailing slashes\n * are stripped, so both `http://x/` and `http://x//` resolve to `http://x`.\n */\nexport function resolveTandemUrl(override?: string): string {\n return resolveTandemUrlCandidate(override).replace(/\\/+$/, \"\");\n}\n\nfunction resolveTandemUrlCandidate(override?: string): string {\n const candidates = [\n override,\n process.env.CLAUDE_PLUGIN_OPTION_SERVER_URL,\n process.env.TANDEM_URL,\n ];\n for (const url of candidates) {\n if (url !== undefined && url.trim() !== \"\") return url.trim();\n }\n return `http://127.0.0.1:${DEFAULT_MCP_PORT}`;\n}\n\n/**\n * Resolve the Tandem auth token. Precedence:\n * (1) explicit override (programmatic, e.g. from tests)\n * (2) CLAUDE_PLUGIN_OPTION_AUTH_TOKEN — injected by plugin host from userConfig\n * (3) TANDEM_AUTH_TOKEN — explicit env override\n * Blank values are treated as absent so a blank plugin option does not mask an\n * explicit TANDEM_AUTH_TOKEN. Returns undefined when all absent (loopback mode,\n * no Authorization header sent).\n */\nexport function resolveAuthToken(override?: string): string | undefined {\n return resolveAuthTokenCandidate(override).token;\n}\n\nexport type AuthTokenSource =\n | \"explicit override\"\n | \"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\"\n | \"TANDEM_AUTH_TOKEN\";\n\nexport function resolveAuthTokenCandidate(\n override?: string,\n): { token: string; source: AuthTokenSource } | { token: undefined; source: undefined } {\n const candidates: Array<[AuthTokenSource, string | undefined]> = [\n [\"explicit override\", override],\n [\"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\", process.env.CLAUDE_PLUGIN_OPTION_AUTH_TOKEN],\n [\"TANDEM_AUTH_TOKEN\", process.env.TANDEM_AUTH_TOKEN],\n ];\n for (const [source, token] of candidates) {\n if (token !== undefined && token.trim() !== \"\") return { token, source };\n }\n return { token: undefined, source: undefined };\n}\n\n/** Regex for a valid Tandem auth token (32+ URL-safe alphanumeric chars). */\nconst VALID_TOKEN_RE = /^[A-Za-z0-9_\\-]{32,}$/;\n\n/** Guard so we only warn once per process (not on every SSE reconnect). */\nlet _warnedInvalidToken = false;\n\n/**\n * Header carrying the Claude Code session id on stdio-shim → Tandem-server\n * requests. Lets the server correlate channel traffic (replies, awareness,\n * error reports) with the originating Claude Code session for multi-session\n * disambiguation and diagnostics. Read-only metadata: routes that don't\n * consume it ignore the header, so no server-route schema change is required.\n */\nexport const CLAUDE_SESSION_HEADER = \"X-Claude-Session-Id\";\n\n/**\n * Bound on the session-id length we forward. Claude Code sets a UUID\n * (the same `session_id` passed to hooks; CHANGELOG 2.1.157 / 2.1.163), but we\n * treat the value as opaque and only guard against an oversized header line.\n */\nconst MAX_SESSION_ID_LEN = 256;\n\n/**\n * Printable-ASCII guard. A stray CR/LF (or other control char) would split the\n * header line — reject anything outside `\\x21`–`\\x7e`. The length bound is\n * enforced separately against MAX_SESSION_ID_LEN so there's one source of truth.\n */\nconst SESSION_ID_RE = /^[\\x21-\\x7e]+$/;\n\n/**\n * Resolve the Claude Code session id from the process environment.\n *\n * Claude Code injects `CLAUDE_CODE_SESSION_ID` (and `CLAUDECODE=1`) into the\n * environment of the stdio MCP server subprocesses it spawns — the Tandem\n * channel shim and stdio proxy are exactly such subprocesses (Claude Code\n * CHANGELOG 2.1.157; also forwarded on `--resume` since 2.1.163). The value\n * mirrors the `session_id` passed to hooks/Bash (a UUID).\n *\n * Returns `undefined` when the launch is not a Claude Code session\n * (`CLAUDECODE !== \"1\"` — so a value a user happened to export in their own\n * shell is never forwarded), the var is unset/blank, or it fails the\n * printable-ASCII length guard. Whitespace is trimmed and the value is\n * length-bounded so it can be forwarded as an HTTP header without\n * header-injection or oversize risk.\n */\nexport function resolveClaudeSessionId(): string | undefined {\n // Only trust the id inside an actual Claude Code launch. `CLAUDECODE=1` is\n // set alongside CLAUDE_CODE_SESSION_ID by the same CLI release; gating on it\n // avoids forwarding a value a user happened to export in their own shell.\n if (process.env.CLAUDECODE !== \"1\") return undefined;\n const raw = process.env.CLAUDE_CODE_SESSION_ID;\n if (raw === undefined) return undefined;\n const trimmed = raw.trim();\n if (trimmed === \"\" || trimmed.length > MAX_SESSION_ID_LEN) return undefined;\n if (!SESSION_ID_RE.test(trimmed)) return undefined;\n return trimmed;\n}\n\n/**\n * Merge the resolved Claude session id (if any) into a `HeadersInit` as the\n * `X-Claude-Session-Id` header. No-op when no session id is resolvable, so\n * callers can wrap every outbound request unconditionally.\n */\nexport function withClaudeSessionHeader(init?: RequestInit[\"headers\"]): Headers {\n const headers = new Headers(init);\n const sessionId = resolveClaudeSessionId();\n if (sessionId !== undefined) headers.set(CLAUDE_SESSION_HEADER, sessionId);\n return headers;\n}\n\n/**\n * Fetch wrapper that automatically injects `Authorization: Bearer <token>`\n * when a resolved Tandem auth token is set and valid.\n *\n * This is the forgiving variant — used by monitor/channel which may run in\n * loopback-only mode without a token. Invalid or absent tokens are silently\n * ignored (no exit-1). The strict validation lives in mcp-stdio.ts only.\n * When the token is set but fails validation, a one-time warning is emitted\n * so operators know why auth headers are absent.\n */\nexport async function authFetch(url: string, init?: RequestInit): Promise<Response> {\n // Always attach the Claude session header (no-op when not resolvable) so the\n // server can correlate channel traffic with the originating Claude session,\n // independent of whether an auth token is configured.\n const headers = withClaudeSessionHeader(init?.headers);\n const { token, source } = resolveAuthTokenCandidate();\n if (token !== undefined) {\n const trimmed = token.trim();\n if (VALID_TOKEN_RE.test(trimmed)) {\n headers.set(\"Authorization\", `Bearer ${trimmed}`);\n return fetch(url, { ...init, headers });\n }\n // Token is set but invalid — warn once so operators know why auth fails\n if (!_warnedInvalidToken) {\n _warnedInvalidToken = true;\n console.error(\n `[tandem] authFetch: ${source} is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header`,\n );\n }\n }\n return fetch(url, { ...init, headers });\n}\n","/**\n * Shared preflight check for stdio MCP subcommands.\n *\n * Both `tandem mcp-stdio` and `tandem channel` need a live Tandem server on\n * localhost before they can do anything useful. Two flavors:\n *\n * - `ensureTandemServer` — fail fast via stderr + exit(1) when the server\n * isn't reachable. Used by `tandem channel`, whose stdio transport can't\n * meaningfully respond on its own.\n * - `probeTandemServer` — returns a result without side effects. Used by\n * `tandem mcp-stdio`, which starts its stdio transport before preflight\n * so it can synthesize -32000 JSON-RPC errors for any in-flight request\n * before exiting (issue #336).\n */\n\nimport { resolveTandemUrl } from \"../shared/cli-runtime.js\";\n\nconst DEFAULT_TIMEOUT_MS = 2000;\n\nexport interface PreflightOptions {\n url?: string;\n timeoutMs?: number;\n}\n\n// Note: \"unreachable\" is a catch-all for any non-HTTP-status failure —\n// DNS, TLS, timeout, ECONNREFUSED, RST all land here. \"unhealthy\" is\n// strictly non-2xx responses from /health.\nexport type PreflightProbe =\n | { ok: true }\n | { ok: false; url: string; reason: string; kind: \"unreachable\" | \"unhealthy\" };\n\nexport async function probeTandemServer(opts: PreflightOptions = {}): Promise<PreflightProbe> {\n const url = resolveTandemUrl(opts.url);\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const res = await fetch(`${url}/health`, { signal: controller.signal });\n if (!res.ok) {\n return {\n ok: false,\n url,\n reason: `health endpoint returned HTTP ${res.status}`,\n kind: \"unhealthy\",\n };\n }\n return { ok: true };\n } catch (err) {\n return {\n ok: false,\n url,\n reason: err instanceof Error ? err.message : String(err),\n kind: \"unreachable\",\n };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport async function ensureTandemServer(opts: PreflightOptions = {}): Promise<void> {\n const probe = await probeTandemServer(opts);\n if (!probe.ok) {\n const guidance =\n probe.kind === \"unreachable\"\n ? \"Start the Tauri app or run `tandem start` on the host, then retry.\"\n : \"The Tandem server is running but unhealthy — check the host logs.\";\n process.stderr.write(\n `[tandem] Tandem server preflight failed at ${probe.url} (${probe.reason}).\\n` +\n `[tandem] ${guidance}\\n`,\n );\n process.exit(1);\n }\n}\n","/**\n * Tandem mcp-stdio subcommand — stdio ↔ Streamable HTTP JSON-RPC proxy.\n *\n * Claude Desktop's plugin loader bridges stdio MCP servers into sandboxed\n * sessions but not HTTP MCP servers, so plugin-cached stdio entries that\n * forward to the local HTTP MCP endpoint are the only supported way to\n * surface tandem_* tools into those sessions.\n *\n * Raw message forwarding: no handler registrations, no per-method logic.\n * Any message the upstream emits (tool results, notifications, future\n * methods we haven't heard of) reaches the stdio client unchanged.\n *\n * Error surfacing (issue #336): the stdio transport is started before\n * preflight and http.start(), and early messages are buffered until the\n * upstream is ready. If the upstream never becomes ready — or dies mid-\n * session — every in-flight request ID is answered with a synthesized\n * `-32000` JSON-RPC error instead of a silent stdio close. Plugin hosts\n * surface `-32000` as actionable; a silent close is what produces \"tools\n * never appear in Cowork\" with nothing diagnosable in the logs.\n *\n * Intentional: no reconnection logic. If the upstream HTTP server dies\n * mid-session, we synthesize errors for pending requests and exit 1.\n * The plugin loader will respawn us on the next tool call and preflight\n * will re-run with a fresh, accurate error if the server is still down.\n */\n\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n CLAUDE_SESSION_HEADER,\n redirectConsoleToStderr,\n resolveAuthTokenCandidate,\n resolveClaudeSessionId,\n resolveTandemUrl,\n} from \"../shared/cli-runtime.js\";\nimport { probeTandemServer } from \"./preflight.js\";\n\nredirectConsoleToStderr();\n\n// After preflight or http.start() fails we wait ~1.5s for any already-in-\n// flight `initialize` from the plugin loader to land on stdin and receive\n// a -32000 reply before tear-down. Sizing covers stdin-read lag between\n// preflight resolution and first message arrival — independent of\n// preflight's own fetch timeout.\nconst PREFLIGHT_GRACE_MS = 1500;\n\n// Per-request timeout. Node's setTimeout uses a 32-bit signed integer\n// internally — values above this constant are silently clamped to 1ms,\n// which would make every request immediately synthesize -32000.\nconst MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1\n\nexport function parseTimeoutMs(raw: string | undefined): number {\n if (raw !== undefined) {\n const parsed = parseInt(raw, 10);\n if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {\n return parsed;\n }\n // Note: parseInt(\"3e4\", 10) returns 3 (stops at 'e'), which passes validation.\n // Scientific notation lands here only when the leading integer is invalid.\n process.stderr.write(\n `[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer ≤ ${MAX_TIMEOUT_MS}; ignoring \"${raw}\", using 30000ms default\\n`,\n );\n }\n return 30_000;\n}\n\nconst STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);\n\n// Last-gasp handlers for truly unexpected crashes: write one diagnostic to\n// stderr before exit. Installed at module load; process.once bounds each\n// handler to a single fire. No -32000 synthesis here because pendingRequests\n// lives inside runMcpStdio()'s closure.\nprocess.once(\"uncaughtException\", (err: Error) => {\n process.stderr.write(\n `[tandem mcp-stdio] uncaughtException: ${err.message}\\n${err.stack ?? \"\"}\\n`,\n );\n process.exit(1);\n});\nprocess.once(\"unhandledRejection\", (reason: unknown) => {\n const detail = reason instanceof Error ? reason.message : String(reason);\n process.stderr.write(`[tandem mcp-stdio] unhandledRejection: ${detail}\\n`);\n process.exit(1);\n});\n\n/** Regex for a valid Tandem auth token (32+ URL-safe alphanumeric chars). */\nconst VALID_TOKEN_RE = /^[A-Za-z0-9_\\-]{32,}$/;\n\n/**\n * Validate the configured auth token if present.\n * Rules (invariant 4):\n * - If not set at all, or if empty/whitespace-only after trim → return null (loopback-only mode).\n * - \"Bearer \" prefix → exit 1 with \"double-prefix\" message.\n * - Must match /^[A-Za-z0-9_-]{32,}$/ (no whitespace, no newlines, no Bearer prefix).\n */\nexport function readAndValidateAuthToken(): string | null {\n const { token, source } = resolveAuthTokenCandidate();\n // Token not set at all, or empty/whitespace-only → loopback-only mode, no auth header, no exit.\n if (token === undefined) return null;\n const trimmed = token.trim();\n if (trimmed === \"\") return null;\n\n if (trimmed.startsWith(\"Bearer \")) {\n process.stderr.write(\n `[tandem mcp-stdio] ${source} is invalid (double-prefix: do not include 'Bearer ' prefix — supply the raw token only)\\n`,\n );\n process.exit(1);\n }\n\n if (!VALID_TOKEN_RE.test(trimmed)) {\n process.stderr.write(\n `[tandem mcp-stdio] ${source} is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\\n`,\n );\n process.exit(1);\n }\n\n return trimmed;\n}\n\nexport async function runMcpStdio(): Promise<void> {\n const baseUrl = resolveTandemUrl();\n const authToken = readAndValidateAuthToken();\n\n // Forward the Claude Code session id (when this proxy was spawned by Claude\n // Code) so the HTTP MCP server can correlate tool calls with the session.\n // No-op when not in a Claude Code launch — see resolveClaudeSessionId.\n const sessionId = resolveClaudeSessionId();\n const upstreamHeaders: Record<string, string> = {};\n if (authToken) upstreamHeaders.Authorization = `Bearer ${authToken}`;\n if (sessionId !== undefined) upstreamHeaders[CLAUDE_SESSION_HEADER] = sessionId;\n\n const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {\n requestInit: Object.keys(upstreamHeaders).length > 0 ? { headers: upstreamHeaders } : undefined,\n });\n const stdio = new StdioServerTransport();\n\n // On upstream failure we synthesize -32000 for every entry before exit.\n // Value is the per-request timeout handle so we can cancel it on response.\n const pendingRequests = new Map<string | number, ReturnType<typeof setTimeout>>();\n // Messages arriving before httpReady flips; either drained and forwarded\n // on success, or each request answered with -32000 on preflight/http-start\n // failure.\n const preReadyBuffer: JSONRPCMessage[] = [];\n let shuttingDown = false;\n let httpReady = false;\n\n async function sendErrorResponse(\n id: string | number,\n message: string,\n detail?: string,\n ): Promise<void> {\n const errorResponse: JSONRPCMessage = {\n jsonrpc: \"2.0\",\n id,\n error: {\n // -32000 is the implementation-defined server error range per\n // JSON-RPC 2.0 §5.1 — upstream unavailability is an application-\n // level condition, not a generic Internal Error.\n code: -32000,\n message,\n ...(detail !== undefined ? { data: { detail } } : {}),\n },\n };\n try {\n await stdio.send(errorResponse);\n } catch (err) {\n // stdio already torn down; log so synth failures during shutdown\n // (e.g., http.onclose racing stdio.onclose) aren't silently dropped —\n // a silent drop here would recreate exactly the failure mode this\n // module exists to prevent.\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `[tandem mcp-stdio] failed to send synthesized error for id ${id}: ${detail}\\n`,\n );\n }\n }\n\n function forwardToUpstream(msg: JSONRPCMessage): void {\n if (shuttingDown) return;\n const requestId = getRequestId(msg);\n if (requestId !== undefined) {\n // Clear any existing timer for this id (duplicate/retry scenario).\n const existing = pendingRequests.get(requestId);\n if (existing) clearTimeout(existing);\n\n const timeoutHandle = setTimeout(() => {\n // Atomic: delete returns false if synthesizePending already drained the map.\n if (!pendingRequests.delete(requestId)) return;\n void sendErrorResponse(\n requestId,\n \"Tandem HTTP upstream not responding (half-open)\",\n `No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`,\n );\n }, STDIO_REQUEST_TIMEOUT_MS);\n pendingRequests.set(requestId, timeoutHandle);\n }\n http.send(msg).catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}\\n`);\n if (requestId !== undefined) {\n const handle = pendingRequests.get(requestId);\n if (handle !== undefined) {\n pendingRequests.delete(requestId);\n clearTimeout(handle);\n void sendErrorResponse(requestId, \"Tandem HTTP upstream unreachable\", detail);\n }\n }\n });\n }\n\n async function synthesizeBuffered(message: string, detail?: string): Promise<void> {\n const buffered = preReadyBuffer.splice(0);\n const ids = buffered\n .map((msg) => getRequestId(msg))\n .filter((id): id is string | number => id !== undefined);\n for (const id of ids) {\n await sendErrorResponse(id, message, detail);\n }\n }\n\n async function synthesizePending(message: string, detail?: string): Promise<void> {\n if (pendingRequests.size === 0) return;\n const ids = [...pendingRequests.keys()];\n // Synchronous before any await: clear all timers + drain map atomically.\n // Timer callbacks that are already queued observe empty map and return early.\n for (const handle of pendingRequests.values()) clearTimeout(handle);\n pendingRequests.clear();\n await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));\n }\n\n const shutdown = async (\n code = 0,\n synth?: { message: string; detail?: string },\n ): Promise<never> => {\n if (!shuttingDown) {\n shuttingDown = true;\n // Hard deadline: if http.close() or stdio.close() hang (e.g., a half-\n // open upstream holding an SSE GET open), don't let cleanup block the\n // process exit indefinitely. .unref() means this timer doesn't itself\n // keep the event loop alive — fast paths resolve and call process.exit\n // below before the deadline fires; hung paths are forcibly terminated.\n setTimeout(() => process.exit(code), 2_000).unref();\n // Unconditionally drain all pending timers before any await — prevents\n // orphan timers from firing into the half-closed transport during\n // http.close() / stdio.close() awaits. synthesizePending will also\n // drain the map if synth is provided; the clearTimeout calls here are\n // defensive for the synth=undefined path (e.g., clean stdio.onclose).\n for (const handle of pendingRequests.values()) clearTimeout(handle);\n if (synth) {\n await synthesizeBuffered(synth.message, synth.detail);\n await synthesizePending(synth.message, synth.detail);\n }\n await http.close().catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] http.close failed: ${detail}\\n`);\n });\n await stdio.close().catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] stdio.close failed: ${detail}\\n`);\n });\n }\n process.exit(code);\n };\n\n // Plugin hosts typically send `initialize` immediately after spawn (MCP\n // lifecycle §initialization). Deferring shutdown by PREFLIGHT_GRACE_MS\n // lets that request land during the preflight/start window and receive\n // a -32000 reply rather than a silent stdio close. stdio.onclose\n // short-circuits this if the loader closes stdin first.\n function deferredShutdown(synth: { message: string; detail?: string }): void {\n setTimeout(() => void shutdown(1, synth), PREFLIGHT_GRACE_MS);\n }\n\n stdio.onmessage = (msg: JSONRPCMessage) => {\n if (!httpReady) {\n preReadyBuffer.push(msg);\n return;\n }\n forwardToUpstream(msg);\n };\n\n http.onmessage = (msg: JSONRPCMessage) => {\n if (shuttingDown) return;\n // Synchronous delete+clear FIRST — before stdio.send — to prevent the\n // per-request timer from firing in the window between response arrival\n // and stdio write completion (which would synthesize a false -32000 for\n // an id that already has a real response in flight).\n const responseId = getResponseId(msg);\n if (responseId !== undefined) {\n const handle = pendingRequests.get(responseId);\n if (handle !== undefined) {\n clearTimeout(handle);\n pendingRequests.delete(responseId);\n }\n }\n const sendHandler = (err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `[tandem mcp-stdio] stdio write failed for id ${responseId ?? \"<notification>\"}: ${detail}\\n`,\n );\n // Map entry already deleted above. Synthesize directly for this id,\n // then tear down — stdio is broken so other pending requests can't\n // be delivered either.\n if (responseId !== undefined) {\n void sendErrorResponse(responseId, \"Tandem stdio write failed\", detail);\n }\n void shutdown(1, {\n message: \"Tandem stdio write failed\",\n detail,\n });\n };\n try {\n stdio.send(msg).catch(sendHandler);\n } catch (err) {\n sendHandler(err);\n }\n };\n\n stdio.onerror = (err) => {\n process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}\\n${err.stack ?? \"\"}\\n`);\n };\n http.onerror = (err) => {\n const cause = (err as { cause?: unknown }).cause;\n process.stderr.write(\n `[tandem mcp-stdio] http error: ${err.message}\\n${err.stack ?? \"\"}${cause !== undefined ? `\\ncause: ${cause}` : \"\"}\\n`,\n );\n };\n\n stdio.onclose = () => {\n void shutdown(0);\n };\n http.onclose = () => {\n // We've observed the current @modelcontextprotocol/sdk (0.20.x) only\n // firing onclose from inside its own close() method — i.e., as a\n // consequence of *our* shutdown. The synth branch below is defensive\n // for future SDK versions that may propagate socket-death as onclose.\n // The `shuttingDown` guard prevents double-synth when shutdown() calls\n // http.close() itself.\n if (shuttingDown) return;\n void shutdown(1, {\n message: \"Tandem HTTP upstream closed unexpectedly\",\n detail: \"upstream connection dropped mid-session\",\n });\n };\n\n // Start stdio BEFORE preflight so any `initialize` that arrives during\n // the preflight window is captured and either forwarded once upstream is\n // ready, or answered with -32000 if upstream never comes up.\n await stdio.start();\n\n // The SDK's StdioServerTransport watches stdin for 'data' and 'error' only —\n // it does not call onclose when the plugin host closes stdin (EOF). Register\n // our own one-shot listener so that plugin-host close (stdin.end() / HUP)\n // triggers the same clean shutdown path as stdio.onclose does.\n process.stdin.once(\"end\", () => {\n void shutdown(0);\n });\n\n const probe = await probeTandemServer({ url: baseUrl });\n if (!probe.ok) {\n const guidance =\n probe.kind === \"unreachable\"\n ? \"Start the Tauri app or run `tandem start` on the host, then retry.\"\n : \"The Tandem server is running but unhealthy — check the host logs.\";\n process.stderr.write(\n `[tandem mcp-stdio] Tandem server preflight failed at ${probe.url} (${probe.reason}).\\n` +\n `[tandem mcp-stdio] ${guidance}\\n`,\n );\n const synthMessage =\n probe.kind === \"unreachable\"\n ? \"Tandem server not running. Start the Tauri app or run `tandem start`.\"\n : \"Tandem server unhealthy (check host logs).\";\n deferredShutdown({ message: synthMessage, detail: probe.reason });\n return;\n }\n\n // The current @modelcontextprotocol/sdk's StreamableHTTPClientTransport.start()\n // only creates an AbortController and returns synchronously — this catch is\n // defensive for future SDK versions that may perform real I/O during start().\n try {\n await http.start();\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}\\n`);\n deferredShutdown({ message: \"Tandem HTTP upstream failed to start\", detail });\n return;\n }\n httpReady = true;\n\n // Held to preserve forwarding semantics — push through the normal path\n // now that upstream is ready. Note: forwardToUpstream does not await the\n // http.send, so buffered requests POST in parallel. Plugin hosts wait\n // for `initialize` to resolve before sending follow-ups per MCP spec, so\n // the buffer is usually ≤1 entry; we don't enforce serial ordering here.\n const buffered = preReadyBuffer.splice(0);\n for (const msg of buffered) forwardToUpstream(msg);\n}\n\nexport function getRequestId(msg: JSONRPCMessage): string | number | undefined {\n const m = msg as { id?: unknown; method?: unknown };\n if (typeof m.method !== \"string\") return undefined;\n if (typeof m.id === \"string\" || typeof m.id === \"number\") return m.id;\n return undefined;\n}\n\nexport function getResponseId(msg: JSONRPCMessage): string | number | undefined {\n const m = msg as { id?: unknown; method?: unknown };\n if (typeof m.method === \"string\") return undefined;\n if (typeof m.id === \"string\" || typeof m.id === \"number\") return m.id;\n return undefined;\n}\n","/**\n * Single source of truth for Tandem's internal HTTP path strings.\n *\n * Server route registration (`src/server/mcp/{api,channel}-routes.ts`) and every\n * client/CLI/channel-shim/monitor caller import from here so a rename hits one file.\n */\n\n// --- Channel / event stream (SSE + push-back) -------------------------------\nexport const API_EVENTS = \"/api/events\";\nexport const API_NOTIFY_STREAM = \"/api/notify-stream\";\nexport const API_CHANNEL_AWARENESS = \"/api/channel-awareness\";\nexport const API_CHANNEL_ERROR = \"/api/channel-error\";\nexport const API_CHANNEL_REPLY = \"/api/channel-reply\";\nexport const API_CHANNEL_PERMISSION = \"/api/channel-permission\";\nexport const API_CHANNEL_PERMISSION_VERDICT = \"/api/channel-permission-verdict\";\nexport const API_LAUNCH_CLAUDE = \"/api/launch-claude\";\n\n// --- Mode / metadata --------------------------------------------------------\nexport const API_MODE = \"/api/mode\";\nexport const API_INFO = \"/api/info\";\n// Embedded `tandem doctor` report for the client's \"Copy diagnostics\" button.\n// Loopback-only (the report embeds absolute paths / PIDs).\nexport const API_DIAGNOSTICS = \"/api/diagnostics\";\n// Diagnostic health endpoint. Loopback callers additionally receive\n// `hasSession: boolean` — whether an MCP client transport is currently open\n// (an agent is connected, regardless of whether the auto-launcher spawned it).\nexport const API_HEALTH = \"/health\";\n\n// --- Document lifecycle -----------------------------------------------------\nexport const API_OPEN = \"/api/open\";\nexport const API_CLOSE = \"/api/close\";\nexport const API_SAVE = \"/api/save\";\nexport const API_RENAME = \"/api/rename\";\nexport const API_UPLOAD = \"/api/upload\";\nexport const API_SCRATCHPAD = \"/api/scratchpad\";\nexport const API_CONVERT = \"/api/convert\";\nexport const API_APPLY_CHANGES = \"/api/apply-changes\";\n// Raw-markdown source view/edit (#1021). GET returns the document's literal\n// markdown; POST replaces the Y.Doc content from a user-supplied markdown string.\nexport const API_DOCUMENT_RAW = \"/api/document/raw\";\nexport const API_DOCUMENT_RELOAD = \"/api/document/reload\";\n// Pre-overwrite document backups (#1086). GET lists a document's restorable\n// snapshots; POST restores one through the reload lifecycle.\nexport const API_BACKUPS = \"/api/backups\";\nexport const API_BACKUPS_RESTORE = \"/api/backups/restore\";\n// Resolve a `.docx` external-conflict prompt (#1069): keep the in-memory\n// unsaved edits (re-baseline) or reload fresh from the on-disk file.\nexport const API_DOCX_CONFLICT_RESOLVE = \"/api/docx-conflict/resolve\";\n\n// --- Annotations ------------------------------------------------------------\nexport const API_ANNOTATION_REPLY = \"/api/annotation-reply\";\nexport const API_REMOVE_ANNOTATION = \"/api/remove-annotation\";\n// Self-healing stale store.lock reclaim (#1077) — wired to the\n// store-readonly banner's Reclaim button.\nexport const API_STORE_RECLAIM_LOCK = \"/api/store/reclaim-lock\";\n\n// --- Chat -------------------------------------------------------------------\nexport const API_CHAT = \"/api/chat\";\n\n// --- Sessions (persisted-session management UI, #103) -----------------------\nexport const API_SESSIONS = \"/api/sessions\";\nexport const API_SESSIONS_DELETE = \"/api/sessions/delete\";\nexport const API_SESSIONS_CLEAR = \"/api/sessions/clear\";\n\n// --- Process lifecycle (#1088) ----------------------------------------------\n// Graceful shutdown trigger. The Tauri shell POSTs here before falling back to\n// a hard kill so the Node shutdown sequence (dirty-doc flush + session save)\n// runs on restart/update. Loopback-only; HTTP mode only.\nexport const API_SHUTDOWN = \"/api/shutdown\";\n\n// --- Licensing (#1116, ADR-040) ---------------------------------------------\n// GET status is loopback-full / LAN-scrubbed (the full state carries the\n// licensee name + opaque licenseId). POST activate (PR-C) gates on origin\n// allowlist + loopback inside the handler.\nexport const API_LICENSE_STATUS = \"/api/license/status\";\nexport const API_LICENSE_ACTIVATE = \"/api/license/activate\";\n\n// --- Auth -------------------------------------------------------------------\n// NOTE: the legacy `/api/setup` route was removed in #477 PR 3c-ii-c; setup is\n// now wizard-driven (`POST /api/integrations/apply`) or scriptable via\n// `tandem setup --apply`.\nexport const API_ROTATE_TOKEN = \"/api/rotate-token\";\n\n// --- Auto-launcher (Claude Code supervisor, #477 PR 4b) ---------------------\nexport const API_LAUNCHER_STATUS = \"/api/launcher/status\";\nexport const API_LAUNCHER_NONCE = \"/api/launcher/nonce\";\nexport const API_LAUNCHER_RELAUNCH = \"/api/launcher/relaunch\";\nexport const API_LAUNCHER_START_FRESH = \"/api/launcher/start-fresh\";\nexport const API_LAUNCHER_WORKING_DIRECTORY = \"/api/launcher/working-directory\";\n","/**\n * Shared fetch-with-timeout helper.\n *\n * Used by `src/monitor/index.ts` and `src/channel/` (event-bridge + run) to\n * give every outbound HTTP call a bounded deadline. Without this, a half-open\n * upstream wedges the caller silently — see #336 (silent failures) and #364\n * (event-bridge transport timeout symmetry).\n *\n * Pure native `fetch` + `AbortSignal.timeout` so it can be imported from any\n * surface without dragging in server deps. Routes through `authFetch` so the\n * resolved Tandem auth token is forwarded automatically when set.\n *\n * `describeFetchError` formats timeout aborts as `<endpoint> timed out after\n * <ms>ms` so logs name the hung endpoint instead of the generic\n * \"operation was aborted\" string from AbortError/TimeoutError.\n */\n\nimport { authFetch } from \"./cli-runtime.js\";\n\n/**\n * Fetch with a per-request deadline.\n *\n * **Do not use for SSE handshake-then-stream patterns** — applying a fetch-level\n * timeout to a streaming response also aborts the body `ReadableStream` when\n * the timeout fires, killing the stream at `timeoutMs`. Use a local\n * `AbortController` cleared after the handshake settles for that case.\n */\nexport async function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number,\n): Promise<Response> {\n const timeoutSignal = AbortSignal.timeout(timeoutMs);\n const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;\n return authFetch(url, { ...init, signal });\n}\n\n/**\n * Format a fetch error for logs. Recognizes `TimeoutError` / `AbortError`\n * (both names that `AbortSignal.timeout` and manual aborts produce) and tags\n * them with the endpoint + threshold so log lines name the hung request.\n */\nexport function describeFetchError(err: unknown, endpoint: string, timeoutMs: number): string {\n if (err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\")) {\n return `${endpoint} timed out after ${timeoutMs}ms`;\n }\n return err instanceof Error ? err.message : String(err);\n}\n\n/** True iff `err` is an AbortError or TimeoutError (the names produced by\n * AbortSignal.timeout and manual aborts). Channel callers re-throw these\n * through broad catches so timeouts surface as structured errors instead of\n * being swallowed as \"non-JSON response\" fake-success. */\nexport function isAbortOrTimeoutError(err: unknown): boolean {\n return err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\");\n}\n","function generateId(prefix: string): string {\n return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nexport function generateAnnotationId(): string {\n return generateId(\"ann\");\n}\n\nexport function generateMessageId(): string {\n return generateId(\"msg\");\n}\n\nexport function generateEventId(): string {\n return generateId(\"evt\");\n}\n\nexport function generateReplyId(): string {\n return generateId(\"rpl\");\n}\n\nexport function generateNotificationId(): string {\n return generateId(\"ntf\");\n}\n\nexport function generateAuthorshipId(author: \"user\" | \"claude\"): string {\n return generateId(author);\n}\n","/**\n * Event types for the Tandem → Claude Code channel.\n *\n * These events flow from browser-originated Y.Map changes through an SSE\n * endpoint to the channel shim, which pushes them into Claude Code as\n * `notifications/claude/channel` messages.\n *\n * This module lives in `src/shared/` so that `src/channel/` and\n * `src/monitor/` can import wire-protocol types without crossing the\n * server layer boundary.\n */\n\nimport type { AnnotationType, ReplyAuthor } from \"../types.js\";\n\n// --- Per-event payload interfaces ---\n\nexport interface AnnotationCreatedPayload {\n annotationId: string;\n annotationType: AnnotationType;\n content: string;\n textSnippet: string;\n hasSuggestedText?: boolean;\n}\n\nexport interface AnnotationAcceptedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface AnnotationDismissedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface ChatMessagePayload {\n messageId: string;\n text: string;\n replyTo: string | null;\n anchor: { from: number; to: number; textSnapshot: string } | null;\n /** Buffered selection context at the time the chat message was sent. */\n selection?: { from: number; to: number; selectedText: string } | { selectedText: string };\n}\n\nexport interface DocumentOpenedPayload {\n fileName: string;\n format: string;\n}\n\nexport interface DocumentClosedPayload {\n fileName: string;\n}\n\nexport interface AnnotationReplyPayload {\n annotationId: string;\n replyId: string;\n replyText: string;\n replyAuthor: ReplyAuthor;\n textSnippet: string;\n}\n\nexport interface DocumentSwitchedPayload {\n fileName: string;\n}\n\nexport interface AnnotationEditedPayload {\n annotationId: string;\n content: string;\n textSnippet: string;\n editedAt: number;\n}\n\n// --- Discriminated union ---\n\ninterface TandemEventBase {\n /** Timestamp-based unique ID for SSE `Last-Event-ID` reconnection. Format: `evt_<timestamp>_<rand>`. Roughly ordered but not strictly monotonic. */\n id: string;\n timestamp: number;\n /** Which document this event relates to (absent for global events). */\n documentId?: string;\n}\n\nexport type TandemEvent =\n | (TandemEventBase & { type: \"annotation:created\"; payload: AnnotationCreatedPayload })\n | (TandemEventBase & { type: \"annotation:accepted\"; payload: AnnotationAcceptedPayload })\n | (TandemEventBase & { type: \"annotation:dismissed\"; payload: AnnotationDismissedPayload })\n | (TandemEventBase & { type: \"annotation:reply\"; payload: AnnotationReplyPayload })\n | (TandemEventBase & { type: \"chat:message\"; payload: ChatMessagePayload })\n | (TandemEventBase & { type: \"document:opened\"; payload: DocumentOpenedPayload })\n | (TandemEventBase & { type: \"document:closed\"; payload: DocumentClosedPayload })\n | (TandemEventBase & { type: \"document:switched\"; payload: DocumentSwitchedPayload })\n | (TandemEventBase & { type: \"annotation:edited\"; payload: AnnotationEditedPayload });\n\n/** Union of all event type discriminants. */\nexport type TandemEventType = TandemEvent[\"type\"];\n\n// Re-export from shared utils (single ID generation pattern)\nexport { generateEventId } from \"../utils.js\";\n\n// --- Parse guard for SSE consumers ---\n\nconst VALID_EVENT_TYPES = new Set<TandemEventType>([\n \"annotation:created\",\n \"annotation:accepted\",\n \"annotation:dismissed\",\n \"annotation:edited\",\n \"annotation:reply\",\n \"chat:message\",\n \"document:opened\",\n \"document:closed\",\n \"document:switched\",\n]);\n\n/**\n * Validate a JSON-parsed value as a TandemEvent.\n * Used by the event-bridge to safely consume SSE data.\n */\nexport function parseTandemEvent(raw: unknown): TandemEvent | null {\n if (\n typeof raw !== \"object\" ||\n raw === null ||\n !(\"id\" in raw) ||\n typeof (raw as Record<string, unknown>).id !== \"string\" ||\n !(\"type\" in raw) ||\n !VALID_EVENT_TYPES.has((raw as Record<string, unknown>).type as TandemEventType) ||\n !(\"timestamp\" in raw) ||\n typeof (raw as Record<string, unknown>).timestamp !== \"number\" ||\n !(\"payload\" in raw) ||\n typeof (raw as Record<string, unknown>).payload !== \"object\"\n ) {\n return null;\n }\n return raw as TandemEvent;\n}\n\n/**\n * Convert a TandemEvent into a human-readable string for the channel `content` field.\n * Claude sees this text inside `<channel source=\"tandem-channel\">` tags.\n */\nexport function formatEventContent(event: TandemEvent): string {\n const doc = event.documentId ? ` [doc: ${event.documentId}]` : \"\";\n\n switch (event.type) {\n case \"annotation:created\": {\n const { annotationType, content, textSnippet, hasSuggestedText } = event.payload;\n const snippet = textSnippet ? ` on \"${textSnippet}\"` : \"\";\n const label = hasSuggestedText ? \"replacement\" : annotationType;\n return `User created ${label}${snippet}: ${content || \"(no content)\"}${doc}`;\n }\n case \"annotation:accepted\": {\n const { annotationId, textSnippet } = event.payload;\n return `User accepted annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"annotation:dismissed\": {\n const { annotationId, textSnippet } = event.payload;\n return `User dismissed annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"annotation:edited\": {\n const { content } = event.payload;\n return `User edited annotation: \"${content}\"${doc}`;\n }\n case \"annotation:reply\": {\n const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;\n const who = replyAuthor === \"claude\" ? \"Claude\" : \"User\";\n const snippet = textSnippet ? ` (on \"${textSnippet}\")` : \"\";\n return `${who} replied to annotation ${annotationId}${snippet}: ${replyText}${doc}`;\n }\n case \"chat:message\": {\n const { text, replyTo, selection } = event.payload;\n const reply = replyTo ? ` (replying to ${replyTo})` : \"\";\n const sel =\n selection && selection.selectedText\n ? ` [selection: \"${selection.selectedText}\"${\"from\" in selection ? ` (${selection.from}-${selection.to})` : \"\"}]`\n : \"\";\n return `User says${reply}: ${text}${sel}${doc}`;\n }\n case \"document:opened\": {\n const { fileName, format } = event.payload;\n return `User opened document: ${fileName} (${format})${doc}`;\n }\n case \"document:closed\": {\n const { fileName } = event.payload;\n return `User closed document: ${fileName}${doc}`;\n }\n case \"document:switched\": {\n const { fileName } = event.payload;\n return `User switched to document: ${fileName}${doc}`;\n }\n default: {\n const _exhaustive: never = event;\n void _exhaustive;\n return `Unknown event${doc}`;\n }\n }\n}\n\n/**\n * Build the `meta` record for a channel notification.\n * Keys use underscores only (Channels API silently drops hyphenated keys).\n */\nexport function formatEventMeta(event: TandemEvent): Record<string, string> {\n const meta: Record<string, string> = {\n event_type: event.type,\n };\n if (event.documentId) meta.document_id = event.documentId;\n\n switch (event.type) {\n case \"annotation:created\":\n case \"annotation:accepted\":\n case \"annotation:dismissed\":\n meta.annotation_id = event.payload.annotationId;\n break;\n case \"annotation:edited\":\n meta.annotation_id = event.payload.annotationId;\n meta.edited_at = String(event.payload.editedAt);\n break;\n case \"annotation:reply\":\n meta.annotation_id = event.payload.annotationId;\n meta.reply_id = event.payload.replyId;\n break;\n case \"chat:message\":\n meta.message_id = event.payload.messageId;\n if (event.payload.selection?.selectedText) meta.has_selection = \"true\";\n break;\n case \"document:opened\":\n case \"document:closed\":\n case \"document:switched\":\n break;\n default: {\n const _exhaustive: never = event;\n void _exhaustive;\n break;\n }\n }\n\n return meta;\n}\n","/**\n * Shared types for the position/coordinate system.\n *\n * Three coordinate systems exist in Tandem:\n * 1. Flat text offsets — server-side, includes heading prefixes and \\n separators\n * 2. ProseMirror positions — client-side, structural node positions\n * 3. Yjs RelativePositions — CRDT-anchored, survive concurrent edits\n *\n * This module defines the shared vocabulary. Environment-specific logic lives in:\n * - src/server/positions.ts (Y.Doc operations)\n * - src/client/positions.ts (ProseMirror operations)\n */\n\n// ---------------------------------------------------------------------------\n// Branded types — compile-time guards against mixing coordinate systems\n// ---------------------------------------------------------------------------\n\ndeclare const FlatOffsetBrand: unique symbol;\ndeclare const PmPosBrand: unique symbol;\ndeclare const SerializedRelPosBrand: unique symbol;\n\n/** Flat text offset (includes heading prefixes & \\n separators). Server/MCP boundary. */\nexport type FlatOffset = number & { readonly [FlatOffsetBrand]: true };\n\n/** ProseMirror position (structural node boundaries). Client-side only. */\nexport type PmPos = number & { readonly [PmPosBrand]: true };\n\n/** JSON-serialized Y.js RelativePosition. Opaque — only created/consumed by position modules. */\nexport type SerializedRelPos = unknown & { readonly [SerializedRelPosBrand]: true };\n\n// ---------------------------------------------------------------------------\n// Factory functions — cast raw values into branded types\n// ---------------------------------------------------------------------------\n\nexport const toFlatOffset = (n: number): FlatOffset => n as FlatOffset;\nexport const toPmPos = (n: number): PmPos => n as PmPos;\nexport const toSerializedRelPos = (json: unknown): SerializedRelPos => json as SerializedRelPos;\n\n// ---------------------------------------------------------------------------\n// Range and result types\n// ---------------------------------------------------------------------------\n\n/** Flat-offset range used by MCP tools and annotations. */\nexport interface DocumentRange {\n from: FlatOffset;\n to: FlatOffset;\n}\n\n/** CRDT-anchored range that survives concurrent edits. Serialized via Y.relativePositionToJSON(). */\nexport interface RelativeRange {\n fromRel: SerializedRelPos;\n toRel: SerializedRelPos;\n}\n\n/** Result of validating a flat-offset range against a document. */\nexport type RangeValidation =\n | { ok: true; range: DocumentRange }\n | { ok: false; code: \"RANGE_GONE\" }\n | { ok: false; code: \"RANGE_MOVED\"; resolvedFrom: FlatOffset; resolvedTo: FlatOffset }\n | { ok: false; code: \"INVALID_RANGE\"; message: string }\n | { ok: false; code: \"HEADING_OVERLAP\" };\n\n/** Result of anchoredRange: validated flat + CRDT-anchored range ready to store on an Annotation. */\nexport type AnchoredRangeResult =\n | { ok: true; fullyAnchored: true; range: DocumentRange; relRange: RelativeRange }\n | { ok: true; fullyAnchored: false; range: DocumentRange; relRange?: undefined };\n\n/** A resolved element position inside a Y.Doc XmlFragment. */\nexport interface ElementPosition {\n elementIndex: number;\n /** Character offset within the element's text. Always 0 when clampedFromPrefix is true. */\n textOffset: number;\n /** True if the original offset fell inside a heading prefix and was clamped to 0 */\n clampedFromPrefix: boolean;\n}\n\n/** Resolution method used by annotationToPmRange, for diagnostic observability. */\nexport type ResolutionMethod = \"rel\" | \"flat\";\n\n/** Result of resolving an annotation to ProseMirror positions. */\nexport interface PmRangeResult {\n from: PmPos;\n to: PmPos;\n /** Which coordinate path was used to resolve the range. */\n method: ResolutionMethod;\n}\n\n/**\n * Tagged variant for the outcome of `refreshRange` (ADR-032).\n *\n * Each kind names a distinct resolution path the function previously\n * collapsed into a bare `Annotation` return:\n * - `ok` — annotation unchanged; range was already healthy\n * - `updated` — `relRange` resolved to new offsets; flat `range` was rewritten\n * - `attached` — annotation had no `relRange`; one was computed from the flat range\n * - `repaired` — dead `relRange` was re-anchored from the flat range\n * - `degraded` — dead `relRange` was stripped; annotation is now flat-only and will\n * be lazy-attached on a later read if conditions improve\n * - `failed` — `from > to` after refresh (\"inverted CRDT range\" — concurrent\n * edits moved the anchors past each other). Annotation is returned\n * unchanged for the caller's inspection.\n */\nexport type RefreshResult = {\n kind: \"ok\" | \"updated\" | \"attached\" | \"repaired\" | \"degraded\" | \"failed\";\n annotation: import(\"../types.js\").Annotation;\n};\n","import { z } from \"zod\";\nimport type { DocumentRange, RelativeRange } from \"./positions/types.js\";\n\n// Canonical definitions live in the positions module; re-exported for backward compatibility.\nexport type {\n DocumentRange,\n FlatOffset,\n PmPos,\n RelativeRange,\n SerializedRelPos,\n} from \"./positions/types.js\";\nexport { toFlatOffset, toPmPos, toSerializedRelPos } from \"./positions/types.js\";\n\n// --- Zod schemas (source of truth) ---\n\nexport const AnnotationTypeSchema = z.enum([\"highlight\", \"note\", \"comment\"]);\n\nexport const AnnotationStatusSchema = z.enum([\"pending\", \"accepted\", \"dismissed\"]);\nexport const HighlightColorSchema = z.enum([\"yellow\", \"green\", \"blue\", \"pink\"]);\nexport const SeveritySchema = z.enum([\"info\", \"warning\", \"error\", \"success\"]);\nexport const TandemModeSchema = z.enum([\"solo\", \"tandem\"]);\nexport const AuthorSchema = z.enum([\"user\", \"claude\", \"import\"]);\n/** Reply authors. `import` carries Word-comment reply threads (#1000); such replies are user-private. */\nexport const ReplyAuthorSchema = z.enum([\"user\", \"claude\", \"import\"]);\nexport const AnnotationActionSchema = z.enum([\"accept\", \"dismiss\"]);\nexport const ExportFormatSchema = z.enum([\"markdown\", \"json\"]);\nexport const DocumentFormatSchema = z.enum([\"md\", \"txt\", \"html\", \"docx\"]);\nexport const ToolErrorCodeSchema = z.enum([\n \"RANGE_GONE\",\n \"RANGE_MOVED\",\n \"FILE_LOCKED\",\n \"FILE_NOT_FOUND\",\n \"NO_DOCUMENT\",\n \"INVALID_RANGE\",\n \"INVALID_ARGUMENT\",\n \"NOT_FOUND\",\n \"ANNOTATION_RESOLVED\",\n \"FORMAT_ERROR\",\n \"PERMISSION_DENIED\",\n]);\n\n/**\n * Identifier strings the channel shim or monitor can POST to\n * `/api/channel-error` on terminal failure. The server logs them; defining\n * them as a closed set lets call sites import the constants instead of\n * free-form strings, and the route handler can validate before logging.\n */\nexport const ChannelErrorCodeSchema = z.enum([\"CHANNEL_CONNECT_FAILED\", \"MONITOR_CONNECT_FAILED\"]);\nexport type ChannelErrorCode = z.infer<typeof ChannelErrorCodeSchema>;\nexport const CHANNEL_CONNECT_FAILED: ChannelErrorCode = \"CHANNEL_CONNECT_FAILED\";\nexport const MONITOR_CONNECT_FAILED: ChannelErrorCode = \"MONITOR_CONNECT_FAILED\";\n\n// --- Derived TypeScript types ---\n\nexport type AnnotationType = z.infer<typeof AnnotationTypeSchema>;\nexport type AnnotationStatus = z.infer<typeof AnnotationStatusSchema>;\nexport type TandemMode = z.infer<typeof TandemModeSchema>;\nexport type HighlightColor = z.infer<typeof HighlightColorSchema>;\nexport type Severity = z.infer<typeof SeveritySchema>;\nexport type ReplyAuthor = z.infer<typeof ReplyAuthorSchema>;\n\n// --- Reply types ---\n\nexport interface AnnotationReply {\n id: string;\n annotationId: string;\n author: ReplyAuthor;\n text: string;\n timestamp: number;\n editedAt?: number;\n /**\n * ADR-027/#1000: when true, this reply is user-private and must NEVER reach\n * Claude — not via the channel, `tandem_getAnnotations`, or\n * `tandem_exportAnnotations`. Set at creation for replies authored on a note\n * and for imported Word replies. Privacy is a durable property of the reply,\n * not of the parent's current type, so a later note→comment promotion cannot\n * back-publish it. Claude-facing reads strip it via `channelVisibleReplies`.\n */\n private?: boolean;\n /**\n * For `author: \"import\"` replies: the original Word reviewer name, shown as a\n * byline in the client. Mirrors `Annotation.importSource.author`. Stored at\n * rest in the durable JSON; never serialized to any Claude-facing surface.\n */\n importAuthor?: string;\n /**\n * Durable-annotation last-writer-wins counter. Server-internal field\n * mirrored from the on-disk envelope schema (see\n * `src/server/annotations/schema.ts` `AnnotationReplyRecordV1`). Optional\n * here so client code and legacy in-memory state that predates the durable\n * store don't trip TS. Every server-side write bumps this; legacy entries\n * lacking `rev` are treated as `rev: 0` on merge.\n */\n rev?: number;\n}\n\n// --- Annotation types ---\n\ninterface AnnotationBase {\n id: string;\n author: \"user\" | \"claude\" | \"import\";\n range: DocumentRange;\n /** CRDT-anchored range that survives edits. Falls back to `range` if absent. */\n relRange?: RelativeRange;\n content: string;\n status: AnnotationStatus;\n timestamp: number;\n /** Snapshot of the annotated document text at creation time. Truncated to 200 chars. */\n textSnapshot?: string;\n /** Timestamp of last edit to the annotation content. */\n editedAt?: number;\n /**\n * Durable-annotation last-writer-wins counter. Server-internal field\n * mirrored from the on-disk envelope schema (see\n * `src/server/annotations/schema.ts` `AnnotationRecordV1`). Optional here\n * so legacy session-restored state (pre-durable-store) and client code\n * that doesn't care about durability still type-check. Every server-side\n * user-intent write bumps this; entries lacking `rev` are treated as\n * `rev: 0` by the merge/sync code.\n */\n rev?: number;\n /** When true, marks this annotation as created during Solo mode. Consumers use this to hold back display until mode changes. */\n heldInSolo?: boolean;\n /** Audience: 'private' = personal (note/highlight), 'outbound' = visible to Claude. Derived by AR1 migration on read for legacy annotations. */\n audience?: \"private\" | \"outbound\";\n /** Set when this annotation was promoted from a note via \"Send to Claude\". */\n promotedFrom?: \"note\";\n /**\n * For import-author annotations: original Word author and source file.\n * `commentId` is the original Word `w:id` from `comments.xml` (#1068) —\n * reused on .docx export so a promoted Word comment keeps its identity\n * across save → re-open (deterministic `importAnnotationId` dedup).\n */\n importSource?: { author: string; file: string; commentId?: string };\n}\n\n/**\n * Discriminated union for annotations. Three canonical types:\n * - `highlight` — visual marker with color, not sent to Claude\n * - `note` — personal text annotation, findable but Claude doesn't act\n * - `comment` — text for Claude; optionally carries `suggestedText` (replacement)\n */\nexport type Annotation =\n | (AnnotationBase & {\n type: \"highlight\";\n color?: HighlightColor;\n suggestedText?: undefined;\n })\n | (AnnotationBase & {\n type: \"note\";\n color?: undefined;\n suggestedText?: undefined;\n })\n | (AnnotationBase & {\n type: \"comment\";\n color?: undefined;\n suggestedText?: string;\n });\n\n/**\n * Returns true for annotations that should be reviewed (accepted/dismissed).\n * User-authored notes are personal and never review targets.\n * Import-authored (.docx Word comments) ARE review targets — the primary docx use case.\n */\nexport function isReviewTarget(a: Annotation): boolean {\n return a.author !== \"user\";\n}\n\n/** Convenience: pending status AND a review target — used at bulk-action and keyboard-nav callsites. */\nexport function isPendingReviewTarget(a: Annotation): boolean {\n return a.status === \"pending\" && isReviewTarget(a);\n}\n\n/**\n * Authorship tracking range stored in Y.Map('authorship').\n * Uses the same flat-offset coordinate system as annotations.\n * RelativePositions anchor the range to survive concurrent edits.\n */\nexport interface AuthorshipRange {\n id: string;\n author: \"user\" | \"claude\";\n range: DocumentRange;\n /** CRDT-anchored range for edit survival. */\n relRange?: RelativeRange;\n /** Timestamp of when this range was created. */\n timestamp: number;\n}\n\nexport interface AnchoredRange {\n start: { nodeId: string; offset: number };\n end: { nodeId: string; offset: number };\n textSnapshot: string;\n stale: boolean;\n}\n\nexport interface OverlayEntry {\n id: string;\n overlayId: string;\n range: AnchoredRange;\n score: string;\n numericScore?: number;\n detail: {\n summary: string;\n explanation: string;\n suggestion?: string;\n severity?: Severity;\n references?: Array<{ label: string; url?: string; documentNodeId?: string }>;\n };\n dismissed: boolean;\n accepted?: boolean;\n data: Record<string, unknown>;\n}\n\nexport interface OverlayDefinition {\n id: string;\n label: string;\n type: string;\n visible: boolean;\n mode: \"snapshot\" | \"live\";\n entries: OverlayEntry[];\n createdAt: number;\n updatedAt: number;\n}\n\nexport interface DocumentGroup {\n id: string;\n name: string;\n documents: DocumentInfo[];\n createdAt: number;\n}\n\nexport interface DocumentInfo {\n id: string;\n filePath: string;\n fileName: string;\n format: z.infer<typeof DocumentFormatSchema>;\n tokenEstimate: number;\n pageEstimate: number;\n readOnly: boolean;\n}\n\nexport interface ToolSuccess<T = unknown> {\n error: false;\n data: T;\n version?: string;\n}\n\nexport interface ToolError {\n error: true;\n code: z.infer<typeof ToolErrorCodeSchema>;\n message: string;\n details?: Record<string, unknown>;\n}\n\nexport type ToolResponse<T = unknown> = ToolSuccess<T> | ToolError;\n\n/** Claude's awareness state as stored in Y.Map('awareness') key 'claude' */\nexport interface ClaudeAwareness {\n status: string;\n timestamp: number;\n active: boolean;\n focusParagraph: number | null;\n /** Flat character offset for character-level cursor positioning. */\n focusOffset: number | null;\n /**\n * Typing-presence indicator (#651). When set, Claude is actively executing\n * an MCP tool. `annotationId` (when present) lets per-card UI render an\n * inline typing indicator; an absent annotationId indicates a generic\n * \"Claude is working\" state surfaced in the status bar.\n *\n * ADR-027: never broadcast `annotationId` for `type === \"note\"` annotations\n * (the server middleware enforces this on write).\n */\n working?: {\n tool: string;\n annotationId?: string;\n /** Display-only wall-clock start time (ms). NOT an ownership key — see `token`. */\n startedAt: number;\n /**\n * Monotonic, collision-free ownership token (#823). Two same-doc tool calls\n * in the same millisecond would collide on `startedAt`; the clear path keys\n * identity on this counter instead so finishing one handler never wipes\n * another's still-active marker. Optional for back-compat with snapshots\n * written before #823.\n */\n token?: number;\n } | null;\n}\n\nexport interface SessionData {\n filePath: string;\n format: string;\n ydocState: string; // Base64-encoded Y.encodeStateAsUpdate()\n sourceFileMtime: number; // Source file mtime at save — detect external changes on resume\n lastAccessed: number;\n /**\n * True when the Y.Doc held unsaved (not-written-to-disk) body edits at\n * session-save time (#1069). Drives the `.docx` restore-vs-reload prompt:\n * a dirty `.docx` session is the ONLY copy of those edits (binary formats\n * never auto-save to disk), so restore keeps it even when the source file\n * changed, and the user is prompted to keep or reload. Absent/false on\n * sessions written before this field existed — treated as clean.\n */\n dirty?: boolean;\n}\n\n/**\n * Per-document external-conflict state (#1069, `.docx` only). Stored in\n * Y_MAP_DOCUMENT_META under Y_MAP_EXTERNAL_CONFLICT while the document's\n * unsaved edits diverge from the on-disk source.\n */\nexport interface ExternalConflictState {\n /**\n * - \"external-edit\": the source file changed on disk while the open document\n * holds unsaved edits (file-watcher detection). Explicit save is blocked by\n * the external-modification guard until resolved.\n * - \"unsaved-restore\": a session carrying unsaved edits was restored on\n * reopen/restart; the in-memory document diverges from the on-disk file.\n */\n kind: \"external-edit\" | \"unsaved-restore\";\n /** True when the on-disk mtime diverged from the session/save baseline. Always true for \"external-edit\". */\n diskChanged: boolean;\n detectedAt: number;\n}\n\n/**\n * Per-document docx fidelity report (#1145, `.docx` only) — the \"honesty layer\".\n * Tells the user what won't round-trip BEFORE they invest edits. Stored under\n * Y_MAP_DOCUMENT_META at Y_MAP_FIDELITY_REPORT; server write-only, client reads\n * it to render a calm, self-erasing notice (hidden while both lists are empty).\n */\nexport interface FidelityReport {\n /**\n * Word features mammoth dropped on import (footnotes, headers/footers,\n * tracked changes, custom styles — the round-trip ceiling). Set at open and\n * re-set on every re-import (force-reload, file-watcher reload).\n */\n importLosses: string[];\n /**\n * Content the export downgraded on the most recent save (unsupported blocks,\n * non-`data:` images). Refreshed each binary save; reset by a re-import.\n * These are ANNOUNCED, expected downgrades — rendered as a calm/info notice.\n */\n exportDowngrades: string[];\n /**\n * Post-write verification advisories (#1123 Phase 0e). Distinct from\n * `exportDowngrades`: these flag content the save may have lost UNEXPECTEDLY\n * (a comment or footnote body that didn't survive a verify reimport, a\n * gross-but-not-blocking text-retention shortfall) — a louder, warning-level\n * signal with a restore affordance, never folded into the \"N features\n * simplified\" count. CONTENT-FREE by construction: fixed strings + counts\n * only, never document text (the advisory is also Claude-visible via the\n * `tandem_save` MCP result). Optional for forward-compat: pre-0e reports lack\n * it, so every reader uses `?? []`. Refreshed each binary save; reset by a\n * re-import.\n */\n integrityWarnings?: string[];\n /** ms epoch of the last update. */\n updatedAt: number;\n}\n\n/**\n * A reconstructed Word footnote body (#1123 Tier-A #3 PR 2). Captured from\n * `word/footnotes.xml` on import, stored off-fragment under\n * Y_MAP_DOCUMENT_META at Y_MAP_FOOTNOTE_BODIES (keyed by the OOXML footnote id,\n * the same id mammoth puts in its `#footnote-N` href), and re-emitted as a real\n * `<w:footnote>` on export. Server write-only, opaque to the client and Claude.\n */\nexport interface FootnoteBody {\n /**\n * Plain body text. Rich body formatting (bold/italic, multi-paragraph) is\n * deliberately flattened to plain text in PR 2 and reported honestly via\n * `hadFormatting`; rich-body fidelity is a deferred fast-follow.\n */\n text: string;\n /**\n * Whether the source OOXML body carried formatting we drop on import\n * (`<w:b>`/`<w:i>`/`<w:u>`/`<w:hyperlink>` or >1 `<w:p>`). Drives a count-only\n * honesty line — NEVER thread the body text through the loss-line path (it\n * bypasses the mammoth-message redaction; see `footnoteLossLines`).\n */\n hadFormatting: boolean;\n}\n\n/** Text selection snapshot captured when opening chat, attached to the next outgoing ChatMessage as its anchor. */\nexport interface CapturedAnchor extends DocumentRange {\n textSnapshot: string;\n}\n\n/** Chat message between user and Claude, stored in Y.Map('chat') on CTRL_ROOM */\nexport interface ChatMessage {\n id: string;\n author: \"user\" | \"claude\";\n text: string;\n timestamp: number;\n documentId?: string;\n anchor?: CapturedAnchor;\n replyTo?: string;\n read: boolean;\n}\n\n/** Server-to-client ephemeral notification (toast). Not persisted via CRDT. */\nexport interface TandemNotification {\n id: string;\n type:\n | \"annotation-error\"\n | \"save-error\"\n | \"session-restored\"\n | \"general-error\"\n | \"file-reloaded\"\n | \"review-pending\"\n | \"external-conflict\"\n | \"launcher\";\n severity: \"info\" | \"warning\" | \"error\";\n message: string;\n documentId?: string;\n dedupKey?: string;\n timestamp: number;\n toolName?: string;\n errorCode?: string;\n}\n","/**\n * Shared SSE consumer for the Tandem channel shim and plugin monitor.\n *\n * Extracted in #282 to deduplicate ~140 lines of retry / frame-parse /\n * awareness / mode-cache logic that used to be copy-pasted between\n * `src/channel/event-bridge.ts` and `src/monitor/index.ts`.\n *\n * Callers inject their per-event delivery mechanism via the `onEvent`\n * callback. The shared module never touches the MCP SDK or stdout — that\n * preserves the MCP-free constraint and keeps the channel shim and monitor\n * free to evolve their delivery surfaces independently.\n *\n * Per-request timeouts (#364) mirror the original monitor pattern: every\n * outbound fetch has a bounded deadline, the SSE body has an inactivity\n * watchdog, and the parse buffer is capped so a malformed upstream can't\n * OOM us. Without these, a half-open Tandem server wedges the consumer\n * silently.\n *\n * Mode-cache policy: stale-preserving. Once a real mode has been observed\n * from `/api/mode`, a transient fetch failure (network error or non-OK)\n * NEVER changes the cached mode — the consumer keeps reporting the last\n * successfully-fetched value. The mode only changes when the server reports\n * a different mode (i.e. the user actually toggled Solo/Tandem). This holds\n * for ALL failure paths, including the startup warm-up / first-fetch path:\n * a failure after a successful fetch can never downgrade a known mode.\n * The hardcoded `TANDEM_MODE_DEFAULT` is used ONLY on a genuine cold start —\n * a failure before any successful fetch has ever landed. The channel and\n * monitor previously diverged here (channel failed open to \"tandem\", monitor\n * failed closed to \"solo\"); both flipped the mode to a default on a hiccup.\n * Neither honored the user directive that mode must not change unless the\n * user changes it — stale-preserving does.\n *\n * Retry policy: exponential backoff with stable-uptime reset (monitor's\n * pattern). The channel previously reset retries on every successful event\n * — bringing exponential backoff + stable uptime gives the channel the\n * same robustness guarantees.\n *\n * Frame-skip policy: advance `lastEventId` past malformed-JSON and\n * failed-validation frames (channel's \"advance past garbage\" pattern). The\n * monitor previously did NOT advance on these — its catch / `!event`\n * branches just `continue`d, so a permanently-unparseable frame would be\n * replayed from `Last-Event-ID` on every reconnect forever (an infinite\n * parse-fail loop). Unifying on the channel's semantics fixes that latent\n * infinite re-delivery bug.\n */\n\nimport { API_CHANNEL_AWARENESS, API_CHANNEL_ERROR, API_EVENTS, API_MODE } from \"./api-paths.js\";\nimport { authFetch } from \"./cli-runtime.js\";\nimport {\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n CHANNEL_ERROR_REPORT_TIMEOUT_MS,\n CHANNEL_MAX_RETRIES,\n CHANNEL_MAX_SSE_BUFFER_BYTES,\n CHANNEL_MODE_FETCH_TIMEOUT_MS,\n CHANNEL_RETRY_DELAY_MS,\n CHANNEL_SSE_INACTIVITY_TIMEOUT_MS,\n TANDEM_MODE_DEFAULT,\n} from \"./constants.js\";\nimport type { TandemEvent } from \"./events/types.js\";\nimport { parseTandemEvent } from \"./events/types.js\";\nimport { describeFetchError, fetchWithTimeout } from \"./fetch-with-timeout.js\";\nimport { type ChannelErrorCode, type TandemMode, TandemModeSchema } from \"./types.js\";\n\nconst AWARENESS_DEBOUNCE_MS = 500;\nconst AWARENESS_CLEAR_MS = 3000;\nconst MODE_CACHE_TTL_MS = 2000;\nconst STABLE_CONNECTION_MS = 60_000; // Reset retries after this much continuous uptime\nconst RETRY_MAX_DELAY_MS = 30_000; // Exponential backoff cap\n\nexport interface EventConsumerOptions {\n /** Base URL of the Tandem server (no trailing slash). */\n tandemUrl: string;\n /** Log prefix used in stderr lines (e.g. `[Channel]` or `[Monitor]`). */\n logPrefix: string;\n /** Error code POSTed to /api/channel-error on retry exhaustion. */\n errorCode: ChannelErrorCode;\n /**\n * Per-event delivery callback. Called for every parsed, non-suppressed\n * SSE event. If this throws or rejects, `lastEventId` is NOT advanced and\n * the stream is torn down so the retry loop reconnects with the previous\n * `Last-Event-ID` — the server replays the dropped event.\n */\n onEvent: (event: TandemEvent, eventId: string | undefined) => Promise<void> | void;\n /**\n * Optional hook called after the retry-exhaustion error POST returns but\n * before `process.exit(1)`. The monitor uses it to write the visible\n * \"disconnected\" notice to stdout. Default is a noop.\n */\n onExhaustion?: () => void;\n}\n\n// --- Module-level state ---\n//\n// Kept at module scope (not function-local) so `flushFinalAwareness` (called\n// from the monitor's signal handler) can drain in-flight awareness POSTs\n// and send the shutdown clear. `_resetSseConsumerStateForTests` clears\n// every byte of state below in one call.\n\nconst shutdownTimers: {\n awarenessTimer: ReturnType<typeof setTimeout> | null;\n clearAwarenessTimer: ReturnType<typeof setTimeout> | null;\n lastDocumentId: string | null;\n} = { awarenessTimer: null, clearAwarenessTimer: null, lastDocumentId: null };\n\n/** Outstanding awareness POSTs — drained on shutdown so the server's last\n * seen awareness is the shutdown \"active:false\", not a racing update. */\nconst outstandingAwareness = new Set<Promise<unknown>>();\nfunction trackAwareness(p: Promise<unknown>): void {\n outstandingAwareness.add(p);\n p.finally(() => outstandingAwareness.delete(p));\n}\n\nlet cachedMode: TandemMode = TANDEM_MODE_DEFAULT;\nlet cachedModeAt = 0;\nlet cachedModeFailedAt = 0;\nlet _modeRefreshInFlight: Promise<void> | null = null;\n\n// --- Public entry point ---\n\n/**\n * Drive the SSE consumer: connect, parse frames, deliver events via\n * `onEvent`, debounce awareness POSTs, and reconnect with exponential\n * backoff on failure. Reports `opts.errorCode` to `/api/channel-error` and\n * calls `process.exit(1)` after `CHANNEL_MAX_RETRIES` consecutive\n * failures.\n */\nexport async function runEventConsumer(opts: EventConsumerOptions): Promise<void> {\n // Warm the mode cache before the first event so we don't default-suppress\n // or default-deliver under an unknown user setting. Errors are already\n // logged inside getCachedMode (stale-preserving; cold-start default only\n // when no fetch has ever succeeded) — keep going.\n await getCachedMode(opts.tandemUrl, opts.logPrefix).catch(() => {});\n\n let retries = 0;\n let lastEventId: string | undefined;\n\n while (retries < CHANNEL_MAX_RETRIES) {\n try {\n await connectAndStreamOnce(opts, lastEventId, {\n onEventId: (id) => {\n lastEventId = id;\n },\n onStable: () => {\n retries = 0;\n },\n });\n } catch (err) {\n retries++;\n console.error(\n `${opts.logPrefix} SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,\n err instanceof Error ? err.message : err,\n );\n\n if (retries >= CHANNEL_MAX_RETRIES) {\n console.error(`${opts.logPrefix} SSE connection exhausted, reporting error and exiting`);\n try {\n await fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_ERROR}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n error: opts.errorCode,\n message: `${opts.logPrefix} lost connection after ${CHANNEL_MAX_RETRIES} retries.`,\n }),\n },\n CHANNEL_ERROR_REPORT_TIMEOUT_MS,\n );\n } catch (reportErr) {\n console.error(\n `${opts.logPrefix} Could not report failure to server:`,\n describeFetchError(reportErr, API_CHANNEL_ERROR, CHANNEL_ERROR_REPORT_TIMEOUT_MS),\n );\n }\n opts.onExhaustion?.();\n process.exit(1);\n }\n\n // Exponential backoff: 2s, 4s, 8s, 16s, 30s (capped).\n const delay = Math.min(CHANNEL_RETRY_DELAY_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);\n console.error(\n `${opts.logPrefix} Retrying in ${delay}ms (attempt ${retries}/${CHANNEL_MAX_RETRIES})...`,\n );\n await new Promise((r) => setTimeout(r, delay));\n }\n }\n // Defensive: under normal exhaustion the catch above calls process.exit(1)\n // before we return here. Survives any future refactor that removes the\n // exit or makes it non-terminating (e.g. test shim).\n console.error(\n `${opts.logPrefix} Retry loop exited unexpectedly (retries=${retries}/${CHANNEL_MAX_RETRIES})`,\n );\n process.exit(1);\n}\n\nexport interface StreamCallbacks {\n onEventId: (id: string) => void;\n onStable?: () => void;\n}\n\n/**\n * Single-attempt SSE consumer. Performs one handshake, streams frames,\n * and returns / throws when the stream ends. Exported for tests that want\n * to exercise per-attempt behavior without driving the full retry loop.\n *\n * Production code should call `runEventConsumer` instead — it owns the\n * reconnect/backoff/exhaustion-report logic.\n */\nexport async function connectAndStreamOnce(\n opts: EventConsumerOptions,\n lastEventId: string | undefined,\n cb: StreamCallbacks,\n): Promise<void> {\n const onStable = cb.onStable ?? (() => {});\n const headers: Record<string, string> = { Accept: \"text/event-stream\" };\n if (lastEventId) headers[\"Last-Event-ID\"] = lastEventId;\n\n // Split handshake timeout from body lifetime. Using AbortSignal.timeout on\n // the fetch would kill the response body ReadableStream when the timeout\n // fires — every SSE stream would abort at CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n // making STABLE_CONNECTION_MS unreachable. A local AbortController cleared\n // in `finally` after the handshake settles means the body stream is no\n // longer governed by it.\n const connectCtrl = new AbortController();\n const connectTimer = setTimeout(\n () => connectCtrl.abort(new Error(\"handshake timeout\")),\n CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n );\n let res: Response;\n try {\n res = await authFetch(`${opts.tandemUrl}${API_EVENTS}`, {\n headers,\n signal: connectCtrl.signal,\n });\n } finally {\n clearTimeout(connectTimer);\n }\n if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);\n if (!res.body) throw new Error(\"SSE endpoint returned no body\");\n\n // Stable-uptime reset: if the connection stays healthy for\n // STABLE_CONNECTION_MS, signal the caller to reset its retry budget.\n const stableTimer = setTimeout(onStable, STABLE_CONNECTION_MS);\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n // Inactivity watchdog. A healthy stream emits keepalive comments\n // periodically; if no bytes arrive for CHANNEL_SSE_INACTIVITY_TIMEOUT_MS,\n // cancel the reader. reader.cancel() resolves a pending read() with\n // {done: true} (does not reject), so we surface the cause via a flag.\n let lastActivityAt = Date.now();\n let inactivityTimedOut = false;\n const watchdog = setInterval(() => {\n if (Date.now() - lastActivityAt > CHANNEL_SSE_INACTIVITY_TIMEOUT_MS) {\n inactivityTimedOut = true;\n reader.cancel(new Error(\"SSE inactivity timeout\")).catch(() => {});\n }\n }, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);\n\n let pendingAwareness: TandemEvent | null = null;\n\n function clearAwarenessNow(documentId?: string) {\n const p = fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: documentId ?? null,\n status: \"idle\",\n active: false,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ).catch((err) => {\n console.error(\n `${opts.logPrefix} Awareness clear failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} clear`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n });\n trackAwareness(p);\n }\n\n function flushAwareness() {\n if (!pendingAwareness) return;\n const event = pendingAwareness;\n pendingAwareness = null;\n // Only update when the event has a real documentId. A doc-less event\n // (e.g. chat:message) must NOT wipe the last-known docId —\n // flushFinalAwareness needs a non-null id to send the shutdown clear.\n if (event.documentId) shutdownTimers.lastDocumentId = event.documentId;\n const p = fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: event.documentId,\n status: `processing: ${event.type}`,\n active: true,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ).catch((err) => {\n console.error(\n `${opts.logPrefix} Awareness update failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} update`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n });\n trackAwareness(p);\n\n // Auto-clear after timeout so the indicator doesn't stick.\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n shutdownTimers.clearAwarenessTimer = setTimeout(\n () => clearAwarenessNow(event.documentId),\n AWARENESS_CLEAR_MS,\n );\n }\n\n function scheduleAwareness(event: TandemEvent) {\n pendingAwareness = event;\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n shutdownTimers.awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);\n }\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n if (inactivityTimedOut) throw new Error(\"SSE inactivity timeout\");\n throw new Error(\"SSE stream ended\");\n }\n lastActivityAt = Date.now();\n\n buffer += decoder.decode(value, { stream: true });\n\n if (buffer.length > CHANNEL_MAX_SSE_BUFFER_BYTES) {\n throw new Error(\n `SSE buffer exceeded ${CHANNEL_MAX_SSE_BUFFER_BYTES} bytes without a frame boundary`,\n );\n }\n\n let boundary: number;\n while ((boundary = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const frame = buffer.slice(0, boundary);\n buffer = buffer.slice(boundary + 2);\n\n if (frame.startsWith(\":\")) continue;\n\n let eventId: string | undefined;\n let data: string | undefined;\n\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\"id: \")) eventId = line.slice(4);\n else if (line.startsWith(\"data: \")) data = line.slice(6);\n }\n\n if (!data) continue;\n\n let raw: unknown;\n try {\n raw = JSON.parse(data);\n } catch (err) {\n console.error(\n `${opts.logPrefix} SSE JSON parse failed (eventId=${eventId ?? \"none\"}, len=${\n data.length\n }): ${err instanceof Error ? err.message : err}. Tail:`,\n data.slice(Math.max(0, data.length - 200)),\n );\n // Permanently unparseable — advance past it to prevent infinite\n // re-delivery on reconnect.\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n\n const event = parseTandemEvent(raw);\n if (!event) {\n console.error(\n `${opts.logPrefix} SSE event failed validation (eventId=${\n eventId ?? \"none\"\n }): shape mismatch`,\n );\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n\n // Solo mode suppression: drop non-chat events when mode is \"solo\".\n if (event.type !== \"chat:message\") {\n refreshMode(opts.tandemUrl, opts.logPrefix); // fire-and-forget\n if (getModeSync() === \"solo\") {\n console.error(`${opts.logPrefix} Solo mode: suppressed ${event.type} event`);\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n }\n\n // Deliver the event. False-checkpoint guard: `cb.onEventId` MUST\n // stay below this so lastEventId never advances past an event that\n // didn't reach the consumer's delivery surface.\n try {\n await opts.onEvent(event, eventId);\n } catch (err) {\n console.error(`${opts.logPrefix} onEvent failed (transport broken?):`, err);\n throw err;\n }\n\n if (eventId) cb.onEventId(eventId);\n scheduleAwareness(event);\n }\n }\n } finally {\n // Single source of truth for timer cleanup — every exit path (success,\n // throw, reader.cancel) runs through here so awareness/inactivity\n // timers can't leak across reconnects.\n clearTimeout(stableTimer);\n clearInterval(watchdog);\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n shutdownTimers.awarenessTimer = null;\n shutdownTimers.clearAwarenessTimer = null;\n pendingAwareness = null;\n }\n}\n\n// --- Mode cache ---\n\ntype FetchModeResult = { ok: true; mode: TandemMode } | { ok: false; reason: string };\n\n/** Fetch + validate /api/mode. Callers apply their own failure policy. */\nasync function fetchMode(tandemUrl: string): Promise<FetchModeResult> {\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_MODE}`,\n {},\n CHANNEL_MODE_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) return { ok: false, reason: `status ${res.status}` };\n const body = (await res.json()) as { mode?: unknown };\n const parsed = TandemModeSchema.safeParse(body.mode);\n if (!parsed.success) return { ok: false, reason: `invalid mode ${JSON.stringify(body.mode)}` };\n return { ok: true, mode: parsed.data };\n } catch (err) {\n return { ok: false, reason: describeFetchError(err, API_MODE, CHANNEL_MODE_FETCH_TIMEOUT_MS) };\n }\n}\n\n/**\n * Get the current collaboration mode, with a 2s TTL cache.\n *\n * **Stale-preserving** on any failure: once a real mode has been fetched\n * successfully, a transient `/api/mode` failure (network error or non-OK)\n * NEVER changes the cached mode — `cachedMode` is left untouched and the last\n * known value is returned. The mode only ever changes when the server reports\n * a new mode, i.e. when the user actually toggles Solo/Tandem.\n *\n * `cachedModeAt === 0` is the cold-start sentinel (no successful fetch ever).\n * In that one case — and only that case — a failure falls back to the\n * documented `TANDEM_MODE_DEFAULT`. After the first success, `cachedModeAt`\n * is non-zero forever, so failures can never revert to the cold-start default.\n *\n * On failure, `cachedModeAt` is NOT updated, so the next call retries\n * immediately rather than waiting out MODE_CACHE_TTL_MS.\n */\nexport async function getCachedMode(\n tandemUrl: string,\n logPrefix = \"[Tandem]\",\n): Promise<TandemMode> {\n const now = Date.now();\n if (now - cachedModeAt < MODE_CACHE_TTL_MS && cachedModeAt !== 0) return cachedMode;\n\n const result = await fetchMode(tandemUrl);\n if (!result.ok) {\n // Stale-preserving: keep the last known mode. A failure must never\n // overwrite a successfully-observed mode. Only on a genuine cold start\n // (no successful fetch ever, cachedModeAt === 0) do we fall back to the\n // documented default. cachedModeAt is left untouched so the next call\n // retries immediately instead of serving a stale cache window.\n if (cachedModeAt !== 0) {\n console.error(\n `${logPrefix} Mode check failed (${result.reason}), preserving last known mode '${cachedMode}'`,\n );\n return cachedMode;\n }\n console.error(\n `${logPrefix} Mode check failed (${result.reason}), no prior mode — using cold-start default '${TANDEM_MODE_DEFAULT}'`,\n );\n cachedMode = TANDEM_MODE_DEFAULT; // propagate cold-start default to hot path; do NOT update cachedModeAt\n return TANDEM_MODE_DEFAULT;\n }\n cachedMode = result.mode;\n cachedModeAt = now;\n return cachedMode;\n}\n\n/** Sync reader — always returns the last known mode. Use this on the hot path. */\nexport function getModeSync(): TandemMode {\n return cachedMode;\n}\n\n/**\n * Background refresh — fire-and-forget, deduplicated.\n *\n * Leaves `cachedMode` UNCHANGED on failure (stale-preferred). getCachedMode\n * fails closed at startup because no baseline exists; refreshMode prefers\n * stale because flipping mid-session would randomly suppress events and\n * surprise the user.\n */\nfunction refreshMode(tandemUrl: string, logPrefix: string): void {\n if (_modeRefreshInFlight) return;\n const now = Date.now();\n if (now - cachedModeAt < MODE_CACHE_TTL_MS) return;\n // Rate-limit retries after a failure so a server returning 500 quickly\n // (or hanging up to MODE_FETCH_TIMEOUT_MS) doesn't spawn a new fetch on\n // every hot-path event.\n if (now - cachedModeFailedAt < MODE_CACHE_TTL_MS) return;\n\n // Fire-and-forget. fetchMode() converts network/parse errors into\n // { ok: false }, and the inner try/finally clears `_modeRefreshInFlight` on\n // both success and thrown rejection — so today, the outer .catch is\n // unreachable. It exists as a belt-and-suspenders guard.\n _modeRefreshInFlight = (async () => {\n try {\n const result = await fetchMode(tandemUrl);\n if (result.ok) {\n cachedMode = result.mode;\n cachedModeAt = Date.now();\n cachedModeFailedAt = 0;\n } else {\n cachedModeFailedAt = Date.now();\n console.error(\n `${logPrefix} Background mode refresh failed (${result.reason}), keeping cached`,\n );\n }\n } finally {\n _modeRefreshInFlight = null;\n }\n })().catch((err) => {\n console.error(`${logPrefix} refreshMode unexpected error:`, err);\n cachedModeFailedAt = Date.now();\n });\n}\n\n// --- Shutdown drain (used by the monitor's signal handler) ---\n\n/**\n * Drain any in-flight awareness POSTs and send a final shutdown\n * \"active: false\" so the server's last-observed awareness state is clean.\n *\n * Returns true on success (or no-op when no docId was ever scheduled),\n * false when the shutdown POST itself fails.\n */\nexport async function flushFinalAwareness(\n tandemUrl: string,\n logPrefix = \"[Tandem]\",\n): Promise<boolean> {\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n if (outstandingAwareness.size > 0) {\n await Promise.allSettled(outstandingAwareness);\n }\n // If no awareness was ever scheduled for a document, skip the POST —\n // sending {documentId: null} is ambiguous and the server may reject it.\n if (shutdownTimers.lastDocumentId === null) return true;\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: shutdownTimers.lastDocumentId,\n status: \"idle\",\n active: false,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) {\n console.error(`${logPrefix} Shutdown awareness clear returned ${res.status}`);\n return false;\n }\n return true;\n } catch (err) {\n console.error(\n `${logPrefix} Shutdown awareness clear failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} shutdown`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n return false;\n }\n}\n\n// --- Test-only helpers ---\n\n/** Testing-only. Resets module-level state so tests within a single file\n * don't contaminate each other. DO NOT call from production code. */\nexport function _resetSseConsumerStateForTests(): void {\n cachedMode = TANDEM_MODE_DEFAULT;\n cachedModeAt = 0;\n cachedModeFailedAt = 0;\n _modeRefreshInFlight = null;\n shutdownTimers.awarenessTimer = null;\n shutdownTimers.clearAwarenessTimer = null;\n shutdownTimers.lastDocumentId = null;\n outstandingAwareness.clear();\n}\n\n/** Testing-only — seeds the lastDocumentId that shutdown reads. */\nexport function _setLastDocumentIdForTests(id: string | null): void {\n shutdownTimers.lastDocumentId = id;\n}\n\n/** Testing-only — reads the last document id that shutdown would send. */\nexport function _getLastDocumentIdForTests(): string | null {\n return shutdownTimers.lastDocumentId;\n}\n\n/** Testing-only — seeds an outstanding awareness POST so the shutdown test\n * can assert the drain-before-exit behavior. */\nexport function _addOutstandingAwarenessForTests(p: Promise<unknown>): void {\n trackAwareness(p);\n}\n","/**\n * SSE event bridge: connects to Tandem server's /api/events endpoint and\n * pushes received events to Claude Code as channel notifications.\n *\n * All retry / SSE-frame / awareness / mode-cache logic lives in the shared\n * `src/shared/sse-consumer.ts` module (extracted in #282). This file is the\n * thin MCP-aware adapter that owns the delivery callback.\n */\n\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { formatEventContent, formatEventMeta } from \"../shared/events/types.js\";\nimport { runEventConsumer } from \"../shared/sse-consumer.js\";\nimport { CHANNEL_CONNECT_FAILED } from \"../shared/types.js\";\n\n/**\n * Stdio-mode SSE bridge. New push-path work should target src/monitor/.\n * This path remains active for stdio-mode Claude Code connections.\n */\nexport async function startEventBridge(mcp: Server, tandemUrl: string): Promise<void> {\n return runEventConsumer({\n tandemUrl,\n logPrefix: \"[Channel]\",\n errorCode: CHANNEL_CONNECT_FAILED,\n onEvent: (event) =>\n mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n }),\n });\n}\n","/**\n * Tandem Channel Shim — core runtime, shared by:\n * - src/channel/index.ts (standalone binary, used by the Desktop sidecar)\n * - src/cli/channel.ts (npm-delivered entry for the plugin `tandem-channel`)\n *\n * Bridges Tandem's SSE event stream → Claude Code channel notifications,\n * and exposes a `tandem_reply` tool for Claude to respond to chat messages.\n *\n * Uses the low-level MCP `Server` class (not `McpServer`) as required by\n * the Channels API spec.\n */\n\nimport { createConnection } from \"node:net\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\nimport { API_CHANNEL_PERMISSION, API_CHANNEL_REPLY } from \"../shared/api-paths.js\";\nimport {\n redirectConsoleToStderr,\n resolveTandemUrl,\n withClaudeSessionHeader,\n} from \"../shared/cli-runtime.js\";\nimport {\n CHANNEL_PERMISSION_FETCH_TIMEOUT_MS,\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n DEFAULT_MCP_PORT,\n} from \"../shared/constants.js\";\nimport {\n describeFetchError,\n fetchWithTimeout,\n isAbortOrTimeoutError,\n} from \"../shared/fetch-with-timeout.js\";\nimport { startEventBridge } from \"./event-bridge.js\";\n\nexport interface RunChannelOptions {\n /** Skip the non-fatal reachability probe. The CLI wrapper runs a strict\n * preflight upstream and we don't want to double-log \"server not reachable\"\n * noise. Defaults to false. */\n skipReachabilityLog?: boolean;\n}\n\nexport async function runChannel(opts: RunChannelOptions = {}): Promise<void> {\n redirectConsoleToStderr();\n\n const tandemUrl = resolveTandemUrl();\n\n const mcp = new Server(\n { name: \"tandem-channel\", version: \"0.1.0\" },\n {\n capabilities: {\n experimental: {\n \"claude/channel\": {},\n \"claude/channel/permission\": {},\n },\n tools: {},\n },\n instructions: [\n 'Events from Tandem arrive as <channel source=\"tandem-channel\" event_type=\"...\" document_id=\"...\">.',\n \"These are real-time push notifications of user actions in the collaborative document editor.\",\n \"Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,\",\n \"chat:message, document:opened, document:closed, document:switched.\",\n \"Chat messages may include a 'selection' field with buffered selection context.\",\n \"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_edit, etc.) to act on them.\",\n \"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.\",\n \"Do not reply to non-chat events — just act on them using tools.\",\n \"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback.\",\n ].join(\" \"),\n },\n );\n\n mcp.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: [\n {\n name: \"tandem_reply\",\n description: \"Reply to a chat message in Tandem\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n text: { type: \"string\", description: \"The reply message\" },\n documentId: {\n type: \"string\",\n description: \"Document ID from the channel event (optional)\",\n },\n replyTo: {\n type: \"string\",\n description: \"Message ID being replied to (optional)\",\n },\n },\n required: [\"text\"],\n },\n },\n ],\n }));\n\n mcp.setRequestHandler(CallToolRequestSchema, async (req) => {\n if (req.params.name === \"tandem_reply\") {\n const args = req.params.arguments as Record<string, unknown>;\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_REPLY}`,\n {\n method: \"POST\",\n headers: withClaudeSessionHeader({ \"Content-Type\": \"application/json\" }),\n body: JSON.stringify(args),\n },\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n );\n let data: unknown;\n try {\n data = await res.json();\n } catch (parseErr) {\n // Re-throw timeout/abort errors so they surface as structured\n // failures to Claude. AbortSignal.timeout fires DURING `res.json()`\n // (headers landed but body hung); without this re-throw, the bare\n // catch swallows AbortError and reports a fake-success \"Non-JSON\n // response\" payload — exactly the silent-failure pattern #364\n // exists to prevent.\n if (isAbortOrTimeoutError(parseErr)) throw parseErr;\n data = { message: \"Non-JSON response\" };\n }\n if (!res.ok) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Reply failed (${res.status}): ${JSON.stringify(data)}`,\n },\n ],\n isError: true,\n };\n }\n return { content: [{ type: \"text\" as const, text: JSON.stringify(data) }] };\n } catch (err) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Failed to send reply: ${describeFetchError(\n err,\n API_CHANNEL_REPLY,\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n )}`,\n },\n ],\n isError: true,\n };\n }\n }\n throw new Error(`Unknown tool: ${req.params.name}`);\n });\n\n const PermissionRequestSchema = z.object({\n method: z.literal(\"notifications/claude/channel/permission_request\"),\n params: z.object({\n request_id: z.string(),\n tool_name: z.string(),\n description: z.string(),\n input_preview: z.string(),\n }),\n });\n\n mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_PERMISSION}`,\n {\n method: \"POST\",\n headers: withClaudeSessionHeader({ \"Content-Type\": \"application/json\" }),\n body: JSON.stringify({\n requestId: params.request_id,\n toolName: params.tool_name,\n description: params.description,\n inputPreview: params.input_preview,\n }),\n },\n CHANNEL_PERMISSION_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) {\n console.error(\n `[Channel] Permission relay got HTTP ${res.status} — browser may not see prompt`,\n );\n }\n } catch (err) {\n console.error(\n \"[Channel] Failed to forward permission request:\",\n describeFetchError(err, API_CHANNEL_PERMISSION, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS),\n );\n }\n });\n\n console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);\n\n if (!opts.skipReachabilityLog) {\n const reachable = await checkServerReachable(tandemUrl);\n if (!reachable) {\n console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);\n console.error(\"[Channel] Start it with: tandem start\");\n // Continue anyway — the event bridge will retry, and the server may start later\n }\n }\n\n const transport = new StdioServerTransport();\n await mcp.connect(transport);\n console.error(\"[Channel] Connected to Claude Code via stdio\");\n\n startEventBridge(mcp, tandemUrl).catch((err) => {\n console.error(\"[Channel] Event bridge failed unexpectedly:\", err);\n process.exit(1);\n });\n}\n\nasync function checkServerReachable(url: string, timeoutMs = 2000): Promise<boolean> {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n console.error(\n `[Channel] Invalid TANDEM_URL: \"${url}\" — expected format: http://127.0.0.1:3479`,\n );\n return false;\n }\n const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);\n return new Promise((resolve) => {\n const socket = createConnection({ port, host: parsed.hostname }, () => {\n socket.destroy();\n resolve(true);\n });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.on(\"error\", (err) => {\n console.error(`[Channel] Server probe failed: ${err.message}`);\n socket.destroy();\n resolve(false);\n });\n });\n}\n","/**\n * Tandem channel subcommand — npm-delivered entry for the plugin\n * `tandem-channel` MCP server. Runs the unified preflight, then hands off to\n * the shared channel shim runtime in src/channel/run.ts.\n */\n\nimport { runChannel } from \"../channel/run.js\";\nimport { ensureTandemServer } from \"./preflight.js\";\n\nexport async function runChannelCli(): Promise<void> {\n await ensureTandemServer();\n await runChannel({ skipReachabilityLog: true });\n}\n","/**\n * `store.lock` payload format + parsing, isolated from {@link ../store.ts} so\n * that lightweight consumers (the `tandem doctor` CLI) can read a lock without\n * pulling in the store's file-io / notifications / platform dependency graph.\n *\n * Two on-disk formats, both must stay readable:\n * - v2 (#1077): JSON `{pid, startedAtMs?, app}` — written by current versions.\n * - legacy: a bare PID string — written by older versions.\n */\n\n/** App identifier stamped into v2 lockfiles. */\nexport const LOCK_APP_ID = \"tandem\";\n\n/** Parsed contents of `store.lock` (v2 JSON or legacy raw-PID). */\nexport interface LockfileContents {\n pid: number;\n /** v2 only — epoch ms when the holder took the lock. */\n startedAtMs?: number;\n /** v2 only — always `\"tandem\"` when written by Tandem. */\n app?: string;\n}\n\n/** Serialize the v2 lockfile payload for the current process. */\nexport function lockfilePayload(): string {\n return JSON.stringify({ pid: process.pid, startedAtMs: Date.now(), app: LOCK_APP_ID });\n}\n\n/**\n * Parse `store.lock` contents. Two formats:\n * - v2 (#1077): JSON `{pid, startedAtMs?, app}` — written by current versions.\n * - legacy: a bare PID string — written by older versions; must stay readable.\n *\n * Returns `null` for garbage content (callers treat that as a stale lock).\n */\nexport function parseLockfile(raw: string): LockfileContents | null {\n const trimmed = raw.trim();\n if (trimmed.startsWith(\"{\")) {\n try {\n const parsed = JSON.parse(trimmed) as Record<string, unknown>;\n const pid = parsed.pid;\n if (typeof pid !== \"number\" || !Number.isInteger(pid) || pid <= 0) return null;\n return {\n pid,\n startedAtMs: typeof parsed.startedAtMs === \"number\" ? parsed.startedAtMs : undefined,\n app: typeof parsed.app === \"string\" ? parsed.app : undefined,\n };\n } catch {\n return null;\n }\n }\n // Legacy raw-PID format. parseInt (not Number()) preserves the historical\n // tolerance for trailing junk after the digits.\n const pid = Number.parseInt(trimmed, 10);\n if (!Number.isFinite(pid) || pid <= 0) return null;\n return { pid };\n}\n","/**\n * Tandem Doctor — diagnose common setup issues.\n *\n * This module is the importable core behind both `tandem doctor` (the bundled\n * CLI subcommand) and `npm run doctor` (the standalone `scripts/doctor.mjs`\n * shim). It is split into a PURE collector (`runDoctor`) and a thin printer +\n * exit-code wrapper (`runDoctorCli`):\n *\n * - `runDoctor()` reads NOTHING from `process.argv` and calls `process.exit`\n * NEVER. It returns a structured {@link DoctorReport} so callers and tests\n * can inspect results without side effects.\n * - `runDoctorCli({ json })` formats the report (human-readable TTY lines or a\n * single JSON document on stdout) and applies the shared exit code.\n *\n * BUNDLING RATIONALE (do not \"simplify\" this into a spawn): the diagnostics\n * logic MUST live in this TS module so tsup bundles it into `dist/cli`. The\n * `scripts/` directory is NOT shipped in the npm package (see package.json\n * `files`), so a dispatcher that spawned `scripts/doctor.mjs` would have\n * nothing to run inside a global install. Keeping the logic here is the only\n * correct path for `tandem doctor` to work after `npm install -g`.\n *\n * Pure Node.js built-ins only (no external dependencies) so the module bundles\n * cleanly and the standalone shim can mirror it.\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { request } from \"node:http\";\nimport { createConnection } from \"node:net\";\nimport { homedir, platform } from \"node:os\";\nimport { join } from \"node:path\";\nimport { parseLockfile } from \"../server/annotations/lockfile.js\";\nimport { DEFAULT_MCP_PORT, DEFAULT_WS_PORT } from \"../shared/constants.js\";\n\nexport type DoctorStatus = \"pass\" | \"warn\" | \"fail\";\n\nexport interface DoctorResult {\n check: string;\n status: DoctorStatus;\n message: string;\n fix?: string;\n data?: Record<string, unknown>;\n}\n\nexport interface DoctorReport {\n ok: boolean;\n crashed: boolean;\n failures: number;\n warnings: number;\n summary: string;\n error: string | null;\n results: DoctorResult[];\n}\n\n/**\n * Internal recorder shared by every check. Mirrors the recorder in the legacy\n * `scripts/doctor.mjs`: each check groups one or more results under a `name`.\n * No TTY output happens here — that's the wrapper's job, so the pure collector\n * stays side-effect-free.\n */\nclass Recorder {\n failures = 0;\n warnings = 0;\n readonly results: DoctorResult[] = [];\n private currentCheck = \"\";\n\n async check<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n const prev = this.currentCheck;\n this.currentCheck = name;\n try {\n return await fn();\n } finally {\n this.currentCheck = prev;\n }\n }\n\n private record(\n status: DoctorStatus,\n msg: string,\n fix?: string,\n fields?: Record<string, unknown>,\n ): void {\n const entry: DoctorResult = { check: this.currentCheck, status, message: msg };\n if (fix) entry.fix = fix;\n if (fields) entry.data = fields;\n this.results.push(entry);\n }\n\n pass(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.record(\"pass\", msg, fix, fields);\n }\n\n warn(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.warnings++;\n this.record(\"warn\", msg, fix, fields);\n }\n\n fail(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.failures++;\n this.record(\"fail\", msg, fix, fields);\n }\n}\n\n// ── Check: Node.js version ──────────────────────────────────────────\n\nfunction checkNodeVersion(r: Recorder): void {\n const version = process.version;\n const major = Number.parseInt(version.slice(1), 10);\n if (major >= 22) {\n r.pass(`Node.js ${version} (>= 22 required)`);\n } else {\n r.fail(\n `Node.js ${version} — version 22+ required`,\n \"Install Node.js 22+ from https://nodejs.org\",\n );\n }\n}\n\n// ── Check: node_modules exists ──────────────────────────────────────\n\nfunction checkNodeModules(r: Recorder): void {\n if (existsSync(join(process.cwd(), \"node_modules\"))) {\n r.pass(\"node_modules/ exists\");\n } else {\n r.fail(\"node_modules/ not found\", \"npm install\");\n }\n}\n\n// ── Check: .mcp.json ────────────────────────────────────────────────\n\nfunction checkMcpJson(r: Recorder): void {\n const mcpPath = join(process.cwd(), \".mcp.json\");\n if (!existsSync(mcpPath)) {\n r.fail(\".mcp.json not found\", \"Restore it from git: git checkout .mcp.json\");\n return;\n }\n\n let raw: string;\n try {\n raw = readFileSync(mcpPath, \"utf-8\");\n } catch (err) {\n r.fail(`.mcp.json could not be read: ${errMsg(err)}`);\n return;\n }\n\n let config: {\n mcpServers?: Record<\n string,\n {\n type?: string;\n url?: string;\n command?: string;\n args?: string[];\n env?: Record<string, string>;\n }\n >;\n };\n try {\n config = JSON.parse(raw);\n } catch {\n // Deliberately no parse detail: V8 SyntaxErrors embed a snippet of the\n // source text, and this file carries auth-token headers. Doctor output\n // gets pasted into public issues.\n r.fail(\".mcp.json is not valid JSON\", \"Restore it from git: git checkout .mcp.json\");\n return;\n }\n\n const servers = config.mcpServers;\n if (!servers) {\n r.fail('.mcp.json missing \"mcpServers\" key');\n return;\n }\n\n // Check tandem (HTTP MCP) entry\n const tandem = servers.tandem;\n if (!tandem) {\n r.fail('.mcp.json missing \"tandem\" server entry');\n } else if (tandem.type !== \"http\" || !tandem.url?.includes(\"/mcp\")) {\n r.warn(`.mcp.json tandem: unexpected config — type=${tandem.type}, url=${tandem.url}`);\n } else {\n r.pass(`.mcp.json tandem → ${tandem.url}`);\n }\n\n // Check tandem-channel entry\n const channel = servers[\"tandem-channel\"];\n if (!channel) {\n r.warn(\n \".mcp.json missing tandem-channel — Claude will use polling instead of push notifications\",\n );\n } else {\n const cmd = channel.command;\n const args = (channel.args || []).join(\" \");\n\n if (cmd === \"cmd\" && args.includes(\"/c\")) {\n r.warn(\n `.mcp.json tandem-channel uses Windows-only \"cmd /c\" — won't work on macOS/Linux`,\n 'Change to: \"command\": \"npx\", \"args\": [\"tsx\", \"src/channel/index.ts\"]',\n );\n } else {\n r.pass(`.mcp.json tandem-channel → ${cmd} ${args}`);\n }\n\n if (!channel.env?.TANDEM_URL) {\n r.warn(\n \"tandem-channel missing TANDEM_URL env var\",\n 'Add \"env\": {\"TANDEM_URL\": \"http://127.0.0.1:3479\"}',\n );\n }\n }\n}\n\n// ── Check: user-level MCP config (global install path) ─────────────\n\nfunction checkUserMcpConfig(r: Recorder): void {\n const home = process.env.HOME || process.env.USERPROFILE || \"\";\n // Claude Code reads global MCP servers from ~/.claude.json (under\n // `mcpServers`), which is exactly where `tandem setup` writes them. The\n // legacy ~/.claude/mcp_settings.json is not the file Claude Code consults,\n // so checking it produced false warnings even on a correct install (#985).\n const claudeCodePath = join(home, \".claude.json\");\n\n if (!existsSync(claudeCodePath)) {\n r.warn(\n \"~/.claude.json not found\",\n \"Run: tandem setup (or ignore if using project-local .mcp.json)\",\n );\n return;\n }\n\n let config: { mcpServers?: Record<string, unknown> };\n try {\n config = JSON.parse(readFileSync(claudeCodePath, \"utf-8\"));\n } catch {\n // Deliberately no parse detail: V8 SyntaxErrors embed a snippet of the\n // source text, and ~/.claude.json carries bearer tokens / API keys. This\n // check survives the /api/diagnostics filter, so its message reaches the\n // Copy Diagnostics clipboard — destined for public issues.\n r.warn(\"~/.claude.json is malformed JSON\", \"Run: tandem setup to rewrite it\");\n return;\n }\n\n const servers = config?.mcpServers ?? {};\n if (!servers.tandem) {\n r.warn(\"tandem not registered in ~/.claude.json\", \"Run: tandem setup\");\n } else {\n r.pass(\"tandem registered in ~/.claude.json\");\n }\n if (!servers[\"tandem-channel\"]) {\n r.warn(\n \"tandem-channel not registered in ~/.claude.json — Claude Code will poll instead of receiving real-time push\",\n \"Run: tandem setup\",\n );\n } else {\n r.pass(\"tandem-channel registered in ~/.claude.json\");\n }\n}\n\n// ── Check: port status ──────────────────────────────────────────────\n\nfunction probePort(port: number, timeoutMs = 2000): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = createConnection({ port, host: \"127.0.0.1\" }, () => {\n socket.destroy();\n resolve(true);\n });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.on(\"error\", () => {\n socket.destroy();\n resolve(false);\n });\n });\n}\n\nasync function checkPorts(\n r: Recorder,\n wsPort: number,\n mcpPort: number,\n): Promise<{ ws: boolean; mcp: boolean }> {\n const [ws, mcp] = await Promise.all([probePort(wsPort), probePort(mcpPort)]);\n\n if (ws && mcp) {\n r.pass(`Ports ${wsPort} (WebSocket) + ${mcpPort} (MCP HTTP) in use`, undefined, { ws, mcp });\n } else if (!ws && !mcp) {\n r.fail(\n `Ports ${wsPort} + ${mcpPort} not listening — server not running`,\n \"npm run dev:standalone\",\n { ws, mcp },\n );\n } else {\n r.warn(\n `Partial: port ${wsPort} ${ws ? \"up\" : \"down\"}, port ${mcpPort} ${mcp ? \"up\" : \"down\"}`,\n \"Server may be starting up or partially crashed\",\n { ws, mcp },\n );\n }\n\n return { ws, mcp };\n}\n\n// ── Check: /health endpoint ─────────────────────────────────────────\n\ninterface HttpGetResult {\n status?: number;\n data?: { version?: string; transport?: string; hasSession?: boolean } | null;\n error?: string;\n}\n\nfunction httpGet(url: string, timeoutMs = 3000): Promise<HttpGetResult | null> {\n return new Promise((resolve) => {\n const req = request(url, { timeout: timeoutMs }, (res) => {\n let body = \"\";\n res.on(\"data\", (chunk) => {\n body += chunk;\n });\n res.on(\"end\", () => {\n try {\n resolve({ status: res.statusCode, data: JSON.parse(body) });\n } catch {\n resolve({ status: res.statusCode, data: null });\n }\n });\n });\n req.on(\"error\", (err: Error) => resolve({ error: err.message }));\n req.on(\"timeout\", () => {\n req.destroy();\n resolve(null);\n });\n req.end();\n });\n}\n\nasync function checkHealth(r: Recorder, mcpPort: number): Promise<boolean> {\n const result = await httpGet(`http://127.0.0.1:${mcpPort}/health`);\n\n if (!result) {\n r.fail(`Server not responding on 127.0.0.1:${mcpPort}`, \"npm run dev:standalone\");\n return false;\n }\n\n if (result.error) {\n r.fail(\n `Server not responding on 127.0.0.1:${mcpPort} (${result.error})`,\n \"npm run dev:standalone\",\n );\n return false;\n }\n\n if (result.status !== 200) {\n r.fail(`/health returned status ${result.status}`);\n return false;\n }\n\n const d = result.data;\n if (d) {\n const session = d.hasSession ? \"session active\" : \"no MCP session\";\n r.pass(`Server healthy (v${d.version}, ${d.transport}, ${session})`, undefined, {\n version: d.version,\n transport: d.transport,\n hasSession: !!d.hasSession,\n });\n if (!d.hasSession) {\n r.warn(\"No active MCP session — Claude Code hasn't connected yet\");\n }\n } else {\n r.pass(\"Server responded on /health (could not parse body)\");\n }\n return true;\n}\n\n// ── Check: SSE event stream ─────────────────────────────────────────\n\nfunction checkSseEndpoint(r: Recorder, mcpPort: number): Promise<void> {\n return new Promise((resolve) => {\n const req = request(`http://127.0.0.1:${mcpPort}/api/events`, { timeout: 2000 }, (res) => {\n // SSE endpoint responds with 200 and text/event-stream\n req.destroy(); // don't hold the connection open\n const ct = res.headers[\"content-type\"] || \"\";\n if (res.statusCode === 200 && ct.includes(\"text/event-stream\")) {\n r.pass(\"SSE event stream reachable (/api/events)\");\n } else {\n r.warn(`/api/events responded with status ${res.statusCode}, content-type: ${ct}`);\n }\n resolve();\n });\n req.on(\"error\", (err: Error) => {\n r.warn(`/api/events not reachable: ${err.message}`);\n resolve();\n });\n req.on(\"timeout\", () => {\n req.destroy();\n r.warn(\"/api/events timed out\");\n resolve();\n });\n req.end();\n });\n}\n\n// ── Check: annotation store health ──────────────────────────────────\n\n/** Mirror of `env-paths(\"tandem\").data` for the current OS. */\nfunction resolveAppDataDir(): string {\n const override = process.env.TANDEM_APP_DATA_DIR;\n if (override && override.length > 0) return override;\n\n const home = homedir();\n switch (platform()) {\n case \"win32\":\n return join(process.env.LOCALAPPDATA || join(home, \"AppData\", \"Local\"), \"tandem\", \"Data\");\n case \"darwin\":\n return join(home, \"Library\", \"Application Support\", \"tandem\");\n default:\n return join(process.env.XDG_DATA_HOME || join(home, \".local\", \"share\"), \"tandem\");\n }\n}\n\n/** Cross-platform test that a PID currently points at a live process. */\nfunction isPidLive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n return (err as NodeJS.ErrnoException)?.code === \"EPERM\";\n }\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction checkAnnotationStore(r: Recorder): void {\n const dir = join(resolveAppDataDir(), \"annotations\");\n if (!existsSync(dir)) {\n r.pass(`Annotation store dir not yet created (${dir}) — first open will create it`, undefined, {\n dir,\n docCount: 0,\n totalBytes: 0,\n corruptCount: 0,\n exists: false,\n });\n return;\n }\n\n let entries: string[];\n try {\n entries = readdirSync(dir);\n } catch (err) {\n r.fail(`Annotation store dir unreadable: ${errMsg(err)}`, `Check permissions on ${dir}`);\n return;\n }\n\n const jsonFiles = entries.filter((f) => f.endsWith(\".json\") && !f.endsWith(\".corrupt.json\"));\n const corruptFiles = entries.filter((f) => f.includes(\".corrupt.\"));\n\n let totalBytes = 0;\n let newest: { name: string | null; mtime: number } = { name: null, mtime: 0 };\n let sampleSchemaVersion: number | null = null;\n\n for (const f of jsonFiles) {\n try {\n const s = statSync(join(dir, f));\n totalBytes += s.size;\n if (s.mtimeMs > newest.mtime) {\n newest = { name: f, mtime: s.mtimeMs };\n }\n if (sampleSchemaVersion === null) {\n try {\n const parsed = JSON.parse(readFileSync(join(dir, f), \"utf-8\"));\n if (typeof parsed?.schemaVersion === \"number\") {\n sampleSchemaVersion = parsed.schemaVersion;\n }\n } catch {\n // malformed individual file — counted under corruptFiles check below\n }\n }\n } catch {\n // file vanished between readdir and stat — ignore\n }\n }\n\n r.pass(\n `Annotation store: ${jsonFiles.length} doc(s), ${formatBytes(totalBytes)} total`,\n undefined,\n {\n dir,\n docCount: jsonFiles.length,\n totalBytes,\n corruptCount: corruptFiles.length,\n },\n );\n\n if (newest.name) {\n const ageMs = Date.now() - newest.mtime;\n const ageStr =\n ageMs < 60_000 ? `${Math.floor(ageMs / 1000)}s` : `${Math.floor(ageMs / 60_000)}m`;\n r.pass(`Most recent annotation write: ${newest.name} (${ageStr} ago)`, undefined, {\n name: newest.name,\n mtimeMs: newest.mtime,\n ageMs,\n });\n }\n\n if (sampleSchemaVersion !== null) {\n r.pass(`Annotation schema version: ${sampleSchemaVersion}`, undefined, {\n schemaVersion: sampleSchemaVersion,\n });\n }\n\n if (corruptFiles.length > 0) {\n r.warn(\n `${corruptFiles.length} quarantined annotation file(s) in ${dir}`,\n \"Safe to delete after inspection; kept 7d by design.\",\n {\n corruptCount: corruptFiles.length,\n dir,\n },\n );\n }\n\n // Lock status\n const lockPath = join(dir, \"store.lock\");\n if (!existsSync(lockPath)) {\n r.pass(\"Annotation store lock: not held (no running writer)\", undefined, { lockHeld: false });\n return;\n }\n\n try {\n const raw = readFileSync(lockPath, \"utf-8\").trim();\n // Current locks are v2 JSON (`{pid, startedAtMs, app}`, #1077); older ones\n // are a bare PID. parseLockfile reads both and returns null for true garbage.\n const lock = parseLockfile(raw);\n if (lock === null) {\n r.warn(\n `Annotation store lock at ${lockPath} has unparseable content: \"${raw}\"`,\n \"Restart Tandem or delete the lock file if no server is running.\",\n { lockHeld: true, lockPath, lockContent: raw },\n );\n return;\n }\n const { pid } = lock;\n if (isPidLive(pid)) {\n r.pass(`Annotation store lock held by live PID ${pid}`, undefined, {\n lockHeld: true,\n pid,\n pidLive: true,\n });\n } else {\n r.warn(\n `Annotation store lock at ${lockPath} points to dead PID ${pid}`,\n \"The next server start will reclaim the stale lock automatically.\",\n { lockHeld: true, pid, pidLive: false },\n );\n }\n } catch (err) {\n r.warn(`Could not read annotation store lock: ${errMsg(err)}`);\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n// ── Pure collector ──────────────────────────────────────────────────\n\n/**\n * Three-tier summary line shared by `runDoctor` and the `/api/diagnostics`\n * route's filtered recomputation — keep wording in one place.\n */\nexport function summarizeDoctorResults(failures: number, warnings: number): string {\n if (failures > 0) return `${failures} issue(s) found.`;\n if (warnings > 0)\n return `${warnings} warning(s) — Tandem should work, but check the items above.`;\n return \"All checks passed. Tandem is ready.\";\n}\n\nexport interface RunDoctorOptions {\n /** WebSocket (Hocuspocus) port to probe. Defaults to {@link DEFAULT_WS_PORT}. */\n wsPort?: number;\n /** MCP HTTP port to probe. Defaults to {@link DEFAULT_MCP_PORT}. */\n mcpPort?: number;\n}\n\n/**\n * Run every diagnostic check and return a structured report. Performs NO\n * `process.argv` reads and NEVER calls `process.exit`. Safe to call from tests\n * and from both CLI entry points. Embedders that know their live ports (the\n * `/api/diagnostics` route on a `TANDEM_PORT`-overridden server) pass them via\n * `opts` so the self-probe doesn't report \"server not running\".\n */\nexport async function runDoctor(opts: RunDoctorOptions = {}): Promise<DoctorReport> {\n const wsPort = opts.wsPort ?? DEFAULT_WS_PORT;\n const mcpPort = opts.mcpPort ?? DEFAULT_MCP_PORT;\n const r = new Recorder();\n\n await r.check(\"node-version\", () => checkNodeVersion(r));\n await r.check(\"node-modules\", () => checkNodeModules(r));\n await r.check(\"mcp-json\", () => checkMcpJson(r));\n await r.check(\"user-mcp-config\", () => checkUserMcpConfig(r));\n await r.check(\"annotation-store\", () => checkAnnotationStore(r));\n\n const { mcp } = await r.check(\"ports\", () => checkPorts(r, wsPort, mcpPort));\n\n if (mcp) {\n const healthy = await r.check(\"health\", () => checkHealth(r, mcpPort));\n if (healthy) {\n await r.check(\"sse\", () => checkSseEndpoint(r, mcpPort));\n }\n }\n\n return {\n ok: r.failures === 0,\n crashed: false,\n failures: r.failures,\n warnings: r.warnings,\n summary: summarizeDoctorResults(r.failures, r.warnings),\n error: null,\n results: r.results,\n };\n}\n\n// ── Printer + exit-code wrapper ─────────────────────────────────────\n\nexport interface RunDoctorCliOptions {\n json?: boolean;\n}\n\n/** ANSI-colored status tag for the human-readable TTY printer. */\nfunction colorTag(status: DoctorStatus): string {\n switch (status) {\n case \"pass\":\n return \"\\x1b[32m[PASS]\\x1b[0m\";\n case \"warn\":\n return \"\\x1b[33m[WARN]\\x1b[0m\";\n case \"fail\":\n return \"\\x1b[31m[FAIL]\\x1b[0m\";\n }\n}\n\n/**\n * Format the report and apply the shared exit code (0 pass, 1 failures,\n * 2 crash). In `--json` mode stdout is a SINGLE pure JSON document — human\n * lines are suppressed so the stream is machine-parseable. Both `tandem\n * doctor` and `npm run doctor` route through here.\n *\n * Note: writing JSON to stdout is correct for the CLI. Critical Rule #3\n * (\"stdout is reserved\") applies to the MCP stdio server, not this command —\n * `src/cli/index.ts` deliberately uses stdout for `--version`/`--help`.\n */\nexport async function runDoctorCli(opts: RunDoctorCliOptions = {}): Promise<number> {\n const json = opts.json ?? false;\n\n let report: DoctorReport;\n try {\n report = await runDoctor();\n } catch (err) {\n const message = errMsg(err);\n if (json) {\n const crashed: DoctorReport = {\n ok: false,\n crashed: true,\n failures: 0,\n warnings: 0,\n summary: `Tandem Doctor crashed unexpectedly: ${message}`,\n error: message,\n results: [],\n };\n process.stdout.write(`${JSON.stringify(crashed, null, 2)}\\n`);\n } else {\n process.stderr.write(`\\n Tandem Doctor crashed unexpectedly: ${message}\\n`);\n process.stderr.write(\n \" Please report this at https://github.com/bloknayrb/tandem/issues\\n\\n\",\n );\n }\n return 2;\n }\n\n const exitCode = report.failures > 0 ? 1 : 0;\n\n if (json) {\n process.stdout.write(`${JSON.stringify(report, null, 2)}\\n`);\n return exitCode;\n }\n\n // Human-readable TTY output.\n const out = (line: string) => process.stdout.write(`${line}\\n`);\n out(\"\");\n out(\" Tandem Doctor\");\n out(\" =============\");\n out(\"\");\n\n for (const res of report.results) {\n out(` ${colorTag(res.status)} ${res.message}`);\n if (res.status !== \"pass\" && res.fix) {\n out(` Fix: ${res.fix}`);\n }\n }\n\n out(\"\");\n if (report.failures > 0) {\n out(` ${report.failures} issue(s) found. Fix the items above and re-run: tandem doctor`);\n } else if (report.warnings > 0) {\n out(` ${report.warnings} warning(s) — Tandem should work, but check the items above.`);\n } else {\n out(\" All checks passed. Tandem is ready.\");\n }\n out(\"\");\n\n return exitCode;\n}\n","import envPaths from \"env-paths\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { TOKEN_FILE_NAME } from \"../constants.js\";\n\nexport function getTokenFilePath(): string {\n return path.join(envPaths(\"tandem\", { suffix: \"\" }).data, TOKEN_FILE_NAME);\n}\n\nexport async function readTokenFromFile(): Promise<string | null> {\n const filePath = getTokenFilePath();\n try {\n const content = await fs.promises.readFile(filePath, \"utf8\");\n // Remediate insecure permissions if a previous chmod failed (e.g., process crashed).\n if (process.platform !== \"win32\") {\n try {\n const stat = await fs.promises.stat(filePath);\n if ((stat.mode & 0o077) !== 0) {\n console.error(\"[tandem] auth token file has insecure permissions; attempting chmod 0600\");\n await fs.promises.chmod(filePath, 0o600);\n }\n } catch {\n // Non-fatal: stat/chmod failure doesn't invalidate the token we already read\n }\n }\n const trimmed = content.trim();\n return trimmed.length > 0 ? trimmed : null;\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw err;\n }\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport { promises as fsPromises } from \"node:fs\";\nimport path from \"node:path\";\nimport { applyConfigWithToken } from \"../server/integrations/apply.js\";\nimport { API_ROTATE_TOKEN } from \"../shared/api-paths.js\";\nimport { getTokenFilePath, readTokenFromFile } from \"../shared/auth/token-file.js\";\nimport { resolveAuthTokenCandidate, resolveTandemUrl } from \"../shared/cli-runtime.js\";\n\n/** SHA-256 fingerprint — first 8 hex chars. Never logs the full token value. */\nfunction fingerprint(token: string): string {\n return createHash(\"sha256\").update(token, \"utf8\").digest(\"hex\").slice(0, 8);\n}\n\nfunction generateToken(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\nexport async function rotateToken(): Promise<void> {\n console.error(\"\\n[tandem] Rotating auth token...\\n\");\n\n // Refuse to rotate when token comes from env — Tauri injects TANDEM_AUTH_TOKEN\n // before sidecar spawn, and Claude Code's plugin host injects\n // CLAUDE_PLUGIN_OPTION_AUTH_TOKEN from userConfig. In either case we have no\n // way to update the launcher; rotating the file would desync with what's\n // re-injected on the next launch.\n const { source: envAuthSource } = resolveAuthTokenCandidate();\n if (\n envAuthSource === \"TANDEM_AUTH_TOKEN\" ||\n envAuthSource === \"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\"\n ) {\n console.error(\n `[tandem] Error: ${envAuthSource} is set in the environment.\\n` +\n \" Token rotation is not supported in env-token mode (used by Tauri\\n\" +\n \" and Claude Code's plugin host). Unset the variable and let Tandem\\n\" +\n \" manage the token file, or rotate via the launcher's token management.\",\n );\n process.exit(1);\n }\n\n const oldToken = await readTokenFromFile();\n if (!oldToken) {\n console.error(\n \"[tandem] Error: no token file found. Start the server once with `tandem` — it creates the token on first launch.\",\n );\n process.exit(1);\n }\n\n // writeTokenToFile uses O_EXCL; bypass it here — rotation is an intentional overwrite.\n // Use atomic write: write to a temp file first, then rename() into place.\n // rename() is atomic on the same filesystem — power-loss mid-write cannot leave an empty file.\n const newToken = generateToken();\n const tokenPath = getTokenFilePath();\n const dir = path.dirname(tokenPath);\n const tmpPath = path.join(dir, `.auth-token-tmp-${randomBytes(4).toString(\"hex\")}`);\n try {\n await fsPromises.writeFile(tmpPath, newToken, { encoding: \"utf8\", mode: 0o600 });\n await fsPromises.rename(tmpPath, tokenPath);\n } catch (err) {\n await fsPromises.unlink(tmpPath).catch(() => {});\n throw err;\n }\n\n const serverUrl = resolveTandemUrl();\n\n // Three distinct outcomes:\n // graceWindowActive = true → server accepted the rotation; grace window is live\n // serverRejected = true → server reachable but returned non-2xx\n // (neither) → fetch threw; server was not running\n let graceWindowActive = false;\n let serverRejected = false;\n let serverRejectedStatus = 0;\n try {\n const resp = await fetch(`${serverUrl}${API_ROTATE_TOKEN}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${oldToken}`,\n },\n body: JSON.stringify({}),\n signal: AbortSignal.timeout(5000),\n });\n if (resp.ok) {\n graceWindowActive = true;\n } else {\n serverRejected = true;\n serverRejectedStatus = resp.status;\n }\n } catch {\n console.error(\n \"[tandem] Warning: server is not reachable. The new token is written to disk.\\n\" +\n \" Restart the server to activate the grace window; reconnect Claude Code after.\",\n );\n }\n\n let updatedCount = 0;\n let configErrors: string[] = [];\n try {\n const result = await applyConfigWithToken(newToken);\n updatedCount = result.updated;\n configErrors = result.errors;\n } catch (err) {\n console.error(\n `[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n // TODO(v0.8.1): After rotation, re-walk Cowork workspaces to rewrite\n // env.TANDEM_AUTH_TOKEN so post-rotation Cowork sessions don't 401\n // (security invariant §6 — silent-failure H1). The Tauri IPC dynamic import\n // approach is inert here: this CLI runs as a Node subprocess with no WebView,\n // so `@tauri-apps/api/core`'s `invoke()` has no bridge to Rust. The fix is\n // an HTTP bridge — add a POST /api/cowork-apply-token endpoint in the server\n // (guarded by the auth middleware) and call it from here after the server\n // accepts the rotation.\n\n if (serverRejected) {\n // Configs now reference the new token but the server still holds the old one.\n // Print a strong warning — do NOT print \"Rotated auth token\" as that implies success.\n console.error(\n `[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`,\n );\n if (updatedCount > 0) {\n console.error(\n ` ${updatedCount} config file(s) updated to the new token, but the server still\\n` +\n \" holds the old token. Restart the server to complete rotation.\",\n );\n }\n console.error(` Old fingerprint: ${fingerprint(oldToken)}`);\n console.error(` New fingerprint: ${fingerprint(newToken)}`);\n for (const e of configErrors) {\n console.error(` Warning: could not update config — ${e}`);\n }\n console.error(\"\");\n return;\n }\n\n console.error(\"[tandem] Rotated auth token.\");\n console.error(` Old fingerprint: ${fingerprint(oldToken)}`);\n console.error(` New fingerprint: ${fingerprint(newToken)}`);\n console.error(` Updated ${updatedCount} config file(s).`);\n\n for (const e of configErrors) {\n console.error(` Warning: could not update config — ${e}`);\n }\n\n if (graceWindowActive) {\n console.error(\n \" Old token remains valid for 60 seconds; reconnect Claude Code within that window.\",\n );\n } else {\n console.error(\n \" Server was not running — start it with `tandem` and reconnect Claude Code with the new token.\",\n );\n }\n\n console.error(\"\");\n}\n","// The license gate ships DARK behind this build flag (ADR-040 consequences).\n// `__LICENSE_GATE_ENABLED__` is injected by tsup (see tsup.config.ts) into every\n// bundle whose tree can import this module — today the server + cli bundles.\n// Default: false (v0.16.0). Flip the single tsup const to `true` at v1.0.\ndeclare const __LICENSE_GATE_ENABLED__: boolean;\n\n/**\n * Pure flag resolver (injectable for tests). The build-time `define` value wins;\n * absent a define (tsx dev / vitest), the `TANDEM_LICENSE_GATE=1` env var enables\n * the gate so both paths can be exercised without rebuilding.\n */\nexport function readGateFlag(deps: {\n defineValue: boolean | undefined;\n env: Record<string, string | undefined>;\n}): boolean {\n if (typeof deps.defineValue !== \"undefined\") return deps.defineValue;\n return deps.env.TANDEM_LICENSE_GATE === \"1\";\n}\n\nconst defineValue: boolean | undefined =\n typeof __LICENSE_GATE_ENABLED__ !== \"undefined\" ? __LICENSE_GATE_ENABLED__ : undefined;\n\n// Ship-dark guard: a production sidecar bundle MUST carry the define. If it\n// doesn't, we'd silently fall back to the env var and ship dark regardless of\n// the build const — warn loudly so a mis-built bundle is caught at boot.\nif (process.env.TANDEM_TAURI_SIDECAR === \"1\" && typeof defineValue === \"undefined\") {\n console.error(\n \"[license] WARNING: __LICENSE_GATE_ENABLED__ define missing in sidecar bundle — \" +\n \"gate flag is falling back to the TANDEM_LICENSE_GATE env var\",\n );\n}\n\nexport const GATE_ENABLED = readGateFlag({ defineValue, env: process.env });\n","/**\n * Shared offset math for the flat-text coordinate system.\n *\n * The server's extractText() builds a flat string from Y.Doc elements by:\n * 1. Prepending heading prefixes (\"# \", \"## \", \"### \") to heading content\n * 2. Joining elements with \"\\n\" separators\n *\n * Both server (Y.Doc → flat offsets) and client (ProseMirror positions ↔ flat offsets)\n * must agree on these conventions. This module is the single source of truth.\n */\n\n/** Flat-text separator between block elements. */\nexport const FLAT_SEPARATOR = \"\\n\";\n\n/**\n * Length of the heading prefix in flat text for a given heading level.\n * Level 1 → \"# \" (2 chars), level 2 → \"## \" (3 chars), etc.\n * Returns 0 for non-heading nodes (level null/undefined/0).\n */\nexport function headingPrefixLength(level: number | null | undefined): number {\n if (!level) return 0;\n return level + 1;\n}\n\n/**\n * Build the heading prefix string for a given level.\n * Level 1 → \"# \", level 2 → \"## \", etc.\n */\nexport function headingPrefix(level: number): string {\n return \"#\".repeat(level) + \" \";\n}\n","import type { AlignType, PhrasingContent, Root, RootContent, Table } from \"mdast\";\nimport * as Y from \"yjs\";\nimport { serializeMdastBlock, serializeMdastInline } from \"./markdown.js\";\n\nconst MARKDOWN_HTML_ATTR = \"markdownHtml\";\n/**\n * Marks a `paragraph` whose Y.XmlText holds the verbatim markdown source of a\n * construct Tandem has no first-class editor node for (footnote/reference\n * definitions, unknown blocks). Re-emitted as an mdast `html` node on save so\n * it round-trips byte-exact, and surfaced to the editor as `data-markdown-raw`\n * for the show/hide toggle. Sibling to MARKDOWN_HTML_ATTR. See #981 / ADR-042.\n */\nconst MARKDOWN_RAW_ATTR = \"markdownRaw\";\n/**\n * Delta-attribute key for an inline run holding verbatim markdown source\n * (footnoteReference, linkReference, imageReference, inline image, inline html).\n * MUST byte-match the Tiptap `rawMarkdown` Mark name. Listed in ALL_MARKS so\n * `buildAttrs` emits it; read back in `deltaToPhrasingContent`.\n */\nconst RAW_MARKDOWN_MARK = \"rawMarkdown\";\n\n/**\n * Convert an MDAST tree into Y.Doc XmlFragment elements.\n * Block nodes become Y.XmlElements with Tiptap-compatible nodeNames.\n * Inline content becomes formatted Y.XmlText within those elements.\n *\n * Elements are attached to the doc BEFORE text is populated — Yjs\n * requires this for correct insert ordering on Y.XmlText.\n */\nexport function mdastToYDoc(doc: Y.Doc, tree: Root): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Clear existing content in a single operation\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n insertBlocks(doc, tree, 0);\n}\n\n/**\n * Append an MDAST tree's blocks to the END of the Y.Doc's XmlFragment without\n * clearing existing content. Reuses the same two-pass build as `mdastToYDoc`.\n *\n * Append is offset-safe: new elements land after every existing top-level\n * element, so existing flat offsets (and the annotation / authorship ranges\n * anchored to them) are unchanged. Used by the `tandem_appendContent` MCP tool.\n * The caller is responsible for the origin-tagged transaction wrapper.\n */\nexport function appendMdast(doc: Y.Doc, tree: Root): void {\n insertBlocks(doc, tree, doc.getXmlFragment(\"default\").length);\n}\n\n/**\n * Build the tree's blocks and insert them at `index` in the `default` fragment.\n * Two-pass: build detached elements (pass 1), attach via `fragment.insert`,\n * then populate text (pass 2) — Yjs requires Y.XmlText to be attached before\n * insert for correct ordering. `blockToYxml` is fragment-pure, so `index` (read\n * by the caller against the live fragment length) stays valid across pass 1.\n */\nfunction insertBlocks(doc: Y.Doc, tree: Root, index: number): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Pass 1 collects deferred text operations while building the element tree.\n const deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }> = [];\n const allElements: Y.XmlElement[] = [];\n for (const node of tree.children) {\n allElements.push(...blockToYxml(node, deferred));\n }\n\n // Attach all elements to the doc (pass 1 complete)\n if (allElements.length > 0) {\n fragment.insert(index, allElements);\n }\n\n // Pass 2: populate text now that elements are attached to the Y.Doc\n for (const { xmlText, nodes, plainText } of deferred) {\n if (nodes) {\n processInline(xmlText, nodes, {});\n } else if (plainText != null) {\n xmlText.insert(0, plainText);\n }\n }\n}\n\n/** Convert a block-level MDAST node to one or more Y.XmlElements */\nfunction blockToYxml(\n node: RootContent,\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement[] {\n switch (node.type) {\n case \"heading\": {\n const el = new Y.XmlElement(\"heading\");\n el.setAttribute(\"level\", node.depth as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: node.children });\n return [el];\n }\n\n case \"paragraph\": {\n // A standalone markdown image (`![alt](url)`) parses as `paragraph > image`\n // (image is phrasing content). Promote any top-level image children to\n // block-level `image` Y.XmlElements (issue #153) so they render via\n // Tiptap's block Image node. Inline text runs around an image stay as\n // their own paragraphs. Block images live as top-level fragment children\n // with empty getElementText(), preserving flat-offset alignment.\n if (node.children.some((c) => c.type === \"image\")) {\n return splitParagraphImages(node.children, deferred);\n }\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: node.children });\n return [el];\n }\n\n case \"blockquote\": {\n const el = new Y.XmlElement(\"blockquote\");\n let insertIndex = 0;\n for (const child of node.children) {\n const childEls = blockToYxml(child, deferred);\n for (const c of childEls) {\n el.insert(insertIndex, [c]);\n insertIndex++;\n }\n }\n return [el];\n }\n\n case \"list\": {\n const nodeName = node.ordered ? \"orderedList\" : \"bulletList\";\n const el = new Y.XmlElement(nodeName);\n if (node.ordered && node.start != null && node.start !== 1) {\n el.setAttribute(\"start\", node.start as any);\n }\n let listIndex = 0;\n for (const item of node.children) {\n const listItem = new Y.XmlElement(\"listItem\");\n // GFM task list: a list item with non-null `checked` carries the\n // tri-state as an attribute on the ordinary listItem (#982). `null`\n // (plain bullet) stores no attribute so the editor's PM-default `null`\n // reconciles without a phantom transaction. Stored as a real boolean —\n // the same representation y-prosemirror writes when a user toggles the\n // checkbox — so it round-trips byte-identically (yjs ContentAny).\n if (item.checked != null) {\n listItem.setAttribute(\"checked\", item.checked as any);\n }\n let itemIndex = 0;\n for (const child of item.children) {\n const childEls = blockToYxml(child, deferred);\n for (const c of childEls) {\n listItem.insert(itemIndex, [c]);\n itemIndex++;\n }\n }\n el.insert(listIndex, [listItem]);\n listIndex++;\n }\n return [el];\n }\n\n case \"code\": {\n const el = new Y.XmlElement(\"codeBlock\");\n if (node.lang) {\n el.setAttribute(\"language\", node.lang);\n }\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n\n case \"thematicBreak\": {\n return [new Y.XmlElement(\"horizontalRule\")];\n }\n\n case \"table\": {\n // GFM table: first MDAST tableRow becomes a row of tableHeader cells;\n // subsequent rows become tableCell rows. Column alignment is stored as\n // a JSON-stringified table-level \"align\" attribute (matches MDAST's\n // table-level storage and avoids per-cell alignment plumbing).\n //\n // Per CLAUDE.md \"Y.XmlText must be attached before populating\": each\n // cell's paragraph + Y.XmlText is attached to the tree first, and the\n // inline children are pushed to deferred[] for the second pass — same\n // pattern used by paragraph/heading above.\n const tableEl = new Y.XmlElement(\"table\");\n const align: (AlignType | null | undefined)[] = node.align ?? [];\n tableEl.setAttribute(\"align\", JSON.stringify(align) as any);\n node.children.forEach((row, rowIdx) => {\n const rowEl = new Y.XmlElement(\"tableRow\");\n const cellNodeName = rowIdx === 0 ? \"tableHeader\" : \"tableCell\";\n for (const cell of row.children) {\n const cellEl = new Y.XmlElement(cellNodeName);\n // Tiptap wraps cell content in a paragraph; mirror that here so the\n // Y.Doc structure matches what the editor would produce.\n const para = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n // Attach in order: row → cell → paragraph → text. Each child is\n // attached to its parent before deferring inline population.\n cellEl.insert(0, [para]);\n para.insert(0, [text]);\n rowEl.insert(rowEl.length, [cellEl]);\n deferred.push({ xmlText: text, nodes: cell.children });\n }\n tableEl.insert(tableEl.length, [rowEl]);\n });\n return [tableEl];\n }\n\n case \"image\": {\n return [imageToYxml(node)];\n }\n\n case \"html\": {\n const el = new Y.XmlElement(\"paragraph\");\n el.setAttribute(MARKDOWN_HTML_ATTR, true as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n\n // Footnote/reference definitions and any other structured block Tandem has\n // no first-class node for: store the verbatim markdown source in a\n // `paragraph[markdownRaw]` and re-emit as an mdast `html` node on save so\n // nothing is silently dropped (the historical bug — these carry no `.value`\n // so the old default returned []). See #981 / ADR-042.\n case \"footnoteDefinition\":\n case \"definition\":\n return [rawBlockParagraph(serializeMdastBlock(node), deferred)];\n\n default: {\n if (\"value\" in node && typeof node.value === \"string\") {\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n // Unknown structured block: preserve verbatim rather than drop it.\n const serialized = serializeMdastBlock(node);\n return serialized.length > 0 ? [rawBlockParagraph(serialized, deferred)] : [];\n }\n }\n}\n\n/**\n * Build a `paragraph[markdownRaw]` carrying verbatim markdown source as text.\n * Mirrors the `markdownHtml` block pattern: text is deferred to pass 2 so the\n * Y.XmlText is attached before population (CLAUDE.md two-pass rule).\n */\nfunction rawBlockParagraph(\n source: string,\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement {\n const el = new Y.XmlElement(\"paragraph\");\n el.setAttribute(MARKDOWN_RAW_ATTR, true as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: source });\n return el;\n}\n\n/** Build a block-level `image` Y.XmlElement from an MDAST image node. */\nfunction imageToYxml(node: Extract<PhrasingContent, { type: \"image\" }>): Y.XmlElement {\n const el = new Y.XmlElement(\"image\");\n el.setAttribute(\"src\", node.url);\n if (node.alt) el.setAttribute(\"alt\", node.alt);\n if (node.title) el.setAttribute(\"title\", node.title);\n return el;\n}\n\n/**\n * Split a paragraph's phrasing children into block-level `image` elements and\n * paragraphs for the surrounding inline content. Used when a markdown paragraph\n * contains one or more images (issue #153). Each top-level image becomes its own\n * block; runs of non-image phrasing between/around images become paragraphs.\n * Whitespace-only inline runs adjacent to an image are dropped so a lone image\n * doesn't leave an empty paragraph behind; boundary whitespace on real runs is\n * trimmed to avoid serializer escape noise.\n */\nfunction splitParagraphImages(\n children: PhrasingContent[],\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement[] {\n const result: Y.XmlElement[] = [];\n let inlineRun: PhrasingContent[] = [];\n\n const flushInline = () => {\n if (inlineRun.length === 0) return;\n const run = inlineRun;\n inlineRun = [];\n // Trim whitespace at the run boundaries (where it abutted an image) so the\n // serializer doesn't emit `&#x20;` escape noise around the split.\n const first = run[0];\n if (first?.type === \"text\") first.value = first.value.replace(/^\\s+/, \"\");\n const last = run[run.length - 1];\n if (last?.type === \"text\") last.value = last.value.replace(/\\s+$/, \"\");\n const hasContent = run.some((n) => n.type !== \"text\" || n.value.length > 0);\n if (hasContent) {\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: run });\n result.push(el);\n }\n };\n\n for (const child of children) {\n if (child.type === \"image\") {\n flushInline();\n result.push(imageToYxml(child));\n } else {\n inlineRun.push(child);\n }\n }\n flushInline();\n\n return result;\n}\n\n/** All mark names that can appear on inline text */\nconst ALL_MARKS = [\"bold\", \"italic\", \"strike\", \"code\", \"link\", RAW_MARKDOWN_MARK] as const;\n\n/**\n * Build Yjs insert attributes from the current mark stack.\n * Explicitly sets null for inactive marks to prevent Yjs from\n * inheriting formatting from adjacent formatted segments.\n */\nfunction buildAttrs(marks: Record<string, object>): Record<string, object | null> {\n const attrs: Record<string, object | null> = {};\n for (const name of ALL_MARKS) {\n attrs[name] = name in marks ? marks[name] : null;\n }\n return attrs;\n}\n\n/**\n * Insert verbatim markdown source as a `rawMarkdown`-marked text run.\n * MUST use insert-with-attributes (never `insertEmbed`, never a fresh\n * Y.XmlText): the source stays as real text so every character counts 1-for-1\n * in `getElementText()`, keeping flat annotation offsets aligned. An embed\n * would collapse the run to flat-length 1 and desync every later anchor (#981).\n */\nfunction insertRaw(xmlText: Y.XmlText, source: string, marks: Record<string, object>): void {\n if (source.length === 0) return;\n xmlText.insert(xmlText.length, source, buildAttrs({ ...marks, [RAW_MARKDOWN_MARK]: {} }));\n}\n\n/**\n * Process inline/phrasing MDAST nodes into a single Y.XmlText with marks.\n * Uses insert-with-attributes (not insert + format) because Yjs requires\n * the Y.XmlText to be attached to a doc for format() to preserve order.\n */\nfunction processInline(\n xmlText: Y.XmlText,\n nodes: PhrasingContent[],\n marks: Record<string, object>,\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case \"text\": {\n xmlText.insert(xmlText.length, node.value, buildAttrs(marks));\n break;\n }\n\n case \"strong\":\n processInline(xmlText, node.children, { ...marks, bold: {} });\n break;\n\n case \"emphasis\":\n processInline(xmlText, node.children, { ...marks, italic: {} });\n break;\n\n case \"delete\":\n processInline(xmlText, node.children, { ...marks, strike: {} });\n break;\n\n case \"inlineCode\": {\n xmlText.insert(xmlText.length, node.value, buildAttrs({ ...marks, code: {} }));\n break;\n }\n\n case \"link\":\n processInline(xmlText, node.children, {\n ...marks,\n link: { href: node.url, ...(node.title ? { title: node.title } : {}) },\n });\n break;\n\n case \"break\": {\n const embed = new Y.XmlElement(\"hardBreak\");\n xmlText.insertEmbed(xmlText.length, embed);\n break;\n }\n\n case \"image\": {\n // Truly-inline images (standalone images are promoted to block-level\n // `image` nodes before reaching here, see the paragraph case). Preserve\n // the full `![alt](url \"title\")` source as a raw run so the URL/title\n // survive the round-trip instead of degrading to alt-text only (#981).\n insertRaw(xmlText, serializeMdastInline(node) || node.alt || node.url, marks);\n break;\n }\n\n // footnoteReference / linkReference / imageReference carry no `.value`;\n // serialize each to its verbatim markdown source and store as a raw run so\n // the construct round-trips (the historical silent drop). See #981.\n case \"footnoteReference\":\n case \"linkReference\":\n case \"imageReference\":\n insertRaw(xmlText, serializeMdastInline(node), marks);\n break;\n\n // Inline (phrasing) HTML. mdast emits one `html` node per tag, so paired\n // tags like <span>…</span> arrive as separate nodes around real prose;\n // mark each tag's value as raw — the prose between stays normal text.\n case \"html\":\n insertRaw(xmlText, node.value, marks);\n break;\n\n default: {\n // Unreachable for the static PhrasingContent union (all variants are\n // cased above) — kept as a runtime net for any plugin-added node type.\n // `node` is `never` here, so widen through `unknown` before inspecting.\n const widened = node as unknown as PhrasingContent & { value?: string };\n const src = serializeMdastInline(widened);\n if (src.length > 0) {\n insertRaw(xmlText, src, marks);\n } else if (typeof widened.value === \"string\") {\n xmlText.insert(xmlText.length, widened.value, buildAttrs(marks));\n }\n break;\n }\n }\n }\n}\n\n/**\n * Convert a Y.Doc's XmlFragment back to an MDAST Root tree.\n */\nexport function yDocToMdast(doc: Y.Doc): Root {\n const fragment = doc.getXmlFragment(\"default\");\n const children: RootContent[] = [];\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n const mdastNode = yxmlToMdast(node);\n if (mdastNode) children.push(mdastNode);\n }\n }\n\n return { type: \"root\", children };\n}\n\n/** Convert a Y.XmlElement back to an MDAST block node */\nfunction yxmlToMdast(el: Y.XmlElement): RootContent | null {\n switch (el.nodeName) {\n case \"heading\": {\n const depth = Number(el.getAttribute(\"level\") ?? 1) as 1 | 2 | 3 | 4 | 5 | 6;\n return { type: \"heading\", depth, children: deltaToPhrasingContent(el) };\n }\n\n case \"paragraph\":\n // Both markdownRaw (footnote/reference defs, unknown blocks) and\n // markdownHtml (raw HTML blocks) re-emit as an mdast `html` node — its\n // value serializes verbatim, reproducing the original source exactly.\n if (el.getAttribute(MARKDOWN_RAW_ATTR) || el.getAttribute(MARKDOWN_HTML_ATTR)) {\n return { type: \"html\", value: getElementPlainText(el) } as any;\n }\n return { type: \"paragraph\", children: deltaToPhrasingContent(el) };\n\n case \"blockquote\": {\n const children: RootContent[] = [];\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlElement) {\n const m = yxmlToMdast(child);\n if (m) children.push(m);\n }\n }\n // blockquote.children is BlockContent[] but RootContent covers it\n return { type: \"blockquote\", children: children as any };\n }\n\n case \"bulletList\":\n case \"orderedList\": {\n const ordered = el.nodeName === \"orderedList\";\n const start = ordered ? Number(el.getAttribute(\"start\")) || 1 : undefined;\n const listItems: any[] = [];\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlElement && child.nodeName === \"listItem\") {\n const itemChildren: any[] = [];\n for (let j = 0; j < child.length; j++) {\n const grandchild = child.get(j);\n if (grandchild instanceof Y.XmlElement) {\n const m = yxmlToMdast(grandchild);\n if (m) itemChildren.push(m);\n }\n }\n // GFM task list (#982): re-emit the per-item `checked` tri-state so\n // remark-gfm stringifies `- [ ]` / `- [x]`. Absent attribute → plain\n // bullet (no `checked` field). Read tolerantly (boolean from\n // y-prosemirror / server writes, or a string from any future writer).\n const checkedAttr = child.getAttribute(\"checked\") as boolean | string | undefined;\n const listItemNode: any = { type: \"listItem\", spread: false, children: itemChildren };\n if (checkedAttr != null) {\n listItemNode.checked = checkedAttr === true || checkedAttr === \"true\";\n }\n listItems.push(listItemNode);\n }\n }\n return {\n type: \"list\",\n ordered,\n spread: false,\n ...(ordered && start !== 1 ? { start } : {}),\n children: listItems,\n } as any;\n }\n\n case \"codeBlock\": {\n const lang = el.getAttribute(\"language\") as string | undefined;\n let value = \"\";\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlText) {\n value += child.toString();\n }\n }\n return { type: \"code\", lang: lang || null, value } as any;\n }\n\n case \"horizontalRule\":\n return { type: \"thematicBreak\" };\n\n case \"table\": {\n // Read column alignment from the table-level \"align\" attribute (stored\n // as JSON). Default to [] if missing or unparseable — alignment is\n // optional in GFM, body-row alignment attrs are ignored.\n let align: (AlignType | null)[] = [];\n const rawAlign = el.getAttribute(\"align\");\n if (typeof rawAlign === \"string\" && rawAlign.length > 0) {\n try {\n const parsed = JSON.parse(rawAlign);\n if (Array.isArray(parsed)) align = parsed as (AlignType | null)[];\n } catch {\n // fall through with empty align\n }\n }\n const rows: any[] = [];\n for (let i = 0; i < el.length; i++) {\n const rowChild = el.get(i);\n if (!(rowChild instanceof Y.XmlElement) || rowChild.nodeName !== \"tableRow\") {\n continue;\n }\n const cells: any[] = [];\n for (let j = 0; j < rowChild.length; j++) {\n const cellChild = rowChild.get(j);\n if (\n cellChild instanceof Y.XmlElement &&\n (cellChild.nodeName === \"tableHeader\" || cellChild.nodeName === \"tableCell\")\n ) {\n cells.push({ type: \"tableCell\", children: cellToPhrasingContent(cellChild) });\n }\n }\n rows.push({ type: \"tableRow\", children: cells });\n }\n return { type: \"table\", align, children: rows } as Table;\n }\n\n case \"image\": {\n // MDAST `image` is phrasing content, not a valid direct root child.\n // Wrap it in a paragraph so remark-stringify emits proper block\n // separation (a bare root-level image serializes with no surrounding\n // newlines, mangling the document on save). Mirrors how remark-parse\n // produces `paragraph > image` for a standalone `![alt](url)` (#153).\n const image = {\n type: \"image\",\n url: (el.getAttribute(\"src\") as string) || \"\",\n alt: (el.getAttribute(\"alt\") as string) || undefined,\n title: (el.getAttribute(\"title\") as string) || null,\n };\n return { type: \"paragraph\", children: [image] } as any;\n }\n\n // Unknown node types — try to extract text content as a paragraph\n default: {\n const phrasing = deltaToPhrasingContent(el);\n if (phrasing.length > 0) {\n return { type: \"paragraph\", children: phrasing };\n }\n return null;\n }\n }\n}\n\n/**\n * Strip y-prosemirror hash suffixes from attribute keys.\n * y-prosemirror appends \"--<hash>\" to mark names in delta attributes.\n */\nfunction stripHashSuffix(key: string): string {\n const dashIdx = key.indexOf(\"--\");\n return dashIdx >= 0 ? key.slice(0, dashIdx) : key;\n}\n\nfunction getElementPlainText(el: Y.XmlElement): string {\n let value = \"\";\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlText) value += child.toString();\n }\n return value;\n}\n\n/**\n * Convert Y.XmlText delta segments into MDAST phrasing content.\n * Handles marks (bold, italic, strike, code, link) and hardBreak embeds.\n */\nfunction deltaToPhrasingContent(el: Y.XmlElement): PhrasingContent[] {\n const result: PhrasingContent[] = [];\n\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n\n if (child instanceof Y.XmlText) {\n const delta = child.toDelta();\n for (const op of delta) {\n // Embedded elements (hardBreak, etc.)\n if (typeof op.insert !== \"string\") {\n if (op.insert instanceof Y.XmlElement && op.insert.nodeName === \"hardBreak\") {\n result.push({ type: \"break\" });\n }\n continue;\n }\n\n const text = op.insert;\n if (text.length === 0) continue;\n\n // Collect marks from delta attributes\n const attrs = op.attributes || {};\n const marks = new Map<string, any>();\n for (const [key, value] of Object.entries(attrs)) {\n marks.set(stripHashSuffix(key), value);\n }\n\n // A `rawMarkdown` run is verbatim markdown source (footnote/reference\n // refs, inline image, inline html). Emit as an inline `html` node — it\n // serializes byte-exact, bypassing the `text` escaper (PhrasingContent\n // includes Html, so the cast is structural only). It then flows through\n // the SAME link/strike/italic/bold wrapping below as ordinary text, so:\n // (a) an outer mark on the run is preserved (e.g. bold around a\n // footnote ref), and\n // (b) crucially, a raw inline IMAGE stays wrapped inside its mark\n // rather than becoming a bare paragraph-child image — which the\n // #153 `splitParagraphImages` promotion would otherwise turn into\n // a block image on reload, collapsing the inline run's flat length\n // and desyncing every later annotation offset.\n // Two adjacent UNMARKED raw runs (e.g. `[^1][^2]`) stay separate: `html`\n // has no wrapper, so `coalescePhrasing`'s `sameWrapper` never merges them.\n //\n // `code` is a leaf-level mark: the segment is either an inlineCode leaf\n // or a plain-text leaf. link/strike/italic/bold then each wrap whatever\n // `node` is — inlineCode is valid PhrasingContent inside all of them, so\n // a code span keeps its mark even when combined with bold/italic/etc.\n let node: PhrasingContent = marks.has(RAW_MARKDOWN_MARK)\n ? ({ type: \"html\", value: text } as any)\n : marks.has(\"code\")\n ? { type: \"inlineCode\", value: text }\n : { type: \"text\", value: text };\n\n // Wrap from innermost to outermost: link, then strike, italic, bold.\n if (marks.has(\"link\")) {\n const linkAttrs = marks.get(\"link\") || {};\n node = {\n type: \"link\",\n url: linkAttrs.href || \"\",\n ...(linkAttrs.title ? { title: linkAttrs.title } : {}),\n children: [node],\n };\n }\n if (marks.has(\"strike\")) {\n node = { type: \"delete\", children: [node] } as any;\n }\n if (marks.has(\"italic\")) {\n node = { type: \"emphasis\", children: [node] };\n }\n if (marks.has(\"bold\")) {\n node = { type: \"strong\", children: [node] };\n }\n\n result.push(node);\n }\n } else if (child instanceof Y.XmlElement) {\n // Non-text child elements embedded in a block (shouldn't happen often)\n if (child.nodeName === \"hardBreak\") {\n result.push({ type: \"break\" });\n }\n }\n }\n\n return coalescePhrasing(result);\n}\n\n/**\n * Merge adjacent phrasing nodes that share the same wrapper (strong/emphasis/\n * delete, or a link with identical url+title) into one node, recursing into\n * children. Each delta segment is wrapped independently above, so a bold run\n * containing a code span produces a `strong > text` node adjacent to a\n * `strong > inlineCode` node. Left un-merged, remark-stringify pads the two\n * adjacent emphasis runs with `&#x20;` / doubled `**`, corrupting the file on\n * save. Y.js `toDelta()` already collapses runs with identical attributes, so\n * the only adjacent same-wrapper nodes here differ in some inner mark.\n */\nfunction coalescePhrasing(nodes: PhrasingContent[]): PhrasingContent[] {\n const out: PhrasingContent[] = [];\n for (const node of nodes) {\n const prev = out[out.length - 1];\n if (prev && sameWrapper(prev, node)) {\n const merged = prev as Extract<PhrasingContent, { children: PhrasingContent[] }>;\n merged.children = coalescePhrasing([...merged.children, ...(node as typeof merged).children]);\n } else {\n out.push(node);\n }\n }\n return out;\n}\n\nfunction sameWrapper(a: PhrasingContent, b: PhrasingContent): boolean {\n if (a.type !== b.type) return false;\n switch (a.type) {\n case \"strong\":\n case \"emphasis\":\n case \"delete\":\n return true;\n case \"link\": {\n const al = a as Extract<PhrasingContent, { type: \"link\" }>;\n const bl = b as Extract<PhrasingContent, { type: \"link\" }>;\n return al.url === bl.url && al.title === bl.title;\n }\n default:\n return false;\n }\n}\n\nfunction cellToPhrasingContent(cell: Y.XmlElement): PhrasingContent[] {\n const chunks: PhrasingContent[][] = [];\n\n for (let i = 0; i < cell.length; i++) {\n const child = cell.get(i);\n if (child instanceof Y.XmlElement) {\n const chunk =\n child.nodeName === \"paragraph\"\n ? deltaToPhrasingContent(child)\n : plainTextToPhrasingContent(plainTextFromElement(child));\n if (isNonEmptyPhrasing(chunk)) chunks.push(chunk);\n } else if (child instanceof Y.XmlText) {\n const direct = deltaToPhrasingContent(cell);\n if (isNonEmptyPhrasing(direct)) chunks.push(direct);\n break;\n }\n }\n\n const result: PhrasingContent[] = [];\n chunks.forEach((chunk, index) => {\n if (index > 0) result.push({ type: \"text\", value: \" \" });\n result.push(...chunk);\n });\n return result;\n}\n\nfunction isNonEmptyPhrasing(nodes: PhrasingContent[]): boolean {\n return nodes.some((node) => phrasingPlainText(node).trim().length > 0);\n}\n\nfunction phrasingPlainText(node: PhrasingContent): string {\n switch (node.type) {\n case \"text\":\n case \"inlineCode\":\n return node.value;\n case \"break\":\n return \"\\n\";\n case \"strong\":\n case \"emphasis\":\n case \"delete\":\n case \"link\":\n return node.children.map(phrasingPlainText).join(\"\");\n case \"image\":\n return node.alt ?? \"\";\n default:\n return \"value\" in node && typeof node.value === \"string\" ? node.value : \"\";\n }\n}\n\nfunction plainTextToPhrasingContent(text: string): PhrasingContent[] {\n return text.trim().length > 0 ? [{ type: \"text\", value: text }] : [];\n}\n\nfunction plainTextFromElement(element: Y.XmlElement): string {\n const parts: string[] = [];\n let hasPriorContent = false;\n\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n parts.push(xmlTextToPlainText(child));\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (child.nodeName === \"hardBreak\") {\n parts.push(\"\\n\");\n hasPriorContent = true;\n } else {\n if (hasPriorContent) parts.push(\"\\n\");\n parts.push(plainTextFromElement(child));\n hasPriorContent = true;\n }\n }\n }\n\n return parts.join(\"\");\n}\n\nfunction xmlTextToPlainText(xmlText: Y.XmlText): string {\n let text = \"\";\n for (const op of xmlText.toDelta()) {\n text += typeof op.insert === \"string\" ? op.insert : \"\\n\";\n }\n return text;\n}\n","import type { PhrasingContent, Root, RootContent } from \"mdast\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkParse from \"remark-parse\";\nimport remarkStringify from \"remark-stringify\";\nimport { unified } from \"unified\";\nimport { visit } from \"unist-util-visit\";\nimport * as Y from \"yjs\";\nimport { mdastToYDoc, yDocToMdast } from \"./mdast-ydoc.js\";\n\n/**\n * Normalize a reference label to mdast's canonical form (whitespace collapsed,\n * trimmed, lowercased). Matches what `mdast-util-from-markdown` stores in\n * `definition.identifier`. We don't use `micromark-util-normalize-identifier`\n * because that utility ends in `.toUpperCase()` while mdast's stored\n * `identifier` is lowercase.\n */\nfunction normalizeLabel(s: string): string {\n return s\n .replace(/[\\t\\n\\r ]+/g, \" \")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Host shape (anchored at the char after `@`) that can re-form a GFM email\n * autolink-literal: a run of host chars (`[A-Za-z0-9._-]`) containing a dot\n * followed by a letter-bearing final label. Deliberately conservative — it\n * KEEPS the `\\@` escape for anything host-shaped and only un-escapes positions\n * that provably cannot autolink:\n * - no dot at all (`user@host`) -> safe to un-escape\n * - numeric-only final label (`user@a.1`) -> safe to un-escape\n * - `@` not followed by a host run -> safe to un-escape\n * It intentionally matches a few non-autolinking shapes (e.g. `host..com`,\n * which has an empty middle label) — over-keeping leaves harmless escape noise,\n * whereas under-keeping would re-form a link. Verified zero false-negatives\n * (no autolink-forming host is un-escaped) against the GFM autolink boundary,\n * including the leading-dot host case `user@.com`. See the `text` handler step\n * 5. The classes don't nest with overlapping quantifiers, so matching is linear\n * on adversarial input.\n */\nconst HOST_AFTER_AT = /^[A-Za-z0-9._-]*\\.[A-Za-z0-9_-]*[A-Za-z]/;\n\n/** Markdown parser shared by production and tests. */\nexport const mdParser = unified().use(remarkParse).use(remarkGfm).freeze();\n\nconst stringifyOptions = {\n bullet: \"-\",\n emphasis: \"*\",\n strong: \"*\",\n listItemIndent: \"one\",\n rule: \"-\",\n} as const;\n\n/** Parse markdown string and populate a Y.Doc's XmlFragment */\nexport function loadMarkdown(doc: Y.Doc, markdown: string): void {\n const tree = mdParser.parse(markdown) as Root;\n mdastToYDoc(doc, tree);\n}\n\n/** Serialize a Y.Doc's XmlFragment back to markdown */\nexport function saveMarkdown(doc: Y.Doc): string {\n return serializeMdast(yDocToMdast(doc));\n}\n\n/**\n * Ref-def identifiers for the tree currently being serialized. Module-level so\n * the frozen `mdStringifier` below can be built once (not rebuilt per call — the\n * serializer is invoked per raw-construct node on document load, #981); set\n * synchronously before each `mdStringifier.stringify(...)` call. Safe because\n * stringify is synchronous, single-threaded, and never re-entrant.\n */\nlet activeRefDefs = new Set<string>();\n\n/**\n * The project's configured markdown stringifier, frozen once (mirrors the\n * `mdParser` pattern). The custom `text` handler reads `activeRefDefs` at call\n * time, so the same frozen processor serves every tree.\n */\nconst mdStringifier = unified()\n .use(remarkGfm)\n .use(remarkStringify, {\n ...stringifyOptions,\n handlers: {\n // Call state.safe() first (mirroring the default text handler) so\n // block-context escapes (line-leading `# `, `- `, `> `, fence runs,\n // table pipes, setext underlines) remain intact, then selectively\n // un-escape intra-text noise that the default `unsafe` table over-flags.\n //\n // GFM extensions (autolink-literal `@`/`.`/`:`, strikethrough `~~`,\n // table `|`) register no `text` handler and contribute `unsafe` entries\n // that flow through safe(). Of those, `~` and (conditionally) `@` are\n // un-escaped below — `~` because GFM strikethrough requires `~~`, and\n // `@` only when the following text cannot re-form an email autolink.\n text(node, _parent, state, info) {\n let s = state.safe(node.value, info);\n\n // Will the next sibling's serialized output start with `[`? If yes,\n // a `\\[label]` at the end of our text node must stay escaped — the\n // un-escaped `[label][...]` would be parsed as a full reference link.\n const nextStartsBracket = typeof info.after === \"string\" && info.after.startsWith(\"[\");\n\n // 1. `\\[label]`: un-escape only when `label` does NOT match any\n // `definition` identifier in this tree (otherwise the un-escaped\n // form would re-parse as a collapsed/full reference link).\n // `label` is normalized per CommonMark before comparison.\n // Negative lookahead also rejects an immediately following `[`\n // inside this text node.\n // The label character class excludes `\\` to keep matching linear\n // on adversarial input like `\\[\\[\\[\\[\\[…`.\n s = s.replace(/\\\\\\[([^\\\\\\]\\n`]+)\\](?!\\s*[:([])/g, (match, label, offset) => {\n const atEnd = offset + match.length === s.length;\n if (atEnd && nextStartsBracket) return match;\n return activeRefDefs.has(normalizeLabel(label)) ? match : `[${label}]`;\n });\n\n // 2. `\\_` strictly between word chars: intra-word underscores never\n // open emphasis in CommonMark/GFM. Punctuation-flanked `_` (e.g.\n // `(\\_foo\\_)`) stays escaped — those flanks CAN form emphasis.\n s = s.replace(/(?<=\\w)\\\\_(?=\\w)/g, \"_\");\n\n // 3. `` \\` `` standalone, not adjacent to another backtick. Real code\n // spans round-trip through the `inlineCode` handler, never `text`.\n s = s.replace(/(?<![`\\\\])\\\\`(?!`)/g, \"`\");\n\n // 4. `\\~` not followed by another `~`. GFM strikethrough needs `~~`\n // so a lone `~` is unambiguous prose (e.g. `~4500 tokens`).\n s = s.replace(/\\\\~(?!~)/g, \"~\");\n\n // 5. `\\@` only where the following text is NOT host-shaped. remark-gfm\n // escapes `@` whenever a word-ish local-part char precedes it\n // (`user\\@host.tld`, `user\\@host`), so the local side is implicit\n // and the decision turns on what FOLLOWS `@` (see HOST_AFTER_AT).\n // Where a host shape follows, keep the escape — that is the\n // position a GFM email autolink-literal occupies, so un-escaping\n // there would re-emit prose that *appears* to invite the autolink,\n // mirroring the chain's conservative posture for `\\[`/`\\_`.\n // (CommonMark un-escapes `\\@`→`@` at parse time and the autolink\n // forms from the bare `@` regardless, so the escape is cosmetic at\n // parser level; the point is to strip escape noise only where it is\n // unambiguously safe.)\n s = s.replace(/\\\\@/g, (match, offset) =>\n HOST_AFTER_AT.test(s.slice(offset + match.length)) ? match : \"@\",\n );\n\n return s;\n },\n },\n })\n .freeze();\n\n/**\n * Serialize an mdast Root tree to markdown using the project's configured\n * serializer. Exposed for tests and any future code path that has an mdast\n * tree but no Y.Doc. Reuses the frozen `mdStringifier`; the per-tree ref-def\n * set is published to `activeRefDefs` before stringify (already lowercase +\n * whitespace-collapsed by mdast/from-markdown; labels pulled from text nodes\n * are re-normalized before comparison in the handler).\n */\nexport function serializeMdast(tree: Root): string {\n activeRefDefs = new Set<string>();\n visit(tree, \"definition\", (node) => {\n activeRefDefs.add(node.identifier);\n });\n return mdStringifier.stringify(tree);\n}\n\n/**\n * Serialize a single block-level MDAST node to its verbatim markdown source.\n * Used by the raw-construct passthrough path (#981): constructs Tandem has no\n * first-class editor node for (footnote/reference definitions, etc.) are stored\n * as their literal markdown text and re-emitted as an mdast `html` node, which\n * `remark-stringify` writes verbatim (bypassing the custom `text` escaper). The\n * block node is wrapped in a `root` directly. `trimEnd()` strips the trailing\n * newline the serializer appends so no stray `\\n` enters the paragraph's\n * Y.XmlText (which would desync flat offsets).\n */\nexport function serializeMdastBlock(node: RootContent): string {\n return serializeMdast({ type: \"root\", children: [node] }).trimEnd();\n}\n\n/**\n * Serialize a single inline/phrasing MDAST node (footnoteReference,\n * linkReference, imageReference, inline image, etc.) to its markdown source.\n * Phrasing nodes are not valid root children, so they are wrapped in a\n * `paragraph`. The result is the gfm-canonical form for the node's\n * `referenceType` (full `[t][ref]`, collapsed `[ref][]`, shortcut `[ref]`).\n */\nexport function serializeMdastInline(node: PhrasingContent): string {\n return serializeMdast({\n type: \"root\",\n children: [{ type: \"paragraph\", children: [node] }],\n }).trim();\n}\n","import path from \"path\";\nimport * as Y from \"yjs\";\nimport {\n FLAT_SEPARATOR,\n headingPrefix,\n headingPrefixLength as sharedHeadingPrefixLength,\n} from \"../../shared/offsets.js\";\nimport { saveMarkdown } from \"../file-io/markdown.js\";\n\n/**\n * Detect file format from extension.\n */\nexport function detectFormat(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase();\n switch (ext) {\n case \".md\":\n return \"md\";\n case \".txt\":\n return \"txt\";\n case \".html\":\n case \".htm\":\n return \"html\";\n case \".docx\":\n return \"docx\";\n default:\n return \"txt\";\n }\n}\n\n/**\n * Generate a stable, readable document ID from a file path.\n * Used as both the map key and the Hocuspocus room name.\n */\nexport function docIdFromPath(filePath: string): string {\n const normalized = filePath.replace(/\\\\/g, \"/\").toLowerCase();\n let hash = 0;\n for (let i = 0; i < normalized.length; i++) {\n hash = ((hash << 5) - hash + normalized.charCodeAt(i)) | 0;\n }\n const name = path\n .basename(normalized, path.extname(normalized))\n .replace(/[^a-zA-Z0-9]/g, \"-\")\n .replace(/-+/g, \"-\")\n .slice(0, 16);\n return `${name}-${Math.abs(hash).toString(36).slice(0, 6)}`;\n}\n\n/** Insert text content into a Y.Doc's XmlFragment as paragraphs */\nexport function populateYDoc(doc: Y.Doc, text: string): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n if (text === \"\") return;\n\n const lines = text.split(\"\\n\");\n for (const line of lines) {\n if (line === \"\") {\n const empty = new Y.XmlElement(\"paragraph\");\n empty.insert(0, [new Y.XmlText(\"\")]);\n fragment.insert(fragment.length, [empty]);\n continue;\n }\n\n let element: Y.XmlElement;\n\n if (line.startsWith(\"### \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 3 as any);\n element.insert(0, [new Y.XmlText(line.slice(4))]);\n } else if (line.startsWith(\"## \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 2 as any);\n element.insert(0, [new Y.XmlText(line.slice(3))]);\n } else if (line.startsWith(\"# \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 1 as any);\n element.insert(0, [new Y.XmlText(line.slice(2))]);\n } else {\n element = new Y.XmlElement(\"paragraph\");\n element.insert(0, [new Y.XmlText(line)]);\n }\n\n fragment.insert(fragment.length, [element]);\n }\n}\n\n/**\n * Extract plain text from a Y.XmlElement by recursively collecting Y.XmlText content.\n * Inserts FLAT_SEPARATOR between nested XmlElement children so offsets are consistent\n * with the document-level separator convention (e.g., list items and table cells\n * get \\n between them).\n *\n * Separator contract (must stay in sync with getElementTextLength):\n * every gap between nested block/container XmlElement children contributes one\n * FLAT_SEPARATOR character. Offset helpers account for that as a one-character\n * between-element gap.\n */\nexport function getElementText(element: Y.XmlElement): string {\n const parts: string[] = [];\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n for (const op of child.toDelta()) {\n if (typeof op.insert === \"string\") {\n parts.push(op.insert);\n } else {\n // Embed (hardBreak, etc.) — emit \\n to keep flat offset aligned\n // with Y.XmlText internal index (embeds count as 1 in xmlText.length)\n parts.push(\"\\n\");\n }\n }\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) parts.push(FLAT_SEPARATOR);\n parts.push(getElementText(child));\n hasPriorContent = true;\n }\n }\n return parts.join(\"\");\n}\n\n/**\n * Compute the flat text length of a Y.XmlElement without building the string.\n * Uses the same one-character separator invariant as getElementText().\n */\nexport function getElementTextLength(element: Y.XmlElement): number {\n let len = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n len += child.length;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) len += 1;\n len += getElementTextLength(child);\n hasPriorContent = true;\n }\n }\n return len;\n}\n\n/**\n * Find the Y.XmlText that contains a given flat text offset within a Y.XmlElement.\n * Returns the XmlText and the offset within it, or null if the offset falls on a\n * separator character or cannot be resolved.\n */\nexport function findXmlTextAtOffset(\n element: Y.XmlElement,\n textOffset: number,\n): { xmlText: Y.XmlText; offsetInXmlText: number } | null {\n let accumulated = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n const len = child.length;\n if (accumulated + len > textOffset) {\n return { xmlText: child, offsetInXmlText: textOffset - accumulated };\n }\n accumulated += len;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) {\n if (textOffset === accumulated) {\n // Offset lands ON the separator — return null (between-element gap)\n return null;\n }\n accumulated += 1;\n }\n const childTextLen = getElementTextLength(child);\n if (accumulated + childTextLen > textOffset) {\n return findXmlTextAtOffset(child, textOffset - accumulated);\n }\n accumulated += childTextLen;\n hasPriorContent = true;\n }\n }\n // Handle end-of-element: offset equals total length\n if (textOffset === accumulated) {\n // Walk backwards to find the last XmlText\n for (let i = element.length - 1; i >= 0; i--) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n return { xmlText: child, offsetInXmlText: child.length };\n } else if (child instanceof Y.XmlElement) {\n return findXmlTextAtOffset(child, getElementTextLength(child));\n }\n }\n }\n return null;\n}\n\n/**\n * Collect all Y.XmlText nodes in a Y.XmlElement with their flat offsets from the\n * element's start. Uses the same one-character separator invariant as getElementText().\n */\nexport function collectXmlTexts(\n element: Y.XmlElement,\n): Array<{ xmlText: Y.XmlText; offsetFromStart: number }> {\n const results: Array<{ xmlText: Y.XmlText; offsetFromStart: number }> = [];\n let accumulated = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n results.push({ xmlText: child, offsetFromStart: accumulated });\n accumulated += child.length;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) accumulated += 1;\n for (const nested of collectXmlTexts(child)) {\n results.push({\n xmlText: nested.xmlText,\n offsetFromStart: accumulated + nested.offsetFromStart,\n });\n }\n accumulated += getElementTextLength(child);\n hasPriorContent = true;\n }\n }\n return results;\n}\n\n/** Extract plain text from a Y.Doc's XmlFragment */\nexport function extractText(doc: Y.Doc): string {\n const fragment = doc.getXmlFragment(\"default\");\n const lines: string[] = [];\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n const text = getElementText(node);\n if (node.nodeName === \"heading\") {\n const level = Number(node.getAttribute(\"level\") ?? 1);\n lines.push(headingPrefix(level) + text);\n } else {\n lines.push(text);\n }\n }\n }\n\n return lines.join(FLAT_SEPARATOR);\n}\n\n/**\n * Extract readable markdown from a Y.Doc via remark serialization.\n * NOT used by resolveToElement or tandem_edit (those use extractText).\n */\nexport function extractMarkdown(doc: Y.Doc): string {\n return saveMarkdown(doc).trimEnd();\n}\n\n/**\n * Get the heading prefix length for a Y.XmlElement.\n * Delegates to shared headingPrefixLength for the actual math.\n */\nexport function getHeadingPrefixLength(node: Y.XmlElement): number {\n if (node.nodeName === \"heading\") {\n const level = Number(node.getAttribute(\"level\") ?? 1);\n return sharedHeadingPrefixLength(level);\n }\n return 0;\n}\n\n// -- Range staleness detection ------------------------------------------------\n\nexport type RangeVerifyResult =\n | { valid: true }\n | { valid: false; gone: true }\n | { valid: false; gone: false; resolvedFrom: number; resolvedTo: number };\n\n/**\n * Check whether [from, to] still contains textSnapshot. If not, search the\n * full document and return the relocated range or { gone: true }.\n */\nexport function verifyAndResolveRange(\n doc: Y.Doc,\n from: number,\n to: number,\n textSnapshot: string | undefined,\n): RangeVerifyResult {\n if (!textSnapshot) return { valid: true };\n const fullText = extractText(doc);\n if (fullText.slice(from, to) === textSnapshot) return { valid: true };\n const candidates: number[] = [];\n let searchFrom = 0;\n while (true) {\n const idx = fullText.indexOf(textSnapshot, searchFrom);\n if (idx === -1) break;\n candidates.push(idx);\n searchFrom = idx + 1;\n }\n if (candidates.length === 0) return { valid: false, gone: true };\n const best = candidates.reduce((a, b) => (Math.abs(a - from) <= Math.abs(b - from) ? a : b));\n return { valid: false, gone: false, resolvedFrom: best, resolvedTo: best + textSnapshot.length };\n}\n\n/**\n * Find the first Y.XmlText child of a Y.XmlElement (read-only).\n * Returns null if no XmlText child exists.\n */\nexport function findXmlText(element: Y.XmlElement): Y.XmlText | null {\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n return child;\n }\n }\n return null;\n}\n\nexport const TEXTBLOCK_NODES = new Set([\"paragraph\", \"heading\", \"codeBlock\"]);\n\n/**\n * Merge all delta segments from `source` into `target` at `offset`,\n * preserving inline formatting and embeds. XmlElement embeds (hardBreak)\n * are cloned to avoid moving attached nodes out of `source`.\n */\nexport function mergeXmlTextDelta(target: Y.XmlText, source: Y.XmlText, offset: number): void {\n let pos = offset;\n for (const seg of source.toDelta()) {\n // Pass {} (not undefined) — Y.js insert(pos, str, undefined) inherits\n // formatting from the preceding character; insert(pos, str, {}) terminates it.\n if (typeof seg.insert === \"string\") {\n target.insert(pos, seg.insert, seg.attributes ?? {});\n pos += seg.insert.length;\n } else {\n const embed = seg.insert instanceof Y.XmlElement ? seg.insert.clone() : { ...seg.insert };\n target.insertEmbed(pos, embed, seg.attributes ?? {});\n pos += 1;\n }\n }\n}\n\n/**\n * Return the XmlText child of a textblock element, creating one if empty.\n * Throws on non-textblock nodes (containers like blockquote, bulletList, etc.).\n */\nexport function getOrCreateXmlText(element: Y.XmlElement): Y.XmlText {\n if (!TEXTBLOCK_NODES.has(element.nodeName)) {\n throw new Error(\n `Cannot create XmlText on \"${element.nodeName}\" — only textblock elements ` +\n `(paragraph, heading, codeBlock) should have direct XmlText children. ` +\n `Edit a specific paragraph or list item instead.`,\n );\n }\n return (\n findXmlText(element) ??\n (() => {\n const textNode = new Y.XmlText(\"\");\n element.insert(0, [textNode]);\n return textNode;\n })()\n );\n}\n","// HTML → Y.Doc conversion: htmlparser2 DOM traversal → Yjs XmlFragment\n\nimport type { ChildNode, Element, Text } from \"domhandler\";\nimport * as htmlparser2 from \"htmlparser2\";\nimport * as Y from \"yjs\";\nimport { DOCX_INLINE_MARKS } from \"../../shared/constants.js\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\n\n/** All marks that can appear on inline text (superset of mdast-ydoc) */\nconst ALL_MARKS = DOCX_INLINE_MARKS;\n\n/** Map HTML tag names to the mark they apply */\nconst INLINE_MARK_TAGS: Record<string, (el: Element) => Record<string, object>> = {\n strong: () => ({ bold: {} }),\n b: () => ({ bold: {} }),\n em: () => ({ italic: {} }),\n i: () => ({ italic: {} }),\n u: () => ({ underline: {} }),\n s: () => ({ strike: {} }),\n del: () => ({ strike: {} }),\n sup: () => ({ superscript: {} }),\n sub: () => ({ subscript: {} }),\n a: (el) => {\n const href = el.attribs.href || \"\";\n const safeHref = /^https?:\\/\\//i.test(href) || href.startsWith(\"mailto:\") ? href : \"\";\n return { link: { href: safeHref } };\n },\n};\n\n/** Tags that represent block-level elements */\nconst BLOCK_TAGS = new Set([\n \"h1\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"h5\",\n \"h6\",\n \"p\",\n \"ul\",\n \"ol\",\n \"li\",\n \"blockquote\",\n \"table\",\n \"tr\",\n \"td\",\n \"th\",\n \"pre\",\n \"img\",\n \"hr\",\n \"br\",\n \"div\",\n]);\n\ntype DeferredText = { xmlText: Y.XmlText; children: ChildNode[]; marks: Record<string, object> };\n\n// -- Footnote reconstruction (#1123 Tier-A #3 PR 2) ---------------------------\n//\n// mammoth renders a Word footnote as an inline\n// <sup><a href=\"#footnote-N\" id=\"footnote-ref-N\">[N]</a></sup>\n// plus a trailing\n// <ol><li id=\"footnote-N\"><p>body <a href=\"#footnote-ref-N\">↑</a></p></li></ol>\n// (N is the OOXML footnote id, mirrored in the href). Endnotes use the disjoint\n// `#endnote-N` / `id=\"endnote-N\"` namespace, so the footnote patterns below never\n// match them — endnotes keep degrading to a visible list (CRITICAL-2).\n\nconst FOOTNOTE_REF_HREF = /^#footnote-(\\d+)$/;\nconst FOOTNOTE_LI_ID = /^footnote-(\\d+)$/;\n\n/** If `<a>` is a footnote inline reference, its id; else null. */\nfunction footnoteRefId(el: Element): string | null {\n const match = (el.attribs.href || \"\").match(FOOTNOTE_REF_HREF);\n return match ? match[1] : null;\n}\n\n/**\n * If `<li>` is a mammoth footnote list item — `id=\"footnote-N\"` AND a back-link\n * `<a href=\"#footnote-ref-N\">` inside — its id; else null. The back-link is\n * required so a coincidental author-authored `id=\"footnote-5\"` is never mistaken\n * for a flattened footnote (false-removal guard).\n */\nfunction footnoteListItemId(li: Element): string | null {\n const match = (li.attribs.id || \"\").match(FOOTNOTE_LI_ID);\n if (!match) return null;\n const backLink = `#footnote-ref-${match[1]}`;\n const stack: ChildNode[] = [...li.children];\n while (stack.length > 0) {\n const node = stack.pop();\n if (node && isElement(node)) {\n if (node.tagName.toLowerCase() === \"a\" && (node.attribs.href || \"\") === backLink) {\n return match[1];\n }\n stack.push(...node.children);\n }\n }\n return null;\n}\n\n/** Collect every footnote ref id (A) and footnote list-item id (B) in the DOM. */\nfunction collectFootnoteSignals(nodes: ChildNode[]): {\n refIds: Set<string>;\n listIds: Set<string>;\n} {\n const refIds = new Set<string>();\n const listIds = new Set<string>();\n const walk = (ns: ChildNode[]): void => {\n for (const node of ns) {\n if (!isElement(node)) continue;\n const tag = node.tagName.toLowerCase();\n if (tag === \"a\") {\n const id = footnoteRefId(node);\n if (id !== null) refIds.add(id);\n } else if (tag === \"li\") {\n const id = footnoteListItemId(node);\n if (id !== null) listIds.add(id);\n }\n walk(node.children);\n }\n };\n walk(nodes);\n return { refIds, listIds };\n}\n\n/**\n * RECONCILIATION INVARIANT (CRITICAL-3): a footnote id is reconstructed only\n * when it has an inline mark target (A) AND a removable trailing `<li>` (B) AND\n * a captured body (C). Any id where these disagree (a mammoth-format drift) is\n * left to degrade to a visible list — no mark, no `<li>` removal, export emits\n * nothing for it — converting a half-state into the lesser evil\n * (degraded-but-present), never silent loss or duplication.\n */\nfunction reconcileFootnotes(\n nodes: ChildNode[],\n footnoteBodies: Record<string, FootnoteBody>,\n): Set<string> {\n const { refIds, listIds } = collectFootnoteSignals(nodes);\n const bodyIds = new Set(Object.keys(footnoteBodies));\n const approved = new Set<string>();\n for (const id of new Set([...refIds, ...listIds, ...bodyIds])) {\n if (refIds.has(id) && listIds.has(id) && bodyIds.has(id)) {\n approved.add(id);\n } else {\n console.error(\n `[docx-footnotes] footnote id=${id} failed reconciliation ` +\n `(inline-ref=${refIds.has(id)} list-item=${listIds.has(id)} body=${bodyIds.has(id)}); ` +\n \"degrading to a visible list for this id (no reconstruction).\",\n );\n }\n }\n return approved;\n}\n\n/**\n * Compute which CAPTURED footnote ids will reconstruct vs be dropped, for a\n * given mammoth HTML + captured bodies, WITHOUT mutating a doc or logging. The\n * import honesty line (computed in `parse`, before `apply` runs the transform)\n * calls this so it reflects the SAME reconciliation `htmlToYDoc` performs in\n * `apply` — identical inputs (`loaded.html` + bodies) → identical partition — so\n * a footnote that fails reconciliation (an orphaned definition with no inline\n * ref, or a future mammoth-format drift) is reported as a real loss instead of\n * being silently claimed \"preserved\". Logging stays in `reconcileFootnotes` (the\n * apply path) so a discrepancy is recorded exactly once.\n */\nexport function reconcileFootnoteIds(\n html: string,\n footnoteBodies: Record<string, FootnoteBody>,\n): { reconstructed: string[]; dropped: string[] } {\n const bodyIds = Object.keys(footnoteBodies);\n if (bodyIds.length === 0) return { reconstructed: [], dropped: [] };\n if (!html.trim()) return { reconstructed: [], dropped: bodyIds };\n const { refIds, listIds } = collectFootnoteSignals(htmlparser2.parseDocument(html).children);\n const reconstructed: string[] = [];\n const dropped: string[] = [];\n for (const id of bodyIds) {\n (refIds.has(id) && listIds.has(id) ? reconstructed : dropped).push(id);\n }\n return { reconstructed, dropped };\n}\n\n/**\n * Detector B: remove approved footnotes' trailing `<li>`s so the reconstructed\n * body doesn't ALSO survive as a visible list. Operates at `<li>` granularity\n * (leaves endnote / non-approved items in place) and drops an enclosing `<ol>`\n * only when no `<li>` survives. Returns the rewritten children array.\n */\nfunction pruneFootnoteListItems(nodes: ChildNode[], approved: Set<string>): ChildNode[] {\n const out: ChildNode[] = [];\n for (const node of nodes) {\n if (isElement(node)) {\n if (node.tagName.toLowerCase() === \"ol\") {\n const kept = node.children.filter((child) => {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n const id = footnoteListItemId(child);\n if (id !== null && approved.has(id)) return false;\n }\n return true;\n });\n const survivingLi = kept.some(\n (child) => isElement(child) && child.tagName.toLowerCase() === \"li\",\n );\n if (!survivingLi) continue; // list emptied by removal → drop it entirely\n node.children = pruneFootnoteListItems(kept, approved);\n out.push(node);\n continue;\n }\n node.children = pruneFootnoteListItems(node.children, approved);\n }\n out.push(node);\n }\n return out;\n}\n\n/**\n * Build Yjs insert attributes from the current mark stack.\n * Explicitly sets null for inactive marks to prevent Yjs mark inheritance.\n */\nfunction buildAttrs(marks: Record<string, object>): Record<string, object | null> {\n const attrs: Record<string, object | null> = {};\n for (const name of ALL_MARKS) {\n attrs[name] = name in marks ? marks[name] : null;\n }\n return attrs;\n}\n\nfunction isElement(node: ChildNode): node is Element {\n return node.type === \"tag\";\n}\n\nfunction isText(node: ChildNode): node is Text {\n return node.type === \"text\";\n}\n\n/**\n * Convert parsed HTML into Y.Doc XmlFragment elements.\n * Two-pass pattern per ADR-009: build element tree first, then populate text.\n *\n * `footnoteBodies` (#1123 Tier-A #3 PR 2) are the footnote bodies captured from\n * `word/footnotes.xml`, keyed by OOXML id. When provided, footnotes that pass\n * reconciliation get a `footnote-ref` mark on their inline `[N]` text and have\n * their trailing `<li>` pruned. Returns the RECONCILED subset (only ids that\n * actually reconstructed) — the caller persists exactly these to\n * Y_MAP_FOOTNOTE_BODIES so a stale id from a prior reload can't linger.\n */\nexport function htmlToYDoc(\n doc: Y.Doc,\n html: string,\n footnoteBodies: Record<string, FootnoteBody> = {},\n): Record<string, FootnoteBody> {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Clear existing content\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n if (!html.trim()) return {};\n\n const parsed = htmlparser2.parseDocument(html);\n\n // Footnote reconciliation: which ids have a mark target (A), a removable\n // trailing <li> (B), AND a captured body (C). Then prune the approved <li>s\n // from the DOM BEFORE the transform so the body doesn't double as a list.\n const approvedFootnotes = reconcileFootnotes(parsed.children, footnoteBodies);\n parsed.children = pruneFootnoteListItems(parsed.children, approvedFootnotes);\n\n const deferred: DeferredText[] = [];\n const allElements: Y.XmlElement[] = [];\n\n // Pass 1: build element tree, collect deferred text ops\n for (const child of parsed.children) {\n allElements.push(...domNodeToYxml(child, deferred));\n }\n\n // Attach all elements to the doc\n if (allElements.length > 0) {\n fragment.insert(0, allElements);\n }\n\n // Pass 2: populate text now that elements are attached to Y.Doc (Detector A\n // attaches footnote-ref marks for approved ids).\n for (const { xmlText, children, marks } of deferred) {\n processInlineNodes(xmlText, children, marks, approvedFootnotes);\n }\n\n const reconciled: Record<string, FootnoteBody> = {};\n for (const id of approvedFootnotes) reconciled[id] = footnoteBodies[id];\n return reconciled;\n}\n\n/** Convert a DOM node to Y.XmlElement(s). Inline-only containers become paragraphs. */\nfunction domNodeToYxml(node: ChildNode, deferred: DeferredText[]): Y.XmlElement[] {\n if (isText(node)) {\n // Top-level text node — wrap in paragraph\n const text = node.data;\n if (!text.trim()) return [];\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: [node], marks: {} });\n return [el];\n }\n\n if (!isElement(node)) return [];\n\n const tag = node.tagName.toLowerCase();\n\n // Heading\n const headingMatch = tag.match(/^h([1-6])$/);\n if (headingMatch) {\n const el = new Y.XmlElement(\"heading\");\n el.setAttribute(\"level\", parseInt(headingMatch[1]) as any);\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n switch (tag) {\n case \"p\": {\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n case \"blockquote\": {\n const el = new Y.XmlElement(\"blockquote\");\n const blockChildren = collectBlockChildren(node.children, deferred);\n for (const child of blockChildren) {\n el.insert(el.length, [child]);\n }\n return [el];\n }\n\n case \"ul\": {\n const el = new Y.XmlElement(\"bulletList\");\n for (const child of node.children) {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n el.insert(el.length, [buildListItem(child, deferred)]);\n }\n }\n return [el];\n }\n\n case \"ol\": {\n const el = new Y.XmlElement(\"orderedList\");\n const start = parseInt(node.attribs.start || \"1\");\n if (start !== 1) {\n el.setAttribute(\"start\", start as any);\n }\n for (const child of node.children) {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n el.insert(el.length, [buildListItem(child, deferred)]);\n }\n }\n return [el];\n }\n\n case \"table\": {\n const el = new Y.XmlElement(\"table\");\n // Walk tbody/thead/tfoot or direct tr children\n const rows = collectTableRows(node);\n for (const row of rows) {\n el.insert(el.length, [buildTableRow(row, deferred)]);\n }\n return [el];\n }\n\n case \"pre\": {\n const el = new Y.XmlElement(\"codeBlock\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n // Collect all text content from pre (which may contain a <code> child)\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n case \"img\": {\n const el = new Y.XmlElement(\"image\");\n el.setAttribute(\"src\", node.attribs.src || \"\");\n if (node.attribs.alt) el.setAttribute(\"alt\", node.attribs.alt);\n if (node.attribs.title) el.setAttribute(\"title\", node.attribs.title);\n return [el];\n }\n\n case \"hr\": {\n return [new Y.XmlElement(\"horizontalRule\")];\n }\n\n case \"br\": {\n // Top-level <br> — produce an empty paragraph\n const el = new Y.XmlElement(\"paragraph\");\n el.insert(0, [new Y.XmlText(\"\")]);\n return [el];\n }\n\n case \"div\": {\n // Recurse into div, treating it as a transparent container\n const results: Y.XmlElement[] = [];\n for (const child of node.children) {\n results.push(...domNodeToYxml(child, deferred));\n }\n return results;\n }\n\n default: {\n // Unknown block tag or inline-as-block: wrap in paragraph\n if (hasBlockChildren(node)) {\n // Contains blocks — recurse\n const results: Y.XmlElement[] = [];\n for (const child of node.children) {\n results.push(...domNodeToYxml(child, deferred));\n }\n return results;\n }\n // Pure inline content — wrap in paragraph\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n }\n}\n\n/** Check if a node has any block-level element children */\nfunction hasBlockChildren(node: Element): boolean {\n return node.children.some(\n (child) => isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase()),\n );\n}\n\n/** Collect block children from a list of DOM nodes, wrapping stray text in paragraphs */\nfunction collectBlockChildren(children: ChildNode[], deferred: DeferredText[]): Y.XmlElement[] {\n const result: Y.XmlElement[] = [];\n let inlineBuffer: ChildNode[] = [];\n\n const flushInline = () => {\n if (inlineBuffer.length === 0) return;\n // Only flush if there's non-whitespace content\n const hasContent = inlineBuffer.some((n) => (isText(n) ? n.data.trim().length > 0 : true));\n if (hasContent) {\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: inlineBuffer, marks: {} });\n result.push(el);\n }\n inlineBuffer = [];\n };\n\n for (const child of children) {\n if (isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase())) {\n flushInline();\n result.push(...domNodeToYxml(child, deferred));\n } else {\n inlineBuffer.push(child);\n }\n }\n flushInline();\n\n // Ensure at least one paragraph (Tiptap requires content in block containers)\n if (result.length === 0) {\n const el = new Y.XmlElement(\"paragraph\");\n el.insert(0, [new Y.XmlText(\"\")]);\n result.push(el);\n }\n\n return result;\n}\n\n/** Build a listItem Y.XmlElement from an <li> DOM node */\nfunction buildListItem(li: Element, deferred: DeferredText[]): Y.XmlElement {\n const listItem = new Y.XmlElement(\"listItem\");\n const blockChildren = collectBlockChildren(li.children, deferred);\n for (const child of blockChildren) {\n listItem.insert(listItem.length, [child]);\n }\n return listItem;\n}\n\n/** Collect all <tr> elements from a <table>, walking through tbody/thead/tfoot */\nfunction collectTableRows(table: Element): Element[] {\n const rows: Element[] = [];\n for (const child of table.children) {\n if (!isElement(child)) continue;\n const tag = child.tagName.toLowerCase();\n if (tag === \"tr\") {\n rows.push(child);\n } else if (tag === \"thead\" || tag === \"tbody\" || tag === \"tfoot\") {\n for (const grandchild of child.children) {\n if (isElement(grandchild) && grandchild.tagName.toLowerCase() === \"tr\") {\n rows.push(grandchild);\n }\n }\n }\n }\n return rows;\n}\n\n/** Build a tableRow Y.XmlElement from a <tr> */\nfunction buildTableRow(tr: Element, deferred: DeferredText[]): Y.XmlElement {\n const row = new Y.XmlElement(\"tableRow\");\n for (const child of tr.children) {\n if (!isElement(child)) continue;\n const tag = child.tagName.toLowerCase();\n if (tag === \"td\" || tag === \"th\") {\n const nodeName = tag === \"th\" ? \"tableHeader\" : \"tableCell\";\n const cell = new Y.XmlElement(nodeName);\n\n // Copy colspan/rowspan\n if (child.attribs.colspan && child.attribs.colspan !== \"1\") {\n cell.setAttribute(\"colspan\", parseInt(child.attribs.colspan) as any);\n }\n if (child.attribs.rowspan && child.attribs.rowspan !== \"1\") {\n cell.setAttribute(\"rowspan\", parseInt(child.attribs.rowspan) as any);\n }\n\n // Tiptap requires cells to contain block elements (content: 'block+')\n const cellBlocks = collectBlockChildren(child.children, deferred);\n for (const block of cellBlocks) {\n cell.insert(cell.length, [block]);\n }\n\n row.insert(row.length, [cell]);\n }\n }\n return row;\n}\n\n/**\n * Process inline DOM nodes into a Y.XmlText with marks.\n * Uses insert-with-attributes per ADR-009.\n */\nfunction processInlineNodes(\n xmlText: Y.XmlText,\n nodes: ChildNode[],\n marks: Record<string, object>,\n approvedFootnotes: Set<string>,\n): void {\n for (const node of nodes) {\n if (isText(node)) {\n const text = node.data;\n if (text.length > 0) {\n xmlText.insert(xmlText.length, text, buildAttrs(marks));\n }\n continue;\n }\n\n if (!isElement(node)) continue;\n\n const tag = node.tagName.toLowerCase();\n\n // Hard break\n if (tag === \"br\") {\n const embed = new Y.XmlElement(\"hardBreak\");\n xmlText.insertEmbed(xmlText.length, embed);\n continue;\n }\n\n // Detector A: a reconstructed footnote's inline reference. Attach the\n // `footnote-ref` mark ONLY — DROP the inherited superscript (export's\n // FootnoteReferenceRun renders superscript natively) and the now-empty link,\n // so gen1/gen2 mark-key sets match. Read the id from the RAW href before the\n // `a` mark factory below sanitizes \"#footnote-N\" to \"\".\n if (tag === \"a\") {\n const fnId = footnoteRefId(node);\n if (fnId !== null && approvedFootnotes.has(fnId)) {\n processInlineNodes(\n xmlText,\n node.children,\n { \"footnote-ref\": { id: fnId, kind: \"footnote\" } },\n approvedFootnotes,\n );\n continue;\n }\n }\n\n // Inline mark tag?\n const markFactory = INLINE_MARK_TAGS[tag];\n if (markFactory) {\n const newMarks = { ...marks, ...markFactory(node) };\n processInlineNodes(xmlText, node.children, newMarks, approvedFootnotes);\n continue;\n }\n\n // Code element inside pre — just extract text\n if (tag === \"code\") {\n processInlineNodes(xmlText, node.children, marks, approvedFootnotes);\n continue;\n }\n\n // Unknown inline element — recurse (best effort)\n processInlineNodes(xmlText, node.children, marks, approvedFootnotes);\n }\n}\n","// .docx import: mammoth.js → HTML → Y.Doc. Editing is held in the Y.Doc and\n// written back on explicit save (#576); annotations persist via the session\n// system.\n\nimport mammoth from \"mammoth\";\nimport * as Y from \"yjs\";\nimport type { Annotation } from \"../../shared/types.js\";\nimport { getElementText } from \"../mcp/document-model.js\";\n\n// Re-export for backward compatibility — consumers can import from either module\nexport { htmlToYDoc, reconcileFootnoteIds } from \"./docx-html.js\";\n\n/**\n * Convert a .docx buffer to HTML via mammoth.js.\n * Warnings logged to stderr (stdout reserved for MCP).\n */\nexport async function loadDocx(content: Buffer): Promise<string> {\n const { html } = await loadDocxWithWarnings(content);\n return html;\n}\n\n/**\n * Convert a .docx buffer to HTML via mammoth.js, returning both the HTML and a\n * deduped, human-readable summary of what mammoth could NOT faithfully import\n * (#576 fidelity warnings). mammoth is a lossy importer — it silently drops\n * footnotes, headers/footers, tracked changes, and unsupported styles. The\n * `.docx` write-back path can only re-export what mammoth preserved, so we\n * surface these so the user understands the round-trip ceiling BEFORE editing.\n *\n * Individual mammoth messages are still logged verbatim to stderr; the returned\n * `warnings` are a collapsed, user-facing subset (one line per distinct kind of\n * loss, capped so a pathological document can't flood the toast).\n */\nexport async function loadDocxWithWarnings(\n content: Buffer,\n): Promise<{ html: string; warnings: string[] }> {\n // styleMap is the SECOND arg of convertToHtml(input, options) — merging it into\n // the input object silently no-ops. mammoth IGNORES underline by default (its\n // docs: underline \"can be easily confused with hyperlinks in HTML\"), so without\n // this map a <w:u> run reaches htmlToYDoc as plain text and the underline mark\n // is lost on round-trip. \"u => u\" emits a literal <u> (which docx-html.ts maps\n // to the underline mark) — NOT \"u => em\", which would silently re-code underline\n // as italic. Additive: includeDefaultStyleMap stays true, so headings/bold/lists\n // still resolve via mammoth's defaults. (Underline style/color — double/dotted/\n // wavy/colored — is flattened to a boolean by mammoth upstream, so it round-trips\n // as a single black underline; an accepted import ceiling, undetectable to warn on.)\n const result = await mammoth.convertToHtml({ buffer: content }, { styleMap: [\"u => u\"] });\n\n for (const msg of result.messages) {\n console.error(`[mammoth] ${msg.type}: ${msg.message}`);\n }\n\n return { html: result.value, warnings: summarizeMammothMessages(result.messages) };\n}\n\n/** Max distinct fidelity warnings surfaced to the user (avoid toast flooding). */\nconst MAX_FIDELITY_WARNINGS = 8;\n\n/**\n * Max characters per emitted warning line. The quote-stripping below redacts\n * the variable payload mammoth quotes (style names), but the report these feed\n * is now a PERSISTENT surface (#1145), not a 4s toast — so clamp each line as a\n * content-oracle backstop: a future mammoth message that surfaces user text\n * unquoted can leak at most this many chars, not an unbounded paragraph.\n */\nconst MAX_WARNING_LINE_LENGTH = 120;\n\n/**\n * Collapse mammoth's per-occurrence messages into a small set of distinct,\n * user-readable phrases. mammoth emits one message PER dropped element (e.g. a\n * line per unrecognized style run), which would be unreadable surfaced raw.\n */\nexport function summarizeMammothMessages(\n messages: Array<{ type: string; message: string }>,\n): string[] {\n const seen = new Set<string>();\n for (const msg of messages) {\n // Only warnings/errors matter for fidelity — mammoth has no \"info\" today,\n // but guard defensively so a future info-level message isn't surfaced.\n if (msg.type !== \"warning\" && msg.type !== \"error\") continue;\n // Normalize \"Unrecognised paragraph style: 'Foo' (Style ID: Bar)\" and\n // similar to a single bucket so 200 runs collapse to one line.\n // Redact every slot mammoth can put user-authored text in. Style/font\n // names can be sensitive (client names, project codenames, foundry-licensed\n // brand fonts), and these strings now feed a PERSISTENT banner (#1145), so\n // redaction must cover mammoth's actual message forms (enumerated from its\n // warning() emitters), not just the quoted one:\n // - \"Unrecognised paragraph style: 'Name' (Style ID: ID)\" → quotes + (…)\n // - \"…style with ID ID was referenced but not defined…\" → bare UNQUOTED\n // `with ID <token>` slot.\n // - \"…w:sym… ignored: char F0A7 in font <Brand Font>\" → the trailing\n // `in font <name>` slot (a name CAN contain spaces, so anchor to EOL).\n // The MAX_WARNING_LINE_LENGTH clamp below is the forward backstop for any\n // slot a future mammoth version introduces.\n const normalized = msg.message\n .replace(/['\"][^'\"]*['\"]/g, \"…\")\n .replace(/\\(Style ID:[^)]*\\)/gi, \"\")\n .replace(/\\bwith ID \\S+/gi, \"with ID …\")\n .replace(/\\bin font \\S.*$/i, \"in font …\")\n .replace(/\\s+/g, \" \")\n .trim();\n // Clamp per-line (defense-in-depth, see MAX_WARNING_LINE_LENGTH). Dedup on\n // the clamped form so two messages differing only past the cap collapse.\n seen.add(\n normalized.length > MAX_WARNING_LINE_LENGTH\n ? `${normalized.slice(0, MAX_WARNING_LINE_LENGTH - 1)}…`\n : normalized,\n );\n }\n return [...seen].slice(0, MAX_FIDELITY_WARNINGS);\n}\n\n// -- Annotation export --\n\n/**\n * Human-readable author label for the exported Markdown review summary (#438).\n *\n * The durable `author` enum (`\"claude\"`) is an internal role discriminator, not\n * a display label — emitting it verbatim leaks \"(claude)\" into a report a\n * GPT/Gemini user generated. The server has no access to the browser's Models\n * registry, so it can't name the specific model; it maps to a neutral\n * \"Assistant\" instead. Imported Word comments surface their real reviewer.\n */\nfunction exportAuthorLabel(ann: Annotation): string {\n if (ann.author === \"user\") return \"You\";\n if (ann.author === \"import\") return ann.importSource?.author?.trim() || \"Imported\";\n return \"Assistant\";\n}\n\n/**\n * Generate a Markdown summary of all annotations, grouped by type.\n * Includes a text snippet from the document for context.\n */\nexport function exportAnnotations(doc: Y.Doc, annotations: Annotation[]): string {\n // Defense-in-depth (ADR-027): notes are user-private and must never appear in\n // an export, regardless of what the caller passes. The MCP tool already\n // filters them out, but this function is privacy-safe on its own.\n const visible = annotations.filter((a) => a.type !== \"note\");\n if (visible.length === 0) {\n return \"# Document Review\\n\\nNo annotations found.\";\n }\n\n const fragment = doc.getXmlFragment(\"default\");\n const fullText = extractFullText(fragment);\n\n // Group by derived category using field presence, not raw type.\n // Notes are already filtered out above (ADR-027), so there is no notes group.\n type GroupKey = \"highlights\" | \"comments\" | \"suggestions\";\n const groups: Partial<Record<GroupKey, Annotation[]>> = {};\n for (const ann of visible) {\n let key: GroupKey;\n if (ann.type === \"highlight\") key = \"highlights\";\n else if (ann.suggestedText !== undefined) key = \"suggestions\";\n else key = \"comments\";\n if (!groups[key]) groups[key] = [];\n groups[key]?.push(ann);\n }\n\n const lines: string[] = [\"# Document Review\", \"\"];\n\n const groupLabels: Record<GroupKey, string> = {\n highlights: \"Highlights\",\n comments: \"Comments\",\n suggestions: \"Suggestions\",\n };\n\n const groupOrder: GroupKey[] = [\"highlights\", \"comments\", \"suggestions\"];\n\n for (const key of groupOrder) {\n const anns = groups[key];\n if (!anns) continue;\n lines.push(`## ${groupLabels[key]}`, \"\");\n\n for (const ann of anns) {\n const snippet = safeSlice(fullText, ann.range.from, ann.range.to);\n const truncated = snippet.length > 80 ? snippet.slice(0, 77) + \"...\" : snippet;\n\n lines.push(`- **\"${truncated}\"** (${exportAuthorLabel(ann)})`);\n\n if (ann.suggestedText !== undefined) {\n lines.push(` - Replace with: \"${ann.suggestedText}\"`);\n if (ann.content) lines.push(` - Reason: ${ann.content}`);\n } else if (ann.content) {\n lines.push(` - ${ann.content}`);\n }\n\n if (ann.color) {\n lines.push(` - Color: ${ann.color}`);\n }\n\n lines.push(\"\");\n }\n }\n\n return lines.join(\"\\n\").trimEnd();\n}\n\n/** Extract full flat text from a Y.Doc fragment (simplified — no heading prefixes) */\nfunction extractFullText(fragment: Y.XmlFragment): string {\n const parts: string[] = [];\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n parts.push(getElementText(node));\n }\n }\n return parts.join(\"\\n\");\n}\n\n/** Safe string slice that handles out-of-bounds gracefully */\nfunction safeSlice(text: string, from: number, to: number): string {\n const start = Math.max(0, Math.min(from, text.length));\n const end = Math.max(start, Math.min(to, text.length));\n return text.slice(start, end);\n}\n","/**\n * Origin-tagged Y.Doc transaction wrappers (ADR-031).\n *\n * Every Y.Doc write — server-side or browser-side — MUST go through one of\n * the five helpers below. Direct `*.transact()` calls outside this file are\n * surfaced (warn-only) by the `.claude/hooks/check-raw-transact.sh` PostToolUse\n * hook and the `npm run audit:origins` static walk — there is no blocking\n * pre-commit hook or Biome rule. The wrapper\n * choice is the contract: the rest of the system reads `txn.origin` and\n * decides whether to project events, persist to disk, record tombstones,\n * etc.\n *\n * | Origin | Channel event queue | Durable-sync observer | Tombstone observer |\n * |------------|---------------------|-----------------------|--------------------|\n * | `mcp` | skip | persist | record |\n * | `file-sync`| skip | skip | record |\n * | `internal` | skip | skip | record |\n * | `reload` | skip | persist | record |\n * | `browser` | emit | persist | record |\n *\n * Picking the wrong helper is a silent bug. See ADR-031 for the full\n * \"how to choose\" enumeration with worked examples.\n */\n\nimport type * as Y from \"yjs\";\n\n// ---------------------------------------------------------------------------\n// Origin constants\n// ---------------------------------------------------------------------------\n\n/** Origin for Claude-initiated writes from MCP tool handlers. */\nexport const MCP_ORIGIN = \"mcp\";\n\n/** Origin for durable-annotation file-writer echoes (JSON → Y.Map sync). */\nexport const FILE_SYNC_ORIGIN = \"file-sync\";\n\n/**\n * Origin for server-internal setup writes. See ADR-031's `withInternal`\n * worked examples — session restore, file population, tutorial / scratchpad\n * seeding, clear-and-reload (user-initiated force-reload), cleanup-after-\n * failure paths, server metadata broadcasts on CTRL_ROOM.\n */\nexport const INTERNAL_ORIGIN = \"internal\";\n\n/**\n * Origin for the file-watcher mid-session `reloadFromDisk` flow. Channel\n * skips (not a user action), durable-sync persists (we want the re-anchored\n * relRanges saved), tombstone observer records.\n */\nexport const RELOAD_ORIGIN = \"reload\";\n\n/** Origin for user edits originating in the browser (no current observer\n * filters on this — explicit label preserves the universal rule). */\nexport const BROWSER_ORIGIN = \"browser\";\n\nexport type TandemOrigin =\n | typeof MCP_ORIGIN\n | typeof FILE_SYNC_ORIGIN\n | typeof INTERNAL_ORIGIN\n | typeof RELOAD_ORIGIN\n | typeof BROWSER_ORIGIN;\n\n// ---------------------------------------------------------------------------\n// Skip-set predicates\n// ---------------------------------------------------------------------------\n\n/**\n * Origins that channel-event observers must skip — every internal-purpose\n * origin. Only `browser` produces channel events today.\n */\nconst CHANNEL_SKIP: ReadonlySet<unknown> = new Set([\n MCP_ORIGIN,\n FILE_SYNC_ORIGIN,\n INTERNAL_ORIGIN,\n RELOAD_ORIGIN,\n]);\n\n/** Origins that the durable-annotation sync observer must skip. */\nconst DURABLE_SKIP: ReadonlySet<unknown> = new Set([FILE_SYNC_ORIGIN, INTERNAL_ORIGIN]);\n\nexport function shouldSkipChannel(origin: unknown): boolean {\n return CHANNEL_SKIP.has(origin);\n}\n\nexport function shouldSkipDurableSync(origin: unknown): boolean {\n return DURABLE_SKIP.has(origin);\n}\n\n// ---------------------------------------------------------------------------\n// Wrapper helpers\n// ---------------------------------------------------------------------------\n\nfunction runTransact<T>(doc: Y.Doc, fn: () => T, origin: TandemOrigin): T {\n let result: T | undefined;\n let captured = false;\n // biome-ignore lint/suspicious/noExplicitAny: Y.Doc.transact's second arg is `unknown`; passing a typed string is safe.\n (doc as any).transact(() => {\n result = fn();\n captured = true;\n }, origin);\n if (!captured) {\n // Should be unreachable — Y.Doc.transact invokes the callback synchronously.\n throw new Error(`origins: transact callback did not run (origin=${origin})`);\n }\n return result as T;\n}\n\n/** Wrap user-intent writes from MCP tool handlers. */\nexport function withMcp<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, MCP_ORIGIN);\n}\n\n/** Wrap echoes from the durable-annotation file-writer / file-watcher\n * reload path. The channel skips, the durable-sync observer skips. The\n * tombstone observer RECORDS (not skips) — see sync.ts observer comment. */\nexport function withFileSync<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, FILE_SYNC_ORIGIN);\n}\n\n/** Wrap server-internal setup writes — see the `INTERNAL_ORIGIN` doc\n * comment for the worked-example list. */\nexport function withInternal<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, INTERNAL_ORIGIN);\n}\n\n/** Wrap mid-session `reloadFromDisk` writes. Distinct from `withFileSync`:\n * the durable-sync observer PERSISTS reload writes so the re-anchored\n * relRanges land on disk. */\nexport function withReload<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, RELOAD_ORIGIN);\n}\n\n/** Wrap user edits originating in the browser. */\nexport function withBrowser<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, BROWSER_ORIGIN);\n}\n\n// ---------------------------------------------------------------------------\n// Test helper\n// ---------------------------------------------------------------------------\n\n/**\n * Test-only transact wrapper for synthetic Y.Docs. Tagged with a sentinel\n * origin so observers and lint can distinguish from production transacts.\n * Allowlisted in the `block-raw-transact` hook via the helpers-file\n * exception.\n */\nexport const TEST_ORIGIN = \"test\";\n\nexport function transactForTest<T>(doc: Y.Doc, fn: () => T): T {\n let result: T | undefined;\n // biome-ignore lint/suspicious/noExplicitAny: Y.Doc.transact's second arg is `unknown`.\n (doc as any).transact(() => {\n result = fn();\n }, TEST_ORIGIN);\n return result as T;\n}\n","/**\n * Once-per-(doc, kind) logging for legacy annotation migrations.\n *\n * Several read/write paths silently rewrite v0 annotations into the v1\n * model — flag→note, directedAt strip, unknown-type → comment coercion. The\n * rewrites are correct, but a silent rewrite destroys the v0→v1 forensic\n * trail: an operator investigating \"where did my flags go?\" sees no trace.\n *\n * This module gives those paths a shared dedup mechanism keyed by\n * `${docHash}:${kind}` so each lossy upgrade fires exactly once per doc per\n * kind, regardless of which path triggered it (parseAnnotationDoc, the\n * sync.ts fast-path strip, migrateToV1, etc.).\n *\n * Module placement: `sync.ts` already imports from `schema.ts`, so adding\n * a sync→schema log import would create a cycle. This module is the\n * shared dependency-free home both can import from.\n */\n\nimport type { SanitizationEvent } from \"../../shared/sanitize.js\";\n\nexport type LegacyMigrationKind =\n | \"flag\"\n | \"directedAt\"\n | \"legacy-type\"\n | \"flag-to-note\"\n | \"question-to-comment\"\n | \"malformed-suggestion-json\"\n | \"unknown-type\"\n | \"import-note-to-comment\"\n | \"audience-conflict-resolved\";\n\n/** Dedup state — `${docHash}:${kind}`. Cleared on doc close via `forgetDoc`. */\nconst loggedLegacyMigrations = new Set<string>();\n\n/** Sentinel docHash for `migrateToV1`, whose envelope has `docHash: \"\"`. */\nexport const MIGRATE_TO_V1_DOC_HASH = \"<migrateToV1>\";\n\n/**\n * Log a legacy-migration event the first time it's seen for `(docHash, kind)`.\n * Subsequent calls with the same pair are silent. `docHash === undefined`\n * skips dedup entirely (logs every call) — used in test paths and as a\n * defensive default.\n */\nexport function logLegacyMigration(docHash: string | undefined, kind: LegacyMigrationKind): void {\n if (docHash === undefined) {\n console.error(`[ANNOTATION-STORE] legacy migration: ${kind} (no docHash)`);\n return;\n }\n const key = `${docHash}:${kind}`;\n if (loggedLegacyMigrations.has(key)) return;\n loggedLegacyMigrations.add(key);\n console.error(`[ANNOTATION-STORE] legacy migration: ${kind} in ${docHash}`);\n}\n\n/** Drop dedup state for a specific doc — call on doc close so a reopen logs again. */\nexport function forgetDoc(docHash: string): void {\n for (const key of loggedLegacyMigrations) {\n if (key.startsWith(`${docHash}:`)) loggedLegacyMigrations.delete(key);\n }\n}\n\n/** Reset all dedup state. Tests only. */\nexport function resetMigrationLog(): void {\n loggedLegacyMigrations.clear();\n}\n\n/**\n * Server-side relay for `sanitizeAnnotation`'s `onLossy` callback. Maps the\n * shared `SanitizationEvent` discriminated union to a `LegacyMigrationKind`\n * and routes through the dedup'd `logLegacyMigration` channel so silent\n * sanitize coercions become visible in the migration trail.\n *\n * Imported lazily by callers that already have a docHash/docName in hand.\n * Callers without one pass `undefined` and accept un-deduped logging.\n */\n\nexport function relaySanitizationEvent(\n docHash: string | undefined,\n event: SanitizationEvent,\n): void {\n switch (event.kind) {\n case \"flag-to-note\":\n case \"question-to-comment\":\n case \"malformed-suggestion-json\":\n case \"unknown-type\":\n case \"import-note-to-comment\":\n case \"audience-conflict-resolved\":\n logLegacyMigration(docHash, event.kind);\n return;\n default: {\n // Compile-time exhaustiveness: adding a new SanitizationEvent kind without a\n // matching case here becomes a TypeScript error. Never remove this arm.\n const _exhaustive: never = event;\n console.error(\n `[ANNOTATION-STORE] unhandled SanitizationEvent kind: ${(_exhaustive as SanitizationEvent).kind}`,\n );\n }\n }\n}\n","/**\n * v1 → v2 annotation-envelope migration.\n *\n * **Proof-of-shape, not a real schema change.** The on-disk annotation\n * envelope is still `schemaVersion: 1` in production (`SCHEMA_VERSION` in\n * `../schema.ts` is unchanged). This migration exists to prove the framework\n * end-to-end and to give the first *real* v2 a place to land: when a future\n * PR bumps `SCHEMA_VERSION` to 2, the load path begins running this function\n * automatically with no further wiring.\n *\n * The transform is an identity over the record payload — it only re-stamps\n * `schemaVersion` to 2. No fields are added, removed, or reshaped, so a v1\n * envelope is already a structurally valid v2 payload. The frozen v1 Zod\n * schema below is the input contract (mirrors `integrations/migrations.ts`):\n * we refuse to migrate garbage even though the transform is otherwise a no-op.\n */\n\nimport { z } from \"zod\";\n\nimport { AnnotationDocSchemaV1 } from \"../schema.js\";\n\nimport type { MigrationFn } from \"./runner.js\";\n\nexport const migrateV1ToV2: MigrationFn = (input) => {\n // Build the frozen input contract LAZILY, inside the function — never at\n // module top level. There is an import cycle\n // (schema.ts → migrations/index.ts → runner.ts → v1_to_v2.ts → schema.ts);\n // evaluating `AnnotationDocSchemaV1.extend(...)` during module init would run\n // before schema.ts finishes defining `AnnotationDocSchemaV1`, throwing a TDZ\n // \"Cannot access 'AnnotationDocSchemaV1' before initialization\" that crashes\n // the load path on import. Deferring to call time (the same reason the\n // transform itself is an arrow function) sidesteps the cycle: by the time any\n // migration runs, schema.ts is fully initialized.\n //\n // The contract locks `schemaVersion` to the numeric literal `1`, NOT the live\n // `SCHEMA_VERSION` constant. `AnnotationDocSchemaV1` validates `schemaVersion`\n // against `z.literal(SCHEMA_VERSION)` — the *current* version. The moment a\n // future PR bumps `SCHEMA_VERSION` to 2, that schema starts requiring\n // `schemaVersion === 2`, and this migration — which by definition receives\n // genuine v1 files (`schemaVersion: 1`) — would reject every one of them,\n // quarantining all annotations as `corrupt` on the first load after the\n // upgrade. A migration's input version must be pinned to a literal forever;\n // every subsequent migration must follow the same rule.\n const FrozenV1InputSchema = AnnotationDocSchemaV1.extend({\n schemaVersion: z.literal(1),\n });\n const parsed = FrozenV1InputSchema.parse(input);\n // `.passthrough()` on the schema means `parsed` already carries any\n // forward-compatible extra fields verbatim; spread preserves them and the\n // explicit `schemaVersion` override wins over the parsed `1`.\n return {\n ...parsed,\n schemaVersion: 2,\n };\n};\n","/**\n * Versioned migration framework for the on-disk annotation envelope\n * (`<annotationsDir>/<docHash>.json`).\n *\n * Modeled on `src/server/integrations/migrations.ts`: an ordered\n * `MigrationFn[]` chain plus a `migrateUp(input, fromVersion, toVersion)`\n * runner. `migrations[i]` migrates v(i+1) → v(i+2).\n *\n * **Contract for migration authors:** the `input` parameter is typed\n * `unknown` and the framework does NOT validate the v_n shape before passing\n * it to your function. Validate with Zod against the v_n schema inside the\n * migration — do NOT use `as` casts. The `unknown` input type is the\n * compile-time signal that runtime validation is required.\n *\n * **Current state:** the production envelope is still `SCHEMA_VERSION = 1`\n * (see `../schema.ts`). The v1 → v2 entry below is a dormant proof-of-shape:\n * because `migrateUp` is always called with `toVersion === SCHEMA_VERSION`,\n * it never fires during a normal load while `SCHEMA_VERSION` is 1. When a\n * future PR bumps `SCHEMA_VERSION` to 2, the load path begins running it\n * automatically with no further wiring.\n */\n\nimport { migrateV1ToV2 } from \"./v1_to_v2.js\";\n\nexport type MigrationFn = (input: unknown) => unknown;\n\n/**\n * Ordered migration chain. `migrations[i]` migrates v(i+1) → v(i+2).\n * Module-local — exposed only via `migrateUp` so external code cannot inject\n * a migration at runtime.\n */\nconst migrations: ReadonlyArray<MigrationFn> = [migrateV1ToV2];\n\n/**\n * Run the migration chain forward from `fromVersion` to `toVersion`. The\n * caller is responsible for Zod-validating the result. A missing migration\n * throws — silent default behavior would mask a corrupt or future-version\n * file.\n *\n * Returns `input` unchanged (by reference) when `fromVersion === toVersion`,\n * so the common already-current case allocates nothing.\n */\nexport function migrateUp(input: unknown, fromVersion: number, toVersion: number): unknown {\n if (toVersion < fromVersion) {\n throw new Error(`Cannot migrate down: from v${fromVersion} to v${toVersion}`);\n }\n let current = input;\n for (let v = fromVersion; v < toVersion; v++) {\n const m = migrations[v - 1];\n if (!m) {\n throw new Error(`No migration registered from v${v} to v${v + 1}`);\n }\n current = m(current);\n }\n return current;\n}\n","/**\n * Public entry point for the annotation-envelope migration framework.\n * See `./runner.ts` for the runner contract and `./v1_to_v2.ts` for the\n * first (dormant, proof-of-shape) migration.\n */\n\nexport { type MigrationFn, migrateUp } from \"./runner.js\";\nexport { migrateV1ToV2 } from \"./v1_to_v2.js\";\n","/**\n * On-disk schema for Tandem's durable annotation envelope.\n *\n * ## Unknown-field policy\n *\n * All object schemas use `.passthrough()` (overriding Zod's default strip).\n * Forward-compatibility is the goal: a future version might add fields\n * (e.g. `pinnedBy`, `severity`) that a pre-upgrade Tandem install should\n * *preserve* when rewriting the file, not silently drop. Strict rejection\n * would turn a harmless additive change into a \"corrupt\" error and trip the\n * `.json.future` fallback path unnecessarily. Breaking version jumps\n * (`schemaVersion > 1`) are still handled explicitly via\n * `parseAnnotationDoc` returning `{ ok: false, error: \"future\" }`.\n */\n\nimport { z } from \"zod\";\nimport {\n AnnotationStatusSchema,\n AnnotationTypeSchema,\n AuthorSchema,\n type HighlightColor,\n HighlightColorSchema,\n ReplyAuthorSchema,\n} from \"../../shared/types.js\";\nimport { logLegacyMigration, MIGRATE_TO_V1_DOC_HASH } from \"./migration-log.js\";\nimport { migrateUp } from \"./migrations/index.js\";\n\n/** On-disk envelope version. Bump when making breaking changes to the file shape. */\nexport const SCHEMA_VERSION = 1 as const;\n\n/**\n * Reply size bounds (#1000 security review R2). `REPLY_TEXT_MAX` is a generous\n * durable-schema sanity ceiling — deliberately large so it can NEVER drop a\n * legitimate existing user/claude reply on load (`normalizeReply` discards rows\n * that fail `safeParse`). The tight, product-sensible truncation of untrusted\n * imported Word reply bodies happens at the injection door\n * (`IMPORT_REPLY_BODY_CAP`), well under this ceiling. `IMPORT_AUTHOR_MAX` only\n * ever bounds our own injection-written field, so it is safe to validate tightly.\n */\nexport const REPLY_TEXT_MAX = 100_000;\nexport const IMPORT_REPLY_BODY_CAP = 4_000;\nexport const IMPORT_AUTHOR_MAX = 128;\n\n/**\n * Compute the next revision for a user-intent write. Returns `1` for a brand\n * new record (no `prev`), otherwise `prev.rev + 1`. Pre-T6 records that lack\n * `rev` are treated as `rev: 0` so the first write after migration lands at\n * `rev: 1`.\n *\n * Used at every server-side creation and mutation site so the `?? 0` fallback\n * and increment live in exactly one place — the invariant is part of the\n * schema module's domain, not the call sites'.\n */\nexport function nextRev(prev?: { rev?: number }): number {\n return (prev?.rev ?? 0) + 1;\n}\n\n// ---------------------------------------------------------------------------\n// Primitive sub-schemas\n// ---------------------------------------------------------------------------\n\n/**\n * JSON form of a Yjs RelativePosition (output of `Y.relativePositionToJSON`).\n * All four fields are optional — Yjs omits nulls on serialization. Passthrough\n * because the Yjs internals are opaque and we shouldn't break on schema drift\n * in the upstream library.\n */\nconst SerializedRelPosSchema = z\n .object({\n type: z.unknown().optional(),\n tname: z.string().optional(),\n item: z.unknown().optional(),\n assoc: z.number().optional(),\n })\n .passthrough();\n\nconst DocumentRangeSchema = z\n .object({\n from: z.number().int().nonnegative(),\n to: z.number().int().nonnegative(),\n })\n .passthrough();\n\nconst RelativeRangeSchema = z\n .object({\n fromRel: SerializedRelPosSchema,\n toRel: SerializedRelPosSchema,\n })\n .passthrough();\n\n// ---------------------------------------------------------------------------\n// Annotation + reply per-record schemas\n// ---------------------------------------------------------------------------\n\n/**\n * Per-annotation envelope record. Largely mirrors `AnnotationBase` from\n * `src/shared/types.ts`, plus the optional type-discriminator fields\n * (`color` / `suggestedText`), plus the required `rev`. ADR-027 removed\n * `directedAt` from the model; the schema enforces its absence via a\n * `.refine()` so any caller that skips migration is caught at validation time.\n * Fields not listed here (e.g. `heldInSolo`) are preserved via `.passthrough()`.\n */\nexport const AnnotationRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n author: AuthorSchema,\n type: AnnotationTypeSchema,\n range: DocumentRangeSchema,\n relRange: RelativeRangeSchema.optional(),\n content: z.string(),\n status: AnnotationStatusSchema,\n timestamp: z.number(),\n textSnapshot: z.string().optional(),\n editedAt: z.number().optional(),\n // Type-specific optional fields. Not cross-validated against `type` — the\n // TS discriminated union enforces that invariant at construction time\n // (see `src/shared/types.ts`). Here we only gate shape.\n color: HighlightColorSchema.optional(),\n suggestedText: z.string().optional(),\n // New for v1 envelope: monotonically-increasing revision counter used for\n // last-writer-wins merge between in-memory Y.Map state and on-disk state.\n rev: z.number().int().nonnegative(),\n })\n .passthrough()\n // ADR-027: directedAt is removed from the model. All production read paths\n // (parseAnnotationDoc, migrateToV1) run migrateFlagAndDirectedAt() before\n // reaching this schema, so the field is already gone. This refine catches\n // any caller that bypasses migration and passes a stale record directly.\n .refine((rec) => !(\"directedAt\" in rec), {\n message: \"directedAt is removed in ADR-027; run migrateFlagAndDirectedAt before validation\",\n path: [\"directedAt\"],\n });\n\n/**\n * Reply record (existing `AnnotationReply` shape + `rev`).\n */\nexport const AnnotationReplyRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n annotationId: z.string().min(1),\n author: ReplyAuthorSchema,\n // Bounded to defend against pathological/oversized .docx reply bodies that\n // bypass the SNAPSHOT_CAP path (#1000 security review R2).\n text: z.string().max(REPLY_TEXT_MAX),\n timestamp: z.number(),\n editedAt: z.number().optional(),\n rev: z.number().int().nonnegative(),\n // #1000: user-private (note-authored or imported Word) reply — never sent\n // to Claude. Optional; absent ⇒ surfaces normally on comment parents.\n private: z.boolean().optional(),\n // #1000: original Word reviewer name for `author: \"import\"` replies.\n importAuthor: z.string().max(IMPORT_AUTHOR_MAX).optional(),\n })\n .passthrough();\n\n/**\n * Tombstone for a deleted annotation. `rev` is the rev the annotation carried\n * when it was deleted, so merge logic can decide whether an incoming add from\n * a stale peer is older (drop it) or newer (resurrect).\n */\nexport const TombstoneRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n rev: z.number().int().nonnegative(),\n deletedAt: z.number(),\n })\n .passthrough();\n\n// ---------------------------------------------------------------------------\n// Top-level envelope\n// ---------------------------------------------------------------------------\n\nconst MetaSchema = z\n .object({\n filePath: z.string(),\n lastUpdated: z.number(),\n // Additive (no schema-version bump — MetaSchema is .passthrough()).\n // SHA-256 of `extractText(doc)` recomputed on EVERY durable write, used by\n // the rename-recovery path (#313) to re-associate an orphaned envelope with\n // a renamed-but-byte-identical document. Optional so pre-#313 envelopes\n // (which lack it) still parse; recovery simply skips them.\n contentHash: z.string().optional(),\n })\n .passthrough();\n\n/**\n * The full on-disk JSON envelope.\n * Passthrough at every layer — see module header for the rationale.\n */\nexport const AnnotationDocSchemaV1 = z\n .object({\n schemaVersion: z.literal(SCHEMA_VERSION),\n docHash: z.string(),\n meta: MetaSchema,\n annotations: z.array(AnnotationRecordSchemaV1),\n tombstones: z.array(TombstoneRecordSchemaV1),\n replies: z.array(AnnotationReplyRecordSchemaV1),\n })\n .passthrough();\n\nexport type AnnotationDocV1 = z.infer<typeof AnnotationDocSchemaV1>;\nexport type AnnotationRecordV1 = z.infer<typeof AnnotationRecordSchemaV1>;\nexport type AnnotationReplyRecordV1 = z.infer<typeof AnnotationReplyRecordSchemaV1>;\nexport type TombstoneRecordV1 = z.infer<typeof TombstoneRecordSchemaV1>;\n\n// ---------------------------------------------------------------------------\n// Color migration helpers\n// ---------------------------------------------------------------------------\n\n// Align legacy color remap with the v7 design handoff palette\n// (docs/designs/handoff/tandem/project/calm-v7.css):\n// - red → pink (warm-family remap; the prior red→yellow remap predated the\n// v7 palette decision and silently collapsed two visually distinct\n// highlights into one)\n// - purple → blue (cool-family remap; unchanged)\nconst LEGACY_COLOR_MAP: Record<\"red\" | \"purple\", HighlightColor> = {\n red: \"pink\",\n purple: \"blue\",\n};\n\nfunction migrateHighlightColor(ann: Record<string, unknown>): void {\n const color = ann.color;\n if (typeof color === \"string\" && color in LEGACY_COLOR_MAP) {\n ann.color = LEGACY_COLOR_MAP[color as keyof typeof LEGACY_COLOR_MAP];\n }\n}\n\n// ADR-027: flag→note migration + directedAt removal\n// ---------------------------------------------------------------------------\n\nfunction migrateFlagAndDirectedAt(ann: Record<string, unknown>, docHash?: string): void {\n if (ann.type === \"flag\") {\n ann.type = \"note\";\n logLegacyMigration(docHash, \"flag\");\n }\n if (\"directedAt\" in ann) {\n delete ann.directedAt;\n logLegacyMigration(docHash, \"directedAt\");\n }\n}\n\n// ---------------------------------------------------------------------------\n// Parse + migrate\n// ---------------------------------------------------------------------------\n\n/**\n * Discriminated result of `parseAnnotationDoc`. The `ok` flag is the\n * discriminant; we intentionally avoid keying on `error` because the v1 schema\n * uses `.passthrough()` (see module header) and any alive doc could, in\n * principle, carry a passthrough field named `error`. `ok` is our own\n * invariant, outside the schema's namespace, so narrowing is unambiguous.\n */\nexport type ParseAnnotationDocResult =\n | { ok: true; doc: AnnotationDocV1 }\n | { ok: false; error: \"corrupt\" }\n | { ok: false; error: \"future\"; schemaVersion: number };\n\n/**\n * Validate an on-disk annotation doc.\n *\n * Accepts either a parsed object *or* a raw JSON string (for readability at\n * call sites). On any parse/validation failure returns\n * `{ ok: false, error: \"corrupt\" }`. If the payload looks well-formed but\n * carries `schemaVersion > 1`, returns\n * `{ ok: false, error: \"future\", schemaVersion }` so the caller can rename the\n * file to `<hash>.json.future` and fall back to in-memory state.\n */\nexport function parseAnnotationDoc(raw: unknown): ParseAnnotationDocResult {\n // Optional JSON-string convenience: callers that read the file as text can\n // pass the string straight through.\n let candidate: unknown = raw;\n if (typeof candidate === \"string\") {\n try {\n candidate = JSON.parse(candidate);\n } catch (err) {\n console.error(\"[parseAnnotationDoc] JSON.parse failed:\", err);\n return { ok: false, error: \"corrupt\" };\n }\n }\n\n if (candidate === null || typeof candidate !== \"object\") {\n console.error(\n `[parseAnnotationDoc] expected object, got ${candidate === null ? \"null\" : typeof candidate}`,\n );\n return { ok: false, error: \"corrupt\" };\n }\n\n // Check schemaVersion *before* full validation so we can return `future`\n // without treating a newer-but-otherwise-valid file as corrupt.\n const schemaVersion = (candidate as { schemaVersion?: unknown }).schemaVersion;\n if (\n typeof schemaVersion === \"number\" &&\n Number.isInteger(schemaVersion) &&\n schemaVersion > SCHEMA_VERSION\n ) {\n return { ok: false, error: \"future\", schemaVersion };\n }\n\n // Migrate legacy highlight colors before validation. Clone each annotation\n // so the caller's input objects are not mutated as a side effect of parsing.\n const cand = candidate as { annotations?: unknown[]; docHash?: unknown };\n const candDocHash = typeof cand.docHash === \"string\" ? cand.docHash : undefined;\n if (Array.isArray(cand.annotations)) {\n for (let i = 0; i < cand.annotations.length; i++) {\n const ann = cand.annotations[i];\n if (ann && typeof ann === \"object\") {\n const cloned = { ...(ann as Record<string, unknown>) };\n migrateHighlightColor(cloned);\n migrateFlagAndDirectedAt(cloned, candDocHash);\n cand.annotations[i] = cloned;\n }\n }\n }\n\n // Run the versioned migration framework forward to the current schema\n // version. Today `SCHEMA_VERSION` is 1, so any well-formed file is already\n // at-or-below current and `migrateUp` returns the input unchanged — the\n // wiring is dormant but live. When `SCHEMA_VERSION` is bumped to 2, the\n // registered v1 → v2 migration begins running here with no further changes.\n // A migration that throws (e.g. a record that fails the v_n input contract)\n // is treated as corruption rather than crashing the load path.\n const fromVersion =\n typeof schemaVersion === \"number\" && Number.isInteger(schemaVersion) ? schemaVersion : 1;\n let migrated: unknown;\n try {\n migrated = migrateUp(candidate, fromVersion, SCHEMA_VERSION);\n } catch (err) {\n console.error(\"[parseAnnotationDoc] migration failed:\", err);\n return { ok: false, error: \"corrupt\" };\n }\n\n const result = AnnotationDocSchemaV1.safeParse(migrated);\n if (!result.success) {\n console.error(\"[parseAnnotationDoc] schema validation failed:\", result.error.issues);\n return { ok: false, error: \"corrupt\" };\n }\n return { ok: true, doc: result.data };\n}\n\n/** Result of `migrateToV1`. Drop counts let callers surface lossy upgrades. */\nexport interface MigrationResult {\n doc: AnnotationDocV1;\n /** Count of annotation records skipped during migration (non-object input or schema rejection). */\n droppedAnnotations: number;\n /** Count of reply records skipped during migration (same criteria). */\n droppedReplies: number;\n}\n\n/**\n * Best-effort migration from a legacy session-blob–shaped object (no `rev`,\n * no `tombstones`, no `docHash`, no `meta`) into the v1 envelope shape.\n *\n * Populates sensible defaults:\n * - `rev: 0` on every annotation and reply\n * - `tombstones: []`\n * - `docHash: \"\"` (caller fills in with the real hash of the current document)\n * - `meta: { filePath: \"\", lastUpdated: 0 }` (caller overrides)\n *\n * Expects `raw` to be roughly `{ annotations?: unknown[]; replies?: unknown[] }`.\n * Anything unrecognized is coerced; invalid records are skipped and tallied\n * in `droppedAnnotations` / `droppedReplies` so callers can surface data loss\n * rather than silently discarding records. As a second line of defense, a\n * single `console.error` fires when any records are dropped — without it a\n * caller that forgets to destructure the counts would lose the data-loss\n * signal entirely. The full v1 → vN migration framework is deferred.\n */\nexport function migrateToV1(raw: unknown): MigrationResult {\n const src = (raw && typeof raw === \"object\" ? raw : {}) as Record<string, unknown>;\n\n const annotationsIn = Array.isArray(src.annotations) ? src.annotations : [];\n const repliesIn = Array.isArray(src.replies) ? src.replies : [];\n\n let droppedAnnotations = 0;\n let droppedReplies = 0;\n\n const annotations: AnnotationRecordV1[] = [];\n for (const ann of annotationsIn) {\n if (!ann || typeof ann !== \"object\") {\n droppedAnnotations++;\n continue;\n }\n const withRev = { rev: 0, ...(ann as object) };\n migrateHighlightColor(withRev as Record<string, unknown>);\n migrateFlagAndDirectedAt(withRev as Record<string, unknown>, MIGRATE_TO_V1_DOC_HASH);\n const parsed = AnnotationRecordSchemaV1.safeParse(withRev);\n if (parsed.success) {\n annotations.push(parsed.data);\n } else {\n droppedAnnotations++;\n const annId = (withRev as { id?: unknown }).id ?? \"<missing>\";\n console.error(\n `[ANNOTATION-STORE] migrateToV1: dropping annotation id=${String(annId)}:`,\n parsed.error.issues,\n );\n }\n }\n\n const replies: AnnotationReplyRecordV1[] = [];\n for (const r of repliesIn) {\n if (!r || typeof r !== \"object\") {\n droppedReplies++;\n continue;\n }\n const withRev = { rev: 0, ...(r as object) };\n const parsed = AnnotationReplyRecordSchemaV1.safeParse(withRev);\n if (parsed.success) {\n replies.push(parsed.data);\n } else {\n droppedReplies++;\n const replyId = (withRev as { id?: unknown }).id ?? \"<missing>\";\n console.error(\n `[ANNOTATION-STORE] migrateToV1: dropping reply id=${String(replyId)}:`,\n parsed.error.issues,\n );\n }\n }\n\n if (droppedAnnotations > 0 || droppedReplies > 0) {\n console.error(\n `[ANNOTATION-STORE] migrateToV1 dropped ${droppedAnnotations} annotation(s) and ${droppedReplies} reply/replies as malformed`,\n );\n }\n\n return {\n doc: {\n schemaVersion: SCHEMA_VERSION,\n docHash: \"\",\n meta: { filePath: \"\", lastUpdated: 0 },\n annotations,\n tombstones: [],\n replies,\n },\n droppedAnnotations,\n droppedReplies,\n };\n}\n","export type {\n AnchoredRangeResult,\n DocumentRange,\n ElementPosition,\n FlatOffset,\n PmPos,\n PmRangeResult,\n RangeValidation,\n RefreshResult,\n RelativeRange,\n ResolutionMethod,\n SerializedRelPos,\n} from \"./types.js\";\n\nexport {\n toFlatOffset,\n toPmPos,\n toSerializedRelPos,\n} from \"./types.js\";\n","/**\n * Server-side position module.\n *\n * Consolidates all flat-offset, Y.Doc element resolution, RelativePosition,\n * and range validation logic into caller-optimized functions.\n *\n * High-level (use these):\n * - validateRange() — validate + stale-check a flat-offset range\n * - anchoredRange() — validate + create both flat and CRDT-anchored range\n * - refreshRange() — resolve relRange → flat offsets (or lazily attach)\n * - refreshAllRanges() — batch version in a Y.Doc transaction\n *\n * Low-level (escape hatches):\n * - resolveToElement() — flat offset → Y.Doc element position\n * - flatOffsetToRelPos() — flat offset → serialized RelativePosition\n * - relPosToFlatOffset() — serialized RelativePosition → flat offset\n */\n\nimport * as Y from \"yjs\";\nimport { withMcp } from \"../shared/origins.js\";\nimport type {\n AnchoredRangeResult,\n DocumentRange,\n ElementPosition,\n FlatOffset,\n RangeValidation,\n RefreshResult,\n RelativeRange,\n SerializedRelPos,\n} from \"../shared/positions/index.js\";\nimport { toFlatOffset, toSerializedRelPos } from \"../shared/positions/index.js\";\nimport type { Annotation } from \"../shared/types.js\";\nimport {\n collectXmlTexts,\n extractText,\n findXmlTextAtOffset,\n getElementTextLength,\n getHeadingPrefixLength,\n} from \"./mcp/document-model.js\";\n\n// ---------------------------------------------------------------------------\n// Low-level: element resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a flat character offset to a Y.Doc element position.\n * Needed by tandem_edit for cross-element deletion logic.\n */\nexport function resolveToElement(\n fragment: Y.XmlFragment,\n charOffset: FlatOffset,\n): ElementPosition | null {\n let accumulated = 0;\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n\n const prefixLen = getHeadingPrefixLength(node);\n const textLen = getElementTextLength(node);\n const fullLen = prefixLen + textLen;\n\n if (accumulated + fullLen > charOffset) {\n const offsetInFull = charOffset - accumulated;\n const clampedFromPrefix = offsetInFull < prefixLen && prefixLen > 0;\n const textOffset = Math.max(0, offsetInFull - prefixLen);\n return { elementIndex: i, textOffset, clampedFromPrefix };\n }\n\n accumulated += fullLen;\n\n if (i < fragment.length - 1) {\n accumulated += 1; // \\n separator\n if (accumulated > charOffset) {\n return { elementIndex: i, textOffset: textLen, clampedFromPrefix: false };\n }\n }\n }\n\n if (fragment.length > 0) {\n const lastNode = fragment.get(fragment.length - 1);\n if (lastNode instanceof Y.XmlElement) {\n return {\n elementIndex: fragment.length - 1,\n textOffset: getElementTextLength(lastNode),\n clampedFromPrefix: false,\n };\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Low-level: RelativePosition conversion\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a flat text offset to a JSON-serialized Yjs RelativePosition.\n * Returns null if the offset falls in a heading prefix or can't be resolved.\n *\n * Sole mint of `SerializedRelPos` — no other code path constructs the wire\n * shape. Readers (`relPosToFlatOffset` here + `relRangeToPmPositions` in\n * `src/client/positions.ts`) must tolerate `Y.createRelativePositionFromJSON`\n * throwing on stale items after `reloadFromDisk` replaces the Y.Doc content;\n * see `docs/lessons-learned.md` \"Dead CRDT RelativePositions Must Be Stripped,\n * Not Preserved\" for why the throw is expected rather than a bug.\n */\nexport function flatOffsetToRelPos(\n doc: Y.Doc,\n offset: FlatOffset,\n assoc: 0 | -1,\n): SerializedRelPos | null {\n const fragment = doc.getXmlFragment(\"default\");\n const resolved = resolveToElement(fragment, offset);\n if (!resolved || resolved.clampedFromPrefix) return null;\n\n const node = fragment.get(resolved.elementIndex);\n if (!(node instanceof Y.XmlElement)) return null;\n\n let found = findXmlTextAtOffset(node, resolved.textOffset);\n // If the offset lands exactly on an intra-element separator (between nested block children),\n // fall back based on assoc: -1 (stick left) → try offset-1; 0 (stick right) → try offset+1.\n if (!found && assoc === -1 && resolved.textOffset > 0) {\n found = findXmlTextAtOffset(node, resolved.textOffset - 1);\n if (found) {\n // Advance offsetInXmlText to end of this XmlText to stick to the left boundary\n found = { xmlText: found.xmlText, offsetInXmlText: found.xmlText.length };\n }\n } else if (!found && assoc === 0) {\n const nodeLen = getElementTextLength(node);\n if (resolved.textOffset + 1 <= nodeLen) {\n found = findXmlTextAtOffset(node, resolved.textOffset + 1);\n }\n }\n if (!found) return null;\n const rpos = Y.createRelativePositionFromTypeIndex(found.xmlText, found.offsetInXmlText, assoc);\n return toSerializedRelPos(Y.relativePositionToJSON(rpos));\n}\n\n/**\n * Resolve a JSON-serialized Yjs RelativePosition back to a flat text offset.\n * Returns null if the referenced content was deleted.\n */\nexport function relPosToFlatOffset(doc: Y.Doc, relPosJson: SerializedRelPos): FlatOffset | null {\n let absPos;\n try {\n const rpos = Y.createRelativePositionFromJSON(relPosJson);\n absPos = Y.createAbsolutePositionFromRelativePosition(rpos, doc);\n } catch (err) {\n if (!(err instanceof TypeError) && !(err instanceof SyntaxError)) {\n console.error(\"[positions] relPosToFlatOffset: unexpected error resolving relRange:\", err);\n }\n return null;\n }\n if (!absPos) return null;\n\n const fragment = doc.getXmlFragment(\"default\");\n let accumulated = 0;\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n\n const prefixLen = getHeadingPrefixLength(node);\n\n const xmlTexts = collectXmlTexts(node);\n for (const { xmlText, offsetFromStart } of xmlTexts) {\n if (xmlText === absPos.type) {\n return toFlatOffset(accumulated + prefixLen + offsetFromStart + absPos.index);\n }\n }\n\n accumulated += prefixLen + getElementTextLength(node);\n if (i < fragment.length - 1) {\n accumulated += 1;\n }\n }\n\n console.error(\n \"[positions] relPosToFlatOffset: absPos resolved but no matching XmlText found in traversal\",\n );\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// High-level: range validation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a flat-offset range against a Y.Doc.\n *\n * Checks: ordering, textSnapshot staleness (with relocation), and optionally\n * heading-prefix overlap. Returns a structured RangeValidation.\n *\n */\nexport function validateRange(\n ydoc: Y.Doc,\n from: FlatOffset,\n to: FlatOffset,\n opts?: {\n textSnapshot?: string;\n rejectHeadingOverlap?: boolean;\n },\n): RangeValidation {\n const rejectHeadingOverlap = opts?.rejectHeadingOverlap ?? false;\n\n if (from > to) {\n return {\n ok: false,\n code: \"INVALID_RANGE\",\n message: `Invalid range: from (${from}) must be <= to (${to}).`,\n };\n }\n\n // Staleness check\n if (opts?.textSnapshot) {\n const fullText = extractText(ydoc);\n if (fullText.slice(from, to) !== opts.textSnapshot) {\n const candidates: number[] = [];\n let searchFrom = 0;\n while (true) {\n const idx = fullText.indexOf(opts.textSnapshot, searchFrom);\n if (idx === -1) break;\n candidates.push(idx);\n searchFrom = idx + 1;\n }\n if (candidates.length === 0) {\n return { ok: false, code: \"RANGE_GONE\" };\n }\n const best = candidates.reduce((a, b) => (Math.abs(a - from) <= Math.abs(b - from) ? a : b));\n return {\n ok: false,\n code: \"RANGE_MOVED\",\n resolvedFrom: toFlatOffset(best),\n resolvedTo: toFlatOffset(best + opts.textSnapshot.length),\n };\n }\n }\n\n // Heading overlap check\n if (rejectHeadingOverlap) {\n const fragment = ydoc.getXmlFragment(\"default\");\n const startPos = resolveToElement(fragment, from);\n const endPos = resolveToElement(fragment, to);\n if (!startPos || !endPos) {\n return {\n ok: false,\n code: \"INVALID_RANGE\",\n message: `Cannot resolve offset range [${from}, ${to}] in document.`,\n };\n }\n if (startPos.clampedFromPrefix || endPos.clampedFromPrefix) {\n return { ok: false, code: \"HEADING_OVERLAP\" };\n }\n }\n\n return { ok: true, range: { from, to } };\n}\n\n// ---------------------------------------------------------------------------\n// High-level: anchored range creation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a range and create both flat and CRDT-anchored positions in one call.\n * Pass `opts.rejectHeadingOverlap: true` to also reject ranges that overlap\n * heading prefixes (same guard used by `tandem_edit`).\n *\n * Sole assembler of `RelativeRange` at annotation birth — `refreshRange`'s\n * lazy-attach and dead-relRange repair branches are the only other sites that\n * assemble the `{fromRel, toRel}` shape, and both live in this file. Wire-shape\n * changes to `SerializedRelPos` require updating `SerializedRelPosSchema`,\n * both readers, and any on-disk JSON predating the change.\n */\nexport function anchoredRange(\n ydoc: Y.Doc,\n from: FlatOffset,\n to: FlatOffset,\n textSnapshot?: string,\n opts?: { rejectHeadingOverlap?: boolean },\n): AnchoredRangeResult | (RangeValidation & { ok: false }) {\n const validation = validateRange(ydoc, from, to, {\n textSnapshot,\n rejectHeadingOverlap: opts?.rejectHeadingOverlap,\n });\n if (!validation.ok) return validation;\n\n const range: DocumentRange = { from, to };\n\n // Create CRDT-anchored positions\n const fromRel = flatOffsetToRelPos(ydoc, from, 0); // assoc 0: stick right\n const toRel = flatOffsetToRelPos(ydoc, to, -1); // assoc -1: stick left\n const relRange: RelativeRange | undefined = fromRel && toRel ? { fromRel, toRel } : undefined;\n\n if (!relRange) {\n const fragment = ydoc.getXmlFragment(\"default\");\n const fromEl = resolveToElement(fragment, from);\n const toEl = resolveToElement(fragment, to);\n if (fromEl && !fromEl.clampedFromPrefix && toEl && !toEl.clampedFromPrefix) {\n console.error(`[positions] anchoredRange: relRange creation failed for [${from}, ${to}]`);\n }\n }\n\n if (relRange) {\n return { ok: true, fullyAnchored: true, range, relRange };\n }\n return { ok: true, fullyAnchored: false, range };\n}\n\n// ---------------------------------------------------------------------------\n// High-level: annotation range refresh\n// ---------------------------------------------------------------------------\n\n/**\n * Refresh an annotation's flat offsets from its relRange, or lazily attach\n * relRange if missing. Returns a tagged `RefreshResult` (ADR-032) so\n * callers can distinguish healthy / updated / attached / repaired /\n * degraded / failed paths instead of treating every outcome as success.\n * If `map` is provided, persists changes back to the Y.Map.\n *\n * The lazy-attach and dead-relRange repair branches below are the two\n * intentional `{fromRel, toRel}` re-assembly sites referenced by\n * `anchoredRange`'s JSDoc — both repair existing annotations rather than\n * minting new ones, so the shape duplication is deliberate, not a DRY gap.\n */\nexport function refreshRange(ann: Annotation, ydoc: Y.Doc, map?: Y.Map<unknown>): RefreshResult {\n if (!ann.relRange) {\n // Lazy attachment: compute relRange from current flat offsets\n const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);\n const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);\n if (!fromRel || !toRel) return { kind: \"degraded\", annotation: ann };\n const updated = { ...ann, relRange: { fromRel, toRel } };\n if (map) map.set(ann.id, updated);\n return { kind: \"attached\", annotation: updated };\n }\n\n // Resolve relRange to current flat offsets\n const newFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);\n const newTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);\n if (newFrom === null || newTo === null) {\n if (newFrom !== null || newTo !== null) {\n console.error(\n `[positions] refreshRange: partial CRDT resolution for ${ann.id} ` +\n `(from: ${newFrom !== null ? \"ok\" : \"dead\"}, to: ${newTo !== null ? \"ok\" : \"dead\"})`,\n );\n }\n // CRDT resolution failed (items deleted after content replacement).\n // Strip the dead relRange and attempt re-anchoring from flat offsets.\n const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);\n const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);\n if (fromRel && toRel) {\n const updated: Annotation = { ...ann, relRange: { fromRel, toRel } };\n if (map) map.set(ann.id, updated);\n return { kind: \"repaired\", annotation: updated };\n }\n // Can't re-anchor — strip dead relRange so lazy path works next time\n const stripped: Annotation = { ...ann };\n delete stripped.relRange;\n if (map) map.set(ann.id, stripped);\n return { kind: \"degraded\", annotation: stripped };\n }\n if (newFrom > newTo) {\n console.error(\n `[positions] refreshRange: inverted CRDT range for annotation ${ann.id}: ` +\n `resolved [${newFrom}, ${newTo}] from flat [${ann.range.from}, ${ann.range.to}]`,\n );\n return { kind: \"failed\", annotation: ann };\n }\n if (newFrom === ann.range.from && newTo === ann.range.to) {\n return { kind: \"ok\", annotation: ann };\n }\n\n const updated = { ...ann, range: { from: newFrom, to: newTo } };\n if (map) map.set(ann.id, updated);\n return { kind: \"updated\", annotation: updated };\n}\n\n/**\n * Refresh all annotations in a batch, wrapping Y.Map writes in a transaction.\n *\n * When `skipTransact` is true, writes happen inline without wrapping a\n * `ydoc.transact`. The caller is responsible for providing an outer\n * transaction with the appropriate origin. Used by `reloadFromDisk` to merge\n * this pass with the subsequent textSnapshot relocation pass into a single\n * `MCP_ORIGIN` transaction (closes the two-write crash window — GH #622).\n */\nexport function refreshAllRanges(\n annotations: Annotation[],\n ydoc: Y.Doc,\n map: Y.Map<unknown>,\n opts?: { skipTransact?: boolean },\n): RefreshResult[] {\n const results: RefreshResult[] = [];\n const run = () => {\n for (const ann of annotations) {\n results.push(refreshRange(ann, ydoc, map));\n }\n };\n if (opts?.skipTransact) {\n run();\n } else {\n withMcp(ydoc, run);\n }\n\n // PR #705 review observability: surface CRDT corruption (`failed` kind —\n // inverted CRDT range) at the aggregator boundary. The individual\n // refreshRange already logs via console.error; this lifts a count + IDs\n // above the per-annotation noise so a batched reload makes the corruption\n // visible without log-scraping.\n const failed = results.filter((r) => r.kind === \"failed\");\n if (failed.length > 0) {\n console.warn(\n `[positions] refreshAllRanges: ${failed.length} annotation(s) failed CRDT refresh: ${failed\n .map((r) => r.annotation.id)\n .join(\", \")}`,\n );\n }\n\n return results;\n}\n\n/**\n * Exhaustive-match helper. Use in `switch (result.kind)` defaults so future\n * additions to the `RefreshResult` discriminator produce a compile error\n * at every call site that should branch on the new kind.\n */\nexport function assertNeverRefreshResult(value: never): never {\n throw new Error(`Unexpected RefreshResult kind: ${JSON.stringify(value)}`);\n}\n","// Walk word/document.xml counting flat-text offsets.\n//\n// Shared between comment extraction (docx-comments.ts) and suggestion\n// application (docx-apply.ts). The walker's flat-text output must match\n// `extractText(htmlToYDoc(mammoth(docx)))` for any document.\n//\n// Key invariant: <w:del> subtrees are skipped (mammoth excludes deleted\n// tracked-change text), while <w:ins> subtrees are traversed normally\n// (mammoth includes inserted text).\n\nimport type { ChildNode, Element } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport { headingPrefixLength } from \"../../shared/offsets.js\";\n\n// ---------------------------------------------------------------------------\n// DOM helpers (lightweight — avoids adding domutils as a direct dependency)\n// ---------------------------------------------------------------------------\n\nexport function isElement(node: ChildNode): node is Element {\n return node.type === \"tag\";\n}\n\nexport function getAttr(el: Element, name: string): string | undefined {\n return el.attribs?.[name];\n}\n\n/** Recursively collect text content from a DOM node. */\nexport function getTextContent(node: ChildNode): string {\n if (node.type === \"text\") return (node as { data: string }).data;\n if (!isElement(node)) return \"\";\n return node.children.map(getTextContent).join(\"\");\n}\n\n/** Recursively find all elements with a given name. */\nexport function findAllByName(name: string, nodes: ChildNode[]): Element[] {\n const results: Element[] = [];\n for (const node of nodes) {\n if (isElement(node)) {\n if (node.name === name) results.push(node);\n results.push(...findAllByName(name, node.children));\n }\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Heading detection\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether a <w:p> has a heading paragraph style.\n * Returns the heading level (1–6) or 0 if not a heading.\n *\n * Word heading styles appear as:\n * <w:p><w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>...</w:p>\n *\n * mammoth maps these to <h1>–<h6>, and htmlToYDoc maps those to\n * Y.XmlElement(\"heading\") with a `level` attribute.\n */\nexport function detectHeadingLevel(paragraph: Element): number {\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:pPr\") continue;\n for (const prop of child.children) {\n if (!isElement(prop) || prop.name !== \"w:pStyle\") continue;\n const val = getAttr(prop, \"w:val\") || \"\";\n // Match \"Heading1\" through \"Heading6\" (case-insensitive)\n const match = val.match(/^heading\\s*(\\d)$/i);\n if (match) {\n const level = parseInt(match[1], 10);\n if (level >= 1 && level <= 6) return level;\n }\n }\n }\n return 0;\n}\n\n// ---------------------------------------------------------------------------\n// Walker types\n// ---------------------------------------------------------------------------\n\nexport interface TextHit {\n /** The <w:r> run element containing this text node. */\n run: Element;\n /** The <w:t> element itself. */\n textNode: Element;\n /** Flat-text offset where this text node starts. */\n offsetStart: number;\n /** The text content of this node. */\n text: string;\n /** The enclosing <w:p> paragraph element. */\n paragraph: Element;\n /** w14:paraId attribute from the paragraph, if present. */\n paragraphId: string | undefined;\n}\n\nexport interface CommentStartHit {\n commentId: string;\n offset: number;\n paragraph: Element;\n paragraphId: string | undefined;\n}\n\nexport interface WalkerCallbacks {\n onText?(hit: TextHit): void;\n onCommentStart?(hit: CommentStartHit): void;\n onCommentEnd?(commentId: string, offset: number): void;\n}\n\nexport interface WalkerResult {\n totalLength: number;\n flatText: string;\n}\n\n// ---------------------------------------------------------------------------\n// Single-character elements that mammoth maps to one character\n// ---------------------------------------------------------------------------\n\nconst SINGLE_CHAR_ELEMENTS = new Set([\"w:tab\", \"w:br\", \"w:noBreakHyphen\", \"w:softHyphen\", \"w:sym\"]);\n\n// ---------------------------------------------------------------------------\n// Walker\n// ---------------------------------------------------------------------------\n\n/**\n * Walk `<w:body>` children in document.xml, counting flat-text offsets and\n * firing callbacks for text nodes and comment range markers.\n *\n * Skips `<w:del>` subtrees (mammoth excludes deleted tracked-change text).\n * Traverses `<w:ins>` subtrees normally (mammoth includes inserted text).\n * Skips `<w:instrText>` (field instruction text).\n */\nexport function walkDocumentBody(xml: string, callbacks: WalkerCallbacks = {}): WalkerResult {\n const doc = parseDocument(xml, { xmlMode: true });\n\n let offset = 0;\n let firstParagraph = true;\n const textParts: string[] = [];\n\n // Current paragraph context — set when entering a <w:p>\n let currentParagraph: Element | undefined;\n let currentParagraphId: string | undefined;\n\n // Current run context — set when entering a <w:r>\n let currentRun: Element | undefined;\n\n function walk(nodes: ChildNode[]): void {\n for (const node of nodes) {\n if (!isElement(node)) continue;\n\n if (node.name === \"w:p\") {\n // Paragraph separator (except for first paragraph)\n if (!firstParagraph) {\n offset += 1; // \\n\n textParts.push(\"\\n\");\n }\n firstParagraph = false;\n\n // Set paragraph context\n const prevParagraph = currentParagraph;\n const prevParagraphId = currentParagraphId;\n currentParagraph = node;\n currentParagraphId = getAttr(node, \"w14:paraId\");\n\n // Detect heading style → add prefix length to offset\n const headingLevel = detectHeadingLevel(node);\n if (headingLevel > 0) {\n const prefixLen = headingPrefixLength(headingLevel);\n offset += prefixLen;\n textParts.push(\"#\".repeat(headingLevel) + \" \");\n }\n\n walk(node.children);\n\n // Restore paragraph context\n currentParagraph = prevParagraph;\n currentParagraphId = prevParagraphId;\n } else if (node.name === \"w:del\") {\n // Skip deleted tracked-change text — mammoth excludes it\n } else if (node.name === \"w:commentRangeStart\") {\n const id = getAttr(node, \"w:id\");\n if (id) {\n callbacks.onCommentStart?.({\n commentId: id,\n offset,\n paragraph: currentParagraph!,\n paragraphId: currentParagraphId,\n });\n }\n } else if (node.name === \"w:commentRangeEnd\") {\n const id = getAttr(node, \"w:id\");\n if (id) {\n callbacks.onCommentEnd?.(id, offset);\n }\n } else if (node.name === \"w:instrText\") {\n // Skip field instruction text\n } else if (node.name === \"w:t\") {\n const text = getTextContent(node);\n if (callbacks.onText && currentRun && currentParagraph) {\n callbacks.onText({\n run: currentRun,\n textNode: node,\n offsetStart: offset,\n text,\n paragraph: currentParagraph,\n paragraphId: currentParagraphId,\n });\n }\n offset += text.length;\n textParts.push(text);\n } else if (SINGLE_CHAR_ELEMENTS.has(node.name)) {\n offset += 1;\n textParts.push(\" \"); // placeholder character\n } else if (node.name === \"w:r\") {\n // Track current run for onText callback\n const prevRun = currentRun;\n currentRun = node;\n walk(node.children);\n currentRun = prevRun;\n } else {\n // Recurse into w:ins, w:hyperlink, w:pPr children, etc.\n walk(node.children);\n }\n }\n }\n\n // Find <w:body> and walk its children\n const bodyElements = findAllByName(\"w:body\", doc.children);\n if (bodyElements.length === 0) {\n return { totalLength: 0, flatText: \"\" };\n }\n walk(bodyElements[0].children);\n\n const flatText = textParts.join(\"\");\n return { totalLength: offset, flatText };\n}\n","// Extract Word comments from .docx ZIP and inject as Tandem annotations.\n//\n// Comments are parsed from word/comments.xml; anchor ranges are calculated\n// by walking word/document.xml and tracking w:commentRangeStart/End markers\n// alongside character offsets. Heading prefix offsets are accounted for so\n// flat-text positions match Tandem's coordinate system after mammoth → htmlToYDoc.\n\nimport * as crypto from \"node:crypto\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport * as Y from \"yjs\";\nimport { Y_MAP_ANNOTATION_REPLIES, Y_MAP_ANNOTATIONS } from \"../../shared/constants.js\";\nimport { withInternal } from \"../../shared/origins.js\";\nimport type { Annotation, AnnotationReply, FlatOffset } from \"../../shared/types.js\";\nimport { toFlatOffset } from \"../../shared/types.js\";\nimport { IMPORT_AUTHOR_MAX, IMPORT_REPLY_BODY_CAP, nextRev } from \"../annotations/schema.js\";\nimport { anchoredRange } from \"../positions.js\";\nimport {\n findAllByName,\n getAttr,\n getTextContent,\n isElement,\n walkDocumentBody,\n} from \"./docx-walker.js\";\n\n/**\n * Deterministic annotation id for an imported Word comment.\n *\n * Inputs (commentId + range + comment body) are stable across repeated imports\n * of the same .docx, so re-opening or force-reloading the file produces the\n * same id — which lets the injection loop dedupe against the existing map\n * instead of accumulating duplicates in the durable annotation store.\n */\nexport function importAnnotationId(\n commentId: string,\n from: number,\n to: number,\n bodyText: string,\n): string {\n const hash = crypto\n .createHash(\"sha256\")\n .update(`${commentId}\\u0000${from}\\u0000${to}\\u0000${bodyText}`)\n .digest(\"hex\")\n .slice(0, 12);\n return `import-${hash}`;\n}\n\n/**\n * Deterministic id for an imported Word comment reply (#1000). Stable across\n * re-imports of the same .docx (root id + reply id + body), so re-opening or\n * force-reloading dedupes against the existing replies map rather than\n * accumulating duplicates. Distinct prefix from `importAnnotationId` so a reply\n * id can never collide with a note id.\n */\nexport function importReplyId(\n rootCommentId: string,\n replyCommentId: string,\n bodyText: string,\n): string {\n const hash = crypto\n .createHash(\"sha256\")\n .update(`${rootCommentId}\u0000${replyCommentId}\u0000${bodyText}`)\n .digest(\"hex\")\n .slice(0, 12);\n return `import-reply-${hash}`;\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface DocxComment {\n commentId: string;\n authorName: string;\n bodyText: string;\n from: FlatOffset;\n to: FlatOffset;\n date?: string;\n /**\n * Threaded Word comment replies (#1000), reconstructed from\n * `commentsExtended.xml`. Present only on root comments that have replies;\n * absent for non-threaded documents (backward compatible). Replies inherit\n * the root's anchor range and are injected as private import replies.\n */\n replies?: DocxReply[];\n}\n\n/** A threaded Word comment reply. Inherits its root comment's anchor range. */\nexport interface DocxReply {\n commentId: string;\n authorName: string;\n bodyText: string;\n date?: string;\n}\n\n/**\n * Cycle/runaway guard for the thread-parent walk. Word comment threads are flat\n * (one root + N replies), so this is generous; a crafted `commentsExtended.xml`\n * with a deep/cyclic `paraIdParent` chain degrades to treating the node as a\n * root rather than hanging (#1000 security review R3).\n */\nconst MAX_THREAD_DEPTH = 64;\n\n/**\n * Length cap for the original Word `w:id` stored in `importSource.commentId`\n * (#1068). Real Word ids are short decimal strings; the cap only bounds a\n * crafted/hostile attribute. Export-side reuse additionally validates the\n * stored value is a canonical non-negative decimal before emitting it.\n */\nexport const IMPORT_COMMENT_ID_MAX = 32;\n\n// ---------------------------------------------------------------------------\n// Top-level extraction\n// ---------------------------------------------------------------------------\n\n/**\n * Extract comments and their document ranges from a .docx buffer.\n * Returns an empty array when the document has no comments.\n */\nexport async function extractDocxComments(buffer: Buffer): Promise<DocxComment[]> {\n const zip = await JSZip.loadAsync(buffer);\n\n const commentsXml = await zip.file(\"word/comments.xml\")?.async(\"text\");\n if (!commentsXml) return [];\n\n const documentXml = await zip.file(\"word/document.xml\")?.async(\"text\");\n if (!documentXml) return [];\n\n const commentMap = parseCommentMetadata(commentsXml);\n if (commentMap.size === 0) return [];\n\n const ranges = calculateCommentRanges(documentXml);\n\n // Thread reconstruction (#1000). Absent commentsExtended.xml ⇒ empty threading\n // ⇒ every comment resolves to itself as a root ⇒ identical to pre-#1000.\n const extendedXml = await zip.file(\"word/commentsExtended.xml\")?.async(\"text\");\n const threading = extendedXml ? parseCommentThreading(extendedXml) : new Map<string, string>();\n\n // paraId (lowercased) → commentId, to resolve a reply's parent paraId back to\n // a comment.\n const paraIdToCommentId = new Map<string, string>();\n for (const [id, meta] of commentMap) {\n if (meta.lastParaId) paraIdToCommentId.set(meta.lastParaId, id);\n }\n\n // Walk up paraIdParent links to the thread root. Cycle/self-parent/unresolved/\n // over-depth all terminate by treating the current node as a root.\n const resolveRoot = (startId: string): string => {\n let currentId = startId;\n const visited = new Set<string>();\n for (let depth = 0; depth < MAX_THREAD_DEPTH; depth++) {\n if (visited.has(currentId)) {\n console.error(`[docx-comments] Comment thread cycle at ${currentId}; treating as root`);\n return currentId;\n }\n visited.add(currentId);\n const paraId = commentMap.get(currentId)?.lastParaId;\n if (!paraId) return currentId;\n const parentParaId = threading.get(paraId);\n if (!parentParaId) return currentId;\n const parentId = paraIdToCommentId.get(parentParaId);\n if (!parentId || parentId === currentId) {\n if (!parentId) {\n console.error(\n `[docx-comments] Reply ${currentId} references unresolved parent paraId ${parentParaId}; treating as root`,\n );\n }\n return currentId;\n }\n currentId = parentId;\n }\n console.error(\n `[docx-comments] Comment thread exceeded depth ${MAX_THREAD_DEPTH} at ${startId}; treating as root`,\n );\n return currentId;\n };\n\n // Partition into roots and reply buckets (document order preserved by Map\n // iteration, which is the parse/document order).\n const rootIds: string[] = [];\n const replyBuckets = new Map<string, DocxReply[]>();\n for (const [id, meta] of commentMap) {\n const root = resolveRoot(id);\n if (root === id) {\n rootIds.push(id);\n } else {\n const bucket = replyBuckets.get(root) ?? [];\n bucket.push({\n commentId: id,\n authorName: meta.authorName,\n bodyText: meta.bodyText,\n date: meta.date,\n });\n replyBuckets.set(root, bucket);\n }\n }\n\n const result: DocxComment[] = [];\n for (const id of rootIds) {\n const meta = commentMap.get(id)!;\n const range = ranges.get(id);\n if (!range) {\n console.error(\n `[docx-comments] Comment ${id} has no range markers in document.xml — skipping`,\n );\n continue;\n }\n const replies = replyBuckets.get(id);\n if (replies) {\n // Order replies chronologically; stable sort keeps document order for\n // equal/absent dates.\n replies.sort((a, b) => (a.date ? Date.parse(a.date) : 0) - (b.date ? Date.parse(b.date) : 0));\n }\n result.push({\n commentId: id,\n authorName: meta.authorName,\n bodyText: meta.bodyText,\n from: range.from,\n to: range.to,\n date: meta.date,\n ...(replies && replies.length > 0 ? { replies } : {}),\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Comment metadata (word/comments.xml)\n// ---------------------------------------------------------------------------\n\ninterface CommentMeta {\n authorName: string;\n bodyText: string;\n date?: string;\n /**\n * Lowercased `w14:paraId` of the LAST `<w:p>` in the comment. This is what\n * `commentsExtended.xml`'s `w15:commentEx/@paraId` references (OOXML\n * CT_CommentEx §2.5.39), so it is the join key for thread reconstruction.\n */\n lastParaId?: string;\n}\n\n/** Parse comment id, author, body text, optional date, and last-paragraph paraId. */\nexport function parseCommentMetadata(xml: string): Map<string, CommentMeta> {\n const doc = parseDocument(xml, { xmlMode: true });\n const map = new Map<string, CommentMeta>();\n\n for (const comment of findAllByName(\"w:comment\", doc.children)) {\n const id = getAttr(comment, \"w:id\");\n if (!id) continue;\n\n const author = getAttr(comment, \"w:author\") || \"Unknown\";\n const date = getAttr(comment, \"w:date\");\n\n // Collect text from <w:t> elements within the comment body\n const textNodes = findAllByName(\"w:t\", comment.children);\n const bodyText = textNodes.map((t) => getTextContent(t)).join(\"\");\n\n // The comment's last paragraph paraId is the thread join key.\n // Shallow filter — only direct children of <w:comment>; a recursive\n // `findAllByName` would include <w:p> inside embedded tables, picking the\n // wrong last paraId. CT_CommentEx @paraId references the last top-level\n // paragraph (OOXML §2.5.39).\n const paragraphs = comment.children.filter(isElement).filter((c) => c.name === \"w:p\");\n const lastParaId =\n paragraphs.length > 0\n ? getAttr(paragraphs[paragraphs.length - 1], \"w14:paraId\")?.toLowerCase()\n : undefined;\n\n map.set(id, { authorName: author, bodyText, date, lastParaId });\n }\n return map;\n}\n\n/**\n * Parse `word/commentsExtended.xml` into a `childParaId → parentParaId` map\n * (both lowercased). Only entries with a `paraIdParent` (i.e. replies) are\n * included. Absent file ⇒ empty map ⇒ every comment is treated as a root\n * (backward compatible with non-threaded documents). #1000.\n */\nexport function parseCommentThreading(xml: string): Map<string, string> {\n const doc = parseDocument(xml, { xmlMode: true });\n const map = new Map<string, string>();\n for (const ex of findAllByName(\"w15:commentEx\", doc.children)) {\n const paraId = getAttr(ex, \"w15:paraId\")?.toLowerCase();\n const parent = getAttr(ex, \"w15:paraIdParent\")?.toLowerCase();\n if (paraId && parent) map.set(paraId, parent);\n }\n return map;\n}\n\n// ---------------------------------------------------------------------------\n// Range calculation (word/document.xml)\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the document body, counting flat-text characters (including heading\n * prefixes), and record start/end offsets for each comment range marker.\n *\n * Delegates to the shared `walkDocumentBody` walker which also skips\n * `<w:del>` subtrees (mammoth excludes deleted tracked-change text).\n */\nexport function calculateCommentRanges(\n xml: string,\n): Map<string, { from: FlatOffset; to: FlatOffset }> {\n const ranges = new Map<string, { from: FlatOffset; to: FlatOffset }>();\n const openRanges = new Map<string, number>(); // commentId → startOffset\n\n walkDocumentBody(xml, {\n onCommentStart({ commentId, offset }) {\n openRanges.set(commentId, offset);\n },\n onCommentEnd(commentId, offset) {\n if (openRanges.has(commentId)) {\n ranges.set(commentId, {\n from: toFlatOffset(openRanges.get(commentId)!),\n to: toFlatOffset(offset),\n });\n openRanges.delete(commentId);\n }\n },\n });\n\n if (openRanges.size > 0) {\n console.error(\n `[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(\", \")}`,\n );\n }\n\n return ranges;\n}\n\n// ---------------------------------------------------------------------------\n// Annotation injection\n// ---------------------------------------------------------------------------\n\n/**\n * Inject extracted comments into a Y.Doc's annotation map.\n * Must be called AFTER htmlToYDoc has populated the document content,\n * so that anchoredRange can create CRDT-anchored positions.\n *\n * Imports land as **private notes** (`type: \"note\"`, `audience: \"private\"`,\n * `author: \"import\"`) per the v7 W8 batch-promote flow. They carry the\n * reviewer attribution in `importSource: { author, file }` rather than\n * inlining `[author]` in the content body, so the UI can render a \"From:\n * <author>\" byline. The user batch-promotes notes to comments via\n * `BatchPromoteBar`, which flips `audience: \"private\"` → `\"outbound\"`,\n * `author: \"import\"` → `\"user\"`, and `type: \"note\"` → `\"comment\"`. Only\n * after that promotion do they surface to Claude via channel events or\n * `tandem_getAnnotations`.\n *\n * The fileName argument is best-effort — uploads and force-reload paths\n * that don't have a meaningful file name fall back to \"unknown\".\n */\nexport function injectCommentsAsAnnotations(\n doc: Y.Doc,\n comments: DocxComment[],\n fileName?: string,\n): number {\n if (comments.length === 0) return 0;\n\n const map = doc.getMap(Y_MAP_ANNOTATIONS);\n const repliesMap = doc.getMap(Y_MAP_ANNOTATION_REPLIES);\n const sourceFile = fileName ?? \"unknown\";\n let injected = 0;\n let migrated = 0;\n let injectedReplies = 0;\n\n // `withInternal` here is the authoritative origin. When callers invoke\n // this function inside an outer `withInternal` or `withReload` transact\n // (as file-opener.ts does), Y.js nested transactions inherit the outermost\n // origin — so the effective origin becomes whatever the outer call used.\n // This is intentional: a reload path calling this inside `withReload` wants\n // reload semantics (durable-sync persists, channel skips).\n withInternal(doc, () => {\n for (const comment of comments) {\n const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));\n if (!result.ok) {\n console.error(\n `[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] — ${result.code}`,\n );\n continue;\n }\n\n const id = importAnnotationId(comment.commentId, comment.from, comment.to, comment.bodyText);\n\n // Dedup: idempotent re-import. Same .docx → same id → leave the existing\n // note as-is. Legacy records stored under the pre-W8 model as\n // `type: \"comment\"` with content prefix `[author] ` are migrated in place\n // to the new private-note shape. Unlike the pre-#1000 code we do NOT early\n // `continue` here — reply injection below must run for existing/migrated\n // roots too (dedup is per-reply), so a pre-#1000 imported note picks up\n // its threaded replies on the next open.\n if (map.has(id)) {\n const existing = map.get(id) as Annotation | undefined;\n if (\n existing &&\n existing.author === \"import\" &&\n (existing.type === \"comment\" || existing.audience !== \"private\")\n ) {\n map.set(id, {\n ...existing,\n type: \"note\" as const,\n audience: \"private\" as const,\n content: comment.bodyText,\n importSource: {\n author: comment.authorName,\n file: sourceFile,\n commentId: comment.commentId.slice(0, IMPORT_COMMENT_ID_MAX),\n },\n rev: nextRev(existing),\n });\n migrated++;\n } else if (\n existing &&\n existing.author === \"import\" &&\n existing.importSource &&\n existing.importSource.commentId === undefined\n ) {\n // #1068 backfill: pre-commentId import notes (already note-shaped,\n // so the migration branch above skipped them) gain the original\n // Word id so a later promote → save reuses it. One-shot write —\n // guarded on the field being absent.\n map.set(id, {\n ...existing,\n importSource: {\n ...existing.importSource,\n commentId: comment.commentId.slice(0, IMPORT_COMMENT_ID_MAX),\n },\n rev: nextRev(existing),\n });\n }\n } else {\n const annotation: Annotation = {\n id,\n author: \"import\" as const,\n type: \"note\" as const,\n audience: \"private\" as const,\n range: { from: result.range.from, to: result.range.to },\n content: comment.bodyText,\n status: \"pending\" as const,\n timestamp: comment.date ? new Date(comment.date).getTime() : Date.now(),\n rev: nextRev(),\n importSource: {\n author: comment.authorName,\n file: sourceFile,\n commentId: comment.commentId.slice(0, IMPORT_COMMENT_ID_MAX),\n },\n ...(result.fullyAnchored ? { relRange: result.relRange } : {}),\n };\n\n map.set(id, annotation);\n injected++;\n }\n\n // Inject threaded Word replies as PRIVATE import replies (#1000). They\n // inherit the root note's anchor (no separate range) and never reach\n // Claude (private + the channel/read-path guards). Deterministic\n // `importReplyId` dedupes on the replies map itself — independent of the\n // parent note's existence — so re-import after a cascade delete recreates\n // them without duplicates. Untrusted body/author are length-bounded.\n for (const reply of comment.replies ?? []) {\n const replyId = importReplyId(comment.commentId, reply.commentId, reply.bodyText);\n if (repliesMap.has(replyId)) continue;\n const replyRecord: AnnotationReply = {\n id: replyId,\n annotationId: id,\n author: \"import\",\n text: reply.bodyText.slice(0, IMPORT_REPLY_BODY_CAP),\n timestamp: reply.date ? new Date(reply.date).getTime() : Date.now(),\n rev: nextRev(),\n private: true,\n importAuthor: reply.authorName.slice(0, IMPORT_AUTHOR_MAX),\n };\n repliesMap.set(replyId, replyRecord);\n injectedReplies++;\n }\n }\n });\n\n if (injected > 0 || migrated > 0 || injectedReplies > 0 || comments.length > 0) {\n console.error(\n `[docx-comments] Imported ${injected}/${comments.length} Word comments as private notes` +\n (injectedReplies > 0 ? ` + ${injectedReplies} threaded replies` : \"\") +\n (migrated > 0 ? ` (migrated ${migrated} legacy records to note shape)` : \"\"),\n );\n }\n\n return injected;\n}\n","import type { Annotation } from \"./types.js\";\n\n/** Raw annotation from Y.Map — may contain legacy `suggestion`/`question` types. */\nexport type RawAnnotation = Omit<Annotation, \"type\"> & { type: string };\n\n/**\n * Discriminated union describing a lossy rewrite performed by\n * `sanitizeAnnotation`. Reported via the required `onLossy` callback so\n * callers can route the event to their own observability sink (server:\n * migration-log; client: dev console).\n *\n * NEW kinds added here MUST be handled in `relaySanitizationEvent`\n * (`src/server/annotations/migration-log.ts`) — extend\n * `LegacyMigrationKind` in lockstep.\n */\nexport type SanitizationEvent =\n | { kind: \"flag-to-note\"; id: string }\n | { kind: \"question-to-comment\"; id: string }\n | { kind: \"malformed-suggestion-json\"; id: string }\n | { kind: \"unknown-type\"; id: string; rawType: string }\n /**\n * @deprecated Never emitted by `sanitizeAnnotation` since Wave 8 (PR #756)\n * reversed the import-note→comment rewrite. Retained in the union so that\n * log-parsing tools and `relaySanitizationEvent` don't break on event streams\n * that pre-date W8. Do not add new call sites.\n */\n | { kind: \"import-note-to-comment\"; id: string }\n | { kind: \"audience-conflict-resolved\"; id: string };\n\n/**\n * Required callback invoked once per lossy rewrite. Sync only — Promise\n * returns are forbidden at the type level. Errors thrown from the callback\n * are caught inside `sanitizeAnnotation` and logged to stderr; sanitize\n * never aborts mid-`.map()` because of a faulty relay.\n */\nexport type OnLossy = (event: SanitizationEvent) => void;\n\n/**\n * Normalize a legacy annotation into the unified shape.\n * - `suggestion` → `comment` with `suggestedText` + `content` (parsed from JSON)\n * - `question` → `comment` (directedAt removed per ADR-027)\n * - `flag` → `note` (ADR-027: audience-based model)\n * - Strips stray `color` from non-highlight entries (#245)\n * - Strips `directedAt` from comments (ADR-027)\n * - Preserves `rev` (the durable-annotation last-writer-wins counter — added\n * by the on-disk schema, see `src/server/annotations/schema.ts`). `rev` is\n * a server-internal durability concept, not a client-facing annotation\n * field, so it doesn't appear on the `Annotation` union type. Passthrough\n * here is load-bearing: without it every sanitize-then-write cycle in the\n * MCP tools would reset `rev` to undefined and the sync observer would\n * serialize `rev: 0` forever.\n *\n * `onLossy` is REQUIRED. Lossy rewrites — `flag→note`, `question→comment`,\n * malformed-suggestion-JSON, unknown-type → comment — fire one event each.\n * Making the callback required is the TS-level enforcement that prevents a\n * forgotten callsite from silently regressing observability.\n */\nexport function sanitizeAnnotation(\n input: Annotation | RawAnnotation,\n onLossy: OnLossy,\n): Annotation {\n const ann = input as RawAnnotation;\n\n const emit = (event: SanitizationEvent): void => {\n try {\n onLossy(event);\n } catch (err) {\n // Never abort sanitize because of a faulty relay. The whole point of\n // the callback is observability — if it throws, log to stderr (which\n // the server redirects from console.warn) and move on.\n console.warn(`[sanitizeAnnotation] onLossy threw for ${event.kind}:`, err);\n }\n };\n\n // AR1: derive audience before `base` is built so it flows through all early-return paths.\n // \"flag\" is explicit — it hasn't been mutated to \"note\" at this point.\n // Import annotations are always private initially; users triage Word comments before Claude sees them.\n // Computing a default is normative behavior, not a lossy migration — no event emitted.\n const derivedAudience: \"private\" | \"outbound\" =\n ann.audience === \"private\" || ann.audience === \"outbound\"\n ? ann.audience\n : ann.author === \"import\" ||\n ann.type === \"highlight\" ||\n ann.type === \"note\" ||\n ann.type === \"flag\"\n ? \"private\"\n : \"outbound\";\n\n // Build a base with only AnnotationBase fields (strip legacy type-specific fields)\n const base = {\n id: ann.id,\n author: ann.author,\n range: ann.range,\n content: ann.content,\n status: ann.status,\n timestamp: ann.timestamp,\n ...(ann.relRange !== undefined ? { relRange: ann.relRange } : {}),\n ...(ann.textSnapshot !== undefined ? { textSnapshot: ann.textSnapshot } : {}),\n ...(ann.editedAt !== undefined ? { editedAt: ann.editedAt } : {}),\n ...(typeof ann.rev === \"number\" ? { rev: ann.rev } : {}),\n audience: derivedAudience,\n ...(ann.promotedFrom !== undefined ? { promotedFrom: ann.promotedFrom } : {}),\n ...(ann.importSource !== undefined ? { importSource: ann.importSource } : {}),\n };\n\n // Guard: user-authored notes, highlights, and flags must never be outbound.\n // Flags are included because the flag-to-note migration below preserves audience;\n // without this guard a flag with explicit audience:\"outbound\" would become a\n // note with audience:\"outbound\", violating ADR-027.\n // Only author:\"user\" is guarded; import-promoted comments (author:\"import\") remain\n // outbound-eligible after their own type rewrite below.\n if (\n base.audience === \"outbound\" &&\n ann.author === \"user\" &&\n (ann.type === \"note\" || ann.type === \"highlight\" || ann.type === \"flag\")\n ) {\n base.audience = \"private\";\n emit({ kind: \"audience-conflict-resolved\", id: ann.id });\n }\n\n if (ann.type === \"suggestion\") {\n let suggestedText: string | undefined;\n let content: string;\n try {\n const parsed = JSON.parse(ann.content) as { newText?: string; reason?: string };\n suggestedText = parsed.newText;\n content = parsed.reason ?? \"\";\n } catch {\n emit({ kind: \"malformed-suggestion-json\", id: ann.id });\n content = ann.content;\n }\n return { ...base, type: \"comment\", content, suggestedText } as Annotation;\n }\n\n if (ann.type === \"question\") {\n emit({ kind: \"question-to-comment\", id: ann.id });\n return { ...base, type: \"comment\" } as Annotation;\n }\n\n if (ann.type === \"highlight\") {\n return {\n ...base,\n type: \"highlight\",\n color: (ann as Annotation & { color?: string }).color,\n } as Annotation;\n }\n\n // W8 (PR #756) reverses the #482 policy: imported Word reviewer comments\n // are now first-class private notes (`author: \"import\", type: \"note\",\n // audience: \"private\"`) until the user batch-promotes them via the\n // BatchPromoteBar. The previous import-note→comment rewrite leaked\n // un-promoted imports to Claude on every MCP read; falling through to\n // the note branch below keeps them in the private bucket.\n\n if (ann.type === \"flag\" || ann.type === \"note\") {\n if (ann.type === \"flag\") {\n emit({ kind: \"flag-to-note\", id: ann.id });\n }\n return { ...base, type: \"note\" } as Annotation;\n }\n\n if (ann.type === \"comment\") {\n return {\n ...base,\n type: \"comment\",\n ...(ann.suggestedText !== undefined ? { suggestedText: ann.suggestedText } : {}),\n } as Annotation;\n }\n\n // Truly unknown type — coerce to comment\n emit({ kind: \"unknown-type\", id: ann.id, rawType: ann.type });\n return { ...base, type: \"comment\" } as Annotation;\n}\n","// Annotation → Word-comment export gate (#1068, #576 v1.1).\n//\n// Decides WHICH annotations become Word comments on .docx save and resolves\n// their current document ranges. The OOXML emission itself (CommentRangeStart/\n// End markers + comments.xml) lives in `docx-export.ts`; this module is the\n// privacy and correctness boundary in front of it.\n//\n// ADR-027 GATE (must be preserved by any future change):\n// ADR-027 governs CLAUDE visibility, not the .docx file round-trip. Two kinds\n// of annotation reach this file: (A) user/Claude comments destined for Claude,\n// and (B) imports — Word comments that CAME FROM the source .docx, stored as\n// private notes (Claude-invisible) but written back to the same file on save.\n// Writing an import back to its own file is content preservation, NOT Claude\n// exposure — the Claude-facing surfaces (`tandem_getAnnotations`,\n// `tandem_exportAnnotations`, channel) are untouched by this module.\n//\n// - An ANNOTATION is exported when EITHER:\n// (A) `type === \"comment\"` AND `audience !== \"private\"` AND\n// `status === \"pending\"` (the user/Claude comment path), OR\n// (B) it is an IMPORT ROUND-TRIP — `isImportRoundtrip(ann)`: `author ===\n// \"import\"` AND a populated `importSource` (see the predicate). Imports\n// bypass the type, audience, AND status gates: they are file content,\n// not Claude-facing, and Bryan's directive is \"imported comments\n// should not be dropped\" — even an accepted/dismissed import round-trips\n// (status is Tandem's review state, not the file's content). Only an\n// explicit DELETE (removal from the annotation map) drops an import.\n// - The import predicate (annotations AND replies) keys on `author ===\n// \"import\"` AND a corroborating field (`importSource` / `importAuthor`),\n// never on `author` alone: the `.passthrough()` durable envelope\n// enum-validates `author` but does NOT cross-validate it against the import\n// metadata, so `author:\"import\"` alone must not bypass the gate. A genuine\n// import always populates the corroborating field.\n// - User-authored `note`/`highlight` and user-authored `private` replies\n// NEVER satisfy the import predicate, so they are never exported.\n// - Replies: `private` replies are exported ONLY when they are import replies\n// (`author === \"import\"` AND a populated `importAuthor`) — imported Word\n// reply threads round-trip back to the file; note-authored and other private\n// replies never export. Privacy is a durable property of the reply.\n//\n// Range resolution mirrors the read paths: `refreshRange` resolves the CRDT\n// `relRange` first and falls back to flat offsets (read-only here — no Y.Map\n// writes, no transactions; a .docx save must not mutate the Y.Doc). Ranges\n// that no longer resolve are skipped with a stderr warning instead of\n// failing the save.\n//\n// Threaded replies: docx@9.6 cannot emit `commentsExtended.xml` (the part\n// Word uses for reply threading), so exportable replies are FLATTENED into\n// the comment body as attributed paragraphs. See the #1068 PR for the\n// empirical evidence and trade-offs.\n\nimport type * as Y from \"yjs\";\nimport { Y_MAP_ANNOTATION_REPLIES, Y_MAP_ANNOTATIONS } from \"../../shared/constants.js\";\nimport { sanitizeAnnotation } from \"../../shared/sanitize.js\";\nimport type { Annotation, AnnotationReply } from \"../../shared/types.js\";\nimport { extractText } from \"../mcp/document-model.js\";\nimport { refreshRange } from \"../positions.js\";\n\n/** A privacy-gated, range-resolved comment ready for OOXML emission. */\nexport interface ExportComment {\n /** Numeric Word `w:id`. Unique within one export. */\n id: number;\n /** Word comment author display name. */\n author: string;\n /** Word comment initials (derived from `author`). */\n initials: string;\n /** Comment creation date (from the annotation timestamp). */\n date: Date;\n /** Resolved flat-offset range start (current Y.Doc coordinates). */\n from: number;\n /** Resolved flat-offset range end (current Y.Doc coordinates). */\n to: number;\n /**\n * Comment body, one entry per Word comment paragraph. The FIRST entries are\n * always the annotation content verbatim (split on newlines) so a promoted\n * import without suggestion/replies round-trips to an identical\n * `importAnnotationId` body hash. Suggestion text and flattened replies\n * append AFTER the content.\n */\n bodyParagraphs: string[];\n}\n\n/**\n * Import round-trip predicate: an annotation that ORIGINATED from the source\n * .docx (a Word comment) and must be written back to it on save.\n *\n * Keyed on `author === \"import\"` AND a populated `importSource.author`, never on\n * `author` alone. The durable store's `.passthrough()` envelope (annotations/\n * schema.ts) enum-validates `author` but does NOT cross-validate it against\n * `importSource`, so a tampered/legacy `<hash>.json` record carrying\n * `author:\"import\"` + user content but no `importSource` must not be enough to\n * bypass the privacy gate and leak into a shared file. A genuine import always\n * populates `importSource` (docx-comments.ts injection); requiring it restores\n * belt-and-suspenders alongside the (now import-bypassed) type and audience\n * gates. (A determined local attacker who hand-edits the at-rest JSON can forge\n * both fields — but that is not an escalation: they can already edit the target\n * .docx directly.) `importSource.commentId` is deliberately NOT required — it is\n * about w:id stability, not provenance, and pre-#1068 import notes lack it.\n */\nfunction isImportRoundtrip(ann: Annotation): boolean {\n return (\n ann.author === \"import\" &&\n typeof ann.importSource?.author === \"string\" &&\n ann.importSource.author.length > 0\n );\n}\n\n/**\n * Reply analogue of `isImportRoundtrip`: an imported Word reply that round-trips\n * back to its source file. Same corroboration rationale — `author === \"import\"`\n * alone is insufficient under the `.passthrough()` envelope; require a populated\n * `importAuthor`, which the genuine injection path always sets (reply author\n * defaults to \"Unknown\", never empty — `parseCommentMetadata`). This keeps the\n * reply gate symmetric with the annotation gate.\n */\nfunction isImportReply(reply: AnnotationReply): boolean {\n return (\n reply.author === \"import\" &&\n typeof reply.importAuthor === \"string\" &&\n reply.importAuthor.length > 0\n );\n}\n\n/**\n * Returns the original Word comment id as a number when it can be reused\n * verbatim (canonical non-negative decimal — the 9-digit cap keeps it well\n * inside OOXML's int32 `w:id` range), else null. Reusing the original id\n * keeps `importAnnotationId` stable across a promote → save → re-open cycle.\n */\nfunction reusableCommentId(raw: string | undefined): number | null {\n if (!raw || !/^\\d{1,9}$/.test(raw)) return null;\n const n = Number(raw);\n // Reject non-canonical forms (\"01\") — the re-imported id would be the\n // emitted canonical string and the hash would differ anyway.\n if (String(n) !== raw) return null;\n return n;\n}\n\nfunction authorLabel(ann: Annotation): string {\n const imported = ann.importSource?.author?.trim();\n if (imported) return imported;\n if (ann.author === \"claude\") return \"Claude\";\n if (ann.author === \"user\") return \"User\";\n return \"Imported\";\n}\n\nfunction initialsFor(label: string): string {\n const initials = label\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 3)\n .map((part) => part[0].toUpperCase())\n .join(\"\");\n return initials || \"T\";\n}\n\nfunction replyAuthorLabel(reply: AnnotationReply): string {\n if (reply.author === \"claude\") return \"Claude\";\n if (reply.author === \"user\") return \"User\";\n return reply.importAuthor?.trim() || \"Imported\";\n}\n\n/** Minimal structural guard for raw Y.Map values before sanitize/refresh. */\nfunction isAnnotationShaped(value: unknown): value is Annotation {\n if (typeof value !== \"object\" || value === null) return false;\n const v = value as Record<string, unknown>;\n const range = v.range as Record<string, unknown> | undefined;\n return (\n typeof v.id === \"string\" &&\n typeof v.content === \"string\" &&\n typeof range === \"object\" &&\n range !== null &&\n typeof range.from === \"number\" &&\n typeof range.to === \"number\"\n );\n}\n\n/**\n * Collect exportable (non-private) replies for an annotation, oldest first.\n */\nfunction exportableReplies(repliesMap: Y.Map<unknown>, annotationId: string): AnnotationReply[] {\n const out: AnnotationReply[] = [];\n repliesMap.forEach((value) => {\n if (typeof value !== \"object\" || value === null) return;\n const reply = value as AnnotationReply;\n if (reply.annotationId !== annotationId) return;\n // ADR-027/#1000: private replies never reach Claude. The .docx file\n // round-trip is a separate boundary: an imported Word reply (isImportReply)\n // is written back to the file it came from even though it's private. A\n // user-authored private reply (note-authored, or a private reply on an\n // imported comment) never exports, and an `author:\"import\"` reply lacking\n // the corroborating `importAuthor` is treated as untrusted (fail closed).\n if (reply.private === true && !isImportReply(reply)) return;\n if (typeof reply.id !== \"string\") return;\n if (typeof reply.text !== \"string\" || reply.text.length === 0) return;\n out.push(reply);\n });\n out.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || a.id.localeCompare(b.id));\n return out;\n}\n\n/** Split annotation/reply text into Word comment paragraphs. */\nfunction toParagraphLines(text: string): string[] {\n const lines = text.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n return lines.length > 0 ? lines : [\"\"];\n}\n\n/**\n * Build the privacy-gated, range-resolved Word comment list for a .docx save.\n *\n * READ-ONLY on the Y.Doc: range resolution uses `refreshRange` without a map\n * argument, so no Y.Map writes and no transactions occur during export.\n *\n * Annotations whose ranges no longer resolve (CRDT anchors dead AND flat\n * offsets out of bounds/inverted) are skipped with a stderr warning — a save\n * must never fail because one annotation went stale.\n */\nexport function prepareExportComments(doc: Y.Doc): ExportComment[] {\n const map = doc.getMap(Y_MAP_ANNOTATIONS);\n if (map.size === 0) return [];\n const repliesMap = doc.getMap(Y_MAP_ANNOTATION_REPLIES);\n const docLength = extractText(doc).length;\n\n const candidates: Annotation[] = [];\n map.forEach((value) => {\n if (!isAnnotationShaped(value)) return;\n const ann = sanitizeAnnotation(value, (event) => {\n console.error(`[docx-comment-export] sanitize rewrote ${event.id}: ${event.kind}`);\n });\n // ADR-027 gate — see module header. Import round-trips (author:\"import\" +\n // importSource) bypass the type/audience/status gates: they are file content\n // written back to their own .docx, Claude-invisible throughout. User notes/\n // highlights and user-private content never satisfy the predicate, so they\n // stay excluded by every clause. Sanitize runs FIRST (above) so this sees\n // the canonicalized record; the admitted import set is exactly\n // {author:\"import\" + importSource} × {note, private-comment}.\n const importRoundtrip = isImportRoundtrip(ann);\n if (ann.type !== \"comment\" && !importRoundtrip) return; // never user notes/highlights\n if (ann.audience === \"private\" && !importRoundtrip) return; // defense-in-depth\n if (ann.status !== \"pending\" && !importRoundtrip) return; // resolved user comments drop\n candidates.push(ann);\n });\n if (candidates.length === 0) return [];\n\n // Resolve each candidate's CURRENT range: relRange first, flat fallback\n // (refreshRange does both, read-only without a map argument).\n const resolved: Array<{ ann: Annotation; from: number; to: number }> = [];\n for (const ann of candidates) {\n const refreshed = refreshRange(ann, doc);\n if (refreshed.kind === \"failed\") {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: CRDT range resolution failed`,\n );\n continue;\n }\n const { from, to } = refreshed.annotation.range;\n if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from > to) {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: invalid range [${from}, ${to}]`,\n );\n continue;\n }\n if (to > docLength) {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: range [${from}, ${to}] ` +\n `exceeds document length ${docLength}`,\n );\n continue;\n }\n resolved.push({ ann: refreshed.annotation, from, to });\n }\n if (resolved.length === 0) return [];\n\n // Stable output order: document position, then id.\n resolved.sort((a, b) => a.from - b.from || a.to - b.to || a.ann.id.localeCompare(b.ann.id));\n\n // Allocate w:id values. Promoted imports reuse their original Word id\n // (importAnnotationId stability); everything else gets the next free id.\n const usedIds = new Set<number>();\n const reserved = new Map<string, number>();\n for (const { ann } of resolved) {\n const original = reusableCommentId(ann.importSource?.commentId);\n if (original !== null && !usedIds.has(original)) {\n usedIds.add(original);\n reserved.set(ann.id, original);\n }\n }\n let nextId = 1;\n const allocate = (): number => {\n while (usedIds.has(nextId)) nextId++;\n usedIds.add(nextId);\n return nextId;\n };\n\n const out: ExportComment[] = [];\n for (const { ann, from, to } of resolved) {\n const label = authorLabel(ann);\n const bodyParagraphs = toParagraphLines(ann.content);\n if (ann.type === \"comment\" && ann.suggestedText) {\n bodyParagraphs.push(\"\", `Suggested replacement: ${ann.suggestedText}`);\n }\n for (const reply of exportableReplies(repliesMap, ann.id)) {\n const replyLines = toParagraphLines(reply.text);\n bodyParagraphs.push(\"\", `Reply from ${replyAuthorLabel(reply)}: ${replyLines[0]}`);\n bodyParagraphs.push(...replyLines.slice(1));\n }\n out.push({\n id: reserved.get(ann.id) ?? allocate(),\n author: label,\n initials: initialsFor(label),\n date: new Date(Number.isFinite(ann.timestamp) ? ann.timestamp : Date.now()),\n from,\n to,\n bodyParagraphs,\n });\n }\n return out;\n}\n","// Y.Doc -> .docx export (#576: v1.0 body, #1068: v1.1 Word comments).\n//\n// Production write-back engine using the `docx` npm package. Walks\n// `Y.Doc.getXmlFragment(\"default\")` and maps Tiptap node names onto the\n// `docx` package's Paragraph / Table constructors, flattening Y.XmlText\n// deltas into TextRuns with marks.\n//\n// SCOPE: body content + Word comments + footnotes. Tandem `comment`-type\n// annotations are emitted as Word comments (`comments.xml` +\n// CommentRangeStart/End markers). The privacy gate and range resolution live in\n// `docx-comment-export.ts` (ADR-027: notes and highlights are NEVER exported).\n// Footnotes captured on import (#1123 Tier-A #3) are re-emitted as real\n// `<w:footnote>` parts + `FootnoteReferenceRun`s from the off-fragment\n// Y_MAP_FOOTNOTE_BODIES map; body FORMATTING is flattened to plain text (honestly\n// reported on import), and a marked ref with no captured body falls back to a\n// plain `[N]` superscript (never a corrupt bodyless reference). NOT exported here:\n// - Tracked changes — requires a Y.Doc authorship-diff layer (deferred).\n// - Threaded comment replies — docx@9.x has no `commentsExtended.xml`\n// support; exportable replies are flattened into the comment body.\n// - Inline images — degraded to alt text (mdast-ydoc imports images as\n// inline phrasing content, not top-level <image> nodes; see the docx-npm\n// spike). Top-level <image> nodes ARE exported when present.\n//\n// COMMENT ANCHORING. Comment ranges are flat-offset based (the annotation\n// coordinate system: `extractText` semantics — heading prefixes count,\n// top-level blocks join with \\n, nested blocks separate with \\n, hardBreak\n// embeds count 1). The emitter threads a cursor (`EmitCtx.pos`) through the\n// block walk that advances EXACTLY like `extractText`/`getElementText`, and\n// splits TextRuns at comment boundaries so `w:commentRangeStart/End` land at\n// offsets the import-side walker (`docx-walker.ts#walkDocumentBody`)\n// recomputes identically. Content the exporter drops or rewrites (image alt\n// placeholders, unknown nested blocks) still advances the cursor by its\n// ORIGINAL flat length so later anchors stay aligned.\n//\n// TRUST BOUNDARY (must be preserved by any future change). A .docx Tandem\n// produces must never carry hostile relationships out into the filesystem or\n// network:\n// 1. NO r:link external image references — only inline `data:` images are\n// embedded as raw bytes via `ImageRun({ data: Buffer })`. We never pass a\n// URL / remote-image reference to `docx`.\n// 2. NO `<w:object>` embedded objects — `docx` has no public OLE API and we\n// never call any embed-object method.\n// 3. NO `targetMode=\"External\"` relationships pointing to `file://` or UNC\n// paths — hyperlinks are scrubbed to `http`/`https`/`mailto` only.\n// 4. Unknown Y.XmlElement node names fall through to a text-only paragraph,\n// never to a passthrough that could inject markup.\n//\n// All Tiptap node-name strings in this file mirror those produced by\n// `mdast-ydoc.ts` / `docx-html.ts` and consumed by the editor; they are NOT\n// Y.Map keys, so they do not require the Y_MAP_* constants (Critical Rule #1).\n\nimport {\n AlignmentType,\n CommentRangeEnd,\n CommentRangeStart,\n CommentReference,\n Document,\n ExternalHyperlink,\n FootnoteReferenceRun,\n HeadingLevel,\n type ICommentOptions,\n ImageRun,\n Packer,\n Paragraph,\n type ParagraphChild,\n ShadingType,\n Table,\n TableCell,\n TableRow,\n TextRun,\n WidthType,\n} from \"docx\";\nimport * as Y from \"yjs\";\nimport { Y_MAP_DOCUMENT_META, Y_MAP_FOOTNOTE_BODIES } from \"../../shared/constants.js\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\nimport {\n extractText,\n getElementTextLength,\n getHeadingPrefixLength,\n} from \"../mcp/document-model.js\";\nimport { type ExportComment, prepareExportComments } from \"./docx-comment-export.js\";\n\n// -- Trust-boundary helpers ---------------------------------------------------\n\nconst SAFE_LINK_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\"]);\n\n/**\n * Returns the URL if it is safe to embed as an ExternalHyperlink target,\n * otherwise null. Blocks `file://`, UNC paths, Windows drive paths, and any\n * non-http(s)/mailto scheme so no exported docx can carry a\n * `targetMode=\"External\"` relationship pointing at a local filesystem resource.\n */\nexport function safeHyperlinkUrl(raw: string | null | undefined): string | null {\n if (!raw) return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n // UNC: `\\\\server\\share\\path`\n if (trimmed.startsWith(\"\\\\\\\\\")) return null;\n // Windows drive paths: `C:\\...`\n if (/^[a-zA-Z]:[\\\\/]/.test(trimmed)) return null;\n try {\n const u = new URL(trimmed);\n if (!SAFE_LINK_PROTOCOLS.has(u.protocol)) return null;\n return u.toString();\n } catch {\n // Relative or malformed -- drop it (no implicit base).\n return null;\n }\n}\n\n/**\n * Image embed gate. Only inline `data:` URIs are accepted; everything else\n * (http, https, file, UNC, relative paths) is dropped so we never produce an\n * `r:link` external image reference.\n */\nexport function safeImageEmbed(\n src: string | null | undefined,\n): { data: Buffer; type: \"png\" | \"jpg\" | \"gif\" | \"bmp\" } | null {\n if (!src) return null;\n const match = /^data:image\\/(png|jpe?g|gif|bmp);base64,([A-Za-z0-9+/=]+)$/.exec(src.trim());\n if (!match) return null;\n const rawType = match[1].toLowerCase();\n const type = (rawType === \"jpeg\" ? \"jpg\" : rawType) as \"png\" | \"jpg\" | \"gif\" | \"bmp\";\n try {\n const data = Buffer.from(match[2], \"base64\");\n if (data.length === 0) return null;\n return { data, type };\n } catch {\n return null;\n }\n}\n\n// -- Comment marker events ------------------------------------------------------\n\n/**\n * A comment range marker scheduled at a flat-text offset. `order` breaks ties\n * at equal offsets: real range ends (0) close before new ranges open (1);\n * collapsed (zero-width) ranges keep start-before-end (their end sorts at 2).\n */\ninterface CommentEvent {\n offset: number;\n order: 0 | 1 | 2;\n components: ParagraphChild[];\n}\n\n/**\n * Mutable emission context threaded through the block walk. `pos` is the\n * flat-text cursor (extractText coordinates); `events` is sorted by\n * (offset, order); `idx` is the next unflushed event.\n */\ninterface EmitCtx {\n pos: number;\n events: CommentEvent[];\n idx: number;\n /** Reconstructed footnote bodies keyed by id (read-only input, #1123). */\n footnoteBodies: Record<string, FootnoteBody>;\n /** Accumulated footnotes map for the Document constructor, keyed by numeric\n * id (output — populated as `FootnoteReferenceRun`s are emitted). */\n footnotesMap: Record<number, { children: Paragraph[] }>;\n}\n\nfunction buildCommentEvents(comments: ExportComment[]): CommentEvent[] {\n const events: CommentEvent[] = [];\n for (const c of comments) {\n events.push({ offset: c.from, order: 1, components: [new CommentRangeStart(c.id)] });\n events.push({\n offset: c.to,\n order: c.from === c.to ? 2 : 0,\n // OOXML requires the reference run (which binds the comment bubble to\n // the range) immediately after the range end marker.\n components: [\n new CommentRangeEnd(c.id),\n new TextRun({ children: [new CommentReference(c.id)] }),\n ],\n });\n }\n events.sort((a, b) => a.offset - b.offset || a.order - b.order);\n return events;\n}\n\n/** Emit every pending marker whose offset the cursor has reached. */\nfunction flushCommentEvents(emit: EmitCtx, out: ParagraphChild[]): void {\n while (emit.idx < emit.events.length && emit.events[emit.idx].offset <= emit.pos) {\n out.push(...emit.events[emit.idx].components);\n emit.idx++;\n }\n}\n\n// -- Tiptap -> docx runtime conversion ----------------------------------------\n\ninterface MarkState {\n bold?: boolean;\n italic?: boolean;\n strike?: boolean;\n code?: boolean;\n underline?: boolean;\n superscript?: boolean;\n subscript?: boolean;\n link?: { href: string; title?: string } | null;\n /** Footnote reference marker (#1123 Tier-A #3 PR 2). Emitted atomically as a\n * `FootnoteReferenceRun`; mutually exclusive with other formatting in\n * practice (the import attaches it ALONE on the `[N]` text). */\n footnoteRef?: { id: string; kind: \"footnote\" | \"endnote\" };\n}\n\ninterface InlineRun {\n text: string;\n marks: MarkState;\n}\n\n/** Walk a Y.XmlText, flattening deltas into typed runs. */\nfunction flattenXmlText(xt: Y.XmlText): InlineRun[] {\n const runs: InlineRun[] = [];\n const delta = xt.toDelta() as Array<{\n insert?: string | Record<string, unknown>;\n attributes?: Record<string, unknown>;\n }>;\n for (const op of delta) {\n if (typeof op.insert === \"string\") {\n const attrs = op.attributes ?? {};\n const marks: MarkState = {\n bold: attrs.bold != null,\n italic: attrs.italic != null,\n strike: attrs.strike != null,\n code: attrs.code != null,\n underline: attrs.underline != null,\n superscript: attrs.superscript != null,\n subscript: attrs.subscript != null,\n };\n const link = attrs.link as { href?: string; title?: string } | undefined;\n if (link?.href) {\n marks.link = { href: link.href, ...(link.title ? { title: link.title } : {}) };\n }\n const footnote = attrs[\"footnote-ref\"] as { id?: string; kind?: string } | undefined;\n if (footnote?.id) {\n marks.footnoteRef = {\n id: footnote.id,\n kind: footnote.kind === \"endnote\" ? \"endnote\" : \"footnote\",\n };\n }\n runs.push({ text: op.insert, marks });\n } else if (op.insert && typeof op.insert === \"object\") {\n // hardBreak embed -- represented as `\\n`; the emitter turns it into a\n // dedicated `<w:br/>` run (1 flat character).\n runs.push({ text: \"\\n\", marks: {} });\n }\n }\n return runs;\n}\n\nfunction makeTextRun(text: string, marks: MarkState): TextRun {\n return new TextRun({\n text,\n bold: marks.bold,\n italics: marks.italic,\n strike: marks.strike,\n underline: marks.underline ? {} : undefined,\n superScript: marks.superscript,\n subScript: marks.subscript,\n // `docx` lacks a first-class inline-code style; approximate with a\n // monospace font + light shading.\n font: marks.code ? \"Consolas\" : undefined,\n shading: marks.code ? { type: ShadingType.CLEAR, color: \"auto\", fill: \"F1F3F5\" } : undefined,\n });\n}\n\n/**\n * Emit `text` as one or more TextRuns, splitting at comment marker offsets.\n * Advances the cursor by `text.length`. Hyperlinked segments each get their\n * own ExternalHyperlink wrapper (matches the pre-#1068 one-wrapper-per-run\n * behavior; a split link still navigates from every segment).\n */\nfunction emitTextSegments(\n text: string,\n marks: MarkState,\n emit: EmitCtx,\n out: ParagraphChild[],\n): void {\n let s = text;\n while (s.length > 0) {\n flushCommentEvents(emit, out);\n const nextOffset = emit.idx < emit.events.length ? emit.events[emit.idx].offset : Infinity;\n // flush guarantees nextOffset > pos, so take >= 1 and the loop terminates.\n const take = Math.min(s.length, nextOffset - emit.pos);\n const run = makeTextRun(s.slice(0, take), marks);\n if (marks.link) {\n const safeUrl = safeHyperlinkUrl(marks.link.href);\n if (safeUrl) {\n out.push(new ExternalHyperlink({ children: [run], link: safeUrl }));\n } else {\n // Drop unsafe hyperlink, keep the text.\n out.push(run);\n }\n } else {\n out.push(run);\n }\n emit.pos += take;\n s = s.slice(take);\n }\n}\n\n/**\n * Emit a footnote reference marker (#1123 Tier-A #3 PR 2). A\n * `FootnoteReferenceRun` is atomic OOXML and the cursor must advance by the\n * marker's full flat length, so this is handled OUTSIDE the comment-split loop:\n * markers due before the glyph flush first, the single reference run emits, the\n * cursor advances by the marker text, then any marker that fell INSIDE the span\n * snaps to AFTER it (Word can't anchor inside a footnote glyph).\n */\nfunction emitFootnoteRef(r: InlineRun, emit: EmitCtx, out: ParagraphChild[]): void {\n flushCommentEvents(emit, out);\n // biome-ignore lint/style/noNonNullAssertion: caller guards r.marks.footnoteRef.\n const ref = r.marks.footnoteRef!;\n const body = ref.kind === \"footnote\" ? emit.footnoteBodies[ref.id] : undefined;\n const numId = Number(ref.id);\n if (body && Number.isInteger(numId) && numId > 0) {\n // docx auto-prepends the footnote-number run; we supply only the body text.\n emit.footnotesMap[numId] = { children: [new Paragraph(body.text)] };\n out.push(new FootnoteReferenceRun(numId));\n } else {\n // CRITICAL-1: NEVER emit a bodyless FootnoteReferenceRun — it saves fine but\n // corrupts on reopen. Fall back to the verbatim marker as a superscript run:\n // offset-neutral, lossless, and re-importable as a plain `[N]`.\n console.error(\n `[docx-footnotes] footnote ref id=${ref.id} has no reconstructable body; ` +\n \"exporting the marker as plain superscript text.\",\n );\n out.push(makeTextRun(r.text, { superscript: true }));\n }\n emit.pos += r.text.length;\n flushCommentEvents(emit, out);\n}\n\n/**\n * Emit InlineRuns, honoring marks, hyperlinks, hardBreaks, and comment\n * markers. A hardBreak (`\\n`) becomes a dedicated `<w:br/>` run AFTER the\n * preceding text — `TextRun({ text, break: 1 })` renders the break BEFORE its\n * text, which inverted hardBreak order in the v1.0 exporter (fixed here).\n */\nfunction emitInlineRuns(runs: InlineRun[], emit: EmitCtx, out: ParagraphChild[]): void {\n for (const r of runs) {\n // Footnote reference: emit atomically (never split by \\n or comment markers).\n if (r.marks.footnoteRef) {\n emitFootnoteRef(r, emit, out);\n continue;\n }\n const parts = r.text.split(\"\\n\");\n parts.forEach((part, idx) => {\n if (idx > 0) {\n // The `\\n` occupies one flat character; markers at its offset go\n // before the <w:br/> run.\n flushCommentEvents(emit, out);\n out.push(new TextRun({ break: 1 }));\n emit.pos += 1;\n }\n emitTextSegments(part, r.marks, emit, out);\n });\n }\n flushCommentEvents(emit, out);\n}\n\n/**\n * Build the ParagraphChild list for a leaf inline container (paragraph,\n * heading, …). Direct Y.XmlText children are emitted; nested Y.XmlElement\n * children are NOT exported at inline level (pre-existing behavior) but the\n * cursor still advances past their flat text (+ the 1-char separator\n * `getElementText` would insert) so later comment anchors stay aligned.\n */\nfunction inlineChildren(el: Y.XmlElement, emit: EmitCtx): ParagraphChild[] {\n const out: ParagraphChild[] = [];\n let hasPrior = false;\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) {\n emitInlineRuns(flattenXmlText(c), emit, out);\n hasPrior = true;\n } else if (c instanceof Y.XmlElement) {\n if (hasPrior) emit.pos += 1;\n emit.pos += getElementTextLength(c);\n hasPrior = true;\n }\n }\n flushCommentEvents(emit, out);\n return out;\n}\n\nconst HEADING_BY_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {\n 1: HeadingLevel.HEADING_1,\n 2: HeadingLevel.HEADING_2,\n 3: HeadingLevel.HEADING_3,\n 4: HeadingLevel.HEADING_4,\n 5: HeadingLevel.HEADING_5,\n 6: HeadingLevel.HEADING_6,\n};\n\n/**\n * Node names this exporter knows how to serialize faithfully. Used by the\n * fidelity pre-flight (`detectExportFidelityIssues`) so the caller can warn\n * the user before overwriting their `.docx` with content we'd downgrade.\n * `image` is \"known\" structurally but degrades to alt text unless the src is\n * an inline `data:` URI — the fidelity check flags that separately.\n */\nconst KNOWN_BLOCK_NODES = new Set([\n \"heading\",\n \"paragraph\",\n \"blockquote\",\n \"bulletList\",\n \"orderedList\",\n \"listItem\",\n \"codeBlock\",\n \"horizontalRule\",\n \"image\",\n \"table\",\n \"tableRow\",\n \"tableCell\",\n \"tableHeader\",\n]);\n\nconst BULLET_REF = \"tandem-bullet\";\nconst NUMBERED_REF = \"tandem-numbered\";\n\ninterface BlockCtx {\n numberingDepth: number;\n blockquoteDepth: number;\n}\n\nfunction emptyCtx(): BlockCtx {\n return { numberingDepth: 0, blockquoteDepth: 0 };\n}\n\nfunction blockToDocx(el: Y.XmlElement, ctx: BlockCtx, emit: EmitCtx): Array<Paragraph | Table> {\n const name = el.nodeName;\n switch (name) {\n case \"heading\": {\n const level = Number(el.getAttribute(\"level\") ?? 1);\n const heading = HEADING_BY_LEVEL[level] ?? HeadingLevel.HEADING_1;\n // Flat offsets include the markdown-style heading prefix (\"## \").\n // Markers can't render inside the virtual prefix; any event there\n // flushes at the heading text start (the import walker also counts the\n // prefix before the first run, so a clamp is the closest valid anchor).\n // getHeadingPrefixLength is the SAME function the coordinate system\n // uses (extractText/resolveToElement) — keep the cursor consistent.\n emit.pos += getHeadingPrefixLength(el);\n return [new Paragraph({ heading, children: inlineChildren(el, emit) })];\n }\n case \"paragraph\": {\n return [\n new Paragraph({\n children: inlineChildren(el, emit),\n indent: ctx.blockquoteDepth > 0 ? { left: 720 * ctx.blockquoteDepth } : undefined,\n }),\n ];\n }\n case \"blockquote\": {\n const out: Array<Paragraph | Table> = [];\n const childCtx = { ...ctx, blockquoteDepth: ctx.blockquoteDepth + 1 };\n let hasPrior = false;\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) {\n // Direct text inside a blockquote isn't exported (pre-existing);\n // advance the cursor past it.\n emit.pos += c.length;\n hasPrior = true;\n } else if (c instanceof Y.XmlElement) {\n if (hasPrior) emit.pos += 1;\n out.push(...blockToDocx(c, childCtx, emit));\n hasPrior = true;\n }\n }\n return out;\n }\n case \"bulletList\":\n case \"orderedList\": {\n const kind: \"bullet\" | \"number\" = name === \"orderedList\" ? \"number\" : \"bullet\";\n const out: Array<Paragraph | Table> = [];\n let hasPriorItem = false;\n for (let i = 0; i < el.length; i++) {\n const item = el.get(i);\n if (item instanceof Y.XmlText) {\n emit.pos += item.length;\n hasPriorItem = true;\n continue;\n }\n if (!(item instanceof Y.XmlElement)) continue;\n if (hasPriorItem) emit.pos += 1;\n hasPriorItem = true;\n if (item.nodeName !== \"listItem\") {\n // Skipped in output (pre-existing); cursor still advances.\n emit.pos += getElementTextLength(item);\n continue;\n }\n let hasPriorChild = false;\n for (let j = 0; j < item.length; j++) {\n const child = item.get(j);\n if (child instanceof Y.XmlText) {\n emit.pos += child.length;\n hasPriorChild = true;\n continue;\n }\n if (!(child instanceof Y.XmlElement)) continue;\n if (hasPriorChild) emit.pos += 1;\n hasPriorChild = true;\n if (child.nodeName === \"paragraph\") {\n out.push(\n new Paragraph({\n children: inlineChildren(child, emit),\n numbering: {\n reference: kind === \"number\" ? NUMBERED_REF : BULLET_REF,\n level: ctx.numberingDepth,\n },\n }),\n );\n } else if (child.nodeName === \"bulletList\" || child.nodeName === \"orderedList\") {\n out.push(\n ...blockToDocx(child, { ...ctx, numberingDepth: ctx.numberingDepth + 1 }, emit),\n );\n } else {\n out.push(...blockToDocx(child, ctx, emit));\n }\n }\n }\n return out;\n }\n case \"codeBlock\": {\n const inner = readXmlTextChild(el);\n const lines = inner.split(\"\\n\");\n const codeMarks: MarkState = { code: true };\n return lines.map((line, idx) => {\n if (idx > 0) emit.pos += 1; // the `\\n` between lines\n const children: ParagraphChild[] = [];\n emitTextSegments(line, codeMarks, emit, children);\n flushCommentEvents(emit, children);\n return new Paragraph({ children });\n });\n }\n case \"horizontalRule\": {\n const children: ParagraphChild[] = [];\n flushCommentEvents(emit, children);\n return [\n new Paragraph({\n border: { bottom: { color: \"auto\", space: 1, style: \"single\", size: 6 } },\n children,\n }),\n ];\n }\n case \"image\": {\n // Images contribute 0 flat characters (no XmlText); flush any markers\n // due at this position into the emitted paragraph.\n const markers: ParagraphChild[] = [];\n flushCommentEvents(emit, markers);\n const src = el.getAttribute(\"src\");\n const embed = safeImageEmbed(src);\n if (!embed) {\n // Trust boundary: drop image rather than emit r:link.\n const alt = el.getAttribute(\"alt\") ?? \"\";\n return [\n new Paragraph({\n children: [\n ...markers,\n new TextRun({ text: alt ? `[image: ${alt}]` : \"[image]\", italics: true }),\n ],\n }),\n ];\n }\n return [\n new Paragraph({\n alignment: AlignmentType.CENTER,\n children: [\n ...markers,\n new ImageRun({\n data: embed.data,\n transformation: { width: 480, height: 360 },\n type: embed.type,\n }),\n ],\n }),\n ];\n }\n case \"table\":\n return [tableToDocx(el, emit)];\n default: {\n // Unknown node name — emit text-only, never a passthrough (trust rule #4).\n return [new Paragraph({ children: inlineChildren(el, emit) })];\n }\n }\n}\n\n/**\n * Read a table-cell span attribute (`colspan`/`rowspan`) as a docx span count.\n * Returns undefined for absent/≤1/non-integer values so the caller can omit the\n * option entirely (a span of 1 is the default and must not be emitted).\n */\nfunction readSpanAttr(cell: Y.XmlElement, attr: \"colspan\" | \"rowspan\"): number | undefined {\n const raw = cell.getAttribute(attr);\n if (raw == null) return undefined;\n const n = Number(raw);\n return Number.isInteger(n) && n > 1 ? n : undefined;\n}\n\nfunction tableToDocx(tableEl: Y.XmlElement, emit: EmitCtx): Table {\n const rows: TableRow[] = [];\n let hasPriorRow = false;\n for (let i = 0; i < tableEl.length; i++) {\n const row = tableEl.get(i);\n if (row instanceof Y.XmlText) {\n emit.pos += row.length;\n hasPriorRow = true;\n continue;\n }\n if (!(row instanceof Y.XmlElement)) continue;\n if (hasPriorRow) emit.pos += 1;\n hasPriorRow = true;\n if (row.nodeName !== \"tableRow\") {\n emit.pos += getElementTextLength(row);\n continue;\n }\n const cells: TableCell[] = [];\n let hasPriorCell = false;\n for (let j = 0; j < row.length; j++) {\n const cell = row.get(j);\n if (cell instanceof Y.XmlText) {\n emit.pos += cell.length;\n hasPriorCell = true;\n continue;\n }\n if (!(cell instanceof Y.XmlElement)) continue;\n if (hasPriorCell) emit.pos += 1;\n hasPriorCell = true;\n const cellChildren: Paragraph[] = [];\n let hasPriorBlock = false;\n for (let k = 0; k < cell.length; k++) {\n const c = cell.get(k);\n if (c instanceof Y.XmlText) {\n emit.pos += c.length;\n hasPriorBlock = true;\n continue;\n }\n if (!(c instanceof Y.XmlElement)) continue;\n if (hasPriorBlock) emit.pos += 1;\n hasPriorBlock = true;\n if (c.nodeName === \"paragraph\") {\n cellChildren.push(new Paragraph({ children: inlineChildren(c, emit) }));\n } else {\n // Skipped in output (pre-existing); cursor still advances.\n emit.pos += getElementTextLength(c);\n }\n }\n if (cellChildren.length === 0) cellChildren.push(new Paragraph({ children: [] }));\n // Carry a horizontal merge through to Word. The import preserves `colspan`\n // on the cell element; without `columnSpan` here the export silently\n // un-merges the cell (the priority loss the 0d scoreboard pinned).\n // `rowspan` is intentionally NOT carried yet — docx vertical merge needs\n // continuation cells on the rows below, a separate change.\n const columnSpan = readSpanAttr(cell, \"colspan\");\n cells.push(new TableCell({ children: cellChildren, ...(columnSpan ? { columnSpan } : {}) }));\n }\n rows.push(new TableRow({ children: cells }));\n }\n return new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } });\n}\n\nfunction readXmlTextChild(el: Y.XmlElement): string {\n let out = \"\";\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) out += c.toString();\n }\n return out;\n}\n\n// -- Fidelity pre-flight ------------------------------------------------------\n\n/**\n * Inspect the top-level document body and report fidelity concerns the caller\n * should surface to the user BEFORE overwriting their `.docx`. The export is\n * body + comments: anything mammoth dropped on import (footnotes,\n * headers/footers, tracked changes) is already gone from the Y.Doc and will\n * not be re-exported, and a couple of supported nodes are approximated. We\n * flag:\n *\n * - unknown node names (downgraded to plain text)\n * - non-`data:` images (downgraded to alt text — trust rule #1)\n *\n * Returns a deduped, human-readable list of warnings (empty = clean export).\n */\nexport function detectExportFidelityIssues(doc: Y.Doc): string[] {\n const warnings = new Set<string>();\n const fragment = doc.getXmlFragment(\"default\");\n const walk = (el: Y.XmlElement): void => {\n if (!KNOWN_BLOCK_NODES.has(el.nodeName)) {\n warnings.add(`unsupported \"${el.nodeName}\" block (exported as plain text)`);\n }\n if (el.nodeName === \"image\") {\n const src = el.getAttribute(\"src\");\n if (!safeImageEmbed(src)) {\n warnings.add(\"an image without embedded data (exported as a text placeholder)\");\n }\n }\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlElement) walk(c);\n }\n };\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) walk(node);\n }\n return [...warnings];\n}\n\n// -- Public API ---------------------------------------------------------------\n\n/**\n * Read the reconstructed footnote bodies the import wrote off-fragment to\n * Y_MAP_FOOTNOTE_BODIES (#1123 Tier-A #3 PR 2). Defensive shape validation: the\n * map is server-written but this read path stays robust to a malformed value\n * (an unreconstructable id simply produces no footnote — the emitter's\n * bodyless-ref fallback then keeps the marker as plain text).\n */\nfunction readFootnoteBodies(doc: Y.Doc): Record<string, FootnoteBody> {\n const raw = doc.getMap(Y_MAP_DOCUMENT_META).get(Y_MAP_FOOTNOTE_BODIES);\n if (!raw || typeof raw !== \"object\") return {};\n const out: Record<string, FootnoteBody> = {};\n for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {\n if (value && typeof value === \"object\" && typeof (value as FootnoteBody).text === \"string\") {\n out[id] = {\n text: (value as FootnoteBody).text,\n hadFormatting: Boolean((value as FootnoteBody).hadFormatting),\n };\n }\n }\n return out;\n}\n\nfunction toCommentOptions(c: ExportComment): ICommentOptions {\n return {\n id: c.id,\n author: c.author,\n initials: c.initials,\n date: c.date,\n children: c.bodyParagraphs.map(\n (text) => new Paragraph({ children: text.length > 0 ? [new TextRun(text)] : [] }),\n ),\n };\n}\n\n/**\n * Convert a Tandem Y.Doc into a `.docx` byte buffer (body + Word comments).\n *\n * `comment`-type annotations stored in the doc's annotation map are emitted\n * as Word comments anchored to their CURRENT ranges (relRange-first\n * resolution; see `docx-comment-export.ts` for the ADR-027 privacy gate —\n * notes and highlights are never exported). External hyperlinks, file paths,\n * and remote images are filtered by the trust-boundary helpers so the output\n * cannot exfiltrate references. Tracked changes and authorship coloring are\n * intentionally NOT emitted — see the module header.\n *\n * READ-ONLY on the Y.Doc: no Y.Map writes, no transactions.\n */\nexport async function exportYDocToDocx(doc: Y.Doc): Promise<Buffer> {\n const comments = prepareExportComments(doc);\n const emit: EmitCtx = {\n pos: 0,\n events: buildCommentEvents(comments),\n idx: 0,\n footnoteBodies: readFootnoteBodies(doc),\n footnotesMap: {},\n };\n\n const fragment = doc.getXmlFragment(\"default\");\n const children: Array<Paragraph | Table> = [];\n const ctx = emptyCtx();\n let first = true;\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n if (!first) emit.pos += 1; // top-level \\n separator (extractText join)\n first = false;\n children.push(...blockToDocx(node, ctx, emit));\n }\n\n // Defensive drain: prepareExportComments bounds-checks every range against\n // the document length, so leftovers indicate cursor drift. Emit them in a\n // trailing paragraph anyway — a comment listed in comments.xml without its\n // body markers would be structurally broken — and warn loudly.\n if (emit.idx < emit.events.length) {\n const leftovers: ParagraphChild[] = [];\n while (emit.idx < emit.events.length) {\n leftovers.push(...emit.events[emit.idx].components);\n emit.idx++;\n }\n console.error(\n `[docx-export] ${leftovers.length} comment marker component(s) fell past the end of ` +\n \"the document; appended to a trailing paragraph. Comment anchors may be misplaced.\",\n );\n children.push(new Paragraph({ children: leftovers }));\n }\n\n // Cheap invariant check (only when anchoring mattered): the emission cursor\n // must land exactly on the flat-text length, or anchors drifted.\n if (comments.length > 0) {\n const expected = extractText(doc).length;\n if (emit.pos !== expected) {\n console.error(\n `[docx-export] comment-anchor cursor drift: walked ${emit.pos} chars but the ` +\n `document flat text is ${expected} — exported comment anchors may be misplaced`,\n );\n }\n }\n\n // `docx` requires at least one section child; emit an empty paragraph for a\n // blank document so Packer doesn't produce a malformed file.\n if (children.length === 0) children.push(new Paragraph({ children: [] }));\n\n const document = new Document({\n creator: \"Tandem\",\n ...(comments.length > 0 ? { comments: { children: comments.map(toCommentOptions) } } : {}),\n ...(Object.keys(emit.footnotesMap).length > 0 ? { footnotes: emit.footnotesMap } : {}),\n numbering: {\n config: [\n {\n reference: BULLET_REF,\n levels: [0, 1, 2, 3, 4, 5].map((lvl) => ({\n level: lvl,\n format: \"bullet\",\n text: \"•\",\n alignment: AlignmentType.LEFT,\n style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },\n })),\n },\n {\n reference: NUMBERED_REF,\n levels: [0, 1, 2, 3, 4, 5].map((lvl) => ({\n level: lvl,\n format: \"decimal\",\n text: `%${lvl + 1}.`,\n alignment: AlignmentType.LEFT,\n style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },\n })),\n },\n ],\n },\n sections: [{ properties: {}, children }],\n });\n\n return Packer.toBuffer(document);\n}\n","// Footnote/endnote CAPTURE for the import honesty + reconstruction layers\n// (Tier-A #3). mammoth flattens Word footnotes/endnotes to a trailing <ol> and\n// emits NO warning, so the degradation is otherwise SILENT. This module reads\n// the real notes directly from the .docx ZIP (the same JSZip + htmlparser2 path\n// the comment importer uses) so the import path can BOTH surface an honest\n// FidelityReport line AND — for footnotes — capture the body text so the export\n// can re-emit a real `<w:footnote>` (PR 2 reconstruction).\n//\n// It only READS — no rels, no external refs, no writes (security posture\n// identical to the comment path; ratified in the security review of\n// .claude/plans/docx-footnote-honesty.md). Footnote BODY TEXT captured here is\n// document content and is threaded into the Y.Doc, but is NEVER routed into\n// `footnoteLossLines` (see the redaction note there).\n\nimport type { ChildNode, Element } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\nimport { findAllByName, getAttr, getTextContent, isElement } from \"./docx-walker.js\";\n\n/**\n * Footnote bodies (keyed by OOXML footnote id — the same id mammoth puts in its\n * `#footnote-N` href) plus the endnote count. Footnotes carry bodies (PR 2\n * reconstructs them); endnotes are count-only (still degrade to a list, honestly\n * reported — endnote reconstruction is a deferred fast-follow).\n */\nexport interface DocxNotes {\n footnotes: Record<string, FootnoteBody>;\n endnotes: number;\n}\n\n// Word ALWAYS embeds these structural sentinel notes in word/footnotes.xml and\n// word/endnotes.xml even when the document has ZERO real footnotes (empirically\n// confirmed: the docx package emits BOTH parts, each with a `separator` and a\n// `continuationSeparator` note, for every document). A \"real\" note is one whose\n// `w:type` is NONE of these — i.e. the attribute is absent (defaults to the\n// \"normal\" type per OOXML ST_FtnEdn) or carries a non-structural value.\n//\n// We EXCLUDE this set rather than allow-list \"normal\"/absent so an unknown or\n// future structural type is treated as a REAL note (over-warn) instead of being\n// silently swallowed — honesty is the priority. A future OOXML structural type\n// would surface a (harmless) \"footnotes flattened\" warning; do NOT \"fix\" that by\n// flipping to an allowlist, which would risk silently dropping a real footnote.\nconst STRUCTURAL_NOTE_TYPES = new Set([\"separator\", \"continuationSeparator\", \"continuationNotice\"]);\n\n// Body-formatting markers we DROP on import (the body is flattened to plain\n// text in PR 2). Presence drives the count-only honesty line; rich-body\n// fidelity is a deferred fast-follow. `<w:rStyle>` (the footnote-number style)\n// is deliberately NOT here — it's structural, not body formatting.\nconst FORMATTING_ELEMENTS = new Set([\"w:b\", \"w:i\", \"w:u\", \"w:hyperlink\"]);\n\n/**\n * Collect real (non-structural) note Elements from one notes part, or [] if the\n * part is absent/unreadable. Shared by footnote + endnote extraction.\n */\nasync function collectRealNotes(\n zip: JSZip,\n partPath: string,\n elementName: string,\n): Promise<Element[]> {\n const file = zip.file(partPath);\n if (!file) return []; // Common clean case: a doc with no notes omits the part.\n try {\n const xml = await file.async(\"text\");\n // xmlMode preserves the `w:` prefix on both element names and attributes\n // (same trusted parser as docx-walker / docx-apply); htmlparser2 does not\n // resolve DOCTYPE/external entities, so there is no XXE surface.\n const doc = parseDocument(xml, { xmlMode: true });\n return findAllByName(elementName, doc.children).filter((note) => {\n const type = getAttr(note, \"w:type\");\n return type === undefined || !STRUCTURAL_NOTE_TYPES.has(type);\n });\n } catch (err) {\n // Present but unreadable. Degrade to [] (never block import) but leave a\n // breadcrumb — an honesty feature must not silently conflate \"couldn't read\n // the notes part\" with \"no notes\". htmlparser2 is non-throwing, so this is\n // nearly unreachable (only a corrupt ZIP entry trips it) and the user still\n // sees the flattened content, so a user-facing line for this case is deferred.\n console.error(`[docx-footnotes] failed to analyze ${partPath}:`, err);\n return [];\n }\n}\n\n/** Whether a note subtree carries body formatting we flatten to plain text. */\nfunction noteHadFormatting(note: Element): boolean {\n let paragraphs = 0;\n let formatted = false;\n const walk = (nodes: ChildNode[]): void => {\n for (const node of nodes) {\n if (!isElement(node)) continue;\n if (node.name === \"w:p\") paragraphs++;\n if (FORMATTING_ELEMENTS.has(node.name)) formatted = true;\n walk(node.children);\n }\n };\n walk(note.children);\n return formatted || paragraphs > 1;\n}\n\n/**\n * Parse real footnote bodies + count real endnotes from an UNTRUSTED .docx\n * buffer. Never throws: a non-ZIP/corrupt buffer degrades to empty (mammoth, run\n * in parallel, surfaces a genuinely-broken file — note honesty is moot when the\n * import itself fails).\n */\nexport async function parseDocxFootnotes(buffer: Buffer): Promise<DocxNotes> {\n let zip: JSZip;\n try {\n zip = await JSZip.loadAsync(buffer);\n } catch (err) {\n console.error(\"[docx-footnotes] could not open .docx archive:\", err);\n return { footnotes: {}, endnotes: 0 };\n }\n const [footnoteEls, endnoteEls] = await Promise.all([\n collectRealNotes(zip, \"word/footnotes.xml\", \"w:footnote\"),\n collectRealNotes(zip, \"word/endnotes.xml\", \"w:endnote\"),\n ]);\n const footnotes: Record<string, FootnoteBody> = {};\n for (const note of footnoteEls) {\n const id = getAttr(note, \"w:id\");\n if (id === undefined) continue; // a real footnote always carries an id\n footnotes[id] = { text: getTextContent(note), hadFormatting: noteHadFormatting(note) };\n }\n return { footnotes, endnotes: endnoteEls.length };\n}\n\n/**\n * The reconstruction partition for the captured footnotes (from\n * `reconcileFootnoteIds`): ids that WILL reconstruct as real footnotes vs ids\n * that won't (an orphaned definition with no inline ref, or a mammoth-format\n * drift). Drives an honest loss line per outcome.\n */\nexport interface FootnoteReconciliation {\n reconstructed: string[];\n dropped: string[];\n}\n\n/**\n * Honest, user-facing FidelityReport lines for notes.\n *\n * INPUTS ARE COUNTS/FLAGS/IDS ONLY — never thread the captured footnote body\n * text through here. These lines bypass BOTH `summarizeMammothMessages`'\n * redaction AND the `MAX_WARNING_LINE_LENGTH` clamp (docx.ts), which is safe\n * ONLY because every line below is a fixed string plus an integer count.\n * Threading body text would reintroduce the user-content-leak vector.\n *\n * Post-PR-2 contract (HIGH-2): footnotes that RECONSTRUCT round-trip as real\n * `<w:footnote>` parts, so they get NO structural-loss line — at most a\n * count-only body-FORMATTING line (we store plain text). A footnote that fails\n * reconciliation degrades (orphan → absent; drift → trailing list), so it gets\n * an honest structural line rather than being silently claimed \"preserved\" — the\n * partition is computed pre-`apply` from the same reconciliation `htmlToYDoc`\n * runs. Endnotes always degrade to a trailing list (reconstruction deferred).\n */\nexport function footnoteLossLines(\n notes: DocxNotes,\n reconciliation: FootnoteReconciliation,\n): string[] {\n const lines: string[] = [];\n // Reconstructed footnotes whose body carried formatting we flattened.\n const formattingFlattened = reconciliation.reconstructed.filter(\n (id) => notes.footnotes[id]?.hadFormatting,\n ).length;\n if (formattingFlattened > 0) {\n const n = formattingFlattened;\n lines.push(\n `${n === 1 ? \"1 footnote\" : `${n} footnotes`} preserved, but body formatting ` +\n `(bold/italic/links or multiple paragraphs) was simplified to plain text`,\n );\n }\n // Captured footnotes that won't reconstruct — degraded, NOT preserved.\n if (reconciliation.dropped.length > 0) {\n const n = reconciliation.dropped.length;\n lines.push(\n `${n === 1 ? \"1 footnote\" : `${n} footnotes`} couldn't be reconstructed and ` +\n `won't be preserved as footnotes on save`,\n );\n }\n if (notes.endnotes > 0) {\n const n = notes.endnotes;\n lines.push(\n `${n === 1 ? \"1 endnote\" : `${n} endnotes`} flattened to a trailing list — ` +\n `endnote markers and links aren't preserved on save`,\n );\n }\n return lines;\n}\n","// Apply accepted suggestions to a .docx as tracked changes (w:del + w:ins).\n//\n// Uses the shared walker (docx-walker.ts) to map flat-text offsets into\n// the XML DOM, then mutates the DOM in-place before serializing back to ZIP.\n\nimport render from \"dom-serializer\";\nimport type { ChildNode } from \"domhandler\";\nimport { Element, Text } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport {\n findAllByName,\n getAttr,\n isElement,\n type TextHit,\n walkDocumentBody,\n} from \"./docx-walker.js\";\n\n/** Element names inside <w:r> that would produce malformed XML if split. */\nconst COMPLEX_RUN_ELEMENTS = new Set([\n \"w:footnoteReference\",\n \"w:endnoteReference\",\n \"w:drawing\",\n \"w:pict\",\n \"w:fldChar\",\n]);\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface OffsetEntry {\n run: Element;\n textNode: Element;\n /** Character index within the textNode's text content. */\n charIndex: number;\n paragraph: Element;\n paragraphId?: string;\n}\n\nexport interface OffsetMap {\n get(offset: number): OffsetEntry | undefined;\n flatText: string;\n totalLength: number;\n /** The parsed <w:body> element — same DOM tree as all OffsetEntry references. */\n body: Element;\n /** The full parsed document (parent of body). */\n doc: ReturnType<typeof parseDocument>;\n}\n\nexport interface SuggestionInput {\n from: number;\n to: number;\n newText: string;\n author: string;\n date: string;\n revisionId: number;\n}\n\nexport interface ApplyResult {\n ok: boolean;\n reason?: string;\n}\n\nexport interface AcceptedSuggestion {\n id: string;\n from: number;\n to: number;\n newText: string;\n textSnapshot?: string;\n /** Word comment ID if this suggestion overlaps an imported comment. */\n importCommentId?: string;\n}\n\nexport interface ApplyOptions {\n author: string;\n ydocFlatText: string;\n date?: string;\n}\n\nexport interface ApplyOutput {\n buffer: Buffer;\n applied: number;\n rejected: number;\n rejectedDetails: Array<{ id: string; reason: string }>;\n commentsResolved: number;\n}\n\n// ---------------------------------------------------------------------------\n// buildOffsetMap\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the document body and build a lookup from flat-text offset to the\n * corresponding XML DOM position (run, text node, char index within node).\n */\nexport function buildOffsetMap(xml: string, targetOffsets: Set<number>): OffsetMap {\n const entries = new Map<number, OffsetEntry>();\n\n // Collect text hits so we can resolve offsets after the walk\n const hits: TextHit[] = [];\n\n const { totalLength, flatText } = walkDocumentBody(xml, {\n onText(hit) {\n hits.push(hit);\n },\n });\n\n // For each target offset, find the text hit that contains it\n for (const offset of targetOffsets) {\n // Special case: end-of-document offset\n if (offset === totalLength && hits.length > 0) {\n const lastHit = hits[hits.length - 1];\n entries.set(offset, {\n run: lastHit.run,\n textNode: lastHit.textNode,\n charIndex: lastHit.text.length,\n paragraph: lastHit.paragraph,\n paragraphId: lastHit.paragraphId,\n });\n continue;\n }\n\n for (let i = 0; i < hits.length; i++) {\n const hit = hits[i];\n const start = hit.offsetStart;\n const end = start + hit.text.length;\n if (offset >= start && offset < end) {\n entries.set(offset, {\n run: hit.run,\n textNode: hit.textNode,\n charIndex: offset - start,\n paragraph: hit.paragraph,\n paragraphId: hit.paragraphId,\n });\n break;\n }\n // Allow offset === end for the last character boundary of a text node,\n // but only if no subsequent hit starts at that offset (prefer the start\n // of the next node).\n if (offset === end) {\n const nextHit = hits[i + 1];\n if (!nextHit || nextHit.offsetStart !== offset) {\n entries.set(offset, {\n run: hit.run,\n textNode: hit.textNode,\n charIndex: hit.text.length,\n paragraph: hit.paragraph,\n paragraphId: hit.paragraphId,\n });\n break;\n }\n }\n }\n }\n\n // Recover the walker's parsed DOM by walking up the parent chain from hits.\n // The walker parses internally; all hit nodes share the same DOM tree.\n let walkerBody: Element;\n let walkerDoc: ReturnType<typeof parseDocument>;\n if (hits.length > 0) {\n // Walk up from the first paragraph to find body and document\n let node = hits[0].paragraph.parent;\n while (node && isElement(node) && node.name !== \"w:body\") {\n node = node.parent;\n }\n walkerBody = node as Element;\n if (!walkerBody || walkerBody.name !== \"w:body\") {\n throw new Error(\"Could not recover w:body from walker's parsed DOM\");\n }\n walkerDoc = walkerBody.parent as unknown as ReturnType<typeof parseDocument>;\n } else {\n // No hits — parse ourselves (no entries reference it anyway)\n const doc = parseDocument(xml, { xmlMode: true });\n const bodyElements = findAllByName(\"w:body\", doc.children);\n walkerBody = bodyElements[0] ?? new Element(\"w:body\", {});\n walkerDoc = doc;\n }\n\n return {\n get(offset: number) {\n return entries.get(offset);\n },\n flatText,\n totalLength,\n body: walkerBody,\n doc: walkerDoc,\n };\n}\n\n// ---------------------------------------------------------------------------\n// DOM helpers\n// ---------------------------------------------------------------------------\n\n/** Get the text content of a w:t or w:delText element. */\nfunction getNodeText(node: Element): string {\n for (const child of node.children) {\n if (child.type === \"text\") return (child as unknown as Text).data;\n }\n return \"\";\n}\n\n/** Set the text content of a w:t or w:delText element. */\nfunction setNodeText(node: Element, text: string): void {\n const textChild = new Text(text);\n (textChild as ChildNode).parent = node;\n node.children = [textChild];\n // Preserve spaces if needed\n if (text.startsWith(\" \") || text.endsWith(\" \")) {\n node.attribs[\"xml:space\"] = \"preserve\";\n } else {\n delete node.attribs[\"xml:space\"];\n }\n}\n\n/** Clone a w:rPr element by serializing and re-parsing. */\nfunction cloneRPr(rPr: Element): Element {\n const serialized = render(rPr, { xmlMode: true });\n const doc = parseDocument(serialized, { xmlMode: true });\n return doc.children[0] as Element;\n}\n\n/** Find the w:rPr child of a w:r run, if any. */\nfunction findRPr(run: Element): Element | undefined {\n for (const child of run.children) {\n if (isElement(child) && child.name === \"w:rPr\") return child;\n }\n return undefined;\n}\n\n/** Build a new w:r element with optional rPr and a text element. */\nfunction buildRun(\n textElementName: \"w:t\" | \"w:delText\",\n text: string,\n rPrSource?: Element,\n): Element {\n const children: ChildNode[] = [];\n\n if (rPrSource) {\n const cloned = cloneRPr(rPrSource);\n children.push(cloned);\n }\n\n const textChild = new Text(text);\n const attribs: Record<string, string> = {};\n if (text.startsWith(\" \") || text.endsWith(\" \")) {\n attribs[\"xml:space\"] = \"preserve\";\n }\n const textNode = new Element(textElementName, attribs, [textChild]);\n (textChild as ChildNode).parent = textNode;\n children.push(textNode);\n\n const run = new Element(\"w:r\", {}, children);\n for (const child of children) {\n (child as ChildNode).parent = run;\n }\n return run;\n}\n\n/** Insert a node into a parent's children array at a given index. */\nfunction insertChild(parent: Element, index: number, node: ChildNode): void {\n parent.children.splice(index, 0, node);\n node.parent = parent;\n}\n\n/** Remove a node from its parent's children array. */\nfunction removeChild(node: ChildNode): void {\n if (!node.parent) return;\n const parent = node.parent as Element;\n const idx = parent.children.indexOf(node);\n if (idx >= 0) parent.children.splice(idx, 1);\n node.parent = null;\n}\n\n// ---------------------------------------------------------------------------\n// applySingleSuggestion\n// ---------------------------------------------------------------------------\n\n/**\n * Apply a single tracked-change suggestion to the document body.\n *\n * Wraps the original text in `<w:del>` (using `<w:delText>`) and inserts\n * `<w:ins>` with the replacement. Inherits `<w:rPr>` from the first deleted run.\n */\nexport function applySingleSuggestion(\n offsetMap: OffsetMap,\n suggestion: SuggestionInput,\n): ApplyResult {\n const { from, to, newText, author, date, revisionId } = suggestion;\n\n const fromEntry = offsetMap.get(from);\n const toEntry = offsetMap.get(to);\n\n if (!fromEntry || !toEntry) {\n return { ok: false, reason: `Could not resolve offsets: from=${from} to=${to}` };\n }\n\n if (fromEntry.paragraph !== toEntry.paragraph) {\n return { ok: false, reason: \"Cross-paragraph suggestions not yet supported\" };\n }\n\n const paragraph = fromEntry.paragraph;\n\n // Collect all w:r runs between from and to within this paragraph.\n // We need to find runs that contain text in the [from, to) range.\n // Strategy: split boundary runs if needed, then wrap interior runs.\n\n // Step 1: Split the \"from\" run if charIndex > 0\n if (fromEntry.charIndex > 0) {\n splitRun(fromEntry.run, fromEntry.textNode, fromEntry.charIndex, paragraph);\n // After split, fromEntry.run is the \"before\" part; the text we want\n // starts in the newly created run (next sibling).\n const idx = paragraph.children.indexOf(fromEntry.run);\n const nextRun = paragraph.children[idx + 1];\n if (!nextRun || !isElement(nextRun) || nextRun.name !== \"w:r\") {\n return { ok: false, reason: \"Split failed: no next run after from-split\" };\n }\n // Update fromEntry to point to the new run\n fromEntry.run = nextRun;\n const nextTextNode = findTextNode(nextRun);\n if (!nextTextNode) {\n return { ok: false, reason: \"Run contains no text element\" };\n }\n fromEntry.textNode = nextTextNode;\n fromEntry.charIndex = 0;\n }\n\n // Step 2: Split the \"to\" run if charIndex < text length\n const toText = getNodeText(toEntry.textNode);\n if (toEntry.charIndex < toText.length && toEntry.charIndex > 0) {\n splitRun(toEntry.run, toEntry.textNode, toEntry.charIndex, paragraph);\n // After split, toEntry.run has text [0..charIndex), and the rest is in a new run.\n // We want to include toEntry.run in the deletion (it has the first part).\n // toEntry now correctly points to the run ending at the split point.\n } else if (toEntry.charIndex === 0) {\n // The \"to\" offset is at the start of this run — don't include this run.\n // We delete everything up to but not including toEntry.run.\n }\n\n // Step 3: Collect all runs between fromEntry.run and toEntry.run (inclusive/exclusive)\n const runsToDelete: Element[] = [];\n let collecting = false;\n\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") {\n if (collecting && child === toEntry.run) break;\n continue;\n }\n\n if (child === fromEntry.run) {\n collecting = true;\n }\n\n if (collecting) {\n if (toEntry.charIndex === 0 && child === toEntry.run) {\n // Don't include this run — \"to\" is at its start\n break;\n }\n runsToDelete.push(child);\n if (child === toEntry.run) {\n break;\n }\n }\n }\n\n if (runsToDelete.length === 0) {\n // Edge case: from === to (insertion only, no deletion)\n if (from === to && newText.length > 0) {\n const insertionPoint = paragraph.children.indexOf(fromEntry.run);\n const rPr = findRPr(fromEntry.run);\n const insRun = buildRun(\"w:t\", newText, rPr);\n const ins = new Element(\n \"w:ins\",\n {\n \"w:id\": String(revisionId + 1),\n \"w:author\": author,\n \"w:date\": date,\n },\n [insRun],\n );\n (insRun as ChildNode).parent = ins;\n insertChild(paragraph, insertionPoint, ins);\n return { ok: true };\n }\n return { ok: false, reason: \"No runs found in deletion range\" };\n }\n\n // Step 4: Build w:del element with w:delText runs\n const rPrSource = findRPr(runsToDelete[0]);\n const delChildren: ChildNode[] = [];\n\n for (const run of runsToDelete) {\n const tn = findTextNode(run);\n if (!tn) continue; // skip tab-only or break-only runs with no <w:t>\n const text = getNodeText(tn);\n const delRun = buildRun(\"w:delText\", text, findRPr(run));\n delChildren.push(delRun);\n }\n\n const del = new Element(\n \"w:del\",\n {\n \"w:id\": String(revisionId),\n \"w:author\": author,\n \"w:date\": date,\n },\n delChildren,\n );\n for (const child of delChildren) {\n (child as ChildNode).parent = del;\n }\n\n // Step 5: Build w:ins element if newText is non-empty\n let ins: Element | undefined;\n if (newText.length > 0) {\n const insRun = buildRun(\"w:t\", newText, rPrSource);\n ins = new Element(\n \"w:ins\",\n {\n \"w:id\": String(revisionId + 1),\n \"w:author\": author,\n \"w:date\": date,\n },\n [insRun],\n );\n (insRun as ChildNode).parent = ins;\n }\n\n // Step 6: Replace the original runs with del (+ ins)\n const firstRunIndex = paragraph.children.indexOf(runsToDelete[0]);\n\n // Remove original runs\n for (const run of runsToDelete) {\n removeChild(run);\n }\n\n // Insert del at the position of the first removed run\n insertChild(paragraph, firstRunIndex, del);\n\n // Insert ins after del\n if (ins) {\n insertChild(paragraph, firstRunIndex + 1, ins);\n }\n\n return { ok: true };\n}\n\n/** Split a run at charIndex, creating a new run after it with the remainder. */\nfunction splitRun(run: Element, textNode: Element, charIndex: number, paragraph: Element): void {\n const fullText = getNodeText(textNode);\n const before = fullText.slice(0, charIndex);\n const after = fullText.slice(charIndex);\n\n // Update the original run's text\n setNodeText(textNode, before);\n\n // Create a new run for the remainder\n const rPr = findRPr(run);\n const newRun = buildRun(\"w:t\", after, rPr);\n\n // Insert after the original run in the paragraph\n const idx = paragraph.children.indexOf(run);\n insertChild(paragraph, idx + 1, newRun);\n}\n\n/** Find the w:t element within a run. */\nfunction findTextNode(run: Element): Element | undefined {\n for (const child of run.children) {\n if (isElement(child) && child.name === \"w:t\") return child;\n }\n return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// applyTrackedChanges (orchestrator)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply accepted suggestions to a .docx buffer as tracked changes.\n *\n * Returns a new buffer with the modified document plus statistics.\n */\nexport async function applyTrackedChanges(\n docxBuffer: Buffer,\n suggestions: AcceptedSuggestion[],\n options: ApplyOptions,\n): Promise<ApplyOutput> {\n const zip = await JSZip.loadAsync(docxBuffer);\n const documentXml = await zip.file(\"word/document.xml\")?.async(\"text\");\n if (!documentXml) {\n throw new Error(\"Missing word/document.xml in .docx archive\");\n }\n\n const date = options.date ?? new Date().toISOString();\n\n // Collect all target offsets\n const targetOffsets = new Set<number>();\n for (const s of suggestions) {\n targetOffsets.add(s.from);\n targetOffsets.add(s.to);\n }\n\n // Build offset map (first pass — for comparison guard only)\n const offsetMap = buildOffsetMap(documentXml, targetOffsets);\n\n // Comparison guard\n if (offsetMap.flatText !== options.ydocFlatText) {\n throw new Error(\n \"Flat text mismatch: the .docx content does not match the Y.Doc flat text. \" +\n \"The file may have changed since it was loaded.\",\n );\n }\n\n // Sort descending by `from` so later edits don't shift earlier offsets\n const sorted = [...suggestions].sort((a, b) => b.from - a.from);\n\n // Validate ALL before mutating\n const valid: AcceptedSuggestion[] = [];\n const rejectedDetails: Array<{ id: string; reason: string }> = [];\n\n for (const s of sorted) {\n // textSnapshot check\n if (s.textSnapshot !== undefined) {\n const actual = offsetMap.flatText.slice(s.from, s.to);\n if (actual !== s.textSnapshot) {\n rejectedDetails.push({\n id: s.id,\n reason: `Text snapshot mismatch: expected \"${s.textSnapshot}\", got \"${actual}\"`,\n });\n continue;\n }\n }\n\n // Offset resolution check\n const fromEntry = offsetMap.get(s.from);\n const toEntry = offsetMap.get(s.to);\n if (!fromEntry || !toEntry) {\n rejectedDetails.push({\n id: s.id,\n reason: `Could not resolve offsets: from=${s.from} to=${s.to}`,\n });\n continue;\n }\n\n valid.push(s);\n }\n\n // Check for overlapping ranges among valid suggestions (already sorted desc by from)\n const validAfterOverlapCheck: AcceptedSuggestion[] = [];\n let lastFrom = Infinity;\n for (const s of valid) {\n if (s.to > lastFrom) {\n rejectedDetails.push({\n id: s.id,\n reason: `Overlapping range [${s.from}, ${s.to}) conflicts with another suggestion`,\n });\n continue;\n }\n lastFrom = s.from;\n validAfterOverlapCheck.push(s);\n }\n\n // Reject suggestions whose range contains complex (non-text) elements\n // that would produce malformed XML when the run is split or wrapped.\n const validAfterComplexCheck: AcceptedSuggestion[] = [];\n for (const s of validAfterOverlapCheck) {\n const fromEntry = offsetMap.get(s.from)!;\n const toEntry = offsetMap.get(s.to)!;\n const paragraph = fromEntry.paragraph;\n\n // Collect runs in the range [fromEntry.run .. toEntry.run]\n let collecting = false;\n let hasComplex = false;\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") continue;\n if (child === fromEntry.run) collecting = true;\n if (collecting) {\n for (const rc of child.children) {\n if (isElement(rc) && COMPLEX_RUN_ELEMENTS.has(rc.name)) {\n hasComplex = true;\n break;\n }\n }\n if (hasComplex) break;\n if (child === toEntry.run) break;\n }\n }\n\n if (hasComplex) {\n rejectedDetails.push({\n id: s.id,\n reason: \"Overlaps a complex element (footnote, drawing, or field) and couldn't be applied\",\n });\n } else {\n validAfterComplexCheck.push(s);\n }\n }\n\n // Reject suggestions that target the same <w:r> run — the first split would\n // invalidate the second suggestion's DOM references.\n const validAfterRunCheck: AcceptedSuggestion[] = [];\n const claimedRuns = new Map<Element, string>(); // run -> suggestion id that claimed it\n for (const s of validAfterComplexCheck) {\n const fromEntry = offsetMap.get(s.from)!;\n const toEntry = offsetMap.get(s.to)!;\n const paragraph = fromEntry.paragraph;\n\n // Collect all runs this suggestion touches\n const touchedRuns: Element[] = [];\n let collecting = false;\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") continue;\n if (child === fromEntry.run) collecting = true;\n if (collecting) {\n touchedRuns.push(child);\n if (child === toEntry.run) break;\n }\n }\n\n let conflict = false;\n for (const run of touchedRuns) {\n if (claimedRuns.has(run)) {\n rejectedDetails.push({\n id: s.id,\n reason: \"Targets the same text run as another suggestion\",\n });\n conflict = true;\n break;\n }\n }\n if (!conflict) {\n for (const run of touchedRuns) {\n claimedRuns.set(run, s.id);\n }\n validAfterRunCheck.push(s);\n }\n }\n\n // The offset map already parsed the XML — reuse its DOM for serialization\n const doc = offsetMap.doc;\n\n // Find max existing w:id to avoid collisions\n const idMatches = documentXml.match(/w:id=\"(\\d+)\"/g) || [];\n let maxId = 0;\n for (const m of idMatches) {\n const num = parseInt(m.match(/\\d+/)![0], 10);\n if (num > maxId) maxId = num;\n }\n\n // Apply each valid suggestion in reverse offset order.\n // ID allocation: maxId += 2 per suggestion because each tracked change\n // needs two revision IDs — one for the <w:del> element (revisionId) and\n // one for the <w:ins> element (revisionId + 1).\n let applied = 0;\n const appliedSuggestions: AcceptedSuggestion[] = [];\n for (const s of validAfterRunCheck) {\n maxId += 2;\n const result = applySingleSuggestion(offsetMap, {\n from: s.from,\n to: s.to,\n newText: s.newText,\n author: options.author,\n date,\n revisionId: maxId - 1,\n });\n if (result.ok) {\n applied++;\n appliedSuggestions.push(s);\n } else {\n rejectedDetails.push({ id: s.id, reason: result.reason ?? \"Unknown error\" });\n }\n }\n\n // Serialize back\n const serialized = render(doc, { xmlMode: true });\n zip.file(\"word/document.xml\", serialized);\n\n // Resolve Word comments for successfully applied suggestions only\n const commentsResolved = await resolveWordComments(zip, appliedSuggestions);\n\n const buffer = Buffer.from(await zip.generateAsync({ type: \"nodebuffer\" }));\n\n return {\n buffer,\n applied,\n rejected: rejectedDetails.length,\n rejectedDetails,\n commentsResolved,\n };\n}\n\n// ---------------------------------------------------------------------------\n// resolveWordComments\n// ---------------------------------------------------------------------------\n\nconst W15_NS = \"http://schemas.microsoft.com/office/word/2012/wordml\";\n\n/**\n * Read each Word comment's OWN last-paragraph `w14:paraId` from\n * `word/comments.xml`, keyed by comment id. This is the value OOXML\n * `CT_CommentEx/@paraId` references (§2.5.39) — the comment body's last\n * paragraph, NOT the document paragraph the comment is anchored to. Original\n * case is preserved so the written id matches both the comment's `<w:p>` and\n * any `commentEx` entry Word already wrote. See #1007.\n */\nasync function readCommentLastParaIds(zip: JSZip): Promise<Map<string, string>> {\n const map = new Map<string, string>();\n const xml = await zip.file(\"word/comments.xml\")?.async(\"text\");\n if (!xml) return map;\n const doc = parseDocument(xml, { xmlMode: true });\n for (const comment of findAllByName(\"w:comment\", doc.children)) {\n const id = getAttr(comment, \"w:id\");\n if (!id) continue;\n // Shallow filter — only direct children of <w:comment>. `findAllByName` is\n // recursive and would include <w:p> elements nested inside embedded tables,\n // yielding the wrong last-paragraph paraId. CT_CommentEx @paraId references\n // the comment body's last TOP-LEVEL paragraph (OOXML §2.5.39).\n const paragraphs = comment.children.filter(isElement).filter((c) => c.name === \"w:p\");\n if (paragraphs.length === 0) continue;\n const lastParaId = getAttr(paragraphs[paragraphs.length - 1], \"w14:paraId\");\n if (lastParaId) map.set(id, lastParaId);\n }\n return map;\n}\n\n/**\n * Mark Word comments as \"done\" in commentsExtended.xml.\n *\n * For each applied suggestion that has an `importCommentId`, looks up the\n * comment's own last-paragraph `w14:paraId` (from `word/comments.xml`) and\n * marks the matching `<w15:commentEx>` entry `w15:done=\"1\"` — updating an\n * existing entry in place, or creating one (and the part + relationship +\n * content-type) when `commentsExtended.xml` is absent.\n */\nexport async function resolveWordComments(\n zip: JSZip,\n appliedSuggestions: AcceptedSuggestion[],\n): Promise<number> {\n const commentLastParaIds = await readCommentLastParaIds(zip);\n\n // Collect comment IDs to resolve\n const toResolve: Array<{ commentId: string; paraId: string }> = [];\n for (const s of appliedSuggestions) {\n if (!s.importCommentId) continue;\n const paraId = commentLastParaIds.get(s.importCommentId);\n if (!paraId) {\n console.warn(\n `[docx-apply] No comment-paragraph id for comment ${s.importCommentId}; skipping resolution`,\n );\n continue;\n }\n toResolve.push({ commentId: s.importCommentId, paraId });\n }\n\n if (toResolve.length === 0) return 0;\n\n // Deduplicate by commentId\n const seen = new Set<string>();\n const unique = toResolve.filter((r) => {\n if (seen.has(r.commentId)) return false;\n seen.add(r.commentId);\n return true;\n });\n\n const existingXml = await zip.file(\"word/commentsExtended.xml\")?.async(\"text\");\n\n if (existingXml) {\n // Parse and append\n const doc = parseDocument(existingXml, { xmlMode: true });\n const root = doc.children.find((c) => isElement(c) && c.name === \"w15:commentsEx\") as\n | Element\n | undefined;\n if (root) {\n // Index existing commentEx entries by lowercased paraId. paraIds are hex\n // (ST_LongHexNumber); compare case-insensitively, but keep/write the\n // original case. A comment Word already tracks here must be UPDATED to\n // done=\"1\" in place, not duplicated.\n const existing = new Map<string, Element>();\n for (const child of root.children) {\n if (isElement(child) && child.name === \"w15:commentEx\") {\n const pid = getAttr(child, \"w15:paraId\");\n if (pid) existing.set(pid.toLowerCase(), child);\n }\n }\n\n for (const { paraId } of unique) {\n const found = existing.get(paraId.toLowerCase());\n if (found) {\n found.attribs[\"w15:done\"] = \"1\";\n } else {\n const entry = new Element(\"w15:commentEx\", {\n \"w15:paraId\": paraId,\n \"w15:done\": \"1\",\n });\n insertChild(root, root.children.length, entry);\n }\n }\n zip.file(\"word/commentsExtended.xml\", render(doc, { xmlMode: true }));\n }\n } else {\n // Create new commentsExtended.xml\n const entries = unique\n .map((r) => `<w15:commentEx w15:paraId=\"${r.paraId}\" w15:done=\"1\"/>`)\n .join(\"\");\n const newXml =\n `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>` +\n `<w15:commentsEx xmlns:w15=\"${W15_NS}\">${entries}</w15:commentsEx>`;\n zip.file(\"word/commentsExtended.xml\", newXml);\n\n // Add relationship in word/_rels/document.xml.rels\n const relsXml = await zip.file(\"word/_rels/document.xml.rels\")?.async(\"text\");\n if (relsXml) {\n const relsDoc = parseDocument(relsXml, { xmlMode: true });\n const relsRoot = relsDoc.children.find((c) => isElement(c) && c.name === \"Relationships\") as\n | Element\n | undefined;\n if (relsRoot) {\n // Find a unique rId\n const existingIds = findAllByName(\"Relationship\", relsRoot.children)\n .map((r) => getAttr(r, \"Id\") || \"\")\n .filter((id) => id.startsWith(\"rId\"))\n .map((id) => parseInt(id.slice(3), 10))\n .filter((n) => !isNaN(n));\n const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 100;\n\n const rel = new Element(\"Relationship\", {\n Id: `rId${nextId}`,\n Type: \"http://schemas.microsoft.com/office/2011/relationships/commentsExtended\",\n Target: \"commentsExtended.xml\",\n });\n insertChild(relsRoot, relsRoot.children.length, rel);\n zip.file(\"word/_rels/document.xml.rels\", render(relsDoc, { xmlMode: true }));\n }\n }\n\n // Add content type\n const ctXml = await zip.file(\"[Content_Types].xml\")?.async(\"text\");\n if (ctXml) {\n const ctDoc = parseDocument(ctXml, { xmlMode: true });\n const typesRoot = ctDoc.children.find((c) => isElement(c) && c.name === \"Types\") as\n | Element\n | undefined;\n if (typesRoot) {\n const override = new Element(\"Override\", {\n PartName: \"/word/commentsExtended.xml\",\n ContentType:\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml\",\n });\n insertChild(typesRoot, typesRoot.children.length, override);\n zip.file(\"[Content_Types].xml\", render(ctDoc, { xmlMode: true }));\n }\n }\n }\n\n return unique.length;\n}\n","import * as crypto from \"node:crypto\";\nimport fs from \"fs/promises\";\nimport path from \"path\";\nimport {\n Y_MAP_ANNOTATIONS,\n Y_MAP_DOCUMENT_META,\n Y_MAP_FOOTNOTE_BODIES,\n} from \"../../shared/constants.js\";\nimport { extractText, populateYDoc } from \"../mcp/document-model.js\";\nimport { htmlToYDoc, loadDocxWithWarnings, reconcileFootnoteIds } from \"./docx.js\";\nimport {\n type DocxComment,\n extractDocxComments,\n injectCommentsAsAnnotations,\n} from \"./docx-comments.js\";\nimport { exportYDocToDocx } from \"./docx-export.js\";\nimport { type DocxNotes, footnoteLossLines, parseDocxFootnotes } from \"./docx-footnotes.js\";\nimport { loadMarkdown, saveMarkdown } from \"./markdown.js\";\nimport type { FormatAdapter, LoadIssue, Prepared } from \"./types.js\";\n\nexport {\n type AcceptedSuggestion,\n type ApplyOptions,\n type ApplyOutput,\n applyTrackedChanges,\n} from \"./docx-apply.js\";\nexport type { ApplyContext, FormatAdapter, LoadIssue, Prepared } from \"./types.js\";\n\n// -- Adapter implementations (ADR-036 two-phase parse/apply) --\n\nconst markdownAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n return { format: \"md\", content: content as string, issues: [] };\n },\n apply(doc, prepared) {\n if (prepared.format !== \"md\") return [];\n loadMarkdown(doc, prepared.content);\n return [];\n },\n save(doc) {\n return saveMarkdown(doc);\n },\n};\n\nconst plaintextAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n const text = typeof content === \"string\" ? content : content.toString(\"utf-8\");\n return { format: \"other\", content: text, issues: [] };\n },\n apply(doc, prepared) {\n if (prepared.format !== \"other\") return [];\n populateYDoc(doc, prepared.content);\n return [];\n },\n save(doc) {\n return extractText(doc);\n },\n};\n\n/**\n * The .docx adapter provides `saveBinary` (#576) — .docx write-back holds edits\n * in the Y.Doc and serializes to a `.docx` buffer on EXPLICIT save only. This\n * supersedes ADR-004's read-only default; the protective layer is now \"never\n * overwrite without an explicit save\" rather than `contenteditable=false`.\n * Exports body + Word comments (#1068): user/Claude `comment`-type annotations\n * AND imported Word comments written back to their source file (private notes\n * that round-trip but stay Claude-invisible), per the gate in\n * `docx-comment-export.ts` — tracked changes stay deferred.\n *\n * - `parse` runs `loadDocxWithWarnings` + `extractDocxComments` in parallel.\n * mammoth import-fidelity warnings land as a `LoadIssue { kind: \"other\" }`\n * so the UI can tell the user what formatting mammoth dropped (and thus\n * what the round-trip cannot recover). Comment-extraction failures land as\n * `LoadIssue { kind: \"comments-failed\" }` rather than being swallowed.\n * - `apply` runs `htmlToYDoc` then `injectCommentsAsAnnotations`\n * synchronously inside the caller's transact. The snapshot/undo dance\n * around inject lives here because Yjs doesn't roll back inner-transact\n * writes when a callback throws.\n * - `saveBinary` serializes the current Y.Doc body + pending comment\n * annotations to a `.docx` buffer via `exportYDocToDocx`\n * (trust-boundary-gated). NOT wired into auto-save.\n */\nconst docxAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n const buffer = content as Buffer;\n const issues: LoadIssue[] = [];\n const [loaded, comments, notes] = await Promise.all([\n loadDocxWithWarnings(buffer),\n extractDocxComments(buffer).catch((err) => {\n console.error(\n \"[docx-comments] Comment extraction failed; document will load without imported comments:\",\n err,\n );\n issues.push({ kind: \"comments-failed\", error: err });\n return [] as DocxComment[];\n }),\n // Footnote/endnote capture (#1123 Tier-A #3): mammoth flattens these to a\n // trailing list and emits NO warning. Read the real notes directly from\n // the ZIP so the import can BOTH surface an honest loss line AND capture\n // footnote bodies for reconstruction. parseDocxFootnotes never throws (it\n // catches per-part + per-archive); this .catch is last-resort defense.\n parseDocxFootnotes(buffer).catch((err) => {\n console.error(\"[docx-footnotes] parse failed unexpectedly:\", err);\n return { footnotes: {}, endnotes: 0 } satisfies DocxNotes;\n }),\n ]);\n // Note losses lead (the named, higher-impact loss). mammoth's per-occurrence\n // warnings are already deduped + capped inside summarizeMammothMessages; the\n // note lines are a bounded fixed set (≤3), so they ride on top of that cap\n // without re-flooding — and crucially this guard fires when EITHER source is\n // non-empty, because mammoth emits zero warnings for notes. The honesty line\n // is driven off the RECONCILED partition (same inputs `htmlToYDoc` reconciles\n // in `apply` → identical result), so a footnote that won't reconstruct (an\n // orphaned definition, or a mammoth-format drift) is reported as a loss, not\n // silently claimed \"preserved\".\n const reconciliation = reconcileFootnoteIds(loaded.html, notes.footnotes);\n const importLosses = [...footnoteLossLines(notes, reconciliation), ...loaded.warnings];\n if (importLosses.length > 0) {\n issues.push({\n kind: \"other\",\n error: undefined,\n message:\n \"Some Word formatting couldn't be imported and won't be preserved on save: \" +\n `${importLosses.join(\"; \")}.`,\n // Granular list for the persistent fidelity report (#1145); the joined\n // `message` above drives the transient open-time toast.\n importLosses,\n });\n }\n return { format: \"docx\", html: loaded.html, comments, footnoteBodies: notes.footnotes, issues };\n },\n async saveBinary(doc): Promise<Buffer> {\n return exportYDocToDocx(doc);\n },\n apply(doc, prepared, ctx) {\n if (prepared.format !== \"docx\") return [];\n const reconciledFootnotes = htmlToYDoc(doc, prepared.html, prepared.footnoteBodies);\n // Persist the reconstructed footnote bodies off-fragment so the exporter can\n // re-emit real <w:footnote> parts (#1123 Tier-A #3 PR 2). WHOLE-VALUE replace\n // (a reload with fewer footnotes must not leave stale ids). The caller's\n // transact is already origin-tagged; this documentMeta key has no observer\n // (server write-only, client/Claude-invisible) and sits OUTSIDE the comment-\n // inject rollback zone below, which only deletes newly-added annotation keys.\n doc.getMap(Y_MAP_DOCUMENT_META).set(Y_MAP_FOOTNOTE_BODIES, reconciledFootnotes);\n const out: LoadIssue[] = [];\n if (prepared.comments.length > 0) {\n // Snapshot-and-rollback: Yjs does NOT roll back inner-transact writes\n // when a callback throws, so we capture the key set before inject and\n // delete any newly-added keys on throw. Without this, a partial inject\n // would commit half the comments and re-open would hit importAnnotationId\n // dedup, permanently dropping the failing comment.\n const annotMap = doc.getMap(Y_MAP_ANNOTATIONS);\n const before = new Set(annotMap.keys());\n try {\n injectCommentsAsAnnotations(doc, prepared.comments, ctx?.fileName);\n } catch (err) {\n for (const k of annotMap.keys()) {\n if (!before.has(k)) annotMap.delete(k);\n }\n console.error(\n \"[docx-comments] inject failed mid-transact; document loads without imported comments:\",\n err,\n );\n out.push({ kind: \"inject-failed\", error: err });\n }\n }\n return out;\n },\n};\n\n// -- Registry --\n\nconst adapters: Record<string, FormatAdapter> = {\n md: markdownAdapter,\n other: plaintextAdapter, // covers txt, html, and any other plaintext-routed format\n docx: docxAdapter,\n};\n\n/** Look up the adapter for a given format string. Unknown formats fall back\n * to the plaintext adapter. */\nexport function getAdapter(format: string): FormatAdapter {\n return adapters[format] ?? plaintextAdapter;\n}\n\n// -- Shared helpers --\n\n/**\n * Atomic rename retry constants. Windows can throw EPERM/EACCES when another\n * process (AV scanner, file indexer, or a stale handle) is briefly holding the\n * destination file. A handful of short retries with exponential backoff clears\n * virtually all such contention in practice.\n */\nconst RENAME_MAX_RETRIES = 3;\nconst RENAME_RETRY_BASE_MS = 50;\n\nasync function renameWithRetry(tempPath: string, filePath: string): Promise<void> {\n for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {\n try {\n await fs.rename(tempPath, filePath);\n return;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if ((code === \"EPERM\" || code === \"EACCES\") && attempt < RENAME_MAX_RETRIES - 1) {\n await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));\n continue;\n }\n await fs.unlink(tempPath).catch(() => {});\n throw err;\n }\n }\n}\n\n/**\n * Filename prefix for atomic-write temp siblings. Exported so the startup\n * reaper (`reaper.ts`) can build the exact same name shape it sweeps for.\n */\nexport const ATOMIC_TEMP_PREFIX = \".tandem-tmp-\";\n\n/**\n * Produce a unique temp filename in the same directory as `filePath`. Uses a\n * random suffix so concurrent writers to the same directory cannot collide on\n * a shared `Date.now()` millisecond (the annotation store writes multiple\n * files in the same directory in parallel).\n */\nexport function tempSiblingPath(filePath: string): string {\n const rand = crypto.randomBytes(6).toString(\"hex\");\n return path.join(path.dirname(filePath), `${ATOMIC_TEMP_PREFIX}${Date.now()}-${rand}`);\n}\n\n/**\n * Atomic file write: write to a temp file, then rename.\n * Prevents partial writes on crash. Retries the rename up to 3 times on\n * EPERM/EACCES (Windows file-handle contention) with exponential backoff.\n */\nexport async function atomicWrite(filePath: string, content: string): Promise<void> {\n const tempPath = tempSiblingPath(filePath);\n await fs.writeFile(tempPath, content, \"utf-8\");\n await renameWithRetry(tempPath, filePath);\n}\n\n/**\n * Atomic binary file write: write Buffer to a temp file, then rename.\n * Used for .docx (ZIP) output where UTF-8 encoding would corrupt binary data.\n * Shares the same EPERM/EACCES retry behaviour as `atomicWrite`.\n */\nexport async function atomicWriteBuffer(filePath: string, content: Buffer): Promise<void> {\n const tempPath = tempSiblingPath(filePath);\n await fs.writeFile(tempPath, content);\n await renameWithRetry(tempPath, filePath);\n}\n","import path from \"path\";\n\n/** Trial length in days (ADR-040 §5: under the 30-day BUSL eval ceiling). */\nexport const TRIAL_DAYS = 14;\n/** Trial length in milliseconds — epoch math only, never calendar arithmetic. */\nexport const TRIAL_MS = TRIAL_DAYS * 86_400_000;\n\n/** Path to the activated signed-license blob. */\nexport const licenseFilePath = (appDataDir: string): string =>\n path.join(appDataDir, \"license.json\");\n\n/** Path to the soft on-device trial clock. */\nexport const trialFilePath = (appDataDir: string): string => path.join(appDataDir, \"trial.json\");\n","// Pinned public key for Ed25519 license verification.\n// IMPORTANT: The corresponding private key must be stored securely by Bryan Kolb\n// (not committed to git). Store as the TANDEM_PRIVATE_KEY environment variable on the\n// webhook server. Re-generate with `npx tsx scripts/generate-keys.ts` before first use.\nexport const TANDEM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA7gm5YEEylcqDgxr1rQ/gA/K8ZzOPfqmvUBtE6CI8QCk=\n-----END PUBLIC KEY-----`;\n","/* eslint-disable @typescript-eslint/no-explicit-any */\nimport crypto from \"crypto\";\nimport type { LicenseMetadata, SignatureVerified, SignedLicense } from \"./license-types.js\";\nimport { TANDEM_PUBLIC_KEY } from \"./public-key.js\";\n\nexport function canonicalObject(obj: any): any {\n if (typeof obj !== \"object\" || obj === null) {\n return obj;\n }\n if (Array.isArray(obj)) {\n return obj.map(canonicalObject);\n }\n const sortedKeys = Object.keys(obj).sort();\n // Null-prototype accumulator so a `__proto__` (or `constructor`/`prototype`)\n // own-key is canonicalized as a normal key instead of mutating the prototype\n // (plain `{}` + `sortedObj[\"__proto__\"] = …` would silently drop it from the\n // signed bytes). Symmetric signer/verifier either way, but this removes the\n // foot-gun if the trusted field set ever grows.\n const sortedObj: any = Object.create(null);\n for (const key of sortedKeys) {\n sortedObj[key] = canonicalObject(obj[key]);\n }\n return sortedObj;\n}\n\n/**\n * Deterministically stringify an object by sorting its keys.\n * This guarantees consistent signatures across different platforms and runs.\n */\nexport function canonicalize(obj: any): string {\n return JSON.stringify(canonicalObject(obj));\n}\n\n/**\n * Verifies the Ed25519 signature of a base64-encoded signed license against the\n * embedded public key — signature ONLY, no expiry check.\n *\n * This is the correct check for the *run* gate (ADR-040 §3/§4): a paid license\n * lets you run the current version **forever**; `expiresAt` governs only the\n * update window, not the right to run. Callers that need the update-window\n * decision read `metadata.expiresAt` themselves (see `license-state.ts`).\n * Throws if the format is malformed or the signature is invalid.\n */\nexport function verifyLicenseSignature(licenseString: string): SignatureVerified {\n // Bound the input before any allocation — license blobs are small (<2KB).\n if (licenseString.length > 10_000) {\n throw new Error(\"License verification failed: input exceeds maximum length\");\n }\n try {\n // 1. Decode base64\n const decoded = Buffer.from(licenseString, \"base64\").toString(\"utf-8\");\n // Bound the DECODED payload too — a deeply-nested array can fit inside the\n // 10k input bound yet blow the recursion stack in canonicalObject. Real\n // blobs are <2KB; 4KB is a generous ceiling that rejects the pathological\n // case before any parse/recurse.\n if (decoded.length > 4096) {\n throw new Error(\"License verification failed: payload exceeds maximum length\");\n }\n const signedLicense = JSON.parse(decoded) as SignedLicense;\n\n if (!signedLicense.metadata || !signedLicense.signature) {\n throw new Error(\"Invalid license format: missing metadata or signature\");\n }\n\n // 2. Verify signature\n const data = Buffer.from(canonicalize(signedLicense.metadata));\n const signature = Buffer.from(signedLicense.signature, \"hex\");\n\n const verified = crypto.verify(null, data, TANDEM_PUBLIC_KEY, signature);\n\n if (!verified) {\n throw new Error(\"Signature verification failed\");\n }\n\n // Brand: this metadata is now signature-verified (run-gate may trust it).\n return signedLicense.metadata as SignatureVerified;\n } catch (error: any) {\n // Preserve already-wrapped messages to avoid double-wrapping.\n if (error instanceof Error && error.message.startsWith(\"License verification failed\")) {\n throw error;\n }\n throw new Error(`License verification failed: ${error.message}`, { cause: error });\n }\n}\n\n/**\n * Verifies signature AND rejects an expired license. Stricter than the run\n * gate needs — kept for callers that want a single \"valid & unexpired now\"\n * check. The on-device run/update gate uses {@link verifyLicenseSignature}\n * plus its own `expiresAt` reading instead.\n */\nexport function verifyLicense(licenseString: string): LicenseMetadata {\n const metadata = verifyLicenseSignature(licenseString);\n if (metadata.expiresAt) {\n const expires = new Date(metadata.expiresAt);\n if (expires.getTime() < Date.now()) {\n throw new Error(`License expired on ${metadata.expiresAt}`);\n }\n }\n return metadata;\n}\n","import fs from \"fs\";\nimport { atomicWrite } from \"../file-io/index.js\";\nimport { resolveAppDataDir } from \"../platform.js\";\nimport { GATE_ENABLED } from \"./gate-flag.js\";\nimport type { LicenseFile, LicenseState, SignatureVerified, TrialFile } from \"./license-types.js\";\nimport { licenseFilePath, TRIAL_MS, trialFilePath } from \"./paths.js\";\nimport { verifyLicenseSignature } from \"./verifier.js\";\n\n// Known license schema majors. The signed `version` field becomes load-bearing:\n// an unknown major is rejected rather than silently honored (review §12 L3).\nconst KNOWN_VERSION_MAJORS = new Set([\"1\"]);\nfunction knownVersion(v: string): boolean {\n return typeof v === \"string\" && KNOWN_VERSION_MAJORS.has(v.split(\".\")[0]);\n}\n\nfunction readJson<T>(filePath: string): T | null {\n try {\n return JSON.parse(fs.readFileSync(filePath, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Resolve on-device license state — computed FRESH on every call (no cache).\n * A cache caused the two-writer staleness + mid-session-expiry bugs the spec\n * reviews found, so the gate re-reads `license.json`/`trial.json` per dispatch.\n * Cost is a tiny file read + (at most) one Ed25519 verify.\n *\n * `verify` is injectable for tests; production uses signature-only verification\n * so an expired *update window* never drops a paid user to `restricted`\n * (ADR-040: run forever, updates windowed). The update window is read from\n * `expiresAt` into `updateWindowCurrent`.\n */\nexport function resolveLicenseState(deps: {\n appDataDir: string;\n now: () => number;\n gateEnabled: boolean;\n // Branded: only a signature-verifying function fits, so the expiry-checking\n // `verifyLicense` can't be wired here (it would lock out paid users past their\n // update window). See SignatureVerified in license-types.ts.\n verify?: (blob: string) => SignatureVerified;\n}): LicenseState {\n const { appDataDir, now, gateEnabled, verify = verifyLicenseSignature } = deps;\n\n if (!gateEnabled) {\n return { gateActive: false };\n }\n\n // One timestamp for the whole resolution — the licensed update-window check and\n // the trial-clock math must agree on a single \"now\".\n const nowMs = now();\n\n // 1. A signature-valid license of a known version ⇒ licensed (runs forever).\n const lf = readJson<LicenseFile>(licenseFilePath(appDataDir));\n if (lf?.blob) {\n try {\n const meta = verify(lf.blob);\n if (knownVersion(meta.version)) {\n const updateWindowCurrent =\n meta.expiresAt === null || new Date(meta.expiresAt).getTime() > nowMs;\n return {\n gateActive: true,\n status: \"licensed\",\n license: meta,\n licenseId: meta.id,\n updateWindowCurrent,\n };\n }\n } catch {\n // malformed / bad signature / unknown version — fall through to trial/restricted\n }\n }\n\n // 2. Trial clock (soft by design — ADR-040 §3). Absent file ⇒ day 0.\n const tf = readJson<TrialFile>(trialFilePath(appDataDir));\n const firstRunAt = tf?.firstRunAt ? new Date(tf.firstRunAt).getTime() : nowMs;\n const expiresAt = firstRunAt + TRIAL_MS;\n if (nowMs < expiresAt) {\n const daysRemaining = Math.max(0, Math.ceil((expiresAt - nowMs) / 86_400_000));\n return {\n gateActive: true,\n status: \"trial\",\n updateWindowCurrent: false,\n trial: {\n firstRunAt: new Date(firstRunAt).toISOString(),\n expiresAt: new Date(expiresAt).toISOString(),\n daysRemaining,\n },\n };\n }\n\n // 3. Trial expired, no license ⇒ restricted (read-only escape hatch).\n return { gateActive: true, status: \"restricted\", updateWindowCurrent: false };\n}\n\n/**\n * Production-wired `resolveLicenseState`: the single place the live deps (real\n * app-data dir, wall clock, build-time gate flag) are assembled. Shared by both\n * enforcement surfaces — Hocuspocus `onAuthenticate` (Surface A) and the MCP\n * `gatedTool` / `licenseGateMiddleware` (Surface B) — plus the status route, so\n * a future deps change lands in one spot. Still cache-free: every call re-reads disk.\n */\nexport function resolveLiveLicenseState(): LicenseState {\n return resolveLicenseState({\n appDataDir: resolveAppDataDir(),\n now: () => Date.now(),\n gateEnabled: GATE_ENABLED,\n });\n}\n\n/**\n * Start the trial clock on first boot of a gate-active build. Writes `trial.json`\n * once, with an exclusive create (`flag: \"wx\"`) so concurrent stdio+HTTP first\n * boots agree on a single `firstRunAt` (first writer wins). No-op when the gate\n * is dark — so the v1.0 flag-flip starts a clean 14-day trial.\n */\nexport async function ensureTrialStarted(\n appDataDir: string,\n now: () => number,\n gateEnabled: boolean,\n): Promise<void> {\n if (!gateEnabled) return;\n const filePath = trialFilePath(appDataDir);\n if (fs.existsSync(filePath)) return;\n const body: TrialFile = { version: 1, firstRunAt: new Date(now()).toISOString() };\n try {\n fs.writeFileSync(filePath, JSON.stringify(body), { flag: \"wx\" });\n } catch {\n // Lost the race to a concurrently-starting process — its file stands.\n }\n}\n\n/**\n * Activate a license: verify its signature + known version, persist atomically,\n * and return the freshly-resolved state. Does NOT reject an expired update\n * window — a user may activate an older license and still run forever; they\n * simply won't receive new updates until they renew.\n */\nexport async function activateLicense(\n appDataDir: string,\n blob: string,\n // Injectable for tests (sign with a temp keypair) — mirrors the seam on\n // resolveLicenseState. Production uses the pinned-key signature verifier.\n verify: (blob: string) => SignatureVerified = verifyLicenseSignature,\n): Promise<LicenseState> {\n const meta = verify(blob); // throws on malformed / bad signature\n if (!knownVersion(meta.version)) {\n throw new Error(`Unsupported license version: ${meta.version}`);\n }\n const body: LicenseFile = { version: 1, blob };\n await atomicWrite(licenseFilePath(appDataDir), JSON.stringify(body));\n return resolveLicenseState({ appDataDir, now: () => Date.now(), gateEnabled: true, verify });\n}\n","/**\n * `tandem activate <license-or-path>` and `tandem license` (#1116, ADR-040).\n *\n * Both operate on the LOCAL appData store directly — no running server required.\n * The server re-resolves license state per dispatch (no cache), so activating\n * here takes effect on the running server's next tool call / reconnect.\n */\nimport fs from \"fs\";\nimport { GATE_ENABLED } from \"../server/license/gate-flag.js\";\nimport { activateLicense, resolveLicenseState } from \"../server/license/license-state.js\";\nimport type { LicenseState } from \"../server/license/license-types.js\";\nimport { resolveAppDataDir } from \"../server/platform.js\";\n\n/**\n * Resolve the activate argument to a license blob. If it names a readable file\n * (e.g. a `.license` file emailed to a beta tester), read that file's contents;\n * otherwise treat the argument itself as the pasted blob. `fs.existsSync` never\n * throws — a base64 blob containing `/` simply isn't a real path and falls\n * through to the literal branch.\n */\nexport function resolveLicenseInput(\n arg: string,\n fileExists: (p: string) => boolean,\n readFile: (p: string) => string,\n): string {\n if (fileExists(arg)) return readFile(arg).trim();\n return arg.trim();\n}\n\n/**\n * Human-readable lines for `tandem license`. Pure (given an already-resolved\n * state) so the formatting is unit-testable without touching disk.\n */\nexport function formatLicenseStatus(state: LicenseState, enforcementOn: boolean): string[] {\n const lines: string[] = [\"\", \"Tandem license\", \"\"];\n lines.push(` Enforcement: ${enforcementOn ? \"on\" : \"off (activates at v1.0)\"}`);\n if (state.gateActive && state.status === \"trial\") {\n lines.push(` Status: trial (${state.trial.daysRemaining} days remaining)`);\n } else if (state.gateActive && state.status === \"restricted\") {\n lines.push(\" Status: restricted — trial ended; activate a license to keep editing\");\n } else {\n // licensed (gate-active) — or the dark arm, which `tandem license` never\n // produces (it forces gateEnabled:true), but the union requires the branch.\n lines.push(\" Status: licensed\");\n if (state.gateActive && state.status === \"licensed\") {\n lines.push(` Licensee: ${state.license.name} (${state.license.type})`);\n const window = state.updateWindowCurrent ? \"current\" : \"expired\";\n const through = state.license.expiresAt\n ? ` (through ${state.license.expiresAt.slice(0, 10)})`\n : \"\";\n lines.push(` Update window: ${window}${through}`);\n }\n }\n lines.push(\"\");\n return lines;\n}\n\n/**\n * `tandem license` — print the on-device license/trial state. Forces full\n * resolution (gateEnabled: true) so a beta tester can confirm an installed\n * license even while enforcement still ships dark; the \"Enforcement\" line\n * separately reports the real build flag.\n */\nexport async function runLicenseStatus(): Promise<void> {\n const state = resolveLicenseState({\n appDataDir: resolveAppDataDir(),\n now: () => Date.now(),\n gateEnabled: true,\n });\n for (const line of formatLicenseStatus(state, GATE_ENABLED)) console.log(line);\n}\n\n/**\n * `tandem activate <license-or-path>` — verify + persist a signed license.\n * Exits non-zero with a generic message on a bad license (no blob bytes echoed).\n */\nexport async function runActivate(args: string[]): Promise<void> {\n const input = args[1];\n if (!input) {\n console.error(\"Usage: tandem activate <license-string-or-path>\");\n process.exit(1);\n }\n const blob = resolveLicenseInput(input, fs.existsSync, (p) => fs.readFileSync(p, \"utf-8\"));\n try {\n const state = await activateLicense(resolveAppDataDir(), blob);\n const lic = state.gateActive && state.status === \"licensed\" ? state.license : null;\n const who = lic ? `${lic.name} (${lic.type})` : \"this device\";\n console.log(`\\n✓ License activated for ${who}.`);\n if (lic?.expiresAt) {\n console.log(` Updates included through ${lic.expiresAt.slice(0, 10)}.`);\n }\n console.log(\"\");\n } catch {\n console.error(\n \"\\n[Tandem] License activation failed: the license could not be verified.\\n\" +\n \"Check that you pasted the full license string (or gave the correct file path).\\n\",\n );\n process.exit(1);\n }\n}\n","import { spawn } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SERVER_DIST = resolve(__dirname, \"../server/index.js\");\n\nexport function runStart(): void {\n if (!existsSync(SERVER_DIST)) {\n console.error(`[Tandem] Server not found at ${SERVER_DIST}`);\n console.error(\"[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor\");\n process.exit(1);\n }\n\n console.error(\n \"[Tandem] Browser distribution is deprecated; the Tauri desktop app is the primary form factor.\",\n );\n console.error(\"[Tandem] See https://github.com/bloknayrb/tandem/issues/477 for context.\");\n console.error(\"[Tandem] Starting server...\");\n\n const proc = spawn(\"node\", [SERVER_DIST], {\n stdio: \"inherit\",\n env: process.env,\n });\n\n proc.on(\"error\", (err) => {\n console.error(`[Tandem] Failed to start server: ${err.message}`);\n process.exit(1);\n });\n\n proc.on(\"exit\", (code) => {\n process.exit(code ?? 0);\n });\n\n // Forward signals — proc.kill() with no argument uses SIGTERM on Unix\n // and TerminateProcess on Windows (correct cross-platform behavior).\n // On Windows SIGTERM is not emitted by the OS, but SIGINT (Ctrl+C) works.\n // Both are listed for Unix compatibility.\n for (const sig of [\"SIGINT\", \"SIGTERM\"] as const) {\n process.once(sig, () => proc.kill());\n }\n}\n","const MIN_NODE_MAJOR = 22;\n\n/**\n * Returns a user-facing error message when the running Node.js is below the\n * supported floor, or null when it's acceptable. Unparseable versions fail\n * open — the guard exists to turn a cryptic downstream crash into a clear\n * message, not to gate runtimes it can't identify.\n */\nexport function nodeVersionError(version: string): string | null {\n const major = Number.parseInt(version.replace(/^v/, \"\"), 10);\n if (Number.isNaN(major) || major >= MIN_NODE_MAJOR) {\n return null;\n }\n return [\n `[tandem] Node.js ${MIN_NODE_MAJOR}+ is required — you are running ${version}.`,\n \"[tandem] Install the current LTS from https://nodejs.org, then run tandem again.\",\n ].join(\"\\n\");\n}\n","/**\n * Tandem CLI — entry point for the `tandem` global command.\n * Shebang is added by tsup banner at build time.\n *\n * Usage:\n * tandem Start the Tandem server and open the editor\n * tandem setup Print first-run setup guidance (setup is wizard-driven)\n * tandem setup --apply Non-interactively write MCP config to detected clients\n * tandem doctor Diagnose setup issues (add --json for machine-readable output)\n * tandem --help Show this help\n * tandem --version Show version\n */\n\nimport { nodeVersionError } from \"./node-version.js\";\n\n// Must run before any Tandem code — npm's `engines` field only warns at\n// install time, so an old-Node user otherwise reaches a cryptic runtime\n// failure deep in the server. update-notifier is imported dynamically below\n// so it can't evaluate ahead of this guard. (In the bundled CLI, tsup's\n// splitting:false inlines the subcommand modules and hoists their external\n// deps — zod, the MCP SDK, env-paths — above this guard; those all load\n// cleanly on Node 18/20, so the guard still fires before anything breaks.)\nconst versionError = nodeVersionError(process.version);\nif (versionError) {\n console.error(versionError);\n process.exit(1);\n}\n\nprocess.once(\"uncaughtException\", (err: unknown) => {\n const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);\n try {\n process.stderr.write(`[tandem cli] uncaughtException: ${msg}\\n`);\n } catch {\n /* EPIPE */\n }\n process.exit(1);\n});\nprocess.once(\"unhandledRejection\", (reason: unknown) => {\n const detail = reason instanceof Error ? reason.message : String(reason);\n process.stderr.write(`[tandem cli] unhandledRejection: ${detail}\\n`);\n process.exit(1);\n});\n\n// Injected at build time by tsup define; declared here for TypeScript\ndeclare const __TANDEM_VERSION__: string;\nconst version = typeof __TANDEM_VERSION__ !== \"undefined\" ? __TANDEM_VERSION__ : \"0.0.0-dev\";\n\nconst args = process.argv.slice(2);\n\nif (args.includes(\"--help\") || args.includes(\"-h\")) {\n console.log(`tandem v${version}\n\nUsage:\n tandem Start Tandem server and open the editor\n tandem setup Print first-run setup guidance (setup is wizard-driven)\n tandem setup --apply Write MCP config to detected AI clients non-interactively\n tandem setup --apply --force Apply to default paths regardless of detection\n tandem setup --apply --target=claude-code|claude-desktop\n Restrict --apply to specific client(s)\n tandem setup --apply --with-channel-shim\n Also register the stdio channel shim (legacy opt-in)\n tandem doctor Diagnose setup issues (Node version, .mcp.json,\n ports, server health, annotation store)\n tandem doctor --json Same checks, emit a single JSON report on stdout\n tandem rotate-token Rotate the auth token with a 60-second grace window\n tandem activate <license|path> Activate a signed license (string or file path)\n tandem license Show the current license / trial status\n tandem --uninstall-scrub Remove Tandem's MCP entries, skill, and Cowork\n registration from Claude configs (run before\n uninstalling; the Windows uninstaller runs it)\n tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP\n (used by the plugin's Cowork bridge; requires\n tandem server running on the host)\n tandem channel Run the Tandem channel shim (stdio MCP)\n (used by the plugin's tandem-channel entry)\n tandem --version\n tandem --help\n`);\n process.exit(0);\n}\n\nif (args.includes(\"--version\") || args.includes(\"-v\")) {\n console.log(version);\n process.exit(0);\n}\n\n// --help/--version exit above without paying the import. Skipped for stdio\n// subcommands — the output is machine-consumed by Claude Desktop's plugin\n// loader, and any incidental write risks corrupting the MCP wire or producing\n// log noise no human will ever read.\nconst isStdioMode = args[0] === \"mcp-stdio\" || args[0] === \"channel\";\nif (!isStdioMode) {\n const { default: updateNotifier } = await import(\"update-notifier\");\n updateNotifier({ pkg: { name: \"tandem-editor\", version } }).notify();\n}\n\ntry {\n if (args[0] === \"--uninstall-scrub\") {\n // Invoked by the Tauri NSIS uninstaller hook on Windows, and manually on\n // any platform before removing the app. Removes Tandem's MCP config\n // entries + bundled skill everywhere; Cowork plugin entries + firewall\n // rules on Windows. Runs inside the already-signed tandem.exe (security\n // invariant §10 — prevents binary-planting during uninstall).\n const { runUninstallScrub } = await import(\"./uninstall-scrub.js\");\n const exitCode = await runUninstallScrub();\n process.exit(exitCode);\n } else if (args[0] === \"setup\") {\n const { runSetup, parseTargetArgs } = await import(\"./setup.js\");\n // `--target=claude-code` / `--target=claude-desktop`, repeatable. Warn on\n // unrecognized values so a typo doesn't silently become a confusing \"No\n // matching installations\" downstream.\n const { targets, unknown } = parseTargetArgs(args);\n for (const t of unknown) {\n console.error(\n `[tandem] Ignoring unrecognized --target value \"${t}\" (expected claude-code or claude-desktop).`,\n );\n }\n // If the user asked for targets but NONE resolved, fail closed rather than\n // falling through to \"no filter → apply to every detected client\" — writing\n // config to clients they didn't ask for is a surprising side effect on a\n // --apply write path.\n if (unknown.length > 0 && targets.length === 0) {\n console.error(\n \"[tandem] No valid --target values given (expected claude-code or claude-desktop); refusing to apply to all clients. Aborting.\",\n );\n process.exit(1);\n }\n await runSetup({\n apply: args.includes(\"--apply\"),\n force: args.includes(\"--force\"),\n withChannelShim: args.includes(\"--with-channel-shim\"),\n targets,\n });\n } else if (args[0] === \"mcp-stdio\") {\n const { runMcpStdio } = await import(\"./mcp-stdio.js\");\n await runMcpStdio();\n } else if (args[0] === \"channel\") {\n const { runChannelCli } = await import(\"./channel.js\");\n await runChannelCli();\n } else if (args[0] === \"doctor\") {\n // The doctor logic is bundled into dist/cli (imported, not spawned):\n // scripts/ is NOT shipped in the npm package, so a spawn would have\n // nothing to run in a global install. See src/cli/doctor.ts for the\n // full rationale. --json emits a single JSON report on stdout.\n const { runDoctorCli } = await import(\"./doctor.js\");\n const exitCode = await runDoctorCli({ json: args.includes(\"--json\") });\n process.exit(exitCode);\n } else if (args[0] === \"rotate-token\") {\n const { rotateToken } = await import(\"./rotate-token.js\");\n await rotateToken();\n } else if (args[0] === \"activate\") {\n const { runActivate } = await import(\"./license.js\");\n await runActivate(args);\n } else if (args[0] === \"license\") {\n const { runLicenseStatus } = await import(\"./license.js\");\n await runLicenseStatus();\n } else if (!args[0] || args[0] === \"start\") {\n const { runStart } = await import(\"./start.js\");\n runStart();\n } else {\n console.error(`Unknown command: ${args[0]}`);\n console.error(\"Run 'tandem --help' for usage.\");\n process.exit(1);\n }\n} catch (err) {\n console.error(`\\n[Tandem] Fatal error: ${err instanceof Error ? err.message : String(err)}`);\n console.error(\"If this persists, try reinstalling: npm install -g tandem-editor\\n\");\n process.exit(1);\n}\n"],"mappings":"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","names":["args","path","join","randomUUID","readFileSync","open","dirname","join","resolve","fileURLToPath","path","backupPath","__dirname","path","execFile","randomUUID","homedir","path","promisify","write","execFileAsync","existsSync","join","args","VALID_TOKEN_RE","detail","buffered","init_types","init_types","init_types","init_types","StdioServerTransport","z","args","resolve","pid","existsSync","readdirSync","readFileSync","statSync","createConnection","homedir","join","version","args","resolve","resolveAppDataDir","envPaths","fs","path","fsPromises","path","Y","Y","Y","z","z","init_types","init_types","Y","init_positions","parseDocument","parseDocument","init_types","init_positions","init_positions","Y","parseDocument","JSZip","parseDocument","JSZip","crypto","fs","path","atomicWrite","path","crypto","fs","atomicWrite","fs","readFile","args","existsSync","dirname","resolve","fileURLToPath","__dirname","version","runUninstallScrub","runSetup","parseTargetArgs","runMcpStdio","runChannelCli","runDoctorCli","rotateToken","runActivate","runLicenseStatus","runStart"]}
1
+ {"version":3,"sources":["../../src/cli/skill-content.ts","../../src/shared/constants.ts","../../src/server/platform.ts","../../src/server/integrations/acl-win.ts","../../src/server/integrations/backup.ts","../../src/server/integrations/apply.ts","../../src/cli/win-path-guard.ts","../../src/cli/uninstall-scrub.ts","../../src/cli/setup.ts","../../src/shared/cli-runtime.ts","../../src/cli/preflight.ts","../../src/cli/mcp-stdio.ts","../../src/shared/api-paths.ts","../../src/shared/fetch-with-timeout.ts","../../src/shared/utils.ts","../../src/shared/events/types.ts","../../src/shared/positions/types.ts","../../src/shared/types.ts","../../src/shared/sse-consumer.ts","../../src/channel/event-bridge.ts","../../src/channel/run.ts","../../src/cli/channel.ts","../../src/server/annotations/lockfile.ts","../../src/cli/doctor.ts","../../src/shared/auth/token-file.ts","../../src/cli/rotate-token.ts","../../src/server/license/gate-flag.ts","../../src/shared/offsets.ts","../../src/server/file-io/mdast-ydoc.ts","../../src/server/file-io/markdown.ts","../../src/server/mcp/document-model.ts","../../src/server/file-io/docx-html.ts","../../src/server/file-io/docx.ts","../../src/shared/origins.ts","../../src/server/annotations/migration-log.ts","../../src/server/annotations/migrations/v1_to_v2.ts","../../src/server/annotations/migrations/runner.ts","../../src/server/annotations/migrations/index.ts","../../src/server/annotations/schema.ts","../../src/shared/positions/index.ts","../../src/server/positions.ts","../../src/server/file-io/docx-comment-id.ts","../../src/server/file-io/docx-walker.ts","../../src/server/file-io/docx-comments.ts","../../src/shared/sanitize.ts","../../src/server/file-io/docx-comment-export.ts","../../src/server/file-io/docx-export.ts","../../src/server/file-io/docx-footnotes.ts","../../src/server/file-io/docx-apply.ts","../../src/server/file-io/index.ts","../../src/server/license/paths.ts","../../src/server/license/public-key.ts","../../src/server/license/verifier.ts","../../src/server/license/license-state.ts","../../src/cli/license.ts","../../src/cli/start.ts","../../src/cli/node-version.ts","../../src/cli/index.ts"],"sourcesContent":["/**\n * SKILL.md content installed to ~/.claude/skills/tandem/ by `tandem setup`.\n * Single source of truth lives at `skills/tandem/SKILL.md`. This module\n * reads that file at module load so the plugin install path and the\n * `tandem setup` install path always deliver byte-identical content.\n *\n * The file is shipped via package.json `files: [\"skills/\", ...]`, and the\n * CLI entry (dist/cli/index.js) is not self-contained — so at runtime the\n * relative path `../../skills/tandem/SKILL.md` resolves from either\n * dist/cli/ (tsx dev) or dist/cli/ (npm install) to the package-root\n * `skills/tandem/SKILL.md`.\n */\nimport { readFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SKILL_PATH = resolve(__dirname, \"../../skills/tandem/SKILL.md\");\n\nexport const SKILL_CONTENT = readFileSync(SKILL_PATH, \"utf-8\");\n","export const DEFAULT_WS_PORT = 3478;\nexport const DEFAULT_MCP_PORT = 3479;\n\nexport const TANDEM_REPO_URL = \"https://github.com/bloknayrb/tandem\";\nexport const TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;\n\n/**\n * Feature flag for the in-app \"bring your own API key\" Models registry UI\n * (#1018/#1022). The registry stores provider keys in the OS keychain, but no\n * server-side LLM client consumes them yet (see `src/server/models/api-routes.ts`\n * — \"a future LLM client\"). Until that lands, offering the picker is a dead end:\n * users configure a key, AI still doesn't work, and they conclude the app is\n * broken. So the entire BYO-models surface (first-run picker, Settings → Models\n * tab, titlebar default-model chip) is gated OFF behind this flag. AI today is\n * the external Claude Code integration (MCP). Flip to `true` — or wire to an\n * env/build define — once the outbound LLM client exists.\n */\nexport const BYO_MODELS_ENABLED = false;\n\n/** File extensions the server accepts for opening. */\nexport const SUPPORTED_EXTENSIONS = new Set([\".md\", \".txt\", \".html\", \".htm\", \".docx\"]);\n\n/**\n * Inline marks the `.docx` import path (`docx-html.ts#htmlToYDoc`) can emit onto\n * Y.XmlText. The client Tiptap schema MUST register a mark for every name here:\n * y-prosemirror's sync (`createTextNodesFromYText`) calls `schema.mark(name)` for\n * each delta attribute and, on an UNREGISTERED mark, its catch deletes the whole\n * offending Y.XmlText and propagates the deletion to disk — silent content loss,\n * not a crash. So this list is the contract the editor schema is tested against\n * (`tests/client/editor-schema-marks.test.ts`). Lives in shared (not docx-html)\n * so the client guard imports the real source rather than a drift-prone copy.\n * The markdown path (`mdast-ydoc.ts`) emits a different set (its core + the\n * `rawMarkdown` mark, already client-registered).\n */\nexport const DOCX_INLINE_MARKS = [\n \"bold\",\n \"italic\",\n \"strike\",\n \"code\",\n \"link\",\n \"underline\",\n \"superscript\",\n \"subscript\",\n // Footnote reference marker (#1123 Tier-A #3). Carries `{ id, kind }` on the\n // verbatim `[N]` text mammoth renders, so export can emit a real\n // `<w:footnoteReference>`. The literal \"footnote-ref\" MUST byte-match the\n // delta-attribute key `docx-html.ts` writes AND the client `Mark.create`\n // name (`footnote-ref.ts`) — a one-char drift is silent XmlText deletion.\n \"footnote-ref\",\n] as const;\n\n// DEFAULT_FONT_BY_EXTENSION removed as a #887 follow-up: seeded defaults\n// silently overrode the user's global editor-font choice for un-customized\n// formats (changing Settings > Editor Font did nothing for .docx / .html /\n// .txt until the user clicked every per-format radio group). The global\n// setting is now the true default; `fontByExtension` is a sparse\n// user-overrides map.\n\nexport const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB\nexport const SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days\nexport const TYPING_DEBOUNCE = 3000; // 3 seconds\nexport const DISCONNECT_DEBOUNCE_MS = 3000; // 3 seconds before showing \"server not reachable\"\nexport const PROLONGED_DISCONNECT_MS = 30_000; // 30 seconds before showing App-level disconnect banner\n\nimport type { HighlightColor } from \"./types.js\";\n\nexport const HIGHLIGHT_COLORS: Record<HighlightColor, string> = {\n yellow: \"rgba(255, 235, 59, 0.3)\",\n green: \"rgba(76, 175, 80, 0.3)\",\n blue: \"rgba(33, 150, 243, 0.3)\",\n pink: \"rgba(236, 72, 153, 0.3)\",\n};\n\nexport const HIGHLIGHT_COLOR_VARS: Record<HighlightColor, string> = {\n yellow: \"var(--tandem-highlight-yellow)\",\n green: \"var(--tandem-highlight-green)\",\n blue: \"var(--tandem-highlight-blue)\",\n pink: \"var(--tandem-highlight-pink)\",\n};\n\nexport function normalizeHighlightColor(color: string | null | undefined): HighlightColor {\n return color && color in HIGHLIGHT_COLORS ? (color as HighlightColor) : \"yellow\";\n}\n\nexport const TANDEM_MODE_DEFAULT = \"tandem\" as const;\nexport const TANDEM_MODE_KEY = \"tandem:mode\";\nexport const TANDEM_SETTINGS_KEY = \"tandem:settings\";\n// Panel-width localStorage keys.\n//\n// NOTE: these use legacy hyphen naming (vs the neighboring colon convention\n// `tandem:mode`/`tandem:settings`) because they predate the colon scheme and\n// changing the strings would invalidate every existing user's saved widths.\n// Do not \"fix\" the style — the key string is the persistence contract.\n//\n// Right-side panel width is shared between the tabbed layout and any\n// future left-panel variant. The left key only applies when the left\n// panel is visible.\nexport const PANEL_WIDTH_KEY = \"tandem-panel-width\";\nexport const LEFT_PANEL_WIDTH_KEY = \"tandem-left-panel-width\";\n\nexport type PanelSide = \"left\" | \"right\";\n\n/**\n * Maps a panel side to its localStorage key. Using a Record instead of two\n * bare constants makes the \"both handles write to the same key\" regression\n * (#228) structurally impossible — you can't accidentally map both sides to\n * the same value at a callsite.\n *\n * Uses `as const satisfies Record<PanelSide, string>` so the value type stays\n * as the literal strings rather than widening to `string` — this preserves\n * the persistence-key identity at every callsite while still enforcing\n * exhaustive coverage of `PanelSide`.\n */\nexport const PANEL_WIDTH_KEYS = {\n left: LEFT_PANEL_WIDTH_KEY,\n right: PANEL_WIDTH_KEY,\n} as const satisfies Record<PanelSide, string>;\nexport const SELECTION_DWELL_DEFAULT_MS = 1000;\nexport const SELECTION_DWELL_MIN_MS = 500;\nexport const SELECTION_DWELL_MAX_MS = 3000;\n\n// Large file thresholds\nexport const CHARS_PER_PAGE = 3_000;\nexport const LARGE_FILE_PAGE_THRESHOLD = 50;\nexport const VERY_LARGE_FILE_PAGE_THRESHOLD = 100;\n\nexport const CTRL_ROOM = \"__tandem_ctrl__\";\n\n/** Y.Map key constants — centralized to prevent silent bugs from string typos. */\nexport const Y_MAP_ANNOTATIONS = \"annotations\";\nexport const Y_MAP_AWARENESS = \"awareness\";\nexport const Y_MAP_USER_AWARENESS = \"userAwareness\";\nexport const Y_MAP_MODE = \"mode\";\nexport const Y_MAP_DWELL_MS = \"selectionDwellMs\";\nexport const Y_MAP_CHAT = \"chat\";\nexport const Y_MAP_DOCUMENT_META = \"documentMeta\";\nexport const Y_MAP_ANNOTATION_REPLIES = \"annotationReplies\";\nexport const Y_MAP_SAVED_AT_VERSION = \"savedAtVersion\";\nexport const Y_MAP_AUTHORSHIP = \"authorship\";\n// Y.Map sub-keys: userAwareness\nexport const Y_MAP_SELECTION = \"selection\";\nexport const Y_MAP_ACTIVITY = \"activity\";\n// Y.Map sub-keys: awareness (Claude focus)\nexport const Y_MAP_CLAUDE = \"claude\";\n// Y.Map sub-keys: documentMeta\nexport const Y_MAP_OPEN_DOCUMENTS = \"openDocuments\";\nexport const Y_MAP_ACTIVE_DOCUMENT_ID = \"activeDocumentId\";\nexport const Y_MAP_ACTIVE_DOCUMENT_EPOCH = \"activeDocumentEpoch\";\nexport const Y_MAP_READ_ONLY = \"readOnly\";\nexport const Y_MAP_STORE_READ_ONLY = \"storeReadOnly\";\n/**\n * Per-document external-conflict state (#1069, `.docx` only). Holds an\n * `ExternalConflictState` (see shared/types.ts) while the document's unsaved\n * Y.Doc edits diverge from the on-disk source; absent otherwise. Written via\n * `withInternal` (server metadata); cleared on resolve / reload / explicit save.\n */\nexport const Y_MAP_EXTERNAL_CONFLICT = \"externalConflict\";\n/**\n * Per-document docx fidelity report (#1145, `.docx` only). Holds a\n * `FidelityReport` (see shared/types.ts): Word features mammoth dropped on\n * import (`importLosses`, set at open / force-reload / file-watcher reload) and\n * what the export downgraded on the most recent save (`exportDowngrades`). The\n * client renders a calm, self-erasing notice while either list is non-empty.\n * No observer is attached to the per-document `documentMeta` map (durable-sync\n * watches only annotations/replies; the only `documentMeta` channel observer is\n * `ctrl-meta` on CTRL_ROOM), so the write is inert at any origin — server\n * write-only, client read-only.\n */\nexport const Y_MAP_FIDELITY_REPORT = \"fidelityReport\";\n/**\n * Per-document reconstructed Word footnote bodies (#1123 Tier-A #3 PR 2, `.docx`\n * only). Holds `Record<string, FootnoteBody>` (see shared/types.ts) keyed by the\n * OOXML footnote id, written off-fragment under Y_MAP_DOCUMENT_META so the inline\n * `[N]` marker stays offset-neutral (a footnote body is not document body text).\n * Written as a WHOLE-VALUE replace at import (so a force-reload of a doc with\n * fewer footnotes can't leave stale ids) and read by the exporter to emit real\n * `<w:footnote>` parts. Same inertness as Y_MAP_FIDELITY_REPORT: no observer on\n * per-document documentMeta, so the write is server-only, client/Claude-invisible.\n */\nexport const Y_MAP_FOOTNOTE_BODIES = \"footnoteBodies\";\n\nexport const AUTHORSHIP_TOGGLE_KEY = \"tandem:showAuthorship\";\n/**\n * Per-type annotation decoration visibility, mirrored from settings so the\n * ProseMirror plugin can read it at init before any Svelte effect runs.\n * Value is `JSON.stringify({ comment, highlight, note })` carrying the\n * *effective* booleans (master mute already folded in). Replaces the v8-era\n * single `tandem:showAnnotationDecorations` flag (#596 → 1.13 per-type split).\n */\nexport const DECORATION_VISIBILITY_KEY = \"tandem:decorationVisibility\";\n\nexport const RECENT_FILES_KEY = \"tandem:recentFiles\";\nexport const RECENT_FILES_CAP = 20;\n\nexport const USER_NAME_KEY = \"tandem:userName\";\nexport const USER_NAME_DEFAULT = \"You\";\nexport const USER_NAME_EVENT = \"tandem:user-name-changed\";\nexport const USER_NAME_MAX_LEN = 40;\n\n// Toast notifications\nexport const TOAST_DISMISS_MS = { error: 8000, warning: 6000, info: 4000 } as const;\nexport const MAX_VISIBLE_TOASTS = 5;\nexport const NOTIFICATION_BUFFER_SIZE = 50;\n\n// Activity center — persistent notification tray (sub-PR 1.10).\n// NOTE: NOT \"tandem:activity\" — that would shadow the Y_MAP_ACTIVITY = \"activity\"\n// Y.Map sub-key above. This is a localStorage key for the client-side tray history.\nexport const ACTIVITY_HISTORY_KEY = \"tandem:activityHistory\";\nexport const ACTIVITY_HISTORY_CAP = 50;\n// Tray-side TTL for info-severity items. Deliberately decoupled from\n// TOAST_DISMISS_MS.info: tied together, an info item left the tray the moment\n// its toast vanished, so the tray never answered \"I missed the toast — what\n// just happened?\". Warnings/errors persist until dismissed; coalescing + the\n// cap keep ambient SSE info from flooding the tray in the meantime.\nexport const ACTIVITY_INFO_TTL_MS = 5 * 60_000;\n\n// Onboarding tutorial\nexport const TUTORIAL_COMPLETED_KEY = \"tandem:tutorialCompleted\";\n// Load-bearing: useTutorial.svelte.ts uses this prefix to exclude tutorial\n// SEEDS from \"user-authored annotation\" detection. Tutorial NOTES carry\n// author=\"user\" (ADR-027: only the user can author notes), so the prefix\n// is the ONLY thing distinguishing a seed from a real user note. Renaming\n// this constant without updating useTutorial.svelte.ts would silently\n// re-introduce the step-1 auto-advance bug from PR #621 PR-A2b.\nexport const TUTORIAL_ANNOTATION_PREFIX = \"tutorial-\";\n/** Persists \"user skipped the Cowork onboarding step\" across sessions. */\nexport const COWORK_ONBOARDING_SKIPPED_KEY = \"tandem:coworkOnboardingSkipped\";\n/** Polling interval for `cowork_get_status` while the consumer is active. */\nexport const COWORK_STATUS_POLL_MS = 30_000;\n/** Debounce interval for the \"Re-scan workspaces\" button. */\nexport const COWORK_RESCAN_DEBOUNCE_MS = 2_000;\n\n// Channel / event queue\nexport const CHANNEL_EVENT_BUFFER_SIZE = 200;\nexport const CHANNEL_EVENT_BUFFER_AGE_MS = 60_000; // 60 seconds\nexport const CHANNEL_SSE_KEEPALIVE_MS = 15_000; // 15 seconds\nexport const CHANNEL_MAX_RETRIES = 5;\nexport const CHANNEL_RETRY_DELAY_MS = 2_000;\n\n// Channel shim per-request timeouts. Mirror the monitor pattern (#364) so a\n// half-open Tandem server can't wedge `tandem_reply`, the permission relay,\n// or the event-bridge SSE handshake / awareness / mode / error-report POSTs.\n// Intentionally separate constants per endpoint so a slow endpoint doesn't\n// hold up a faster one — and so log lines name a meaningful threshold.\nexport const CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 10_000; // /api/events handshake\nexport const CHANNEL_SSE_INACTIVITY_TIMEOUT_MS = 60_000; // No-bytes watchdog on SSE body\nexport const CHANNEL_MODE_FETCH_TIMEOUT_MS = 2_000; // /api/mode cache refresh\nexport const CHANNEL_AWARENESS_FETCH_TIMEOUT_MS = 5_000; // /api/channel-awareness POST\nexport const CHANNEL_ERROR_REPORT_TIMEOUT_MS = 3_000; // /api/channel-error POST on exit\nexport const CHANNEL_REPLY_FETCH_TIMEOUT_MS = 5_000; // /api/channel-reply (tandem_reply)\nexport const CHANNEL_PERMISSION_FETCH_TIMEOUT_MS = 5_000; // /api/channel-permission relay\n// Bound the SSE buffer so a misbehaving server that never emits frame\n// boundaries can't wedge the bridge with unbounded string growth.\nexport const CHANNEL_MAX_SSE_BUFFER_BYTES = 1_000_000;\n\n/** Auth token filename inside the app-data directory. */\nexport const TOKEN_FILE_NAME = \"auth-token\";\n\n/** Default MCP bind host — loopback only by default. */\nexport const DEFAULT_BIND_HOST = \"127.0.0.1\";\n\n/** Env var name to opt in to unauthenticated LAN binding. */\nexport const TANDEM_ALLOW_UNAUTHENTICATED_LAN_ENV = \"TANDEM_ALLOW_UNAUTHENTICATED_LAN\";\n\n/**\n * Env var name to suppress the integration-wizard first-run auto-open.\n * Used by the E2E harness (Playwright) so wizard modals don't cover\n * unrelated editor surfaces. The integration-wizard.spec.ts test itself\n * does NOT set this — it exercises the manual-reopen affordance.\n */\nexport const TANDEM_DISABLE_FIRST_RUN_WIZARD_ENV = \"TANDEM_DISABLE_FIRST_RUN_WIZARD\";\n\n/** Tauri WebView origin hostname — must be accepted alongside localhost. */\nexport const TAURI_HOSTNAME = \"tauri.localhost\";\n\n/**\n * Linux Tauri WebView origin. On Linux the WebView serves content from the\n * custom `tauri://` scheme (origin `tauri://localhost`), not `http://tauri.localhost`\n * as on Windows. The `tauri://` scheme is non-network and served only by the\n * Tauri runtime's own WebView, so no remote page can forge this origin — it is\n * the unforgeable Linux analog of the trusted Windows origin. Match the exact\n * string only (never a `tauri://*` wildcard).\n */\nexport const TAURI_LINUX_ORIGIN = \"tauri://localhost\";\n\n// Zoom persistence (Tauri desktop)\nexport const ZOOM_STORAGE_KEY = \"tandem:zoomLevel\";\nexport const ZOOM_MIN = 0.5;\nexport const ZOOM_MAX = 2.0;\nexport const ZOOM_DEFAULT = 1.0;\n","import { execFileSync, execSync } from \"child_process\";\nimport envPaths from \"env-paths\";\nimport net from \"net\";\nimport path from \"path\";\n\n/**\n * Resolve the Tandem app-data root directory. `TANDEM_APP_DATA_DIR` overrides\n * the `env-paths` default. Not memoised so tests can swap tempdirs mid-run.\n */\nexport function resolveAppDataDir(): string {\n const envOverride = process.env.TANDEM_APP_DATA_DIR;\n if (envOverride && envOverride.length > 0) return envOverride;\n return envPaths(\"tandem\", { suffix: \"\" }).data;\n}\n\nconst APP_DATA_DIR = resolveAppDataDir();\n\n/** Platform-appropriate session storage directory. */\nexport const SESSION_DIR = path.join(APP_DATA_DIR, \"sessions\");\n\n/** Path to the file tracking the last version the user ran. */\nexport const LAST_SEEN_VERSION_FILE = path.join(APP_DATA_DIR, \"last-seen-version\");\n\n/**\n * Kill any process currently listening on the given TCP port.\n * Best-effort — swallows all errors so startup always proceeds.\n */\nexport function freePort(port: number): void {\n try {\n if (process.platform === \"win32\") {\n freePortWindows(port);\n } else {\n freePortUnix(port);\n }\n } catch (err) {\n console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);\n }\n}\n\n/**\n * Poll until a TCP port is available for binding.\n * Replaces the fixed 300ms sleep after freePort() — the OS may need\n * longer to release a killed process's socket (especially on Windows).\n */\nexport async function waitForPort(port: number, timeoutMs = 5000): Promise<void> {\n const start = Date.now();\n while (Date.now() - start < timeoutMs) {\n if (await tryBind(port)) return;\n await new Promise((r) => setTimeout(r, 200));\n }\n throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);\n}\n\n/** Attempt to bind a port and immediately release it. Returns true if available. */\nfunction tryBind(port: number): Promise<boolean> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer();\n srv.once(\"error\", (err: NodeJS.ErrnoException) => {\n srv.close(() => {\n if (err.code === \"EADDRINUSE\") {\n resolve(false);\n } else {\n reject(err); // EACCES, etc. — don't mask as \"port in use\"\n }\n });\n });\n srv.listen(port, \"127.0.0.1\", () => {\n srv.close(() => resolve(true));\n });\n });\n}\n\n/** Parse PIDs from lsof output (one PID per line). */\nexport function parseLsofPids(output: string): number[] {\n return output\n .trim()\n .split(\"\\n\")\n .map((line) => parseInt(line.trim(), 10))\n .filter((pid) => Number.isFinite(pid) && pid > 0);\n}\n\n/** Parse a PID from ss output (e.g. `pid=1234`). */\nexport function parseSsPid(output: string): number | null {\n const match = output.match(/pid=(\\d+)/);\n return match ? parseInt(match[1], 10) : null;\n}\n\nfunction freePortWindows(port: number): void {\n const out = execSync(`netstat -ano | findstr \":${port}.*LISTENING\"`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n const pid = out.trim().split(/\\s+/).at(-1);\n if (pid && /^\\d+$/.test(pid)) {\n try {\n const killOut = execFileSync(\"taskkill\", [\"/PID\", pid, \"/F\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n // Some taskkill paths exit 0 with empty stdout — avoid a dangling\n // \": \" in the log when there's no message to append.\n const trimmed = killOut.trim();\n console.error(\n `[Tandem] Killed stale PID ${pid} holding port ${port}${trimmed ? `: ${trimmed}` : \"\"}`,\n );\n } catch (err) {\n // Surface taskkill failure (permission denied, cross-session, race) so a\n // subsequent port-bind EADDRINUSE is diagnosable. The prior `stdio: \"ignore\"`\n // masked these and left the user with a silent \"Disconnected\" state.\n const stderr =\n err && typeof err === \"object\" && \"stderr\" in err\n ? String((err as { stderr: unknown }).stderr ?? \"\")\n : \"\";\n const message = err instanceof Error ? err.message : String(err);\n console.error(\n `[Tandem] taskkill failed for PID ${pid} on port ${port}: ${message}${\n stderr ? ` — stderr: ${stderr.trim()}` : \"\"\n }`,\n );\n }\n }\n}\n\nfunction freePortUnix(port: number): void {\n let pids: number[] = [];\n\n try {\n const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n pids = parseLsofPids(out);\n } catch {\n // lsof not available — try ss (Linux)\n try {\n const out = execSync(`ss -tlnp sport = :${port}`, {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"ignore\"],\n });\n const pid = parseSsPid(out);\n if (pid) pids = [pid];\n } catch {\n // ss also unavailable — give up\n }\n }\n\n for (const pid of pids) {\n try {\n process.kill(pid, \"SIGKILL\");\n console.error(`[Tandem] Killed stale PID ${pid} holding port ${port}`);\n } catch {\n // Process already gone\n }\n }\n}\n","/**\n * Windows DACL hardening for files that contain bearer tokens (`~/.claude.json`\n * and similar). Every function in this module is a no-op on non-Windows\n * platforms — POSIX paths use `chmod 0o600` instead.\n *\n * Security contract:\n * - `icacls`, `whoami`, and `powershell` are always invoked via `execFile`\n * with an argv array, never via a shell. Paths flow from `homedir()`\n * which is user-controlled via `HOME`/`USERPROFILE`; any shell-style\n * invocation is a command-injection bug.\n * - Broad-principal detection (`assertNoBroadAce`) reads the file's SDDL\n * via PowerShell `(Get-Acl).Sddl`. SDDL uses locale-independent 2-letter\n * shortcuts for well-known SIDs (`WD`=Everyone, `AU`=Authenticated\n * Users, `BU`=BUILTIN\\Users). Parsing `icacls` default output instead\n * would false-negative on non-English Windows (where the resolver\n * returns \"Utilisateurs\", \"Benutzer\", etc.) and on partial-success\n * exit-0 cases where icacls's \"Failed processing N files\" summary is\n * also localised.\n * - `setRestrictiveAcl` grants by **SID** (resolved once per process via\n * `whoami /user`), not by `process.env.USERNAME`. USERNAME is\n * in-process spoofable; a poisoned USERNAME could direct the grant at\n * a different local user whose name happens to collide, or contain\n * characters icacls parses unexpectedly (`:`, `/`, embedded UPNs).\n * - `setRestrictiveAcl` calls `/inheritance:r` THEN `/grant:r`. Without\n * `:r` on both flags, inherited ACEs from the parent dir survive and\n * `/grant` adds the user as an additional principal instead of\n * replacing the ACL.\n * - The tempfile self-verify (`assertNoBroadAce` at the end of\n * `setRestrictiveAcl`) is load-bearing — icacls is documented to exit\n * 0 on partial failure (silent no-op). The SDDL re-read is the only\n * trustworthy signal that the DACL is actually correct.\n * - PowerShell paths are passed via the `TANDEM_ACL_PATH` env var, not\n * interpolated into the script string. This avoids every quoting /\n * escaping pitfall for paths that contain spaces, apostrophes, or\n * backticks.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { join } from \"node:path\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\n\n/**\n * Resolve a Windows system binary by absolute path under `%SystemRoot%`.\n * Bypasses `PATH` so a git-bash / MSYS / Cygwin shadow (e.g. their own\n * `whoami` that doesn't understand Windows flags) can't intercept us.\n * `icacls` and `whoami` both live in `System32`.\n */\nfunction systemBin(name: string): string {\n return join(process.env.SystemRoot ?? \"C:\\\\Windows\", \"System32\", name);\n}\n\n/**\n * Run a PowerShell script, preferring PowerShell 7 (`pwsh.exe`) and falling\n * back to Windows PowerShell 5.1 (`powershell.exe`). pwsh is more reliable —\n * 5.1's auto-module-load has been observed to fail intermittently for\n * `Microsoft.PowerShell.Security` in CI sandboxes and on some user setups.\n *\n * Fallback is gated on `ENOENT` only. A spawn error of `EACCES` or `EPERM`\n * almost always reflects an AppLocker / WDAC policy denial — silently\n * routing around it via the legacy shell is the wrong defence posture\n * (admin's policy is a real security boundary). The original error is\n * preserved as `cause` on the fallback's failure for log forensics.\n */\nasync function runPowerShell(script: string, env: NodeJS.ProcessEnv): Promise<{ stdout: string }> {\n const args = [\"-NoProfile\", \"-NonInteractive\", \"-Command\", script];\n try {\n return await execFileAsync(\"pwsh.exe\", args, { env });\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code !== \"ENOENT\") throw err;\n try {\n return await execFileAsync(\"powershell.exe\", args, { env });\n } catch (fallbackErr) {\n throw new Error(\n `runPowerShell: both pwsh.exe and powershell.exe failed (pwsh: ${(err as Error).message})`,\n { cause: fallbackErr },\n );\n }\n }\n}\n\n/**\n * SDDL ACE-string fragments that flag a broad-principal grant. These\n * shortcuts are locale-independent — they appear verbatim in the SDDL\n * regardless of Windows display language.\n *\n * Each pattern matches the principal-SID position of an SDDL ACE,\n * which is the last token before the closing `)`. Example ACE:\n * `(A;;FA;;;WD)` ← Everyone has Full Access\n *\n * Reference: https://learn.microsoft.com/windows/win32/secauthz/sid-strings\n */\nconst BROAD_SDDL_FRAGMENTS = [\n \";WD)\", // Everyone (S-1-1-0)\n \";AU)\", // Authenticated Users (S-1-5-11)\n \";BU)\", // BUILTIN\\Users (S-1-5-32-545)\n] as const;\n\n/**\n * Cached SID of the current Windows user. `whoami /user` is the cheapest\n * locale-independent way to get this. Resolved once per process; cleared\n * to `null` only on test reset.\n */\nlet cachedCurrentUserSid: string | null = null;\n\n/** Reset for tests only. Production callers MUST NOT depend on resetting. */\nexport function _resetCurrentUserSidForTests(): void {\n cachedCurrentUserSid = null;\n}\n\n/**\n * Resolve the SID of the current user via `whoami /user /fo csv /nh`. CSV\n * output is locale-independent and parses cleanly without PowerShell.\n */\nasync function getCurrentUserSid(): Promise<string> {\n if (cachedCurrentUserSid !== null) return cachedCurrentUserSid;\n const { stdout } = await execFileAsync(systemBin(\"whoami.exe\"), [\"/user\", \"/fo\", \"csv\", \"/nh\"]);\n // CSV row format: `\"DOMAIN\\user\",\"S-1-5-21-...\"` — extract the second column.\n const match = stdout.match(/\"(S-[\\d-]+)\"\\s*$/m);\n if (!match) {\n throw new Error(`getCurrentUserSid: could not parse SID from whoami output: ${stdout.trim()}`);\n }\n cachedCurrentUserSid = match[1];\n return cachedCurrentUserSid;\n}\n\n/**\n * Set a restrictive DACL on `path`: break inheritance, then grant Full\n * Control to the current user (by SID, not name) only. On non-Windows,\n * no-op.\n *\n * Includes a post-set SDDL re-verify on `path` because icacls is\n * documented to exit 0 even when \"Failed processing N files\" — the SDDL\n * read is the only trustworthy signal that the DACL is actually correct.\n *\n * @throws if any step (SID resolve, icacls, verify) fails.\n */\nexport async function setRestrictiveAcl(path: string): Promise<void> {\n if (process.platform !== \"win32\") return;\n\n const sid = await getCurrentUserSid();\n\n // icacls processes flags left-to-right in one invocation:\n // /inheritance:r — remove ALL inherited entries (`:d` would copy them\n // as explicit, defeating the purpose)\n // /grant:r — replace any existing explicit ACE for the user\n // rather than ADD a new one (no `:r` → duplicates\n // accumulate over repeated runs).\n // *<SID>:F — grant Full Control by SID. The `*` prefix tells\n // icacls to interpret the principal as a raw SID\n // rather than a name to look up.\n try {\n await execFileAsync(systemBin(\"icacls.exe\"), [path, \"/inheritance:r\", \"/grant:r\", `*${sid}:F`]);\n } catch (err) {\n throw new Error(`setRestrictiveAcl: icacls failed on ${path}: ${(err as Error).message}`, {\n cause: err,\n });\n }\n\n await assertNoBroadAce(path);\n}\n\n/**\n * Read the SDDL of `path` and assert no well-known broad principal is\n * granted access. Uses PowerShell `Get-Acl` so the principal check works\n * regardless of Windows display language.\n *\n * @throws if any broad-principal SDDL shortcut appears in the file's ACL.\n */\nexport async function assertNoBroadAce(path: string): Promise<void> {\n if (process.platform !== \"win32\") return;\n\n // `-LiteralPath` ensures wildcards in the path are not expanded. The\n // explicit `Import-Module` is defensive — `Get-Acl` lives in\n // `Microsoft.PowerShell.Security` which usually auto-loads, but the\n // auto-load fails in some sandboxed shells (e.g. CI runners with\n // restricted module paths).\n const script =\n \"Import-Module Microsoft.PowerShell.Security; (Get-Acl -LiteralPath $env:TANDEM_ACL_PATH).Sddl\";\n\n const { stdout } = await runPowerShell(script, {\n ...process.env,\n TANDEM_ACL_PATH: path,\n });\n\n const sddl = stdout.trim();\n for (const fragment of BROAD_SDDL_FRAGMENTS) {\n if (sddl.includes(fragment)) {\n throw new Error(\n `assertNoBroadAce: ${path} has a broad-principal ACE (SDDL fragment ${fragment}). SDDL:\\n${sddl}`,\n );\n }\n }\n}\n","/**\n * Conditional backup of `~/.claude.json` before Tandem overwrites a\n * user-customised `mcpServers.tandem` entry.\n *\n * The threat we close: a user with a hand-crafted tandem entry pointing\n * at a non-default port, a custom URL, or extra fields runs `tandem\n * setup` (or the auto-launcher) and Tandem replaces the entry with the\n * default `http://127.0.0.1:3479/mcp` shape. The user's customisation\n * is silently destroyed.\n *\n * The backup gives them a recovery path. We only write a backup when\n * the existing entry is **non-default** — token-rotation and fresh-\n * install runs would otherwise generate backup churn that buries the\n * one backup the user actually needs.\n *\n * Storage layout: `${appDataDir}/.backups/claude-json-YYYYMMDD-HHMMSS-\n * ${UUID8}.json`. Dir mode 0o700, file mode 0o600. The UUID suffix\n * defeats predictable-path symlink attacks; `wx` (exclusive create)\n * is the second layer.\n *\n * Atomicity invariant: `writeBackup` MUST complete (or throw) before\n * the caller invokes `atomicWrite` on the destination. If the backup\n * write fails, the destination MUST NOT be touched. `applyConfig`\n * sequences this contract explicitly.\n *\n * Cross-platform note: on Windows, `wx` + `mode: 0o600` doesn't honour\n * POSIX modes, so we apply `setRestrictiveAcl` from `acl-win.ts` to the\n * backup file after write. The same SDDL contract that protects\n * `~/.claude.json` itself now also protects the backup copy.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { open, readdir, rm } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { setRestrictiveAcl } from \"./acl-win.js\";\n\n/** Directory name (under appDataDir) for non-default-config backups. */\nconst BACKUP_DIR_NAME = \".backups\";\n\n/** Filename prefix — present in every backup we create. */\nconst BACKUP_PREFIX = \"claude-json-\";\n\n/** Filename suffix — present in every backup we create. */\nconst BACKUP_SUFFIX = \".json\";\n\n/**\n * Keep at most this many backups. Older ones are pruned on every write\n * and on every server startup (covers crash-mid-prune cases).\n */\nexport const MAX_BACKUPS = 3;\n\n/**\n * Resolve the backup directory under `appDataDir`. Callers MUST pass the\n * dir as a parameter (not read `resolveAppDataDir()` internally) so tests\n * can inject a tmpdir without env-var stubbing.\n */\nexport function backupDir(appDataDir: string): string {\n return join(appDataDir, BACKUP_DIR_NAME);\n}\n\n/**\n * Format a timestamp as `YYYYMMDD-HHMMSS` in the local timezone. Local\n * time is what the user sees in their file manager — useful for\n * forensics. Exported for `file-io/doc-backup.ts`'s snapshot filenames.\n */\nexport function formatTimestamp(d: Date): string {\n const pad = (n: number): string => String(n).padStart(2, \"0\");\n return (\n `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-` +\n `${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`\n );\n}\n\n/**\n * Build a backup filename. UUID suffix defeats predictable-path attacks\n * where an attacker pre-creates a symlink at the predicted name.\n */\nexport function backupFilename(now: Date = new Date()): string {\n const ts = formatTimestamp(now);\n const uuid8 = randomUUID().slice(0, 8);\n return `${BACKUP_PREFIX}${ts}-${uuid8}${BACKUP_SUFFIX}`;\n}\n\n/**\n * Write `content` as a backup under `backupDir(appDataDir)`. Caller\n * passes the directory (already validated via `assertPathSafe` and\n * created with mode 0o700) and the bytes to write.\n *\n * Returns the full path of the written backup.\n *\n * Atomicity contract: this function either completes successfully and\n * the backup is on disk, or it throws and the caller MUST NOT proceed\n * with the rewrite. The atomic-failure-leaves-original-intact guarantee\n * depends on this.\n */\nexport async function writeBackup(dir: string, content: Buffer): Promise<string> {\n const backupPath = join(dir, backupFilename());\n\n // `wx` is exclusive-create: fails if the path already exists (UUID\n // collision is astronomically rare but a symlinked predictable path\n // is the real concern). `0o600` is honoured on POSIX; on Windows\n // we apply setRestrictiveAcl below.\n const fd = await open(backupPath, \"wx\", 0o600);\n let writeFailed = false;\n try {\n try {\n await fd.write(content);\n } catch (writeErr) {\n writeFailed = true;\n throw writeErr;\n }\n } finally {\n await fd.close();\n if (writeFailed) {\n // Remove the partial/zero-byte file so it doesn't count against\n // MAX_BACKUPS or mislead a forensic read. Cleanup errors are\n // swallowed — the write failure is what the caller needs to see.\n await rm(backupPath, { force: true }).catch(() => {});\n }\n }\n\n if (process.platform === \"win32\") {\n try {\n await setRestrictiveAcl(backupPath);\n } catch (aclErr) {\n // ACL failure leaves a bearer-token-bearing file on disk at the\n // dir-inherited permissions. Remove it — the caller treats the\n // throw as \"abort\", so an orphan would be invisible.\n await rm(backupPath, { force: true }).catch(() => {});\n throw aclErr;\n }\n }\n\n return backupPath;\n}\n\n/**\n * List backups currently in `dir`, newest first. Files are matched by\n * the supplied prefix/suffix (defaults match the `.claude.json` backup\n * scheme) so stray non-backup files in the dir aren't touched.\n *\n * The prefix/suffix parameters let a second caller (the broken-\n * integrations.json backup sweep in `storage.ts`) share this\n * implementation. Both call sites filter by a constant pair.\n *\n * Sorting is by filename, which works because the timestamp segment is\n * lexicographically monotonic (`YYYYMMDD-HHMMSS` or `Date.now()`). The\n * UUID suffix is a tiebreaker within the same millisecond.\n */\nexport async function listBackups(\n dir: string,\n prefix: string = BACKUP_PREFIX,\n suffix: string = BACKUP_SUFFIX,\n): Promise<string[]> {\n let entries: string[];\n try {\n entries = await readdir(dir);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return [];\n throw err;\n }\n return entries\n .filter((e) => e.startsWith(prefix) && e.endsWith(suffix))\n .sort()\n .reverse();\n}\n\n/**\n * Delete backups beyond the `max` newest. Returns the list of paths\n * removed (best-effort — entries that failed to remove are still\n * reported in the return list and logged via `console.error`, but the\n * loop never throws).\n *\n * The aggregate-failure shape exists because this runs at server\n * startup: an AV-locked file on Windows or a transient EACCES MUST NOT\n * abandon the rest of the sweep. The previous shape threw on the first\n * `rm` failure and silently skipped the rest.\n *\n * Defaults match the `claude-json-...json` scheme; the broken-\n * integrations sweep passes its own constants.\n */\nexport async function pruneOldBackups(\n dir: string,\n prefix: string = BACKUP_PREFIX,\n suffix: string = BACKUP_SUFFIX,\n max: number = MAX_BACKUPS,\n): Promise<string[]> {\n const all = await listBackups(dir, prefix, suffix);\n const toRemove = all.slice(max);\n const failures: Array<{ path: string; err: unknown }> = [];\n for (const name of toRemove) {\n const fullPath = join(dir, name);\n try {\n await rm(fullPath, { force: true });\n } catch (err) {\n failures.push({ path: fullPath, err });\n }\n }\n if (failures.length > 0) {\n const summary = failures\n .map((f) => `${f.path}: ${f.err instanceof Error ? f.err.message : String(f.err)}`)\n .join(\"; \");\n console.error(\n `[tandem] backup sweep: ${failures.length} entries could not be removed (${summary})`,\n );\n }\n return toRemove.map((name) => join(dir, name));\n}\n\n/**\n * Server-startup hook. Idempotent. Bounded — sweeps only the backup dir.\n *\n * Callers pass `appDataDir` so tests can inject a tmpdir; the production\n * call site uses `resolveAppDataDir()`.\n */\nexport async function sweepBackupsOnStartup(appDataDir: string): Promise<void> {\n await pruneOldBackups(backupDir(appDataDir));\n}\n\n/**\n * Decide whether overwriting `existing` with `newEntry` could lose\n * information the user might care about. The check is content-based,\n * not shape-based: shape-only checks miss the case where a user\n * hand-crafted `headers.Authorization` with a custom Bearer token —\n * Tandem's overwrite would silently destroy it because the entry's\n * SHAPE matches the default (URL identical, only known keys present).\n *\n * Returns `true` when:\n * - existing entry exists, AND\n * - existing != newEntry under canonical-JSON equality.\n *\n * This trades off: token rotation now triggers a backup (the bytes\n * change). That's acceptable churn — `MAX_BACKUPS=3` caps disk usage\n * and the user gets a strict history of recent token-bearing configs\n * as a side benefit. The alternative (shape-only check) had a\n * security review-flagged silent-destruction gap that we cannot close\n * without state we don't have (Tandem can't distinguish \"token Tandem\n * itself wrote 5 minutes ago\" from \"token the user added by hand\").\n */\nexport function shouldBackup(existing: unknown, newEntry: unknown): boolean {\n if (existing == null) return false;\n return canonicalJson(existing) !== canonicalJson(newEntry);\n}\n\n/**\n * Deterministic JSON-string with sorted object keys. Defeats key-order\n * false-positives (`{a:1,b:2}` vs `{b:2,a:1}` would otherwise\n * stringify differently).\n */\nfunction canonicalJson(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value);\n if (Array.isArray(value)) return `[${value.map(canonicalJson).join(\",\")}]`;\n const keys = Object.keys(value as Record<string, unknown>).sort();\n const parts = keys.map(\n (k) => `${JSON.stringify(k)}:${canonicalJson((value as Record<string, unknown>)[k])}`,\n );\n return `{${parts.join(\",\")}}`;\n}\n","/**\n * Library helpers for applying Tandem's MCP entries to a Claude (or other\n * MCP-capable) client's config file. Shared by the wizard's apply route,\n * `tandem setup`, and `tandem rotate-token`.\n *\n * Hardening invariants enforced here (callers MUST NOT bypass):\n * - `assertPathSafe()` rejects symlinks and any path whose realpath falls\n * outside `[homedir(), tmpdir()]`. Prevents a compromised account from\n * replacing `~/.claude.json` with a symlink to `/etc/shadow` or a\n * Windows junction redirecting `~/.claude` into a protected dir.\n * - MSIX detection is anchored to `/^Claude_[A-Za-z0-9]+$/` and the\n * `%LOCALAPPDATA%` realpath must resolve under home (defeats an\n * attacker who controls env).\n * - Malformed JSON backups land in `${appDataDir}/.broken-backups/` at\n * `0o600` rather than next to `~/.claude.json` (which may inherit\n * world-readable perms and would leak co-tenant API keys).\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport {\n chmodSync,\n existsSync,\n constants as fsConstants,\n lstatSync,\n mkdirSync,\n readdirSync,\n readFileSync,\n realpathSync,\n statSync,\n} from \"node:fs\";\nimport {\n chmod,\n copyFile,\n mkdir,\n open,\n readFile,\n rename,\n unlink,\n writeFile,\n} from \"node:fs/promises\";\nimport { homedir, tmpdir } from \"node:os\";\nimport { basename, delimiter, dirname, join, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport { SKILL_CONTENT } from \"../../cli/skill-content.js\";\nimport { DEFAULT_MCP_PORT } from \"../../shared/constants.js\";\nimport type { ClaudeCliPresence } from \"../../shared/integrations/contract.js\";\nimport { resolveAppDataDir } from \"../platform.js\";\nimport { setRestrictiveAcl } from \"./acl-win.js\";\nimport { backupDir, pruneOldBackups, shouldBackup, writeBackup } from \"./backup.js\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n\n// Paths are anchored to the package root. The CLI bundle (`dist/cli/`) and\n// the server bundle (`dist/server/`) both sit one level below `dist/`, so\n// `../..` resolves to the package root in either bundle. In tsx dev,\n// `__dirname` is `src/server/integrations/`, so we need `../../..`.\nconst PACKAGE_ROOT = (() => {\n const fromBundle = resolve(__dirname, \"../..\");\n if (existsSync(join(fromBundle, \"package.json\"))) return fromBundle;\n return resolve(__dirname, \"../../..\");\n})();\n/**\n * Resolve the bundled channel-shim entry registered as Claude Code's push\n * transport.\n *\n * On a Tauri **desktop bundle** the `PACKAGE_ROOT` derivation above points at\n * the sidecar's own location, NOT the app's resource dir where `dist/channel/`\n * is bundled — so the computed path doesn't exist and `shouldRegisterChannelShim`\n * skips registration. The Tauri shell injects `TANDEM_CHANNEL_DIST` with the\n * resource-dir-resolved path on sidecar spawn (`src-tauri/src/lib.rs`); prefer\n * it when it points at an existing file. A bogus/missing injected path is\n * ignored so a broken injection degrades to the computed path rather than\n * registering an unresolvable MCP command. In npm-global / tsx-dev there is no\n * env var and the `PACKAGE_ROOT` derivation is correct.\n *\n * This replaces the old `/api/setup` startup round-trip, which used to be the\n * only path carrying the Tauri-resolved channel path (#477 PR 3c-ii-c).\n *\n * No UNC/traversal validation is applied to the injected path because the sole\n * setter is `resolve_channel_dist()` in `src-tauri/src/lib.rs` — trusted\n * same-user Rust code that emits `resource_dir/dist/channel/index.js` or\n * `cwd/dist/channel/index.js`. The path cannot arrive via any HTTP route.\n *\n * Exported (with `exists` injectable) so the env-precedence can be unit-tested\n * without re-importing the module to re-run the const initializer.\n */\nexport function resolveChannelDist(\n env: NodeJS.ProcessEnv = process.env,\n exists: (p: string) => boolean = existsSync,\n): string {\n const injected = env.TANDEM_CHANNEL_DIST;\n if (injected) {\n if (exists(injected)) return injected;\n // Set-but-missing: a broken desktop injection would otherwise degrade push\n // → polling with no trace. Behavior is unchanged (we still fall back); this\n // only leaves a diagnostic breadcrumb on stderr (Critical Rule #3-safe).\n console.error(\n `[Tandem] TANDEM_CHANNEL_DIST set to \"${injected}\" but no file there — ` +\n \"falling back to bundled path; real-time push may be unavailable.\",\n );\n }\n return resolve(PACKAGE_ROOT, \"dist/channel/index.js\");\n}\n\nconst CHANNEL_DIST = resolveChannelDist();\n\nconst MCP_URL = `http://127.0.0.1:${DEFAULT_MCP_PORT}`;\n\n// Injected by tsup at build time. apply.ts is bundled into BOTH `dist/cli`\n// (carries `__TANDEM_VERSION__`) and `dist/server` (carries `__APP_VERSION__`),\n// and neither define exists in both bundles. esbuild leaves a *bare* reference\n// to an absent define as a free global → runtime ReferenceError (no build\n// error), so each MUST be `typeof`-guarded — mirroring `APP_VERSION` in\n// `src/server/mcp/server.ts`. The disk fallback only runs in tsx dev / vitest,\n// where `__dirname` (src/server/integrations) resolves the repo-root\n// package.json via the same two candidates server.ts uses.\ndeclare const __TANDEM_VERSION__: string;\ndeclare const __APP_VERSION__: string;\nexport function resolveCliVersion(): string {\n if (typeof __TANDEM_VERSION__ !== \"undefined\") return __TANDEM_VERSION__;\n if (typeof __APP_VERSION__ !== \"undefined\") return __APP_VERSION__;\n for (const rel of [\"../../package.json\", \"../../../package.json\"]) {\n try {\n const pkg = JSON.parse(readFileSync(resolve(__dirname, rel), \"utf8\")) as { version: string };\n if (pkg.version) return pkg.version;\n } catch {\n // try next candidate\n }\n }\n console.error(\"[Tandem] Could not resolve CLI version for npx pin; using unpinned fallback\");\n return \"0.0.0-unknown\";\n}\n\n// Pinning the npx spec to an exact version forces `npm exec` to fetch/run the\n// correct published `tandem-editor` instead of reusing whatever (possibly\n// stale, pre-`mcp-stdio`) global copy is installed — the root cause of the\n// \"Server disconnected\"/\"Could not attach\" failure. See the plan/ADR notes.\nconst CLI_VERSION = resolveCliVersion();\n\n/**\n * Refuse to read a config larger than 5 MiB. The realistic `.claude.json` is\n * single-digit kilobytes; anything beyond that is either accidental corruption\n * (log files dropped in) or a deliberate DoS aimed at making the wizard's\n * read-parse-rewrite path exhaust memory. The cap is generous enough that no\n * legitimate user hits it.\n */\nconst MAX_CONFIG_BYTES = 5 * 1024 * 1024;\n\nexport interface McpEntry {\n type?: \"http\";\n url?: string;\n command?: string;\n args?: string[];\n env?: Record<string, string>;\n headers?: Record<string, string>;\n}\n\nexport interface McpEntries {\n tandem: McpEntry;\n \"tandem-channel\"?: McpEntry;\n}\n\n/** MCP entry keys Tandem owns and may remove from an existing config. */\nexport type RemovableEntry = \"tandem\" | \"tandem-channel\";\n\n/**\n * Explicit apply intent. Callers state both `create` and `remove` so\n * absence of a key never implies removal — that distinction matters when\n * the wizard's user-confirmed diff differs from CLI's stale-cleanup\n * default. `applyOpsForCli` builds the CLI-shaped value.\n */\nexport interface ApplyOps {\n /** Entries to create or update in `mcpServers`. */\n create: McpEntries;\n /** Entries to remove if present in `mcpServers`. */\n remove: RemovableEntry[];\n /**\n * Optional callback invoked with the backup path when `applyConfig`\n * preserves a non-default existing `mcpServers.tandem` entry before\n * overwriting it. CLI callers pass a `console.error` printer; the\n * wizard pushes the path onto its structured apply response so the\n * user sees a recovery hint. Callback is NOT invoked on fresh-install\n * or pure token-rotation runs (those don't trigger a backup).\n */\n onBackup?: (backupPath: string) => void;\n}\n\n/**\n * Construct `ApplyOps` with the legacy \"remove tandem-channel unless using\n * the shim\" semantics. CLI callers (`tandem setup`, `tandem rotate-token`)\n * use this to preserve back-compat without re-implementing the diff. The\n * wizard never calls this — it builds `ApplyOps` from the user's diff\n * confirmation.\n *\n * Options object intentional: the boolean-flag `withChannelShim` is the\n * only state, but exposing it as a named field at every call site avoids\n * the boolean-trap antipattern (the previous positional signature read as\n * `applyOpsForCli(entries, true)` with no clue what the boolean meant).\n */\nexport function applyOpsForCli(create: McpEntries, opts: { withChannelShim: boolean }): ApplyOps {\n return {\n create,\n remove: opts.withChannelShim ? [] : [\"tandem-channel\"],\n };\n}\n\n/** Error thrown when a target path fails realpath/symlink validation. */\nexport class PathRejectedError extends Error {\n override readonly name = \"PathRejectedError\";\n constructor(\n public readonly path: string,\n public readonly reason: \"symlink\" | \"outside-home\" | \"unreadable\",\n message: string,\n ) {\n super(message);\n }\n}\n\nexport interface BuildMcpEntriesOptions {\n /** Include the legacy stdio channel shim. Defaults to false — the plugin\n * monitor handles event push for modern installs. Users on older setups\n * can run `tandem setup --with-channel-shim` to preserve the shim. */\n withChannelShim?: boolean;\n nodeBinary?: string;\n /** Auth token to embed in HTTP entry headers and stdio shim env.\n * When omitted (first-run before token provisioned), headers/env are omitted\n * and backward compatibility is preserved. */\n token?: string;\n /** Target kind controls entry shape. Claude Code uses HTTP (direct);\n * Claude Desktop uses stdio (npx bridge) because Cowork sessions can\n * only surface stdio MCP servers. */\n targetKind?: TargetKind;\n}\n\nexport function buildMcpEntries(\n channelPath: string,\n opts: BuildMcpEntriesOptions = {},\n): McpEntries {\n const isDesktop = opts.targetKind === \"claude-desktop\";\n\n let tandemEntry: McpEntry;\n if (isDesktop) {\n const env: Record<string, string> = { TANDEM_URL: MCP_URL };\n if (opts.token) {\n env.TANDEM_AUTH_TOKEN = opts.token;\n }\n tandemEntry = {\n command: \"npx\",\n args: [\"-y\", `tandem-editor@${CLI_VERSION}`, \"mcp-stdio\"],\n env,\n };\n } else {\n tandemEntry = { type: \"http\", url: `${MCP_URL}/mcp` };\n if (opts.token) {\n tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };\n }\n }\n const entries: McpEntries = { tandem: tandemEntry };\n\n if (opts.withChannelShim) {\n const shimEnv: Record<string, string> = { TANDEM_URL: MCP_URL };\n if (opts.token) {\n shimEnv.TANDEM_AUTH_TOKEN = opts.token;\n }\n entries[\"tandem-channel\"] = {\n command: opts.nodeBinary ?? \"node\",\n args: [channelPath],\n env: shimEnv,\n };\n }\n return entries;\n}\n\nexport type TargetKind = \"claude-code\" | \"claude-desktop\";\n\nexport interface DetectedTarget {\n label: string;\n configPath: string;\n kind: TargetKind;\n}\n\n// Realpath of OS roots changes only across process restart (and tmpdir is\n// stable for test fixtures). Memoize so per-integration apply loops and\n// MSIX detection don't re-syscall for every call.\n//\n// Lifetime: process. Tandem doesn't drop privileges and never mutates HOME\n// mid-process; if a future caller does either, the cache is stale and must\n// be invalidated.\nconst DEFAULT_ROOTS_CACHE = new Map<string, string>();\nfunction realpathCached(p: string): string {\n const cached = DEFAULT_ROOTS_CACHE.get(p);\n if (cached !== undefined) return cached;\n try {\n const r = realpathSync(p);\n DEFAULT_ROOTS_CACHE.set(p, r);\n return r;\n } catch {\n return p;\n }\n}\n\n/**\n * Verify the target directory (a) is not a symlink/junction and (b) its\n * realpath resolves under one of the allowed roots. Throws\n * `PathRejectedError` otherwise.\n *\n * Threat model: prior compromised account replaces `~/.claude.json` with a\n * symlink to `/etc/shadow`; on Windows, a junction at `~/.claude`\n * redirecting to `C:\\Windows\\System32\\config\\` could let `mkdir(...\n * recursive)` walk into a protected dir. We reject both cases before any\n * read or write. The ancestor check is the closed-set boundary — any path\n * outside the allowed roots is refused even if it's not a symlink.\n *\n * Residual TOCTOU: the lstat-then-write window admits a same-uid attacker\n * who swaps a regular file for a symlink between check and write. Node's\n * `fs` doesn't expose `O_NOFOLLOW` / `openat`-style per-component\n * traversal, so the window is unavoidable without native bindings. The\n * realpath check shrinks the practical attack but doesn't close it.\n *\n * Defaults to `[homedir(), tmpdir()]`. Callers may nominate alternate\n * roots (e.g., test fixtures that operate inside a specific tmpdir).\n */\nexport function assertPathSafe(targetPath: string, opts: { allowedRoots?: string[] } = {}): void {\n const allowedRoots = (opts.allowedRoots ?? [homedir(), tmpdir()]).map(realpathCached);\n\n // `lstat` a path that may not exist yet (e.g., apply will create the\n // parent dir). Walk up until we find an existing ancestor and validate\n // there — if any walked-through component is a symlink, fail.\n let cursor = targetPath;\n let existing: string | null = null;\n while (true) {\n if (existsSync(cursor)) {\n const st = lstatSync(cursor);\n if (st.isSymbolicLink()) {\n throw new PathRejectedError(\n targetPath,\n \"symlink\",\n `Refusing to operate on symlinked path: ${cursor}`,\n );\n }\n existing = cursor;\n break;\n }\n const parent = dirname(cursor);\n if (parent === cursor) break; // reached filesystem root\n cursor = parent;\n }\n\n const resolved = existing ? realpathSync(existing) : targetPath;\n const ok = allowedRoots.some((root) => {\n if (resolved === root) return true;\n const normRoot = root.endsWith(sep) ? root : `${root}${sep}`;\n return resolved.startsWith(normRoot);\n });\n if (!ok) {\n throw new PathRejectedError(\n targetPath,\n \"outside-home\",\n `Refusing path outside allowed roots: realpath=${resolved}`,\n );\n }\n}\n\n/** MSIX package families that match Claude's installer. Anchored on both ends. */\nexport const MSIX_PACKAGE_PATTERN = /^Claude_[A-Za-z0-9]+$/;\n\nexport interface DetectOptions {\n homeOverride?: string;\n localAppDataOverride?: string;\n force?: boolean;\n}\n\nexport function detectTargets(opts: DetectOptions = {}): DetectedTarget[] {\n const home = opts.homeOverride ?? homedir();\n const targets: DetectedTarget[] = [];\n\n // Claude Code — cross-platform.\n // MCP servers are configured in ~/.claude.json under the \"mcpServers\" key.\n // Detect if the file exists OR if ~/.claude directory exists (Claude Code is installed).\n // With --force, always include regardless.\n const claudeCodeConfig = join(home, \".claude.json\");\n const claudeCodeDir = join(home, \".claude\");\n if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {\n targets.push({ label: \"Claude Code\", configPath: claudeCodeConfig, kind: \"claude-code\" });\n }\n\n // Claude Desktop — platform-specific.\n // Only detect if the config file already exists (user has launched Desktop at least once).\n // With --force, always include.\n let desktopConfig: string | null = null;\n if (process.platform === \"win32\") {\n const appdata = process.env.APPDATA ?? join(home, \"AppData\", \"Roaming\");\n desktopConfig = join(appdata, \"Claude\", \"claude_desktop_config.json\");\n } else if (process.platform === \"darwin\") {\n desktopConfig = join(\n home,\n \"Library\",\n \"Application Support\",\n \"Claude\",\n \"claude_desktop_config.json\",\n );\n } else {\n desktopConfig = join(home, \".config\", \"claude\", \"claude_desktop_config.json\");\n }\n\n if (desktopConfig && (opts.force || existsSync(desktopConfig))) {\n targets.push({ label: \"Claude Desktop\", configPath: desktopConfig, kind: \"claude-desktop\" });\n }\n\n // Claude Desktop (MSIX) — Windows only.\n // MSIX-packaged installs (Microsoft Store) redirect %APPDATA% to a per-package\n // LocalCache dir. The config lives under %LOCALAPPDATA%\\Packages\\Claude_*\\\n // LocalCache\\Roaming\\Claude\\. Multiple package families may exist.\n // Package name is constrained to `Claude_[A-Za-z0-9]+` so an attacker can't\n // smuggle a write target through a hand-crafted package directory like\n // `Claude_../../Windows/System32` (the path constructor would reject such\n // a name on most platforms but we belt-and-suspender).\n if (process.platform === \"win32\") {\n const localAppData =\n opts.localAppDataOverride ?? process.env.LOCALAPPDATA ?? join(home, \"AppData\", \"Local\");\n // Verify LOCALAPPDATA resolves under home. An attacker who controls env\n // could otherwise redirect us to write under any directory.\n try {\n assertPathSafe(localAppData, { allowedRoots: [home] });\n } catch {\n return targets;\n }\n const packagesDir = join(localAppData, \"Packages\");\n try {\n const entries = readdirSync(packagesDir);\n const matching = entries.filter((n) => MSIX_PACKAGE_PATTERN.test(n));\n for (const pkg of matching) {\n const msixConfig = join(\n packagesDir,\n pkg,\n \"LocalCache\",\n \"Roaming\",\n \"Claude\",\n \"claude_desktop_config.json\",\n );\n if (opts.force || existsSync(msixConfig)) {\n const suffix = matching.length > 1 ? ` (${pkg.slice(0, 12)}…)` : \"\";\n targets.push({\n label: `Claude Desktop MSIX${suffix}`,\n configPath: msixConfig,\n kind: \"claude-desktop\",\n });\n }\n }\n } catch {\n // %LOCALAPPDATA%\\Packages doesn't exist or isn't readable — not an MSIX install\n }\n }\n\n return targets;\n}\n\nexport interface DetectClaudeCliOptions {\n /** Override `homedir()` — tests anchor the native-location probe under a tmpdir. */\n homeOverride?: string;\n /** Override `process.env.PATH` — tests inject a controlled PATH. */\n pathOverride?: string;\n /** Override `process.platform` — tests exercise the win32 `.exe` branch. */\n platformOverride?: NodeJS.Platform;\n}\n\n/**\n * Probe whether the `claude` CLI binary is present, independent of any config\n * file. This is the **binary** detector; {@link detectTargets} is the separate\n * **config-presence** detector (it answers \"has Claude ever written a config\n * here?\", which stays true after an uninstall and is true on a machine that\n * only has Claude Desktop).\n *\n * Pure filesystem probe — deliberately no `execFile`/spawn (no shell-injection\n * surface, no hang on a wedged binary). Returns:\n * - `INSTALLED_ON_PATH` — `claude[.exe]` found on the server process's PATH.\n * - `INSTALLED_NOT_ON_PATH` — found only in `~/.local/bin` (the native\n * installer's target, which is typically NOT on the server's PATH at\n * install time → the usual immediately-post-install state).\n * - `NOT_INSTALLED` — neither.\n *\n * PATH wins over `~/.local/bin`: if it's on PATH it's usable right now, which\n * is the more useful signal for the wizard.\n */\nexport function detectClaudeCli(opts: DetectClaudeCliOptions = {}): ClaudeCliPresence {\n const platform = opts.platformOverride ?? process.platform;\n const home = opts.homeOverride ?? homedir();\n const binName = platform === \"win32\" ? \"claude.exe\" : \"claude\";\n\n // `delimiter` is platform-specific (`;` on win32, `:` elsewhere). When a\n // platformOverride disagrees with the host, the override is for test\n // ergonomics only — real callers never pass it, so host `delimiter` is fine.\n const pathVar = opts.pathOverride ?? process.env.PATH ?? \"\";\n for (const dir of pathVar.split(delimiter)) {\n if (dir.length === 0) continue;\n if (existsSync(join(dir, binName))) return \"INSTALLED_ON_PATH\";\n }\n\n // Native install location — same `~/.local/bin` on every platform per the\n // official installer's documented uninstall paths (Windows included).\n if (existsSync(join(home, \".local\", \"bin\", binName))) return \"INSTALLED_NOT_ON_PATH\";\n\n return \"NOT_INSTALLED\";\n}\n\n/**\n * Atomic write: write to a temp file in the SAME directory as the destination,\n * tighten its mode/DACL, then rename. Same-directory tempfile avoids EXDEV\n * errors when `%TEMP%` and `%APPDATA%` are on different drives.\n *\n * Sequence (security-load-bearing):\n * 1. Write tempfile. On Windows the tempfile inherits its parent dir's\n * DACL (`homedir()` is OS-restricted by default to user + SYSTEM +\n * Administrators); on POSIX it lands at `0o666 & ~umask` (typically\n * `0o644` — world-readable, hence step 2's chmod).\n * 2. Tighten on the tempfile:\n * - Windows: `setRestrictiveAcl` breaks inheritance and grants Full\n * Control to the current user's SID only, then self-verifies via\n * SDDL read. The verify is load-bearing — icacls exits 0 even when\n * \"Failed processing N files\".\n * - POSIX: `chmod(tmp, 0o600)` — user-only read/write.\n * 3. Rename tempfile to dest. The kernel preserves mode (POSIX) and DACL\n * (Windows `MoveFileEx`) across the rename, so the tightened\n * permissions survive into the destination.\n * 4. On cleanup failure after a write/ACL/rename error, the original\n * failure propagates with the cleanup error attached via `cause` so\n * operators can diagnose a leaked tempfile or dest from a single log\n * line rather than chasing a stray `console.error`.\n */\nasync function atomicWrite(content: string, dest: string): Promise<void> {\n const tmp = join(dirname(dest), `.tandem-setup-${randomUUID()}.tmp`);\n // mode at create, not chmod-after: on POSIX a plain writeFile lands at\n // 0o666 & ~umask, leaving sibling vendors' tokens world-readable until the\n // tighten below. Windows ignores mode (the ACL step is the protection).\n await writeFile(tmp, content, { encoding: \"utf-8\", mode: 0o600 });\n\n try {\n if (process.platform === \"win32\") {\n await setRestrictiveAcl(tmp);\n } else {\n await chmod(tmp, 0o600);\n }\n } catch (tightenErr) {\n await unlinkOrLeak(tmp, tightenErr);\n throw tightenErr;\n }\n\n try {\n await rename(tmp, dest);\n } catch (err) {\n // EXDEV: cross-device link — fall back to copy + delete. The copy\n // preserves the source mode on POSIX (file is created via copyFile's\n // default which honors the source's mode bits); on Windows the\n // destination DACL is recomputed from the dest dir, so we re-run\n // setRestrictiveAcl on the dest after the cross-device copy.\n if ((err as NodeJS.ErrnoException).code === \"EXDEV\") {\n await copyFile(tmp, dest);\n await unlinkOrLeak(tmp, err);\n if (process.platform === \"win32\") await setRestrictiveAcl(dest);\n else await chmod(dest, 0o600);\n } else {\n await unlinkOrLeak(tmp, err);\n throw err;\n }\n }\n}\n\n/**\n * Attempt to delete `path` after a write/ACL/rename failure. On cleanup\n * success we just return; on cleanup failure we attach the cleanup error\n * as `cause` to the original failure (mutating `originalErr`) so operators\n * see a single composite log line rather than a free-floating\n * `console.error`. Bearer-token-bearing files that fail cleanup MUST be\n * surfaced, not silently logged.\n */\nasync function unlinkOrLeak(path: string, originalErr: unknown): Promise<void> {\n try {\n await unlink(path);\n } catch (cleanupErr) {\n if (originalErr instanceof Error && originalErr.cause === undefined) {\n (originalErr as { cause?: unknown }).cause = cleanupErr;\n }\n console.error(\n ` Warning: could not remove ${path} after a previous failure: ${\n (cleanupErr as Error).message\n }`,\n );\n }\n}\n\n/**\n * Apply explicit create/remove operations to a Claude config file.\n *\n * **Security gates (run before any read or write):**\n * - `assertPathSafe(configPath)` — symlink / outside-home rejection.\n * - Malformed JSON is backed up under Tandem's data dir with mode `0o600`\n * (avoids leaking other vendors' API keys via a world-readable\n * `~/.claude.json.broken-<ts>` sibling).\n *\n * `applyConfig` writes both `ops.create` entries (merging into the existing\n * `mcpServers` object) and removes any key listed in `ops.remove`. Removal\n * is silent if the key was already absent. Callers state both intents\n * explicitly — no implicit \"absent key implies remove\".\n */\nexport async function applyConfig(configPath: string, ops: ApplyOps): Promise<void> {\n // Security gate: refuse symlinks, refuse paths outside home/tmpdir.\n assertPathSafe(configPath);\n\n // Size guard runs before any read — fail closed on oversized files. ENOENT\n // falls through so fresh-install (no .claude.json yet) stays the common path.\n try {\n const { size } = statSync(configPath);\n if (size > MAX_CONFIG_BYTES) {\n throw new Error(\n `${configPath} is ${size} bytes; refusing to read (cap: ${MAX_CONFIG_BYTES}).`,\n );\n }\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") throw err;\n }\n\n // Read existing config or start fresh — no existsSync guard needed.\n // ENOENT and malformed JSON start fresh; other errors (permissions, disk) propagate.\n let existing: { mcpServers?: Record<string, McpEntry> } = {};\n try {\n // Strip a leading UTF-8 BOM (``) before JSON.parse. Some editors\n // (legacy Windows tooling, certain VS Code configs) write `.claude.json`\n // with a BOM; without this strip, `JSON.parse` throws `SyntaxError`\n // and the file would be pushed into `.broken-backups/` as if it were\n // malformed. The BOM is encoded as the literal three bytes\n // `EF BB BF` which Node's \"utf-8\" decoder surfaces as a leading\n // U+FEFF code point.\n let raw = readFileSync(configPath, \"utf-8\");\n if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);\n const parsed: unknown = JSON.parse(raw);\n\n // Shape gate: the rewrite path spreads `existing.mcpServers` and\n // `existing` itself into the new config. If either is the wrong\n // shape, the spread produces a corrupted output (string-spread\n // yields `{0:'a',1:'b',...}`, array-spread yields numeric keys,\n // null-spread throws). Reject up-front so a legitimate-looking\n // config-shape mismatch never silently corrupts the user's file.\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(`${configPath} root is not a JSON object — refusing to rewrite`);\n }\n const maybeServers = (parsed as Record<string, unknown>).mcpServers;\n if (\n maybeServers !== undefined &&\n (maybeServers === null || typeof maybeServers !== \"object\" || Array.isArray(maybeServers))\n ) {\n throw new Error(`${configPath} mcpServers is not an object — refusing to rewrite`);\n }\n existing = parsed as { mcpServers?: Record<string, McpEntry> };\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n // File doesn't exist yet — start fresh\n } else if (err instanceof SyntaxError) {\n // Don't silently wipe the user's other mcpServers. Copy the malformed\n // file under Tandem's data dir (NOT next to ~/.claude.json — that\n // location may inherit world-readable perms and leak co-tenant API\n // keys via the backup). Mode 0o600 hardens against the same.\n const brokenBackupDir = join(resolveAppDataDir(), \".broken-backups\");\n // Validate against default roots [homedir(), tmpdir()] rather than\n // scoping to resolveAppDataDir() — the latter is tautological, and\n // an XDG_DATA_HOME-poisoning attacker can otherwise redirect the\n // backup target outside the home tree.\n assertPathSafe(brokenBackupDir);\n // mode: 0o700 on dir creation — the file mode is 0o600, but a\n // world-readable parent dir lists sibling filenames (older backups\n // carry other vendors' keys). Mode applies only when the dir is\n // newly created; existing dirs retain their mode.\n mkdirSync(brokenBackupDir, { recursive: true, mode: 0o700 });\n // randomUUID() in the path defeats path prediction by an attacker\n // who might pre-create a file at the predicted location and have\n // our mode-at-open inherit world-readable bits. `wx` (exclusive\n // create) below is the second layer.\n const backupPath = join(\n brokenBackupDir,\n `${basename(configPath)}.broken-${Date.now()}-${randomUUID()}`,\n );\n try {\n if (process.platform === \"win32\") {\n // Windows ignores the POSIX `mode` arg on mkdir, so harden the\n // dir with an explicit DACL BEFORE writing the backup file.\n // Mirrors the ordering invariant in\n // `storage.ts#backupBrokenFile`: dir-level ACL closes the\n // TOCTOU window that a per-file ACL would otherwise open\n // between copyFile and the ACL set. Fail loud — orphaning a\n // half-hardened dir is worse than aborting the backup\n // outright. The inner `catch (copyErr)` below surfaces a\n // named error and refuses to overwrite the malformed config.\n try {\n await setRestrictiveAcl(brokenBackupDir);\n } catch (aclErr) {\n throw new Error(\n `failed to apply restrictive ACL to broken-backups dir ${brokenBackupDir}: ${\n aclErr instanceof Error ? aclErr.message : String(aclErr)\n }`,\n { cause: aclErr },\n );\n }\n // Windows doesn't honor POSIX modes — fall back to plain copy.\n // The randomUUID-suffixed path makes collisions effectively\n // impossible. COPYFILE_EXCL refuses to overwrite an existing\n // target, defeating any predictable-path symlink/pre-create\n // attack the UUID suffix might still leave reachable. NOTE:\n // `setRestrictiveAcl` (acl-win.ts) calls `icacls /grant:r\n // *<SID>:F` without (OI)(CI) inheritance flags, so the new\n // file does NOT inherit the parent dir's SID-only ACE.\n // Instead it receives the DACL synthesized from the process\n // token's default (typically user + SYSTEM + Administrators),\n // which is narrow enough to prevent cross-tenant leak in\n // standard contexts. If broader access is observed, the dir\n // ACE should be made inheritable in acl-win.ts (this would\n // also benefit storage.ts which uses the same helper).\n await copyFile(configPath, backupPath, fsConstants.COPYFILE_EXCL);\n } else {\n // Open with mode 0o600 + `wx` so the file is created exclusively\n // at the right mode (no copyFile + chmodSync race window where\n // the backup was briefly 0o644 with another vendor's API keys\n // inside).\n const data = await readFile(configPath);\n const fd = await open(backupPath, \"wx\", 0o600);\n try {\n await fd.write(data);\n } finally {\n await fd.close();\n }\n }\n console.error(\n ` Warning: ${configPath} contains malformed JSON — backed up to ${backupPath}, replacing with fresh config`,\n );\n } catch (copyErr) {\n console.error(\n ` Warning: ${configPath} contains malformed JSON and backup failed (${\n copyErr instanceof Error ? copyErr.message : copyErr\n }) — refusing to overwrite. Fix the JSON manually and rerun 'tandem setup'.`,\n );\n throw copyErr;\n }\n } else {\n throw err; // Permission errors, disk errors, etc. should not be silently swallowed\n }\n }\n\n // Backup the existing config IFF the existing `mcpServers.tandem` entry\n // is non-default (user-customised URL or extra keys). Token rotation and\n // fresh-install runs would otherwise generate backup churn that buries\n // the one backup the user actually needs. The write happens BEFORE the\n // rewrite — atomicity invariant: if backup throws, the original is\n // untouched.\n const backupPath = await maybeBackupExistingConfig(configPath, existing, ops);\n if (backupPath && ops.onBackup) {\n // The onBackup callback is observational — a wizard push, a CLI\n // print, a telemetry hop. If the consumer throws, log it but DO\n // NOT abort the rewrite: doing so would orphan the just-written\n // backup file and leave the user's config un-applied, with no\n // user-visible explanation.\n try {\n ops.onBackup(backupPath);\n } catch (cbErr) {\n console.error(\n ` Warning: onBackup callback threw — continuing with rewrite: ${\n cbErr instanceof Error ? cbErr.message : cbErr\n }`,\n );\n }\n }\n\n const merged: Record<string, McpEntry> = {\n ...(existing.mcpServers ?? {}),\n ...ops.create,\n };\n // Explicit removals — silent if the key was already absent.\n for (const key of ops.remove) {\n // \"create wins\": never remove a key we're also creating this run. The\n // wizard builds `ApplyOps` directly from a user-confirmed diff and can\n // legitimately list the same key in both `create` and `remove`; without\n // this guard the channel shim we just registered would be deleted again.\n // Structural invariant for every flow — see #985.\n if (key in ops.create) continue;\n if (merged[key]) {\n console.error(` Note: removed mcpServers.${key} from ${configPath}`);\n }\n delete merged[key];\n }\n const updated = { ...existing, mcpServers: merged };\n\n await mkdir(dirname(configPath), { recursive: true });\n await atomicWrite(JSON.stringify(updated, null, 2) + \"\\n\", configPath);\n}\n\nexport type RemoveEntriesResult =\n | { status: \"removed\"; removed: RemovableEntry[] }\n | { status: \"no-op\" }\n | { status: \"missing\" }\n | { status: \"skipped\"; reason: \"malformed-json\" | \"not-an-object\" | \"oversize\" };\n\n/**\n * Remove Tandem's `mcpServers` keys from a client config — the uninstall\n * counterpart of `applyConfig`, sharing the same gates (`assertPathSafe`,\n * size cap, BOM strip) and the same `atomicWrite` 0o600/ACL hardening, but\n * with scrub semantics `applyConfig` deliberately does NOT have:\n * - never creates the file (`applyConfig` starts fresh on ENOENT);\n * - never replaces malformed JSON (`applyConfig` backs it up and rewrites —\n * on an uninstall path that would wipe the user's whole config);\n * - never rewrites when nothing matched (no churn of a file other vendors'\n * tokens live in).\n *\n * `skipped` reasons are fixed strings — parse-error detail must never reach\n * the caller's log (V8 SyntaxError messages embed source snippets, and this\n * file holds bearer tokens).\n */\nexport async function removeConfigEntries(\n configPath: string,\n keys: RemovableEntry[],\n): Promise<RemoveEntriesResult> {\n assertPathSafe(configPath);\n\n let raw: string;\n try {\n const { size } = statSync(configPath);\n if (size > MAX_CONFIG_BYTES) return { status: \"skipped\", reason: \"oversize\" };\n raw = readFileSync(configPath, \"utf-8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return { status: \"missing\" };\n throw err;\n }\n if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n return { status: \"skipped\", reason: \"malformed-json\" };\n }\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return { status: \"skipped\", reason: \"not-an-object\" };\n }\n\n const servers = (parsed as Record<string, unknown>).mcpServers;\n if (servers === null || typeof servers !== \"object\" || Array.isArray(servers)) {\n return { status: \"no-op\" };\n }\n const map = servers as Record<string, unknown>;\n const removed = keys.filter((key) => key in map);\n if (removed.length === 0) return { status: \"no-op\" };\n for (const key of removed) {\n delete map[key];\n }\n\n await atomicWrite(JSON.stringify(parsed, null, 2) + \"\\n\", configPath);\n return { status: \"removed\", removed };\n}\n\n/**\n * Conditionally back up `configPath` before `applyConfig` overwrites a\n * user-customised tandem entry. Returns the backup path on write, or\n * `undefined` when no backup was needed (fresh install, token rotation,\n * non-tandem-key changes only).\n *\n * Atomicity contract: if this throws, the caller MUST abort before\n * touching the destination. The original config and any prior backup\n * remain intact.\n */\nasync function maybeBackupExistingConfig(\n configPath: string,\n existing: { mcpServers?: Record<string, McpEntry> },\n ops: ApplyOps,\n): Promise<string | undefined> {\n const existingTandem = existing.mcpServers?.tandem;\n if (!shouldBackup(existingTandem, ops.create.tandem)) return undefined;\n\n const dir = backupDir(resolveAppDataDir());\n // assertPathSafe defeats XDG_DATA_HOME poisoning — same hardening as\n // the broken-JSON backup path above.\n assertPathSafe(dir);\n mkdirSync(dir, { recursive: true, mode: 0o700 });\n\n // mkdirSync's `mode` only applies on creation. If the dir already\n // existed at a more permissive mode (older umask, manual creation,\n // user fiddling), tighten it now. POSIX-only; on Windows the file's\n // own ACL — set by setRestrictiveAcl inside writeBackup — is the\n // protection. Filenames in a broader dir leak timestamps, not\n // bytes; failing closed here would block legit setups for that.\n if (process.platform !== \"win32\") {\n try {\n const dirStat = statSync(dir);\n if ((dirStat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700);\n } catch {\n // stat/chmod failure is non-fatal — proceeds with the wider\n // protection on the file itself.\n }\n }\n\n const content = readFileSync(configPath);\n const backupPath = await writeBackup(dir, content);\n // Prune AFTER successful write so we never delete the previous backup\n // before its replacement is on disk.\n await pruneOldBackups(dir);\n return backupPath;\n}\n\n/**\n * Install the Tandem skill to ~/.claude/skills/tandem/SKILL.md.\n * Claude Code auto-discovers skills in this directory and uses the description\n * field to trigger them when tandem_* tools are present.\n *\n * `homeOverride` is supported for tests only — the apply HTTP handler\n * MUST NOT thread this from the request body. Same symlink/realpath\n * hardening as `applyConfig`.\n */\nexport async function installSkill(opts: { homeOverride?: string } = {}): Promise<void> {\n const home = opts.homeOverride ?? homedir();\n const skillPath = join(home, \".claude\", \"skills\", \"tandem\", \"SKILL.md\");\n assertPathSafe(skillPath, { allowedRoots: opts.homeOverride ? [opts.homeOverride] : undefined });\n await mkdir(dirname(skillPath), { recursive: true });\n await atomicWrite(SKILL_CONTENT, skillPath);\n}\n\n/** Parse the integer `version:` from a skill front-matter block. Returns 0\n * if the file doesn't exist, has no version, or fails to parse — older\n * bundled skills predate the version stamp, so 0 means \"definitely upgrade\". */\nfunction readSkillVersion(skillContent: string): number {\n const match = skillContent.match(/^version:\\s*(\\d+)\\s*$/m);\n if (!match) return 0;\n const n = parseInt(match[1], 10);\n return Number.isFinite(n) ? n : 0;\n}\n\nconst BUNDLED_SKILL_VERSION = readSkillVersion(SKILL_CONTENT);\n\n/** Module-scoped last-failure record. Cleared on successful refresh; set on\n * read/write failure. Surfaced via `GET /api/launcher/status` (loopback only)\n * so the palette/settings UI can warn the user that the bundled skill is\n * out of date. `null` when last refresh succeeded or was a no-op. */\nlet lastSkillRefreshError: { code: \"write-failed\" | \"read-failed\"; message: string } | null = null;\nexport function getSkillRefreshError(): {\n code: \"write-failed\" | \"read-failed\";\n message: string;\n} | null {\n return lastSkillRefreshError;\n}\n/** Test-only — reset module state between tests. */\nexport function _resetSkillRefreshErrorForTests(): void {\n if (process.env.VITEST !== \"true\") return;\n lastSkillRefreshError = null;\n}\n\n/**\n * Idempotent skill refresh — called from supervisor startup so existing\n * users (who already ran `tandem setup` once) pick up bundled-skill\n * updates without re-running the wizard.\n *\n * Compares the bundled `version:` against the on-disk file. Writes only\n * if bundled > on-disk. Silently no-ops on any error (read failure,\n * write failure, missing parent dir) — this is a best-effort refresh,\n * not a critical path. The wizard-driven `installSkill()` remains the\n * authoritative installer.\n */\nexport async function refreshSkillIfStale(opts: { homeOverride?: string } = {}): Promise<void> {\n if (BUNDLED_SKILL_VERSION === 0) return; // Bundled skill has no version stamp — nothing to compare.\n const home = opts.homeOverride ?? homedir();\n const skillPath = join(home, \".claude\", \"skills\", \"tandem\", \"SKILL.md\");\n try {\n assertPathSafe(skillPath, {\n allowedRoots: opts.homeOverride ? [opts.homeOverride] : undefined,\n });\n } catch {\n return;\n }\n let onDiskVersion = -1; // -1 = file missing (treat as needing install)\n let readErr: unknown;\n try {\n const fs = await import(\"node:fs/promises\");\n const current = await fs.readFile(skillPath, \"utf8\");\n onDiskVersion = readSkillVersion(current);\n } catch (err) {\n // ENOENT is expected (first run); any other error is a real read failure.\n if ((err as NodeJS.ErrnoException).code !== \"ENOENT\") readErr = err;\n }\n if (onDiskVersion >= BUNDLED_SKILL_VERSION) {\n // No-op path — clear any prior failure only if read succeeded.\n if (readErr === undefined) lastSkillRefreshError = null;\n else\n lastSkillRefreshError = {\n code: \"read-failed\",\n message: readErr instanceof Error ? readErr.message : String(readErr),\n };\n return;\n }\n try {\n await mkdir(dirname(skillPath), { recursive: true });\n await atomicWrite(SKILL_CONTENT, skillPath);\n lastSkillRefreshError = null;\n console.error(\n `[Tandem] Refreshed bundled skill at ${skillPath} (v${onDiskVersion} → v${BUNDLED_SKILL_VERSION}).`,\n );\n } catch (err) {\n lastSkillRefreshError = {\n code: \"write-failed\",\n message: err instanceof Error ? err.message : String(err),\n };\n console.error(\n `[Tandem] Skill refresh failed (non-fatal): ${err instanceof Error ? err.message : err}`,\n );\n }\n}\n\n/**\n * Returns true if the channel-shim build artifact exists at the given path.\n * Exported so the prereq check can be tested without spawning runSetup.\n */\nexport function validateChannelShimPrereq(channelPath: string): boolean {\n return existsSync(channelPath);\n}\n\n/**\n * Single source of truth for \"should this target get the stdio channel shim?\".\n *\n * The channel shim is Claude Code's real-time push transport (the plugin\n * monitor it was meant to replace cannot activate via any path Tandem can\n * use — see Spike B / #985). So the default for the Claude Code target is\n * ON, gated only by the build artifact actually existing.\n *\n * - `claude-desktop` → always false (Cowork stdio path; the node-process\n * shim does not apply there).\n * - `override` (the explicit `--with-channel-shim` / wizard opt-out) wins\n * when provided.\n * - Otherwise: default-on for Claude Code, but only if `channelPath` exists.\n * That `existsSync` guard does double duty — it degrades gracefully when\n * `tandem` runs from source without a build, AND it stops the CLI/wizard\n * from writing a wrong `CHANNEL_DIST` on a desktop bundle. On the desktop\n * bundle the correct resource-dir channel path is injected via the\n * `TANDEM_CHANNEL_DIST` env var (see `resolveChannelDist`), so `CHANNEL_DIST`\n * already resolves to an existing file there and the shim registers.\n */\nexport function shouldRegisterChannelShim(\n targetKind: TargetKind,\n channelPath: string,\n override?: boolean,\n): boolean {\n if (targetKind === \"claude-desktop\") return false;\n if (override !== undefined) return override;\n return validateChannelShimPrereq(channelPath);\n}\n\n/** Re-exported for `tandem setup` orchestration in `src/cli/setup.ts`. */\nexport { CHANNEL_DIST, PACKAGE_ROOT };\n\n/**\n * Write the given token into all detected Claude MCP config files.\n * Returns the number of configs successfully updated and any per-target errors.\n *\n * CLI-shaped semantics: when `withChannelShim` is false, any existing\n * `tandem-channel` entry is removed (legacy install artifact). The\n * wizard's apply endpoint uses an explicit-confirmation code path\n * instead; this helper is for `tandem rotate-token` / `tandem setup`\n * where the flag already captures user intent.\n */\nexport async function applyConfigWithToken(\n token: string | null,\n opts: { force?: boolean; withChannelShim?: boolean } = {},\n): Promise<{ updated: number; errors: string[] }> {\n const targets = detectTargets({ force: opts.force });\n\n let updated = 0;\n const errors: string[] = [];\n for (const t of targets) {\n // Resolve per-target: a token rotation should preserve/heal the channel\n // shim registration (default-on for Claude Code) rather than silently\n // strip it. `opts.withChannelShim` still wins as an explicit override.\n const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);\n const entries = buildMcpEntries(CHANNEL_DIST, {\n withChannelShim,\n token: token ?? undefined,\n targetKind: t.kind,\n });\n try {\n await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));\n updated++;\n } catch (err) {\n errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);\n }\n }\n return { updated, errors };\n}\n","/**\n * Windows workspace path guard — mirrors the Rust §3 invariant for TypeScript callers.\n *\n * Four steps (in order):\n * a. lstat each ancestor; reject any path whose chain contains a symlink.\n * b. fs.realpath() to canonicalize (safe: symlinks already rejected in (a)).\n * c. Reject UNC paths (\\\\server\\share or \\\\?\\UNC\\...; allow \\\\?\\C:\\...).\n * d. Component-wise containment check under realpath'd %LOCALAPPDATA%\n * (case-insensitive on Windows).\n *\n * Extracted into its own module so it can be unit-tested via vi.mock(\"node:fs\", ...).\n */\n\nimport { promises as fs } from \"node:fs\";\nimport path from \"node:path\";\n\ntype Logger = { warn: (msg: string) => void };\n\n/**\n * Validate that `candidate` is a safe workspace path contained within `realLocalAppData`.\n *\n * @returns the realpath'd canonical path string on success, or null if rejected.\n * Callers are responsible for supplying a realpath'd `realLocalAppData`.\n */\nexport async function assertSafeWorkspacePath(\n candidate: string,\n realLocalAppData: string,\n logger?: Logger,\n): Promise<string | null> {\n const warn = (msg: string) => logger?.warn(`[path-guard] ${msg}`);\n\n // (a) lstat-walk: reject any component that is a symlink.\n if (await hasSymlinkInChain(candidate, warn)) {\n warn(`symlink/reparse point in chain: ${candidate}`);\n return null;\n }\n\n // (b) Canonicalize via realpath (safe now — symlinks already rejected).\n let real: string;\n try {\n real = await fs.realpath(candidate);\n } catch (err) {\n warn(`realpath failed for ${candidate}: ${(err as Error).message}`);\n return null;\n }\n\n // (c) Reject UNC paths.\n if (isUncPath(real)) {\n warn(`UNC path rejected: ${real}`);\n return null;\n }\n\n // (d) Component-wise containment under realLocalAppData (case-insensitive).\n if (!isComponentWiseChild(real, realLocalAppData)) {\n warn(`path outside %LOCALAPPDATA%: ${real}`);\n return null;\n }\n\n return real;\n}\n\n/** Returns true if any ancestor (inclusive) of `p` is a symbolic link. */\nasync function hasSymlinkInChain(p: string, warn: (m: string) => void): Promise<boolean> {\n // Walk from candidate up through all ancestors.\n let current = path.resolve(p);\n const visited = new Set<string>();\n\n while (true) {\n if (visited.has(current)) break;\n visited.add(current);\n\n try {\n const stat = await fs.lstat(current);\n if (stat.isSymbolicLink()) {\n return true;\n }\n } catch (err) {\n // lstat failed — fail closed for safety.\n warn(`lstat failed for ${current}: ${(err as Error).message}`);\n return true;\n }\n\n const parent = path.dirname(current);\n if (parent === current) break; // reached root\n current = parent;\n }\n\n return false;\n}\n\n/** Returns true if the path is a UNC path (\\\\server\\share or \\\\?\\UNC\\...). */\nfunction isUncPath(p: string): boolean {\n // Allow extended-length local paths (\\\\?\\C:\\...) but reject:\n // \\\\?\\UNC\\server\\share — extended UNC\n // \\\\server\\share — classic UNC\n if (p.startsWith(\"\\\\\\\\?\\\\UNC\\\\\") || p.startsWith(\"//?/UNC/\")) return true;\n if (\n (p.startsWith(\"\\\\\\\\\") && !p.startsWith(\"\\\\\\\\?\\\\\")) ||\n (p.startsWith(\"//\") && !p.startsWith(\"//?/\"))\n )\n return true;\n return false;\n}\n\n/**\n * Returns true if `child` is strictly within `root` on a component-wise basis\n * (case-insensitive on Windows).\n */\nfunction isComponentWiseChild(child: string, root: string): boolean {\n // Normalize separators and split on path.sep.\n const normalize = (p: string) => p.replace(/[\\\\/]+/g, path.sep).replace(/[/\\\\]$/, \"\");\n\n const rootNorm = normalize(root);\n const childNorm = normalize(child);\n\n const rootParts = rootNorm.split(path.sep);\n const childParts = childNorm.split(path.sep);\n\n if (childParts.length <= rootParts.length) return false;\n\n for (let i = 0; i < rootParts.length; i++) {\n // Case-insensitive on Windows.\n if (rootParts[i].toLowerCase() !== childParts[i].toLowerCase()) return false;\n }\n return true;\n}\n","/**\n * Tandem `--uninstall-scrub` subcommand.\n *\n * Invoked by the Tauri NSIS installer hook during uninstall on Windows, and\n * manually invocable on every platform (run it *before* deleting the app /\n * `npm uninstall -g` — see docs/data-locations.md). Removes every reference\n * Tandem wrote into other programs' config:\n * - Cowork workspaces (Windows): `installed_plugins.json`\n * (`mcpServers.tandem`), `known_marketplaces.json`\n * (`marketplaces.tandem`), `cowork_settings.json` (`tandem@tandem` in\n * `enabledPlugins`)\n * - MCP configs (all platforms): `mcpServers.tandem` /\n * `mcpServers[\"tandem-channel\"]` from `~/.claude.json` and every\n * detected Claude Desktop config (incl. Windows MSIX)\n * - The bundled skill dir `~/.claude/skills/tandem/` (only when it\n * contains nothing Tandem didn't install)\n * - `Tandem Cowork*` Windows Firewall rules via `netsh`\n *\n * Deliberately does NOT touch app data (sessions, annotations, doc-backups,\n * keychain) — the scrub removes references to a binary about to disappear;\n * user data stays unless the user deletes it (docs/data-locations.md).\n *\n * **Security invariant §10 (ADR):** this runs INSIDE the already-signed\n * `tandem.exe` binary — NOT as a separate `uninstall_scrub.exe`. That\n * prevents binary-planting attacks during uninstall.\n *\n * **Failure policy:** logs every error, exits 0 on clean-or-not-installed,\n * non-zero only on unrecoverable I/O failures. NSIS logs the exit code but\n * does NOT block uninstall — Tandem must always uninstall even if a\n * workspace scrub partially fails. Each step is isolated: a failure in one\n * never skips the rest.\n *\n * **Token safety:** this scrub READS JSON to find Tandem entries but the\n * removed-entry contents (including the auth token) are NEVER logged, and\n * parse-error detail is never interpolated into log lines (V8 SyntaxError\n * messages embed source snippets; these files hold bearer tokens).\n */\n\nimport { execFile } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport { type Dirent, promises as fsPromises } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport path from \"node:path\";\nimport { promisify } from \"node:util\";\nimport {\n assertPathSafe,\n type DetectedTarget,\n detectTargets,\n PathRejectedError,\n removeConfigEntries,\n} from \"../server/integrations/apply.js\";\nimport { assertSafeWorkspacePath } from \"./win-path-guard.js\";\n\nconst execFileAsync = promisify(execFile);\n\nconst TANDEM_PLUGIN_ID = \"tandem\";\nconst TANDEM_ENABLED_KEY = \"tandem@tandem\";\nconst FIREWALL_ALLOW_RULE = \"Tandem Cowork\";\nconst FIREWALL_DENY_RULE = \"Tandem Cowork \\u2014 Deny (elevation refused)\";\n\ntype ScrubLogger = {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n error: (msg: string) => void;\n close: () => Promise<void>;\n};\n\nasync function openLogger(): Promise<ScrubLogger> {\n const localAppData = process.env.LOCALAPPDATA;\n if (!localAppData) {\n // No log file — just use stderr.\n const write = (level: string, msg: string): void => {\n process.stderr.write(`[tandem uninstall-scrub ${level}] ${msg}\\n`);\n };\n return {\n info: (m) => write(\"info\", m),\n warn: (m) => write(\"warn\", m),\n error: (m) => write(\"error\", m),\n close: async () => {},\n };\n }\n\n const logDir = path.join(localAppData, \"tandem\", \"Logs\");\n await fsPromises.mkdir(logDir, { recursive: true }).catch(() => {});\n const logPath = path.join(logDir, \"uninstall.log\");\n const stream = await fsPromises.open(logPath, \"a\").catch(() => null);\n\n const write = (level: string, msg: string): void => {\n const line = `[${new Date().toISOString()}] [${level}] ${msg}\\n`;\n process.stderr.write(line);\n if (stream) {\n stream.write(line).catch(() => {});\n }\n };\n\n return {\n info: (m) => write(\"info\", m),\n warn: (m) => write(\"warn\", m),\n error: (m) => write(\"error\", m),\n close: async () => {\n if (stream) await stream.close().catch(() => {});\n },\n };\n}\n\n/**\n * Find all Cowork workspace directories.\n *\n * Matches `%LOCALAPPDATA%\\Packages\\Claude_*\\LocalCache\\Roaming\\Claude\\\n * local-agent-mode-sessions\\<ws-id>\\<vm-id>\\`.\n *\n * Each candidate vm-path is validated by the 4-step Windows path guard before\n * being included — callers receive only safe, realpath'd paths.\n *\n * Returns an empty array on any error (e.g. Cowork not installed).\n */\nexport async function findCoworkWorkspaces(logger: ScrubLogger): Promise<string[]> {\n const localAppData = process.env.LOCALAPPDATA;\n if (!localAppData) {\n logger.info(\"%LOCALAPPDATA% not set — skipping workspace scan\");\n return [];\n }\n\n // Realpath %LOCALAPPDATA% once for use by the path guard.\n let realLad: string;\n try {\n realLad = await fsPromises.realpath(localAppData);\n } catch {\n realLad = localAppData; // best-effort fallback\n }\n\n const packagesDir = path.join(localAppData, \"Packages\");\n let packageEntries: string[];\n try {\n packageEntries = await fsPromises.readdir(packagesDir);\n } catch (err) {\n logger.info(`cannot read Packages dir: ${(err as Error).message}`);\n return [];\n }\n\n const claudePackages = packageEntries.filter((name) => name.startsWith(\"Claude_\"));\n if (claudePackages.length === 0) {\n logger.info(\"no Claude_* package directories found\");\n return [];\n }\n\n const workspaces: string[] = [];\n for (const pkg of claudePackages) {\n const sessionsRoot = path.join(\n packagesDir,\n pkg,\n \"LocalCache\",\n \"Roaming\",\n \"Claude\",\n \"local-agent-mode-sessions\",\n );\n\n let wsEntries: string[];\n try {\n wsEntries = await fsPromises.readdir(sessionsRoot);\n } catch (err) {\n logger.warn(`cannot read sessions root ${sessionsRoot}: ${(err as Error).message}`);\n continue;\n }\n\n for (const ws of wsEntries) {\n const wsPath = path.join(sessionsRoot, ws);\n let vmEntries: string[];\n try {\n vmEntries = await fsPromises.readdir(wsPath);\n } catch (err) {\n logger.warn(`cannot read workspace dir ${wsPath}: ${(err as Error).message}`);\n continue;\n }\n\n for (const vm of vmEntries) {\n const vmPath = path.join(wsPath, vm);\n try {\n const stat = await fsPromises.stat(vmPath);\n if (!stat.isDirectory()) continue;\n\n // 4-step path guard — only include validated, realpath'd paths.\n const safePath = await assertSafeWorkspacePath(vmPath, realLad, logger);\n if (safePath !== null) {\n workspaces.push(safePath);\n }\n } catch (err) {\n logger.warn(`cannot stat ${vmPath}: ${(err as Error).message}`);\n }\n }\n }\n }\n\n logger.info(`found ${workspaces.length} workspace(s)`);\n return workspaces;\n}\n\n/**\n * Atomically rewrite a JSON file, invoking `mutate` on the parsed object.\n *\n * Precondition: `filePath` has already been validated by the path guard.\n * This function trusts its callers (consistent with `with_locked_json` on the Rust side).\n *\n * Returns true if the mutation changed something and was written, false if\n * the file was absent or unchanged.\n */\nexport async function rewriteJson(\n filePath: string,\n mutate: (obj: Record<string, unknown>) => boolean,\n logger: ScrubLogger,\n): Promise<boolean> {\n let content: string;\n try {\n content = await fsPromises.readFile(filePath, \"utf8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n return false;\n }\n logger.warn(`cannot read ${filePath}: ${(err as Error).message}`);\n return false;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(content);\n } catch {\n // Path only, never the parse error — V8 SyntaxError messages embed a\n // source snippet, and these files can hold bearer tokens that would\n // land in uninstall.log.\n logger.warn(`invalid JSON in ${filePath} — skipping`);\n return false;\n }\n\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n logger.warn(`${filePath} is not a JSON object — skipping`);\n return false;\n }\n\n const changed = mutate(parsed as Record<string, unknown>);\n if (!changed) {\n return false;\n }\n\n const dir = path.dirname(filePath);\n // randomUUID, not Math.random: same path-prediction rationale as\n // apply.ts#atomicWrite — these files can hold bearer tokens.\n const tmpPath = path.join(dir, `.tandem-scrub-tmp-${randomUUID()}`);\n\n try {\n await fsPromises.writeFile(tmpPath, JSON.stringify(parsed, null, 2), \"utf8\");\n await fsPromises.rename(tmpPath, filePath);\n } catch (err) {\n await fsPromises.unlink(tmpPath).catch(() => {});\n throw err;\n }\n return true;\n}\n\n/**\n * Remove the Tandem entry from `installed_plugins.json`.\n */\nexport function removeInstalledPlugins(obj: Record<string, unknown>): boolean {\n let changed = false;\n for (const key of [\"mcpServers\", \"servers\"]) {\n const servers = obj[key];\n if (typeof servers === \"object\" && servers !== null && !Array.isArray(servers)) {\n const map = servers as Record<string, unknown>;\n if (TANDEM_PLUGIN_ID in map) {\n delete map[TANDEM_PLUGIN_ID];\n changed = true;\n }\n }\n }\n return changed;\n}\n\n/**\n * Remove the Tandem marketplace entry from `known_marketplaces.json`.\n */\nexport function removeKnownMarketplaces(obj: Record<string, unknown>): boolean {\n const mp = obj.marketplaces;\n if (typeof mp === \"object\" && mp !== null && !Array.isArray(mp)) {\n const map = mp as Record<string, unknown>;\n if (TANDEM_PLUGIN_ID in map) {\n delete map[TANDEM_PLUGIN_ID];\n return true;\n }\n }\n return false;\n}\n\n/**\n * Remove `tandem@tandem` from `enabledPlugins` in `cowork_settings.json`.\n */\nexport function removeCoworkSettings(obj: Record<string, unknown>): boolean {\n const enabled = obj.enabledPlugins;\n if (Array.isArray(enabled)) {\n const before = enabled.length;\n obj.enabledPlugins = enabled.filter((v) => v !== TANDEM_ENABLED_KEY);\n return (obj.enabledPlugins as unknown[]).length < before;\n }\n if (typeof enabled === \"object\" && enabled !== null) {\n const map = enabled as Record<string, unknown>;\n if (TANDEM_ENABLED_KEY in map) {\n delete map[TANDEM_ENABLED_KEY];\n return true;\n }\n }\n return false;\n}\n\n/**\n * Remove `mcpServers.tandem` / `mcpServers[\"tandem-channel\"]` from every\n * detected Claude config (`~/.claude.json` + Claude Desktop incl. MSIX).\n * Runs on every platform. Returns the failure count; each target is\n * isolated so one failure never skips the rest.\n *\n * `detect` is injectable for tests only.\n */\nexport async function scrubMcpConfigs(\n logger: ScrubLogger,\n detect: () => DetectedTarget[] = detectTargets,\n): Promise<number> {\n let failures = 0;\n let targets: DetectedTarget[];\n try {\n targets = detect();\n } catch (err) {\n logger.error(`cannot detect MCP config targets: ${(err as Error).message}`);\n return 1;\n }\n\n for (const target of targets) {\n try {\n const result = await removeConfigEntries(target.configPath, [\"tandem\", \"tandem-channel\"]);\n switch (result.status) {\n case \"removed\":\n logger.info(`removed ${result.removed.join(\", \")} from ${target.configPath}`);\n break;\n case \"no-op\":\n logger.info(`no Tandem MCP entries in ${target.configPath}`);\n break;\n case \"missing\":\n break;\n case \"skipped\":\n logger.warn(`left ${target.configPath} untouched (${result.reason})`);\n break;\n }\n } catch (err) {\n const detail =\n err instanceof PathRejectedError ? `path rejected (${err.reason})` : (err as Error).message;\n logger.error(`MCP scrub failed for ${target.configPath}: ${detail}`);\n failures++;\n }\n }\n return failures;\n}\n\nconst SKILL_DIR_ALLOWLIST = [/^SKILL\\.md$/, /^\\.tandem-setup-[0-9a-f-]+\\.tmp$/];\n\n/**\n * Remove the bundled skill dir `~/.claude/skills/tandem/` — but only when\n * every entry is a regular file Tandem itself installs (`SKILL.md`, plus\n * orphaned `atomicWrite` temps from a crashed install). Anything else means\n * the user put files there; leave the whole dir intact. A dangling skill is\n * harmless to Claude Code but keeps advertising tandem_* tools that no\n * longer exist; if the install survives and runs again, `refreshSkillIfStale`\n * recreates it.\n *\n * `homeOverride` is for tests only.\n */\nexport async function removeSkillDir(logger: ScrubLogger, homeOverride?: string): Promise<number> {\n const skillDir = path.join(homeOverride ?? homedir(), \".claude\", \"skills\", \"tandem\");\n\n // Symmetric with installSkill: if install would refuse a symlinked\n // ~/.claude, uninstall refuses too.\n try {\n assertPathSafe(skillDir, { allowedRoots: homeOverride ? [homeOverride] : undefined });\n } catch (err) {\n const reason = err instanceof PathRejectedError ? err.reason : (err as Error).message;\n logger.warn(`leaving ${skillDir} — path rejected (${reason})`);\n return 0;\n }\n\n let entries: Dirent[];\n try {\n entries = await fsPromises.readdir(skillDir, { withFileTypes: true });\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return 0;\n logger.warn(`cannot read skill dir ${skillDir}: ${(err as Error).message}`);\n return 0;\n }\n\n const unexpected = entries.filter(\n (e) => !e.isFile() || !SKILL_DIR_ALLOWLIST.some((re) => re.test(e.name)),\n );\n if (unexpected.length > 0) {\n logger.info(`leaving ${skillDir} — contains entries Tandem didn't install`);\n return 0;\n }\n\n try {\n await fsPromises.rm(skillDir, { recursive: true, force: true });\n logger.info(`removed skill dir ${skillDir}`);\n return 0;\n } catch (err) {\n logger.error(`failed to remove skill dir ${skillDir}: ${(err as Error).message}`);\n return 1;\n }\n}\n\n/**\n * Delete a Windows Firewall rule by name. Non-existent rules are not an error.\n */\nasync function deleteFirewallRule(name: string, logger: ScrubLogger): Promise<void> {\n try {\n await execFileAsync(\"netsh\", [\"advfirewall\", \"firewall\", \"delete\", \"rule\", `name=${name}`]);\n logger.info(`deleted firewall rule: ${name}`);\n } catch (err) {\n const e = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string };\n // netsh returns exit code 1 when no rule matches — not fatal.\n const stdoutStr = e.stdout ?? \"\";\n if (stdoutStr.includes(\"No rules match\")) {\n logger.info(`no firewall rule to delete: ${name}`);\n return;\n }\n logger.warn(\n `failed to delete firewall rule ${name}: ${e.message ?? String(err)} ` +\n `(stdout: ${stdoutStr.trim().slice(0, 200)})`,\n );\n }\n}\n\n/**\n * Main entry. Cowork + firewall steps are Windows-only; the MCP config and\n * skill-dir steps run everywhere. Each step is isolated so a failure in one\n * never skips the rest (the firewall rules in particular must always be\n * attempted).\n */\nexport async function runUninstallScrub(): Promise<number> {\n const logger = await openLogger();\n\n logger.info(\"Tandem uninstall scrub starting\");\n\n const isWindows = process.platform === \"win32\";\n let failures = 0;\n\n try {\n if (isWindows) {\n const workspaces = await findCoworkWorkspaces(logger);\n for (const ws of workspaces) {\n const pluginsDir = path.join(ws, \"cowork_plugins\");\n try {\n await rewriteJson(\n path.join(pluginsDir, \"installed_plugins.json\"),\n removeInstalledPlugins,\n logger,\n );\n await rewriteJson(\n path.join(pluginsDir, \"known_marketplaces.json\"),\n removeKnownMarketplaces,\n logger,\n );\n await rewriteJson(\n path.join(pluginsDir, \"cowork_settings.json\"),\n removeCoworkSettings,\n logger,\n );\n } catch (err) {\n logger.error(`scrub failed for ${ws}: ${(err as Error).message}`);\n failures++;\n }\n }\n } else {\n logger.info(`platform ${process.platform}: Cowork + firewall scrub is Windows-only`);\n }\n\n failures += await scrubMcpConfigs(logger);\n failures += await removeSkillDir(logger);\n\n if (isWindows) {\n await deleteFirewallRule(FIREWALL_ALLOW_RULE, logger);\n await deleteFirewallRule(FIREWALL_DENY_RULE, logger);\n }\n\n logger.info(`scrub complete: ${failures} failure(s)`);\n } catch (err) {\n logger.error(`scrub fatal error: ${(err as Error).message}`);\n failures++;\n }\n\n await logger.close();\n\n // Exit 0 on clean-or-not-installed. Non-zero only on unrecoverable failures —\n // and even then, NSIS does NOT block uninstall; it just records the code.\n return failures > 0 ? 1 : 0;\n}\n","import { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport {\n applyConfig,\n applyOpsForCli,\n buildMcpEntries,\n CHANNEL_DIST,\n type DetectedTarget,\n detectTargets,\n installSkill,\n PACKAGE_ROOT,\n shouldRegisterChannelShim,\n type TargetKind,\n validateChannelShimPrereq,\n} from \"../server/integrations/apply.js\";\n\n/**\n * Parse repeatable `--target=<kind>` CLI args into valid target kinds plus the\n * unrecognized leftovers (so the caller can warn on typos). Only the\n * `--target=<value>` form is recognized — `--target foo` (space, no `=`) is\n * silently ignored, and `--target=` (empty value) lands in `unknown` and is\n * treated as a typo by the caller. Pure + side-effect-free for unit testing.\n */\nexport function parseTargetArgs(args: string[]): {\n targets: TargetKind[];\n unknown: string[];\n} {\n const raw = args.filter((a) => a.startsWith(\"--target=\")).map((a) => a.slice(\"--target=\".length));\n const targets = raw.filter((t): t is TargetKind => t === \"claude-code\" || t === \"claude-desktop\");\n const unknown = raw.filter((t) => t !== \"claude-code\" && t !== \"claude-desktop\");\n return { targets, unknown };\n}\n\nexport interface SetupOptions {\n /**\n * When false (the default `tandem setup` with no flags) we only print\n * guidance — first-run setup is wizard-driven now (ADR-038 §2b). `--apply`\n * opts into writing the MCP config non-interactively (scriptable path for\n * CI / dotfile users).\n */\n apply?: boolean;\n force?: boolean;\n withChannelShim?: boolean;\n /**\n * Restrict to specific target kinds (`--target=claude-code|claude-desktop`).\n * Empty/undefined = all detected targets.\n */\n targets?: TargetKind[];\n}\n\n/**\n * `tandem setup` entry point.\n *\n * Auto-configuration of Claude on Tauri startup and the old interactive\n * `tandem setup` flow were removed in #477 PR 3c-ii-c — setup runs through the\n * in-app wizard, with this CLI surviving only as a non-interactive\n * `--apply` escape hatch for scripts. The bare `tandem setup` prints guidance.\n */\nexport async function runSetup(opts: SetupOptions = {}): Promise<void> {\n if (!opts.apply) {\n printGuidance();\n return;\n }\n await applySetup(opts);\n}\n\nfunction printGuidance(): void {\n console.error(\n \"\\nTandem setup is wizard-driven.\\n\\n\" +\n \" • Run `tandem` to launch the editor; the first-run wizard connects\\n\" +\n \" Claude (Claude Code / Claude Desktop) for you.\\n\" +\n \" • Or run `tandem setup --apply` to write the default Claude MCP config\\n\" +\n \" non-interactively. Honors --force, --target=<kind>, --with-channel-shim.\\n\",\n );\n}\n\nasync function applySetup(opts: SetupOptions): Promise<void> {\n console.error(\"\\nTandem Setup (--apply)\\n\");\n\n if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {\n console.error(\n `Error: --with-channel-shim requires dist/channel/index.js at ${CHANNEL_DIST}\\n` +\n `Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor.`,\n );\n process.exit(1);\n }\n\n console.error(\"Detecting Claude installations...\");\n\n let targets = detectTargets({ force: opts.force });\n if (opts.targets && opts.targets.length > 0) {\n const wanted = new Set(opts.targets);\n targets = targets.filter((t) => wanted.has(t.kind));\n }\n\n let failures = 0;\n if (targets.length === 0) {\n console.error(\n \" No matching Claude installations detected.\\n\" +\n \" If Claude Code is installed, ensure ~/.claude exists.\\n\" +\n \" Force configuration to default paths with: tandem setup --apply --force\",\n );\n } else {\n for (const t of targets) {\n console.error(` Found: ${t.label} (${t.configPath})`);\n }\n\n console.error(\"\\nWriting MCP configuration...\");\n failures = await writeTargets(targets, opts);\n\n if (failures === targets.length) {\n console.error(\"\\nSetup failed — could not write any configuration. Check file permissions.\");\n } else if (failures > 0) {\n console.error(\n `\\nSetup partially complete (${failures} target(s) failed). Start Tandem with: tandem`,\n );\n } else {\n console.error(\"\\nSetup complete! Start Tandem with: tandem\");\n console.error(\"Then in Claude, your tandem_* tools will be available.\");\n }\n }\n\n // Skill install is per-user, not per-integration — run it on any --apply\n // invocation (contrarian review S5), even when no targets were written.\n console.error(\"\\nInstalling Claude Code skill...\");\n try {\n await installSkill();\n console.error(\" \\x1b[32m✓\\x1b[0m ~/.claude/skills/tandem/SKILL.md\");\n } catch (err) {\n console.error(\n ` \\x1b[33m⚠\\x1b[0m Could not install skill: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n if (targets.length > 0 && failures < targets.length) {\n printPushStatus();\n }\n\n // Non-zero exit only when we attempted writes and every one failed, so\n // `tandem setup --apply` stays scriptable (CI can branch on exit code).\n if (targets.length > 0 && failures === targets.length) {\n process.exit(1);\n }\n}\n\nasync function writeTargets(targets: DetectedTarget[], opts: SetupOptions): Promise<number> {\n let failures = 0;\n for (const t of targets) {\n // Default-on for Claude Code (channel shim is its push transport, #985);\n // `--with-channel-shim` is now an explicit override. The helper's\n // existence check degrades to \"tandem HTTP entry only\" when the build\n // artifact is absent (an explicit `--with-channel-shim` with a missing\n // file already hard-errored above).\n const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);\n const entries = buildMcpEntries(CHANNEL_DIST, {\n withChannelShim,\n targetKind: t.kind,\n });\n try {\n await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));\n console.error(` \\x1b[32m✓\\x1b[0m ${t.label}`);\n } catch (err) {\n failures++;\n console.error(\n ` \\x1b[31m✗\\x1b[0m ${t.label}: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n return failures;\n}\n\nfunction printPushStatus(): void {\n // Real-time push is delivered by the channel shim, registered by default\n // above; the plugin monitor it was meant to replace cannot activate via any\n // path Claude Code exposes today (Spike B / #985), so it's framed as\n // forward-looking, not the path.\n const channelRegistered = validateChannelShimPrereq(CHANNEL_DIST);\n const pluginManifest = join(PACKAGE_ROOT, \".claude-plugin\", \"plugin.json\");\n const devInstructions = existsSync(pluginManifest)\n ? ` For development, you can also load the package directly:\\n\\n` +\n ` claude --plugin-dir ${PACKAGE_ROOT}\\n\\n`\n : \"\";\n\n console.error(\n \"\\n\\x1b[1mReal-time push notifications:\\x1b[0m\\n\" +\n (channelRegistered\n ? \" \\x1b[32mEnabled\\x1b[0m — the channel shim is registered; Claude Code receives events in real time.\\n\" +\n \" Relaunch any Claude Code session you started manually so it picks up the new server.\\n\\n\"\n : \" \\x1b[33mUnavailable\\x1b[0m — dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\\n\" +\n \" Run 'npm run build' and re-run setup to enable push.\\n\\n\") +\n \" A Tandem plugin is also published (skill + MCP; the real-time monitor it carries is\\n\" +\n \" forward-looking, pending Claude Code support):\\n\\n\" +\n \" claude plugin marketplace add bloknayrb/tandem\\n\" +\n \" claude plugin install tandem@tandem-editor\\n\\n\" +\n devInstructions,\n );\n}\n","/**\n * Helpers shared by CLI stdio entry points (`mcp-stdio`, `channel`) and the\n * standalone server / monitor binaries that all speak MCP over stdout.\n */\n\nimport { DEFAULT_MCP_PORT } from \"./constants.js\";\n\n/**\n * In stdio MCP mode, stdout is the JSON-RPC wire — any stray library write\n * corrupts the protocol. Redirect `console.log/warn/info` to stderr so\n * incidental logging is safe. Callers that also run in-process tests (e.g.\n * `src/monitor/index.ts`) can gate this behind `!process.env.VITEST`.\n */\nexport function redirectConsoleToStderr(): void {\n console.log = console.error;\n console.warn = console.error;\n console.info = console.error;\n}\n\n/**\n * Resolve the Tandem HTTP base URL used by stdio subcommands. Precedence:\n * (1) explicit override (programmatic, e.g. from tests)\n * (2) CLAUDE_PLUGIN_OPTION_SERVER_URL — injected by plugin host from userConfig\n * (3) TANDEM_URL — explicit env override\n * (4) 127.0.0.1 default (apiMiddleware narrowed out bare 'localhost' in PR #477 PR 2)\n * Blank values are treated as absent so a blank plugin option does not mask an\n * explicit TANDEM_URL or the 127.0.0.1 default.\n * The returned string has no trailing slash so callers can concatenate\n * `/health`, `/mcp`, etc. without double-slash. One or more trailing slashes\n * are stripped, so both `http://x/` and `http://x//` resolve to `http://x`.\n */\nexport function resolveTandemUrl(override?: string): string {\n return resolveTandemUrlCandidate(override).replace(/\\/+$/, \"\");\n}\n\nfunction resolveTandemUrlCandidate(override?: string): string {\n const candidates = [\n override,\n process.env.CLAUDE_PLUGIN_OPTION_SERVER_URL,\n process.env.TANDEM_URL,\n ];\n for (const url of candidates) {\n if (url !== undefined && url.trim() !== \"\") return url.trim();\n }\n return `http://127.0.0.1:${DEFAULT_MCP_PORT}`;\n}\n\n/**\n * Resolve the Tandem auth token. Precedence:\n * (1) explicit override (programmatic, e.g. from tests)\n * (2) CLAUDE_PLUGIN_OPTION_AUTH_TOKEN — injected by plugin host from userConfig\n * (3) TANDEM_AUTH_TOKEN — explicit env override\n * Blank values are treated as absent so a blank plugin option does not mask an\n * explicit TANDEM_AUTH_TOKEN. Returns undefined when all absent (loopback mode,\n * no Authorization header sent).\n */\nexport function resolveAuthToken(override?: string): string | undefined {\n return resolveAuthTokenCandidate(override).token;\n}\n\nexport type AuthTokenSource =\n | \"explicit override\"\n | \"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\"\n | \"TANDEM_AUTH_TOKEN\";\n\nexport function resolveAuthTokenCandidate(\n override?: string,\n): { token: string; source: AuthTokenSource } | { token: undefined; source: undefined } {\n const candidates: Array<[AuthTokenSource, string | undefined]> = [\n [\"explicit override\", override],\n [\"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\", process.env.CLAUDE_PLUGIN_OPTION_AUTH_TOKEN],\n [\"TANDEM_AUTH_TOKEN\", process.env.TANDEM_AUTH_TOKEN],\n ];\n for (const [source, token] of candidates) {\n if (token !== undefined && token.trim() !== \"\") return { token, source };\n }\n return { token: undefined, source: undefined };\n}\n\n/** Regex for a valid Tandem auth token (32+ URL-safe alphanumeric chars). */\nconst VALID_TOKEN_RE = /^[A-Za-z0-9_\\-]{32,}$/;\n\n/** Guard so we only warn once per process (not on every SSE reconnect). */\nlet _warnedInvalidToken = false;\n\n/**\n * Header carrying the Claude Code session id on stdio-shim → Tandem-server\n * requests. Lets the server correlate channel traffic (replies, awareness,\n * error reports) with the originating Claude Code session for multi-session\n * disambiguation and diagnostics. Read-only metadata: routes that don't\n * consume it ignore the header, so no server-route schema change is required.\n */\nexport const CLAUDE_SESSION_HEADER = \"X-Claude-Session-Id\";\n\n/**\n * Bound on the session-id length we forward. Claude Code sets a UUID\n * (the same `session_id` passed to hooks; CHANGELOG 2.1.157 / 2.1.163), but we\n * treat the value as opaque and only guard against an oversized header line.\n */\nconst MAX_SESSION_ID_LEN = 256;\n\n/**\n * Printable-ASCII guard. A stray CR/LF (or other control char) would split the\n * header line — reject anything outside `\\x21`–`\\x7e`. The length bound is\n * enforced separately against MAX_SESSION_ID_LEN so there's one source of truth.\n */\nconst SESSION_ID_RE = /^[\\x21-\\x7e]+$/;\n\n/**\n * Resolve the Claude Code session id from the process environment.\n *\n * Claude Code injects `CLAUDE_CODE_SESSION_ID` (and `CLAUDECODE=1`) into the\n * environment of the stdio MCP server subprocesses it spawns — the Tandem\n * channel shim and stdio proxy are exactly such subprocesses (Claude Code\n * CHANGELOG 2.1.157; also forwarded on `--resume` since 2.1.163). The value\n * mirrors the `session_id` passed to hooks/Bash (a UUID).\n *\n * Returns `undefined` when the launch is not a Claude Code session\n * (`CLAUDECODE !== \"1\"` — so a value a user happened to export in their own\n * shell is never forwarded), the var is unset/blank, or it fails the\n * printable-ASCII length guard. Whitespace is trimmed and the value is\n * length-bounded so it can be forwarded as an HTTP header without\n * header-injection or oversize risk.\n */\nexport function resolveClaudeSessionId(): string | undefined {\n // Only trust the id inside an actual Claude Code launch. `CLAUDECODE=1` is\n // set alongside CLAUDE_CODE_SESSION_ID by the same CLI release; gating on it\n // avoids forwarding a value a user happened to export in their own shell.\n if (process.env.CLAUDECODE !== \"1\") return undefined;\n const raw = process.env.CLAUDE_CODE_SESSION_ID;\n if (raw === undefined) return undefined;\n const trimmed = raw.trim();\n if (trimmed === \"\" || trimmed.length > MAX_SESSION_ID_LEN) return undefined;\n if (!SESSION_ID_RE.test(trimmed)) return undefined;\n return trimmed;\n}\n\n/**\n * Merge the resolved Claude session id (if any) into a `HeadersInit` as the\n * `X-Claude-Session-Id` header. No-op when no session id is resolvable, so\n * callers can wrap every outbound request unconditionally.\n */\nexport function withClaudeSessionHeader(init?: RequestInit[\"headers\"]): Headers {\n const headers = new Headers(init);\n const sessionId = resolveClaudeSessionId();\n if (sessionId !== undefined) headers.set(CLAUDE_SESSION_HEADER, sessionId);\n return headers;\n}\n\n/**\n * Fetch wrapper that automatically injects `Authorization: Bearer <token>`\n * when a resolved Tandem auth token is set and valid.\n *\n * This is the forgiving variant — used by monitor/channel which may run in\n * loopback-only mode without a token. Invalid or absent tokens are silently\n * ignored (no exit-1). The strict validation lives in mcp-stdio.ts only.\n * When the token is set but fails validation, a one-time warning is emitted\n * so operators know why auth headers are absent.\n */\nexport async function authFetch(url: string, init?: RequestInit): Promise<Response> {\n // Always attach the Claude session header (no-op when not resolvable) so the\n // server can correlate channel traffic with the originating Claude session,\n // independent of whether an auth token is configured.\n const headers = withClaudeSessionHeader(init?.headers);\n const { token, source } = resolveAuthTokenCandidate();\n if (token !== undefined) {\n const trimmed = token.trim();\n if (VALID_TOKEN_RE.test(trimmed)) {\n headers.set(\"Authorization\", `Bearer ${trimmed}`);\n return fetch(url, { ...init, headers });\n }\n // Token is set but invalid — warn once so operators know why auth fails\n if (!_warnedInvalidToken) {\n _warnedInvalidToken = true;\n console.error(\n `[tandem] authFetch: ${source} is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header`,\n );\n }\n }\n return fetch(url, { ...init, headers });\n}\n","/**\n * Shared preflight check for stdio MCP subcommands.\n *\n * Both `tandem mcp-stdio` and `tandem channel` need a live Tandem server on\n * localhost before they can do anything useful. Two flavors:\n *\n * - `ensureTandemServer` — fail fast via stderr + exit(1) when the server\n * isn't reachable. Used by `tandem channel`, whose stdio transport can't\n * meaningfully respond on its own.\n * - `probeTandemServer` — returns a result without side effects. Used by\n * `tandem mcp-stdio`, which starts its stdio transport before preflight\n * so it can synthesize -32000 JSON-RPC errors for any in-flight request\n * before exiting (issue #336).\n */\n\nimport { resolveTandemUrl } from \"../shared/cli-runtime.js\";\n\nconst DEFAULT_TIMEOUT_MS = 2000;\n\nexport interface PreflightOptions {\n url?: string;\n timeoutMs?: number;\n}\n\n// Note: \"unreachable\" is a catch-all for any non-HTTP-status failure —\n// DNS, TLS, timeout, ECONNREFUSED, RST all land here. \"unhealthy\" is\n// strictly non-2xx responses from /health.\nexport type PreflightProbe =\n | { ok: true }\n | { ok: false; url: string; reason: string; kind: \"unreachable\" | \"unhealthy\" };\n\nexport async function probeTandemServer(opts: PreflightOptions = {}): Promise<PreflightProbe> {\n const url = resolveTandemUrl(opts.url);\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n try {\n const res = await fetch(`${url}/health`, { signal: controller.signal });\n if (!res.ok) {\n return {\n ok: false,\n url,\n reason: `health endpoint returned HTTP ${res.status}`,\n kind: \"unhealthy\",\n };\n }\n return { ok: true };\n } catch (err) {\n return {\n ok: false,\n url,\n reason: err instanceof Error ? err.message : String(err),\n kind: \"unreachable\",\n };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport async function ensureTandemServer(opts: PreflightOptions = {}): Promise<void> {\n const probe = await probeTandemServer(opts);\n if (!probe.ok) {\n const guidance =\n probe.kind === \"unreachable\"\n ? \"Start the Tauri app or run `tandem start` on the host, then retry.\"\n : \"The Tandem server is running but unhealthy — check the host logs.\";\n process.stderr.write(\n `[tandem] Tandem server preflight failed at ${probe.url} (${probe.reason}).\\n` +\n `[tandem] ${guidance}\\n`,\n );\n process.exit(1);\n }\n}\n","/**\n * Tandem mcp-stdio subcommand — stdio ↔ Streamable HTTP JSON-RPC proxy.\n *\n * Claude Desktop's plugin loader bridges stdio MCP servers into sandboxed\n * sessions but not HTTP MCP servers, so plugin-cached stdio entries that\n * forward to the local HTTP MCP endpoint are the only supported way to\n * surface tandem_* tools into those sessions.\n *\n * Raw message forwarding: no handler registrations, no per-method logic.\n * Any message the upstream emits (tool results, notifications, future\n * methods we haven't heard of) reaches the stdio client unchanged.\n *\n * Error surfacing (issue #336): the stdio transport is started before\n * preflight and http.start(), and early messages are buffered until the\n * upstream is ready. If the upstream never becomes ready — or dies mid-\n * session — every in-flight request ID is answered with a synthesized\n * `-32000` JSON-RPC error instead of a silent stdio close. Plugin hosts\n * surface `-32000` as actionable; a silent close is what produces \"tools\n * never appear in Cowork\" with nothing diagnosable in the logs.\n *\n * Intentional: no reconnection logic. If the upstream HTTP server dies\n * mid-session, we synthesize errors for pending requests and exit 1.\n * The plugin loader will respawn us on the next tool call and preflight\n * will re-run with a fresh, accurate error if the server is still down.\n */\n\nimport { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n CLAUDE_SESSION_HEADER,\n redirectConsoleToStderr,\n resolveAuthTokenCandidate,\n resolveClaudeSessionId,\n resolveTandemUrl,\n} from \"../shared/cli-runtime.js\";\nimport { probeTandemServer } from \"./preflight.js\";\n\nredirectConsoleToStderr();\n\n// After preflight or http.start() fails we wait ~1.5s for any already-in-\n// flight `initialize` from the plugin loader to land on stdin and receive\n// a -32000 reply before tear-down. Sizing covers stdin-read lag between\n// preflight resolution and first message arrival — independent of\n// preflight's own fetch timeout.\nconst PREFLIGHT_GRACE_MS = 1500;\n\n// Per-request timeout. Node's setTimeout uses a 32-bit signed integer\n// internally — values above this constant are silently clamped to 1ms,\n// which would make every request immediately synthesize -32000.\nconst MAX_TIMEOUT_MS = 2_147_483_647; // 2^31 - 1\n\nexport function parseTimeoutMs(raw: string | undefined): number {\n if (raw !== undefined) {\n const parsed = parseInt(raw, 10);\n if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {\n return parsed;\n }\n // Note: parseInt(\"3e4\", 10) returns 3 (stops at 'e'), which passes validation.\n // Scientific notation lands here only when the leading integer is invalid.\n process.stderr.write(\n `[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer ≤ ${MAX_TIMEOUT_MS}; ignoring \"${raw}\", using 30000ms default\\n`,\n );\n }\n return 30_000;\n}\n\nconst STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);\n\n// Last-gasp handlers for truly unexpected crashes: write one diagnostic to\n// stderr before exit. Installed at module load; process.once bounds each\n// handler to a single fire. No -32000 synthesis here because pendingRequests\n// lives inside runMcpStdio()'s closure.\nprocess.once(\"uncaughtException\", (err: Error) => {\n process.stderr.write(\n `[tandem mcp-stdio] uncaughtException: ${err.message}\\n${err.stack ?? \"\"}\\n`,\n );\n process.exit(1);\n});\nprocess.once(\"unhandledRejection\", (reason: unknown) => {\n const detail = reason instanceof Error ? reason.message : String(reason);\n process.stderr.write(`[tandem mcp-stdio] unhandledRejection: ${detail}\\n`);\n process.exit(1);\n});\n\n/** Regex for a valid Tandem auth token (32+ URL-safe alphanumeric chars). */\nconst VALID_TOKEN_RE = /^[A-Za-z0-9_\\-]{32,}$/;\n\n/**\n * Validate the configured auth token if present.\n * Rules (invariant 4):\n * - If not set at all, or if empty/whitespace-only after trim → return null (loopback-only mode).\n * - \"Bearer \" prefix → exit 1 with \"double-prefix\" message.\n * - Must match /^[A-Za-z0-9_-]{32,}$/ (no whitespace, no newlines, no Bearer prefix).\n */\nexport function readAndValidateAuthToken(): string | null {\n const { token, source } = resolveAuthTokenCandidate();\n // Token not set at all, or empty/whitespace-only → loopback-only mode, no auth header, no exit.\n if (token === undefined) return null;\n const trimmed = token.trim();\n if (trimmed === \"\") return null;\n\n if (trimmed.startsWith(\"Bearer \")) {\n process.stderr.write(\n `[tandem mcp-stdio] ${source} is invalid (double-prefix: do not include 'Bearer ' prefix — supply the raw token only)\\n`,\n );\n process.exit(1);\n }\n\n if (!VALID_TOKEN_RE.test(trimmed)) {\n process.stderr.write(\n `[tandem mcp-stdio] ${source} is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\\n`,\n );\n process.exit(1);\n }\n\n return trimmed;\n}\n\nexport async function runMcpStdio(): Promise<void> {\n const baseUrl = resolveTandemUrl();\n const authToken = readAndValidateAuthToken();\n\n // Forward the Claude Code session id (when this proxy was spawned by Claude\n // Code) so the HTTP MCP server can correlate tool calls with the session.\n // No-op when not in a Claude Code launch — see resolveClaudeSessionId.\n const sessionId = resolveClaudeSessionId();\n const upstreamHeaders: Record<string, string> = {};\n if (authToken) upstreamHeaders.Authorization = `Bearer ${authToken}`;\n if (sessionId !== undefined) upstreamHeaders[CLAUDE_SESSION_HEADER] = sessionId;\n\n const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {\n requestInit: Object.keys(upstreamHeaders).length > 0 ? { headers: upstreamHeaders } : undefined,\n });\n const stdio = new StdioServerTransport();\n\n // On upstream failure we synthesize -32000 for every entry before exit.\n // Value is the per-request timeout handle so we can cancel it on response.\n const pendingRequests = new Map<string | number, ReturnType<typeof setTimeout>>();\n // Messages arriving before httpReady flips; either drained and forwarded\n // on success, or each request answered with -32000 on preflight/http-start\n // failure.\n const preReadyBuffer: JSONRPCMessage[] = [];\n let shuttingDown = false;\n let httpReady = false;\n\n async function sendErrorResponse(\n id: string | number,\n message: string,\n detail?: string,\n ): Promise<void> {\n const errorResponse: JSONRPCMessage = {\n jsonrpc: \"2.0\",\n id,\n error: {\n // -32000 is the implementation-defined server error range per\n // JSON-RPC 2.0 §5.1 — upstream unavailability is an application-\n // level condition, not a generic Internal Error.\n code: -32000,\n message,\n ...(detail !== undefined ? { data: { detail } } : {}),\n },\n };\n try {\n await stdio.send(errorResponse);\n } catch (err) {\n // stdio already torn down; log so synth failures during shutdown\n // (e.g., http.onclose racing stdio.onclose) aren't silently dropped —\n // a silent drop here would recreate exactly the failure mode this\n // module exists to prevent.\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `[tandem mcp-stdio] failed to send synthesized error for id ${id}: ${detail}\\n`,\n );\n }\n }\n\n function forwardToUpstream(msg: JSONRPCMessage): void {\n if (shuttingDown) return;\n const requestId = getRequestId(msg);\n if (requestId !== undefined) {\n // Clear any existing timer for this id (duplicate/retry scenario).\n const existing = pendingRequests.get(requestId);\n if (existing) clearTimeout(existing);\n\n const timeoutHandle = setTimeout(() => {\n // Atomic: delete returns false if synthesizePending already drained the map.\n if (!pendingRequests.delete(requestId)) return;\n void sendErrorResponse(\n requestId,\n \"Tandem HTTP upstream not responding (half-open)\",\n `No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`,\n );\n }, STDIO_REQUEST_TIMEOUT_MS);\n pendingRequests.set(requestId, timeoutHandle);\n }\n http.send(msg).catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}\\n`);\n if (requestId !== undefined) {\n const handle = pendingRequests.get(requestId);\n if (handle !== undefined) {\n pendingRequests.delete(requestId);\n clearTimeout(handle);\n void sendErrorResponse(requestId, \"Tandem HTTP upstream unreachable\", detail);\n }\n }\n });\n }\n\n async function synthesizeBuffered(message: string, detail?: string): Promise<void> {\n const buffered = preReadyBuffer.splice(0);\n const ids = buffered\n .map((msg) => getRequestId(msg))\n .filter((id): id is string | number => id !== undefined);\n for (const id of ids) {\n await sendErrorResponse(id, message, detail);\n }\n }\n\n async function synthesizePending(message: string, detail?: string): Promise<void> {\n if (pendingRequests.size === 0) return;\n const ids = [...pendingRequests.keys()];\n // Synchronous before any await: clear all timers + drain map atomically.\n // Timer callbacks that are already queued observe empty map and return early.\n for (const handle of pendingRequests.values()) clearTimeout(handle);\n pendingRequests.clear();\n await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));\n }\n\n const shutdown = async (\n code = 0,\n synth?: { message: string; detail?: string },\n ): Promise<never> => {\n if (!shuttingDown) {\n shuttingDown = true;\n // Hard deadline: if http.close() or stdio.close() hang (e.g., a half-\n // open upstream holding an SSE GET open), don't let cleanup block the\n // process exit indefinitely. .unref() means this timer doesn't itself\n // keep the event loop alive — fast paths resolve and call process.exit\n // below before the deadline fires; hung paths are forcibly terminated.\n setTimeout(() => process.exit(code), 2_000).unref();\n // Unconditionally drain all pending timers before any await — prevents\n // orphan timers from firing into the half-closed transport during\n // http.close() / stdio.close() awaits. synthesizePending will also\n // drain the map if synth is provided; the clearTimeout calls here are\n // defensive for the synth=undefined path (e.g., clean stdio.onclose).\n for (const handle of pendingRequests.values()) clearTimeout(handle);\n if (synth) {\n await synthesizeBuffered(synth.message, synth.detail);\n await synthesizePending(synth.message, synth.detail);\n }\n await http.close().catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] http.close failed: ${detail}\\n`);\n });\n await stdio.close().catch((err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] stdio.close failed: ${detail}\\n`);\n });\n }\n process.exit(code);\n };\n\n // Plugin hosts typically send `initialize` immediately after spawn (MCP\n // lifecycle §initialization). Deferring shutdown by PREFLIGHT_GRACE_MS\n // lets that request land during the preflight/start window and receive\n // a -32000 reply rather than a silent stdio close. stdio.onclose\n // short-circuits this if the loader closes stdin first.\n function deferredShutdown(synth: { message: string; detail?: string }): void {\n setTimeout(() => void shutdown(1, synth), PREFLIGHT_GRACE_MS);\n }\n\n stdio.onmessage = (msg: JSONRPCMessage) => {\n if (!httpReady) {\n preReadyBuffer.push(msg);\n return;\n }\n forwardToUpstream(msg);\n };\n\n http.onmessage = (msg: JSONRPCMessage) => {\n if (shuttingDown) return;\n // Synchronous delete+clear FIRST — before stdio.send — to prevent the\n // per-request timer from firing in the window between response arrival\n // and stdio write completion (which would synthesize a false -32000 for\n // an id that already has a real response in flight).\n const responseId = getResponseId(msg);\n if (responseId !== undefined) {\n const handle = pendingRequests.get(responseId);\n if (handle !== undefined) {\n clearTimeout(handle);\n pendingRequests.delete(responseId);\n }\n }\n const sendHandler = (err: unknown) => {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `[tandem mcp-stdio] stdio write failed for id ${responseId ?? \"<notification>\"}: ${detail}\\n`,\n );\n // Map entry already deleted above. Synthesize directly for this id,\n // then tear down — stdio is broken so other pending requests can't\n // be delivered either.\n if (responseId !== undefined) {\n void sendErrorResponse(responseId, \"Tandem stdio write failed\", detail);\n }\n void shutdown(1, {\n message: \"Tandem stdio write failed\",\n detail,\n });\n };\n try {\n stdio.send(msg).catch(sendHandler);\n } catch (err) {\n sendHandler(err);\n }\n };\n\n stdio.onerror = (err) => {\n process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}\\n${err.stack ?? \"\"}\\n`);\n };\n http.onerror = (err) => {\n const cause = (err as { cause?: unknown }).cause;\n process.stderr.write(\n `[tandem mcp-stdio] http error: ${err.message}\\n${err.stack ?? \"\"}${cause !== undefined ? `\\ncause: ${cause}` : \"\"}\\n`,\n );\n };\n\n stdio.onclose = () => {\n void shutdown(0);\n };\n http.onclose = () => {\n // We've observed the current @modelcontextprotocol/sdk (0.20.x) only\n // firing onclose from inside its own close() method — i.e., as a\n // consequence of *our* shutdown. The synth branch below is defensive\n // for future SDK versions that may propagate socket-death as onclose.\n // The `shuttingDown` guard prevents double-synth when shutdown() calls\n // http.close() itself.\n if (shuttingDown) return;\n void shutdown(1, {\n message: \"Tandem HTTP upstream closed unexpectedly\",\n detail: \"upstream connection dropped mid-session\",\n });\n };\n\n // Start stdio BEFORE preflight so any `initialize` that arrives during\n // the preflight window is captured and either forwarded once upstream is\n // ready, or answered with -32000 if upstream never comes up.\n await stdio.start();\n\n // The SDK's StdioServerTransport watches stdin for 'data' and 'error' only —\n // it does not call onclose when the plugin host closes stdin (EOF). Register\n // our own one-shot listener so that plugin-host close (stdin.end() / HUP)\n // triggers the same clean shutdown path as stdio.onclose does.\n process.stdin.once(\"end\", () => {\n void shutdown(0);\n });\n\n const probe = await probeTandemServer({ url: baseUrl });\n if (!probe.ok) {\n const guidance =\n probe.kind === \"unreachable\"\n ? \"Start the Tauri app or run `tandem start` on the host, then retry.\"\n : \"The Tandem server is running but unhealthy — check the host logs.\";\n process.stderr.write(\n `[tandem mcp-stdio] Tandem server preflight failed at ${probe.url} (${probe.reason}).\\n` +\n `[tandem mcp-stdio] ${guidance}\\n`,\n );\n const synthMessage =\n probe.kind === \"unreachable\"\n ? \"Tandem server not running. Start the Tauri app or run `tandem start`.\"\n : \"Tandem server unhealthy (check host logs).\";\n deferredShutdown({ message: synthMessage, detail: probe.reason });\n return;\n }\n\n // The current @modelcontextprotocol/sdk's StreamableHTTPClientTransport.start()\n // only creates an AbortController and returns synchronously — this catch is\n // defensive for future SDK versions that may perform real I/O during start().\n try {\n await http.start();\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}\\n`);\n deferredShutdown({ message: \"Tandem HTTP upstream failed to start\", detail });\n return;\n }\n httpReady = true;\n\n // Held to preserve forwarding semantics — push through the normal path\n // now that upstream is ready. Note: forwardToUpstream does not await the\n // http.send, so buffered requests POST in parallel. Plugin hosts wait\n // for `initialize` to resolve before sending follow-ups per MCP spec, so\n // the buffer is usually ≤1 entry; we don't enforce serial ordering here.\n const buffered = preReadyBuffer.splice(0);\n for (const msg of buffered) forwardToUpstream(msg);\n}\n\nexport function getRequestId(msg: JSONRPCMessage): string | number | undefined {\n const m = msg as { id?: unknown; method?: unknown };\n if (typeof m.method !== \"string\") return undefined;\n if (typeof m.id === \"string\" || typeof m.id === \"number\") return m.id;\n return undefined;\n}\n\nexport function getResponseId(msg: JSONRPCMessage): string | number | undefined {\n const m = msg as { id?: unknown; method?: unknown };\n if (typeof m.method === \"string\") return undefined;\n if (typeof m.id === \"string\" || typeof m.id === \"number\") return m.id;\n return undefined;\n}\n","/**\n * Single source of truth for Tandem's internal HTTP path strings.\n *\n * Server route registration (`src/server/mcp/{api,channel}-routes.ts`) and every\n * client/CLI/channel-shim/monitor caller import from here so a rename hits one file.\n */\n\n// --- Channel / event stream (SSE + push-back) -------------------------------\nexport const API_EVENTS = \"/api/events\";\nexport const API_NOTIFY_STREAM = \"/api/notify-stream\";\nexport const API_CHANNEL_AWARENESS = \"/api/channel-awareness\";\nexport const API_CHANNEL_ERROR = \"/api/channel-error\";\nexport const API_CHANNEL_REPLY = \"/api/channel-reply\";\nexport const API_CHANNEL_PERMISSION = \"/api/channel-permission\";\nexport const API_CHANNEL_PERMISSION_VERDICT = \"/api/channel-permission-verdict\";\nexport const API_LAUNCH_CLAUDE = \"/api/launch-claude\";\n\n// --- Mode / metadata --------------------------------------------------------\nexport const API_MODE = \"/api/mode\";\nexport const API_INFO = \"/api/info\";\n// Embedded `tandem doctor` report for the client's \"Copy diagnostics\" button.\n// Loopback-only (the report embeds absolute paths / PIDs).\nexport const API_DIAGNOSTICS = \"/api/diagnostics\";\n// Diagnostic health endpoint. Loopback callers additionally receive\n// `hasSession: boolean` — whether an MCP client transport is currently open\n// (an agent is connected, regardless of whether the auto-launcher spawned it).\nexport const API_HEALTH = \"/health\";\n\n// --- Document lifecycle -----------------------------------------------------\nexport const API_OPEN = \"/api/open\";\nexport const API_CLOSE = \"/api/close\";\nexport const API_SAVE = \"/api/save\";\nexport const API_RENAME = \"/api/rename\";\nexport const API_UPLOAD = \"/api/upload\";\nexport const API_SCRATCHPAD = \"/api/scratchpad\";\nexport const API_CONVERT = \"/api/convert\";\nexport const API_APPLY_CHANGES = \"/api/apply-changes\";\n// Raw-markdown source view/edit (#1021). GET returns the document's literal\n// markdown; POST replaces the Y.Doc content from a user-supplied markdown string.\nexport const API_DOCUMENT_RAW = \"/api/document/raw\";\nexport const API_DOCUMENT_RELOAD = \"/api/document/reload\";\n// Pre-overwrite document backups (#1086). GET lists a document's restorable\n// snapshots; POST restores one through the reload lifecycle.\nexport const API_BACKUPS = \"/api/backups\";\nexport const API_BACKUPS_RESTORE = \"/api/backups/restore\";\n// Resolve a `.docx` external-conflict prompt (#1069): keep the in-memory\n// unsaved edits (re-baseline) or reload fresh from the on-disk file.\nexport const API_DOCX_CONFLICT_RESOLVE = \"/api/docx-conflict/resolve\";\n\n// --- Annotations ------------------------------------------------------------\nexport const API_ANNOTATION_REPLY = \"/api/annotation-reply\";\nexport const API_REMOVE_ANNOTATION = \"/api/remove-annotation\";\n// Self-healing stale store.lock reclaim (#1077) — wired to the\n// store-readonly banner's Reclaim button.\nexport const API_STORE_RECLAIM_LOCK = \"/api/store/reclaim-lock\";\n\n// --- Chat -------------------------------------------------------------------\nexport const API_CHAT = \"/api/chat\";\n\n// --- Sessions (persisted-session management UI, #103) -----------------------\nexport const API_SESSIONS = \"/api/sessions\";\nexport const API_SESSIONS_DELETE = \"/api/sessions/delete\";\nexport const API_SESSIONS_CLEAR = \"/api/sessions/clear\";\n\n// --- Process lifecycle (#1088) ----------------------------------------------\n// Graceful shutdown trigger. The Tauri shell POSTs here before falling back to\n// a hard kill so the Node shutdown sequence (dirty-doc flush + session save)\n// runs on restart/update. Loopback-only; HTTP mode only.\nexport const API_SHUTDOWN = \"/api/shutdown\";\n\n// --- Licensing (#1116, ADR-040) ---------------------------------------------\n// GET status is loopback-full / LAN-scrubbed (the full state carries the\n// licensee name + opaque licenseId). POST activate (PR-C) gates on origin\n// allowlist + loopback inside the handler.\nexport const API_LICENSE_STATUS = \"/api/license/status\";\nexport const API_LICENSE_ACTIVATE = \"/api/license/activate\";\n\n// --- Auth -------------------------------------------------------------------\n// NOTE: the legacy `/api/setup` route was removed in #477 PR 3c-ii-c; setup is\n// now wizard-driven (`POST /api/integrations/apply`) or scriptable via\n// `tandem setup --apply`.\nexport const API_ROTATE_TOKEN = \"/api/rotate-token\";\n\n// --- Auto-launcher (Claude Code supervisor, #477 PR 4b) ---------------------\nexport const API_LAUNCHER_STATUS = \"/api/launcher/status\";\nexport const API_LAUNCHER_NONCE = \"/api/launcher/nonce\";\nexport const API_LAUNCHER_RELAUNCH = \"/api/launcher/relaunch\";\nexport const API_LAUNCHER_START_FRESH = \"/api/launcher/start-fresh\";\nexport const API_LAUNCHER_WORKING_DIRECTORY = \"/api/launcher/working-directory\";\n","/**\n * Shared fetch-with-timeout helper.\n *\n * Used by `src/monitor/index.ts` and `src/channel/` (event-bridge + run) to\n * give every outbound HTTP call a bounded deadline. Without this, a half-open\n * upstream wedges the caller silently — see #336 (silent failures) and #364\n * (event-bridge transport timeout symmetry).\n *\n * Pure native `fetch` + `AbortSignal.timeout` so it can be imported from any\n * surface without dragging in server deps. Routes through `authFetch` so the\n * resolved Tandem auth token is forwarded automatically when set.\n *\n * `describeFetchError` formats timeout aborts as `<endpoint> timed out after\n * <ms>ms` so logs name the hung endpoint instead of the generic\n * \"operation was aborted\" string from AbortError/TimeoutError.\n */\n\nimport { authFetch } from \"./cli-runtime.js\";\n\n/**\n * Fetch with a per-request deadline.\n *\n * **Do not use for SSE handshake-then-stream patterns** — applying a fetch-level\n * timeout to a streaming response also aborts the body `ReadableStream` when\n * the timeout fires, killing the stream at `timeoutMs`. Use a local\n * `AbortController` cleared after the handshake settles for that case.\n */\nexport async function fetchWithTimeout(\n url: string,\n init: RequestInit,\n timeoutMs: number,\n): Promise<Response> {\n const timeoutSignal = AbortSignal.timeout(timeoutMs);\n const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;\n return authFetch(url, { ...init, signal });\n}\n\n/**\n * Format a fetch error for logs. Recognizes `TimeoutError` / `AbortError`\n * (both names that `AbortSignal.timeout` and manual aborts produce) and tags\n * them with the endpoint + threshold so log lines name the hung request.\n */\nexport function describeFetchError(err: unknown, endpoint: string, timeoutMs: number): string {\n if (err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\")) {\n return `${endpoint} timed out after ${timeoutMs}ms`;\n }\n return err instanceof Error ? err.message : String(err);\n}\n\n/** True iff `err` is an AbortError or TimeoutError (the names produced by\n * AbortSignal.timeout and manual aborts). Channel callers re-throw these\n * through broad catches so timeouts surface as structured errors instead of\n * being swallowed as \"non-JSON response\" fake-success. */\nexport function isAbortOrTimeoutError(err: unknown): boolean {\n return err instanceof Error && (err.name === \"TimeoutError\" || err.name === \"AbortError\");\n}\n","function generateId(prefix: string): string {\n return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n}\n\nexport function generateAnnotationId(): string {\n return generateId(\"ann\");\n}\n\nexport function generateMessageId(): string {\n return generateId(\"msg\");\n}\n\nexport function generateEventId(): string {\n return generateId(\"evt\");\n}\n\nexport function generateReplyId(): string {\n return generateId(\"rpl\");\n}\n\nexport function generateNotificationId(): string {\n return generateId(\"ntf\");\n}\n\nexport function generateAuthorshipId(author: \"user\" | \"claude\"): string {\n return generateId(author);\n}\n","/**\n * Event types for the Tandem → Claude Code channel.\n *\n * These events flow from browser-originated Y.Map changes through an SSE\n * endpoint to the channel shim, which pushes them into Claude Code as\n * `notifications/claude/channel` messages.\n *\n * This module lives in `src/shared/` so that `src/channel/` and\n * `src/monitor/` can import wire-protocol types without crossing the\n * server layer boundary.\n */\n\nimport type { AnnotationType, ReplyAuthor } from \"../types.js\";\n\n// --- Per-event payload interfaces ---\n\nexport interface AnnotationCreatedPayload {\n annotationId: string;\n annotationType: AnnotationType;\n content: string;\n textSnippet: string;\n hasSuggestedText?: boolean;\n}\n\nexport interface AnnotationAcceptedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface AnnotationDismissedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface ChatMessagePayload {\n messageId: string;\n text: string;\n replyTo: string | null;\n anchor: { from: number; to: number; textSnapshot: string } | null;\n /** Buffered selection context at the time the chat message was sent. */\n selection?: { from: number; to: number; selectedText: string } | { selectedText: string };\n}\n\nexport interface DocumentOpenedPayload {\n fileName: string;\n format: string;\n}\n\nexport interface DocumentClosedPayload {\n fileName: string;\n}\n\nexport interface AnnotationReplyPayload {\n annotationId: string;\n replyId: string;\n replyText: string;\n replyAuthor: ReplyAuthor;\n textSnippet: string;\n}\n\nexport interface DocumentSwitchedPayload {\n fileName: string;\n}\n\nexport interface AnnotationEditedPayload {\n annotationId: string;\n content: string;\n textSnippet: string;\n editedAt: number;\n}\n\n// --- Discriminated union ---\n\ninterface TandemEventBase {\n /** Timestamp-based unique ID for SSE `Last-Event-ID` reconnection. Format: `evt_<timestamp>_<rand>`. Roughly ordered but not strictly monotonic. */\n id: string;\n timestamp: number;\n /** Which document this event relates to (absent for global events). */\n documentId?: string;\n}\n\nexport type TandemEvent =\n | (TandemEventBase & { type: \"annotation:created\"; payload: AnnotationCreatedPayload })\n | (TandemEventBase & { type: \"annotation:accepted\"; payload: AnnotationAcceptedPayload })\n | (TandemEventBase & { type: \"annotation:dismissed\"; payload: AnnotationDismissedPayload })\n | (TandemEventBase & { type: \"annotation:reply\"; payload: AnnotationReplyPayload })\n | (TandemEventBase & { type: \"chat:message\"; payload: ChatMessagePayload })\n | (TandemEventBase & { type: \"document:opened\"; payload: DocumentOpenedPayload })\n | (TandemEventBase & { type: \"document:closed\"; payload: DocumentClosedPayload })\n | (TandemEventBase & { type: \"document:switched\"; payload: DocumentSwitchedPayload })\n | (TandemEventBase & { type: \"annotation:edited\"; payload: AnnotationEditedPayload });\n\n/** Union of all event type discriminants. */\nexport type TandemEventType = TandemEvent[\"type\"];\n\n// Re-export from shared utils (single ID generation pattern)\nexport { generateEventId } from \"../utils.js\";\n\n// --- Parse guard for SSE consumers ---\n\nconst VALID_EVENT_TYPES = new Set<TandemEventType>([\n \"annotation:created\",\n \"annotation:accepted\",\n \"annotation:dismissed\",\n \"annotation:edited\",\n \"annotation:reply\",\n \"chat:message\",\n \"document:opened\",\n \"document:closed\",\n \"document:switched\",\n]);\n\n/**\n * Validate a JSON-parsed value as a TandemEvent.\n * Used by the event-bridge to safely consume SSE data.\n */\nexport function parseTandemEvent(raw: unknown): TandemEvent | null {\n if (\n typeof raw !== \"object\" ||\n raw === null ||\n !(\"id\" in raw) ||\n typeof (raw as Record<string, unknown>).id !== \"string\" ||\n !(\"type\" in raw) ||\n !VALID_EVENT_TYPES.has((raw as Record<string, unknown>).type as TandemEventType) ||\n !(\"timestamp\" in raw) ||\n typeof (raw as Record<string, unknown>).timestamp !== \"number\" ||\n !(\"payload\" in raw) ||\n typeof (raw as Record<string, unknown>).payload !== \"object\"\n ) {\n return null;\n }\n return raw as TandemEvent;\n}\n\n/**\n * Convert a TandemEvent into a human-readable string for the channel `content` field.\n * Claude sees this text inside `<channel source=\"tandem-channel\">` tags.\n */\nexport function formatEventContent(event: TandemEvent): string {\n const doc = event.documentId ? ` [doc: ${event.documentId}]` : \"\";\n\n switch (event.type) {\n case \"annotation:created\": {\n const { annotationType, content, textSnippet, hasSuggestedText } = event.payload;\n const snippet = textSnippet ? ` on \"${textSnippet}\"` : \"\";\n const label = hasSuggestedText ? \"replacement\" : annotationType;\n return `User created ${label}${snippet}: ${content || \"(no content)\"}${doc}`;\n }\n case \"annotation:accepted\": {\n const { annotationId, textSnippet } = event.payload;\n return `User accepted annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"annotation:dismissed\": {\n const { annotationId, textSnippet } = event.payload;\n return `User dismissed annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"annotation:edited\": {\n const { content } = event.payload;\n return `User edited annotation: \"${content}\"${doc}`;\n }\n case \"annotation:reply\": {\n const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;\n const who = replyAuthor === \"claude\" ? \"Claude\" : \"User\";\n const snippet = textSnippet ? ` (on \"${textSnippet}\")` : \"\";\n return `${who} replied to annotation ${annotationId}${snippet}: ${replyText}${doc}`;\n }\n case \"chat:message\": {\n const { text, replyTo, selection } = event.payload;\n const reply = replyTo ? ` (replying to ${replyTo})` : \"\";\n const sel =\n selection && selection.selectedText\n ? ` [selection: \"${selection.selectedText}\"${\"from\" in selection ? ` (${selection.from}-${selection.to})` : \"\"}]`\n : \"\";\n return `User says${reply}: ${text}${sel}${doc}`;\n }\n case \"document:opened\": {\n const { fileName, format } = event.payload;\n return `User opened document: ${fileName} (${format})${doc}`;\n }\n case \"document:closed\": {\n const { fileName } = event.payload;\n return `User closed document: ${fileName}${doc}`;\n }\n case \"document:switched\": {\n const { fileName } = event.payload;\n return `User switched to document: ${fileName}${doc}`;\n }\n default: {\n const _exhaustive: never = event;\n void _exhaustive;\n return `Unknown event${doc}`;\n }\n }\n}\n\n/**\n * Build the `meta` record for a channel notification.\n * Keys use underscores only (Channels API silently drops hyphenated keys).\n */\nexport function formatEventMeta(event: TandemEvent): Record<string, string> {\n const meta: Record<string, string> = {\n event_type: event.type,\n };\n if (event.documentId) meta.document_id = event.documentId;\n\n switch (event.type) {\n case \"annotation:created\":\n case \"annotation:accepted\":\n case \"annotation:dismissed\":\n meta.annotation_id = event.payload.annotationId;\n break;\n case \"annotation:edited\":\n meta.annotation_id = event.payload.annotationId;\n meta.edited_at = String(event.payload.editedAt);\n break;\n case \"annotation:reply\":\n meta.annotation_id = event.payload.annotationId;\n meta.reply_id = event.payload.replyId;\n break;\n case \"chat:message\":\n meta.message_id = event.payload.messageId;\n if (event.payload.selection?.selectedText) meta.has_selection = \"true\";\n break;\n case \"document:opened\":\n case \"document:closed\":\n case \"document:switched\":\n break;\n default: {\n const _exhaustive: never = event;\n void _exhaustive;\n break;\n }\n }\n\n return meta;\n}\n","/**\n * Shared types for the position/coordinate system.\n *\n * Three coordinate systems exist in Tandem:\n * 1. Flat text offsets — server-side, includes heading prefixes and \\n separators\n * 2. ProseMirror positions — client-side, structural node positions\n * 3. Yjs RelativePositions — CRDT-anchored, survive concurrent edits\n *\n * This module defines the shared vocabulary. Environment-specific logic lives in:\n * - src/server/positions.ts (Y.Doc operations)\n * - src/client/positions.ts (ProseMirror operations)\n */\n\n// ---------------------------------------------------------------------------\n// Branded types — compile-time guards against mixing coordinate systems\n// ---------------------------------------------------------------------------\n\ndeclare const FlatOffsetBrand: unique symbol;\ndeclare const PmPosBrand: unique symbol;\ndeclare const SerializedRelPosBrand: unique symbol;\n\n/** Flat text offset (includes heading prefixes & \\n separators). Server/MCP boundary. */\nexport type FlatOffset = number & { readonly [FlatOffsetBrand]: true };\n\n/** ProseMirror position (structural node boundaries). Client-side only. */\nexport type PmPos = number & { readonly [PmPosBrand]: true };\n\n/** JSON-serialized Y.js RelativePosition. Opaque — only created/consumed by position modules. */\nexport type SerializedRelPos = unknown & { readonly [SerializedRelPosBrand]: true };\n\n// ---------------------------------------------------------------------------\n// Factory functions — cast raw values into branded types\n// ---------------------------------------------------------------------------\n\nexport const toFlatOffset = (n: number): FlatOffset => n as FlatOffset;\nexport const toPmPos = (n: number): PmPos => n as PmPos;\nexport const toSerializedRelPos = (json: unknown): SerializedRelPos => json as SerializedRelPos;\n\n// ---------------------------------------------------------------------------\n// Range and result types\n// ---------------------------------------------------------------------------\n\n/** Flat-offset range used by MCP tools and annotations. */\nexport interface DocumentRange {\n from: FlatOffset;\n to: FlatOffset;\n}\n\n/** CRDT-anchored range that survives concurrent edits. Serialized via Y.relativePositionToJSON(). */\nexport interface RelativeRange {\n fromRel: SerializedRelPos;\n toRel: SerializedRelPos;\n}\n\n/** Result of validating a flat-offset range against a document. */\nexport type RangeValidation =\n | { ok: true; range: DocumentRange }\n | { ok: false; code: \"RANGE_GONE\" }\n | { ok: false; code: \"RANGE_MOVED\"; resolvedFrom: FlatOffset; resolvedTo: FlatOffset }\n | { ok: false; code: \"INVALID_RANGE\"; message: string }\n | { ok: false; code: \"HEADING_OVERLAP\" };\n\n/** Result of anchoredRange: validated flat + CRDT-anchored range ready to store on an Annotation. */\nexport type AnchoredRangeResult =\n | { ok: true; fullyAnchored: true; range: DocumentRange; relRange: RelativeRange }\n | { ok: true; fullyAnchored: false; range: DocumentRange; relRange?: undefined };\n\n/** A resolved element position inside a Y.Doc XmlFragment. */\nexport interface ElementPosition {\n elementIndex: number;\n /** Character offset within the element's text. Always 0 when clampedFromPrefix is true. */\n textOffset: number;\n /** True if the original offset fell inside a heading prefix and was clamped to 0 */\n clampedFromPrefix: boolean;\n}\n\n/** Resolution method used by annotationToPmRange, for diagnostic observability. */\nexport type ResolutionMethod = \"rel\" | \"flat\";\n\n/** Result of resolving an annotation to ProseMirror positions. */\nexport interface PmRangeResult {\n from: PmPos;\n to: PmPos;\n /** Which coordinate path was used to resolve the range. */\n method: ResolutionMethod;\n}\n\n/**\n * Tagged variant for the outcome of `refreshRange` (ADR-032).\n *\n * Each kind names a distinct resolution path the function previously\n * collapsed into a bare `Annotation` return:\n * - `ok` — annotation unchanged; range was already healthy\n * - `updated` — `relRange` resolved to new offsets; flat `range` was rewritten\n * - `attached` — annotation had no `relRange`; one was computed from the flat range\n * - `repaired` — dead `relRange` was re-anchored from the flat range\n * - `degraded` — dead `relRange` was stripped; annotation is now flat-only and will\n * be lazy-attached on a later read if conditions improve\n * - `failed` — `from > to` after refresh (\"inverted CRDT range\" — concurrent\n * edits moved the anchors past each other). Annotation is returned\n * unchanged for the caller's inspection.\n */\nexport type RefreshResult = {\n kind: \"ok\" | \"updated\" | \"attached\" | \"repaired\" | \"degraded\" | \"failed\";\n annotation: import(\"../types.js\").Annotation;\n};\n","import { z } from \"zod\";\nimport type { DocumentRange, RelativeRange } from \"./positions/types.js\";\n\n// Canonical definitions live in the positions module; re-exported for backward compatibility.\nexport type {\n DocumentRange,\n FlatOffset,\n PmPos,\n RelativeRange,\n SerializedRelPos,\n} from \"./positions/types.js\";\nexport { toFlatOffset, toPmPos, toSerializedRelPos } from \"./positions/types.js\";\n\n// --- Zod schemas (source of truth) ---\n\nexport const AnnotationTypeSchema = z.enum([\"highlight\", \"note\", \"comment\"]);\n\nexport const AnnotationStatusSchema = z.enum([\"pending\", \"accepted\", \"dismissed\"]);\nexport const HighlightColorSchema = z.enum([\"yellow\", \"green\", \"blue\", \"pink\"]);\nexport const SeveritySchema = z.enum([\"info\", \"warning\", \"error\", \"success\"]);\nexport const TandemModeSchema = z.enum([\"solo\", \"tandem\"]);\nexport const AuthorSchema = z.enum([\"user\", \"claude\", \"import\"]);\n/** Reply authors. `import` carries Word-comment reply threads (#1000); such replies are user-private. */\nexport const ReplyAuthorSchema = z.enum([\"user\", \"claude\", \"import\"]);\nexport const AnnotationActionSchema = z.enum([\"accept\", \"dismiss\"]);\nexport const ExportFormatSchema = z.enum([\"markdown\", \"json\"]);\nexport const DocumentFormatSchema = z.enum([\"md\", \"txt\", \"html\", \"docx\"]);\nexport const ToolErrorCodeSchema = z.enum([\n \"RANGE_GONE\",\n \"RANGE_MOVED\",\n \"FILE_LOCKED\",\n \"FILE_NOT_FOUND\",\n \"NO_DOCUMENT\",\n \"INVALID_RANGE\",\n \"INVALID_ARGUMENT\",\n \"NOT_FOUND\",\n \"ANNOTATION_RESOLVED\",\n \"FORMAT_ERROR\",\n \"PERMISSION_DENIED\",\n]);\n\n/**\n * Identifier strings the channel shim or monitor can POST to\n * `/api/channel-error` on terminal failure. The server logs them; defining\n * them as a closed set lets call sites import the constants instead of\n * free-form strings, and the route handler can validate before logging.\n */\nexport const ChannelErrorCodeSchema = z.enum([\"CHANNEL_CONNECT_FAILED\", \"MONITOR_CONNECT_FAILED\"]);\nexport type ChannelErrorCode = z.infer<typeof ChannelErrorCodeSchema>;\nexport const CHANNEL_CONNECT_FAILED: ChannelErrorCode = \"CHANNEL_CONNECT_FAILED\";\nexport const MONITOR_CONNECT_FAILED: ChannelErrorCode = \"MONITOR_CONNECT_FAILED\";\n\n// --- Derived TypeScript types ---\n\nexport type AnnotationType = z.infer<typeof AnnotationTypeSchema>;\nexport type AnnotationStatus = z.infer<typeof AnnotationStatusSchema>;\nexport type TandemMode = z.infer<typeof TandemModeSchema>;\nexport type HighlightColor = z.infer<typeof HighlightColorSchema>;\nexport type Severity = z.infer<typeof SeveritySchema>;\nexport type ReplyAuthor = z.infer<typeof ReplyAuthorSchema>;\n\n// --- Reply types ---\n\nexport interface AnnotationReply {\n id: string;\n annotationId: string;\n author: ReplyAuthor;\n text: string;\n timestamp: number;\n editedAt?: number;\n /**\n * ADR-027/#1000: when true, this reply is user-private and must NEVER reach\n * Claude — not via the channel, `tandem_getAnnotations`, or\n * `tandem_exportAnnotations`. Set at creation for replies authored on a note\n * and for imported Word replies. Privacy is a durable property of the reply,\n * not of the parent's current type, so a later note→comment promotion cannot\n * back-publish it. Claude-facing reads strip it via `channelVisibleReplies`.\n */\n private?: boolean;\n /**\n * For `author: \"import\"` replies: the original Word reviewer name, shown as a\n * byline in the client. Mirrors `Annotation.importSource.author`. Stored at\n * rest in the durable JSON; never serialized to any Claude-facing surface.\n */\n importAuthor?: string;\n /**\n * Durable-annotation last-writer-wins counter. Server-internal field\n * mirrored from the on-disk envelope schema (see\n * `src/server/annotations/schema.ts` `AnnotationReplyRecordV1`). Optional\n * here so client code and legacy in-memory state that predates the durable\n * store don't trip TS. Every server-side write bumps this; legacy entries\n * lacking `rev` are treated as `rev: 0` on merge.\n */\n rev?: number;\n}\n\n// --- Annotation types ---\n\ninterface AnnotationBase {\n id: string;\n author: \"user\" | \"claude\" | \"import\";\n range: DocumentRange;\n /** CRDT-anchored range that survives edits. Falls back to `range` if absent. */\n relRange?: RelativeRange;\n content: string;\n status: AnnotationStatus;\n timestamp: number;\n /** Snapshot of the annotated document text at creation time. Truncated to 200 chars. */\n textSnapshot?: string;\n /** Timestamp of last edit to the annotation content. */\n editedAt?: number;\n /**\n * Durable-annotation last-writer-wins counter. Server-internal field\n * mirrored from the on-disk envelope schema (see\n * `src/server/annotations/schema.ts` `AnnotationRecordV1`). Optional here\n * so legacy session-restored state (pre-durable-store) and client code\n * that doesn't care about durability still type-check. Every server-side\n * user-intent write bumps this; entries lacking `rev` are treated as\n * `rev: 0` by the merge/sync code.\n */\n rev?: number;\n /** When true, marks this annotation as created during Solo mode. Consumers use this to hold back display until mode changes. */\n heldInSolo?: boolean;\n /** Audience: 'private' = personal (note/highlight), 'outbound' = visible to Claude. Derived by AR1 migration on read for legacy annotations. */\n audience?: \"private\" | \"outbound\";\n /** Set when this annotation was promoted from a note via \"Send to Claude\". */\n promotedFrom?: \"note\";\n /**\n * For import-author annotations: original Word author and source file.\n * `commentId` is the original Word `w:id` from `comments.xml` (#1068) —\n * reused on .docx export so a promoted Word comment keeps its identity\n * across save → re-open (deterministic `importAnnotationId` dedup).\n */\n importSource?: { author: string; file: string; commentId?: string };\n}\n\n/**\n * Discriminated union for annotations. Three canonical types:\n * - `highlight` — visual marker with color, not sent to Claude\n * - `note` — personal text annotation, findable but Claude doesn't act\n * - `comment` — text for Claude; optionally carries `suggestedText` (replacement)\n */\nexport type Annotation =\n | (AnnotationBase & {\n type: \"highlight\";\n color?: HighlightColor;\n suggestedText?: undefined;\n })\n | (AnnotationBase & {\n type: \"note\";\n color?: undefined;\n suggestedText?: undefined;\n })\n | (AnnotationBase & {\n type: \"comment\";\n color?: undefined;\n suggestedText?: string;\n });\n\n/**\n * Returns true for annotations that should be reviewed (accepted/dismissed).\n * User-authored notes are personal and never review targets.\n * Import-authored (.docx Word comments) ARE review targets — the primary docx use case.\n */\nexport function isReviewTarget(a: Annotation): boolean {\n return a.author !== \"user\";\n}\n\n/** Convenience: pending status AND a review target — used at bulk-action and keyboard-nav callsites. */\nexport function isPendingReviewTarget(a: Annotation): boolean {\n return a.status === \"pending\" && isReviewTarget(a);\n}\n\n/**\n * Authorship tracking range stored in Y.Map('authorship').\n * Uses the same flat-offset coordinate system as annotations.\n * RelativePositions anchor the range to survive concurrent edits.\n */\nexport interface AuthorshipRange {\n id: string;\n author: \"user\" | \"claude\";\n range: DocumentRange;\n /** CRDT-anchored range for edit survival. */\n relRange?: RelativeRange;\n /** Timestamp of when this range was created. */\n timestamp: number;\n}\n\nexport interface AnchoredRange {\n start: { nodeId: string; offset: number };\n end: { nodeId: string; offset: number };\n textSnapshot: string;\n stale: boolean;\n}\n\nexport interface OverlayEntry {\n id: string;\n overlayId: string;\n range: AnchoredRange;\n score: string;\n numericScore?: number;\n detail: {\n summary: string;\n explanation: string;\n suggestion?: string;\n severity?: Severity;\n references?: Array<{ label: string; url?: string; documentNodeId?: string }>;\n };\n dismissed: boolean;\n accepted?: boolean;\n data: Record<string, unknown>;\n}\n\nexport interface OverlayDefinition {\n id: string;\n label: string;\n type: string;\n visible: boolean;\n mode: \"snapshot\" | \"live\";\n entries: OverlayEntry[];\n createdAt: number;\n updatedAt: number;\n}\n\nexport interface DocumentGroup {\n id: string;\n name: string;\n documents: DocumentInfo[];\n createdAt: number;\n}\n\nexport interface DocumentInfo {\n id: string;\n filePath: string;\n fileName: string;\n format: z.infer<typeof DocumentFormatSchema>;\n tokenEstimate: number;\n pageEstimate: number;\n readOnly: boolean;\n}\n\nexport interface ToolSuccess<T = unknown> {\n error: false;\n data: T;\n version?: string;\n}\n\nexport interface ToolError {\n error: true;\n code: z.infer<typeof ToolErrorCodeSchema>;\n message: string;\n details?: Record<string, unknown>;\n}\n\nexport type ToolResponse<T = unknown> = ToolSuccess<T> | ToolError;\n\n/** Claude's awareness state as stored in Y.Map('awareness') key 'claude' */\nexport interface ClaudeAwareness {\n status: string;\n timestamp: number;\n active: boolean;\n focusParagraph: number | null;\n /** Flat character offset for character-level cursor positioning. */\n focusOffset: number | null;\n /**\n * Typing-presence indicator (#651). When set, Claude is actively executing\n * an MCP tool. `annotationId` (when present) lets per-card UI render an\n * inline typing indicator; an absent annotationId indicates a generic\n * \"Claude is working\" state surfaced in the status bar.\n *\n * ADR-027: never broadcast `annotationId` for `type === \"note\"` annotations\n * (the server middleware enforces this on write).\n */\n working?: {\n tool: string;\n annotationId?: string;\n /** Display-only wall-clock start time (ms). NOT an ownership key — see `token`. */\n startedAt: number;\n /**\n * Monotonic, collision-free ownership token (#823). Two same-doc tool calls\n * in the same millisecond would collide on `startedAt`; the clear path keys\n * identity on this counter instead so finishing one handler never wipes\n * another's still-active marker. Optional for back-compat with snapshots\n * written before #823.\n */\n token?: number;\n } | null;\n}\n\nexport interface SessionData {\n filePath: string;\n format: string;\n ydocState: string; // Base64-encoded Y.encodeStateAsUpdate()\n sourceFileMtime: number; // Source file mtime at save — detect external changes on resume\n lastAccessed: number;\n /**\n * True when the Y.Doc held unsaved (not-written-to-disk) body edits at\n * session-save time (#1069). Drives the `.docx` restore-vs-reload prompt:\n * a dirty `.docx` session is the ONLY copy of those edits (binary formats\n * never auto-save to disk), so restore keeps it even when the source file\n * changed, and the user is prompted to keep or reload. Absent/false on\n * sessions written before this field existed — treated as clean.\n */\n dirty?: boolean;\n}\n\n/**\n * Per-document external-conflict state (#1069, `.docx` only). Stored in\n * Y_MAP_DOCUMENT_META under Y_MAP_EXTERNAL_CONFLICT while the document's\n * unsaved edits diverge from the on-disk source.\n */\nexport interface ExternalConflictState {\n /**\n * - \"external-edit\": the source file changed on disk while the open document\n * holds unsaved edits (file-watcher detection). Explicit save is blocked by\n * the external-modification guard until resolved.\n * - \"unsaved-restore\": a session carrying unsaved edits was restored on\n * reopen/restart; the in-memory document diverges from the on-disk file.\n */\n kind: \"external-edit\" | \"unsaved-restore\";\n /** True when the on-disk mtime diverged from the session/save baseline. Always true for \"external-edit\". */\n diskChanged: boolean;\n detectedAt: number;\n}\n\n/**\n * Per-document docx fidelity report (#1145, `.docx` only) — the \"honesty layer\".\n * Tells the user what won't round-trip BEFORE they invest edits. Stored under\n * Y_MAP_DOCUMENT_META at Y_MAP_FIDELITY_REPORT; server write-only, client reads\n * it to render a calm, self-erasing notice (hidden while both lists are empty).\n */\nexport interface FidelityReport {\n /**\n * Word features mammoth dropped on import (footnotes, headers/footers,\n * tracked changes, custom styles — the round-trip ceiling). Set at open and\n * re-set on every re-import (force-reload, file-watcher reload).\n */\n importLosses: string[];\n /**\n * Content the export downgraded on the most recent save (unsupported blocks,\n * non-`data:` images). Refreshed each binary save; reset by a re-import.\n * These are ANNOUNCED, expected downgrades — rendered as a calm/info notice.\n */\n exportDowngrades: string[];\n /**\n * Post-write verification advisories (#1123 Phase 0e). Distinct from\n * `exportDowngrades`: these flag content the save may have lost UNEXPECTEDLY\n * (a comment or footnote body that didn't survive a verify reimport, a\n * gross-but-not-blocking text-retention shortfall) — a louder, warning-level\n * signal with a restore affordance, never folded into the \"N features\n * simplified\" count. CONTENT-FREE by construction: fixed strings + counts\n * only, never document text (the advisory is also Claude-visible via the\n * `tandem_save` MCP result). Optional for forward-compat: pre-0e reports lack\n * it, so every reader uses `?? []`. Refreshed each binary save; reset by a\n * re-import.\n */\n integrityWarnings?: string[];\n /** ms epoch of the last update. */\n updatedAt: number;\n}\n\n/**\n * A reconstructed Word footnote body (#1123 Tier-A #3 PR 2). Captured from\n * `word/footnotes.xml` on import, stored off-fragment under\n * Y_MAP_DOCUMENT_META at Y_MAP_FOOTNOTE_BODIES (keyed by the OOXML footnote id,\n * the same id mammoth puts in its `#footnote-N` href), and re-emitted as a real\n * `<w:footnote>` on export. Server write-only, opaque to the client and Claude.\n */\nexport interface FootnoteBody {\n /**\n * Plain body text. Rich body formatting (bold/italic, multi-paragraph) is\n * deliberately flattened to plain text in PR 2 and reported honestly via\n * `hadFormatting`; rich-body fidelity is a deferred fast-follow.\n */\n text: string;\n /**\n * Whether the source OOXML body carried formatting we drop on import\n * (`<w:b>`/`<w:i>`/`<w:u>`/`<w:hyperlink>` or >1 `<w:p>`). Drives a count-only\n * honesty line — NEVER thread the body text through the loss-line path (it\n * bypasses the mammoth-message redaction; see `footnoteLossLines`).\n */\n hadFormatting: boolean;\n}\n\n/** Text selection snapshot captured when opening chat, attached to the next outgoing ChatMessage as its anchor. */\nexport interface CapturedAnchor extends DocumentRange {\n textSnapshot: string;\n}\n\n/** Chat message between user and Claude, stored in Y.Map('chat') on CTRL_ROOM */\nexport interface ChatMessage {\n id: string;\n author: \"user\" | \"claude\";\n text: string;\n timestamp: number;\n documentId?: string;\n anchor?: CapturedAnchor;\n replyTo?: string;\n read: boolean;\n}\n\n/** Server-to-client ephemeral notification (toast). Not persisted via CRDT. */\nexport interface TandemNotification {\n id: string;\n type:\n | \"annotation-error\"\n | \"save-error\"\n | \"session-restored\"\n | \"general-error\"\n | \"file-reloaded\"\n | \"review-pending\"\n | \"external-conflict\"\n | \"launcher\";\n severity: \"info\" | \"warning\" | \"error\";\n message: string;\n documentId?: string;\n dedupKey?: string;\n timestamp: number;\n toolName?: string;\n errorCode?: string;\n}\n","/**\n * Shared SSE consumer for the Tandem channel shim and plugin monitor.\n *\n * Extracted in #282 to deduplicate ~140 lines of retry / frame-parse /\n * awareness / mode-cache logic that used to be copy-pasted between\n * `src/channel/event-bridge.ts` and `src/monitor/index.ts`.\n *\n * Callers inject their per-event delivery mechanism via the `onEvent`\n * callback. The shared module never touches the MCP SDK or stdout — that\n * preserves the MCP-free constraint and keeps the channel shim and monitor\n * free to evolve their delivery surfaces independently.\n *\n * Per-request timeouts (#364) mirror the original monitor pattern: every\n * outbound fetch has a bounded deadline, the SSE body has an inactivity\n * watchdog, and the parse buffer is capped so a malformed upstream can't\n * OOM us. Without these, a half-open Tandem server wedges the consumer\n * silently.\n *\n * Mode-cache policy: stale-preserving. Once a real mode has been observed\n * from `/api/mode`, a transient fetch failure (network error or non-OK)\n * NEVER changes the cached mode — the consumer keeps reporting the last\n * successfully-fetched value. The mode only changes when the server reports\n * a different mode (i.e. the user actually toggled Solo/Tandem). This holds\n * for ALL failure paths, including the startup warm-up / first-fetch path:\n * a failure after a successful fetch can never downgrade a known mode.\n * The hardcoded `TANDEM_MODE_DEFAULT` is used ONLY on a genuine cold start —\n * a failure before any successful fetch has ever landed. The channel and\n * monitor previously diverged here (channel failed open to \"tandem\", monitor\n * failed closed to \"solo\"); both flipped the mode to a default on a hiccup.\n * Neither honored the user directive that mode must not change unless the\n * user changes it — stale-preserving does.\n *\n * Retry policy: exponential backoff with stable-uptime reset (monitor's\n * pattern). The channel previously reset retries on every successful event\n * — bringing exponential backoff + stable uptime gives the channel the\n * same robustness guarantees.\n *\n * Frame-skip policy: advance `lastEventId` past malformed-JSON and\n * failed-validation frames (channel's \"advance past garbage\" pattern). The\n * monitor previously did NOT advance on these — its catch / `!event`\n * branches just `continue`d, so a permanently-unparseable frame would be\n * replayed from `Last-Event-ID` on every reconnect forever (an infinite\n * parse-fail loop). Unifying on the channel's semantics fixes that latent\n * infinite re-delivery bug.\n */\n\nimport { API_CHANNEL_AWARENESS, API_CHANNEL_ERROR, API_EVENTS, API_MODE } from \"./api-paths.js\";\nimport { authFetch } from \"./cli-runtime.js\";\nimport {\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n CHANNEL_ERROR_REPORT_TIMEOUT_MS,\n CHANNEL_MAX_RETRIES,\n CHANNEL_MAX_SSE_BUFFER_BYTES,\n CHANNEL_MODE_FETCH_TIMEOUT_MS,\n CHANNEL_RETRY_DELAY_MS,\n CHANNEL_SSE_INACTIVITY_TIMEOUT_MS,\n TANDEM_MODE_DEFAULT,\n} from \"./constants.js\";\nimport type { TandemEvent } from \"./events/types.js\";\nimport { parseTandemEvent } from \"./events/types.js\";\nimport { describeFetchError, fetchWithTimeout } from \"./fetch-with-timeout.js\";\nimport { type ChannelErrorCode, type TandemMode, TandemModeSchema } from \"./types.js\";\n\nconst AWARENESS_DEBOUNCE_MS = 500;\nconst AWARENESS_CLEAR_MS = 3000;\nconst MODE_CACHE_TTL_MS = 2000;\nconst STABLE_CONNECTION_MS = 60_000; // Reset retries after this much continuous uptime\nconst RETRY_MAX_DELAY_MS = 30_000; // Exponential backoff cap\n\nexport interface EventConsumerOptions {\n /** Base URL of the Tandem server (no trailing slash). */\n tandemUrl: string;\n /** Log prefix used in stderr lines (e.g. `[Channel]` or `[Monitor]`). */\n logPrefix: string;\n /** Error code POSTed to /api/channel-error on retry exhaustion. */\n errorCode: ChannelErrorCode;\n /**\n * Per-event delivery callback. Called for every parsed, non-suppressed\n * SSE event. If this throws or rejects, `lastEventId` is NOT advanced and\n * the stream is torn down so the retry loop reconnects with the previous\n * `Last-Event-ID` — the server replays the dropped event.\n */\n onEvent: (event: TandemEvent, eventId: string | undefined) => Promise<void> | void;\n /**\n * Optional hook called after the retry-exhaustion error POST returns but\n * before `process.exit(1)`. The monitor uses it to write the visible\n * \"disconnected\" notice to stdout. Default is a noop.\n */\n onExhaustion?: () => void;\n}\n\n// --- Module-level state ---\n//\n// Kept at module scope (not function-local) so `flushFinalAwareness` (called\n// from the monitor's signal handler) can drain in-flight awareness POSTs\n// and send the shutdown clear. `_resetSseConsumerStateForTests` clears\n// every byte of state below in one call.\n\nconst shutdownTimers: {\n awarenessTimer: ReturnType<typeof setTimeout> | null;\n clearAwarenessTimer: ReturnType<typeof setTimeout> | null;\n lastDocumentId: string | null;\n} = { awarenessTimer: null, clearAwarenessTimer: null, lastDocumentId: null };\n\n/** Outstanding awareness POSTs — drained on shutdown so the server's last\n * seen awareness is the shutdown \"active:false\", not a racing update. */\nconst outstandingAwareness = new Set<Promise<unknown>>();\nfunction trackAwareness(p: Promise<unknown>): void {\n outstandingAwareness.add(p);\n p.finally(() => outstandingAwareness.delete(p));\n}\n\nlet cachedMode: TandemMode = TANDEM_MODE_DEFAULT;\nlet cachedModeAt = 0;\nlet cachedModeFailedAt = 0;\nlet _modeRefreshInFlight: Promise<void> | null = null;\n\n// --- Public entry point ---\n\n/**\n * Drive the SSE consumer: connect, parse frames, deliver events via\n * `onEvent`, debounce awareness POSTs, and reconnect with exponential\n * backoff on failure. Reports `opts.errorCode` to `/api/channel-error` and\n * calls `process.exit(1)` after `CHANNEL_MAX_RETRIES` consecutive\n * failures.\n */\nexport async function runEventConsumer(opts: EventConsumerOptions): Promise<void> {\n // Warm the mode cache before the first event so we don't default-suppress\n // or default-deliver under an unknown user setting. Errors are already\n // logged inside getCachedMode (stale-preserving; cold-start default only\n // when no fetch has ever succeeded) — keep going.\n await getCachedMode(opts.tandemUrl, opts.logPrefix).catch(() => {});\n\n let retries = 0;\n let lastEventId: string | undefined;\n\n while (retries < CHANNEL_MAX_RETRIES) {\n try {\n await connectAndStreamOnce(opts, lastEventId, {\n onEventId: (id) => {\n lastEventId = id;\n },\n onStable: () => {\n retries = 0;\n },\n });\n } catch (err) {\n retries++;\n console.error(\n `${opts.logPrefix} SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,\n err instanceof Error ? err.message : err,\n );\n\n if (retries >= CHANNEL_MAX_RETRIES) {\n console.error(`${opts.logPrefix} SSE connection exhausted, reporting error and exiting`);\n try {\n await fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_ERROR}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n error: opts.errorCode,\n message: `${opts.logPrefix} lost connection after ${CHANNEL_MAX_RETRIES} retries.`,\n }),\n },\n CHANNEL_ERROR_REPORT_TIMEOUT_MS,\n );\n } catch (reportErr) {\n console.error(\n `${opts.logPrefix} Could not report failure to server:`,\n describeFetchError(reportErr, API_CHANNEL_ERROR, CHANNEL_ERROR_REPORT_TIMEOUT_MS),\n );\n }\n opts.onExhaustion?.();\n process.exit(1);\n }\n\n // Exponential backoff: 2s, 4s, 8s, 16s, 30s (capped).\n const delay = Math.min(CHANNEL_RETRY_DELAY_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);\n console.error(\n `${opts.logPrefix} Retrying in ${delay}ms (attempt ${retries}/${CHANNEL_MAX_RETRIES})...`,\n );\n await new Promise((r) => setTimeout(r, delay));\n }\n }\n // Defensive: under normal exhaustion the catch above calls process.exit(1)\n // before we return here. Survives any future refactor that removes the\n // exit or makes it non-terminating (e.g. test shim).\n console.error(\n `${opts.logPrefix} Retry loop exited unexpectedly (retries=${retries}/${CHANNEL_MAX_RETRIES})`,\n );\n process.exit(1);\n}\n\nexport interface StreamCallbacks {\n onEventId: (id: string) => void;\n onStable?: () => void;\n}\n\n/**\n * Single-attempt SSE consumer. Performs one handshake, streams frames,\n * and returns / throws when the stream ends. Exported for tests that want\n * to exercise per-attempt behavior without driving the full retry loop.\n *\n * Production code should call `runEventConsumer` instead — it owns the\n * reconnect/backoff/exhaustion-report logic.\n */\nexport async function connectAndStreamOnce(\n opts: EventConsumerOptions,\n lastEventId: string | undefined,\n cb: StreamCallbacks,\n): Promise<void> {\n const onStable = cb.onStable ?? (() => {});\n const headers: Record<string, string> = { Accept: \"text/event-stream\" };\n if (lastEventId) headers[\"Last-Event-ID\"] = lastEventId;\n\n // Split handshake timeout from body lifetime. Using AbortSignal.timeout on\n // the fetch would kill the response body ReadableStream when the timeout\n // fires — every SSE stream would abort at CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n // making STABLE_CONNECTION_MS unreachable. A local AbortController cleared\n // in `finally` after the handshake settles means the body stream is no\n // longer governed by it.\n const connectCtrl = new AbortController();\n const connectTimer = setTimeout(\n () => connectCtrl.abort(new Error(\"handshake timeout\")),\n CHANNEL_CONNECT_FETCH_TIMEOUT_MS,\n );\n let res: Response;\n try {\n res = await authFetch(`${opts.tandemUrl}${API_EVENTS}`, {\n headers,\n signal: connectCtrl.signal,\n });\n } finally {\n clearTimeout(connectTimer);\n }\n if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);\n if (!res.body) throw new Error(\"SSE endpoint returned no body\");\n\n // Stable-uptime reset: if the connection stays healthy for\n // STABLE_CONNECTION_MS, signal the caller to reset its retry budget.\n const stableTimer = setTimeout(onStable, STABLE_CONNECTION_MS);\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n // Inactivity watchdog. A healthy stream emits keepalive comments\n // periodically; if no bytes arrive for CHANNEL_SSE_INACTIVITY_TIMEOUT_MS,\n // cancel the reader. reader.cancel() resolves a pending read() with\n // {done: true} (does not reject), so we surface the cause via a flag.\n let lastActivityAt = Date.now();\n let inactivityTimedOut = false;\n const watchdog = setInterval(() => {\n if (Date.now() - lastActivityAt > CHANNEL_SSE_INACTIVITY_TIMEOUT_MS) {\n inactivityTimedOut = true;\n reader.cancel(new Error(\"SSE inactivity timeout\")).catch(() => {});\n }\n }, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);\n\n let pendingAwareness: TandemEvent | null = null;\n\n function clearAwarenessNow(documentId?: string) {\n const p = fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: documentId ?? null,\n status: \"idle\",\n active: false,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ).catch((err) => {\n console.error(\n `${opts.logPrefix} Awareness clear failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} clear`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n });\n trackAwareness(p);\n }\n\n function flushAwareness() {\n if (!pendingAwareness) return;\n const event = pendingAwareness;\n pendingAwareness = null;\n // Only update when the event has a real documentId. A doc-less event\n // (e.g. chat:message) must NOT wipe the last-known docId —\n // flushFinalAwareness needs a non-null id to send the shutdown clear.\n if (event.documentId) shutdownTimers.lastDocumentId = event.documentId;\n const p = fetchWithTimeout(\n `${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: event.documentId,\n status: `processing: ${event.type}`,\n active: true,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ).catch((err) => {\n console.error(\n `${opts.logPrefix} Awareness update failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} update`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n });\n trackAwareness(p);\n\n // Auto-clear after timeout so the indicator doesn't stick.\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n shutdownTimers.clearAwarenessTimer = setTimeout(\n () => clearAwarenessNow(event.documentId),\n AWARENESS_CLEAR_MS,\n );\n }\n\n function scheduleAwareness(event: TandemEvent) {\n pendingAwareness = event;\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n shutdownTimers.awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);\n }\n\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) {\n if (inactivityTimedOut) throw new Error(\"SSE inactivity timeout\");\n throw new Error(\"SSE stream ended\");\n }\n lastActivityAt = Date.now();\n\n buffer += decoder.decode(value, { stream: true });\n\n if (buffer.length > CHANNEL_MAX_SSE_BUFFER_BYTES) {\n throw new Error(\n `SSE buffer exceeded ${CHANNEL_MAX_SSE_BUFFER_BYTES} bytes without a frame boundary`,\n );\n }\n\n let boundary: number;\n while ((boundary = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const frame = buffer.slice(0, boundary);\n buffer = buffer.slice(boundary + 2);\n\n if (frame.startsWith(\":\")) continue;\n\n let eventId: string | undefined;\n let data: string | undefined;\n\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\"id: \")) eventId = line.slice(4);\n else if (line.startsWith(\"data: \")) data = line.slice(6);\n }\n\n if (!data) continue;\n\n let raw: unknown;\n try {\n raw = JSON.parse(data);\n } catch (err) {\n console.error(\n `${opts.logPrefix} SSE JSON parse failed (eventId=${eventId ?? \"none\"}, len=${\n data.length\n }): ${err instanceof Error ? err.message : err}. Tail:`,\n data.slice(Math.max(0, data.length - 200)),\n );\n // Permanently unparseable — advance past it to prevent infinite\n // re-delivery on reconnect.\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n\n const event = parseTandemEvent(raw);\n if (!event) {\n console.error(\n `${opts.logPrefix} SSE event failed validation (eventId=${\n eventId ?? \"none\"\n }): shape mismatch`,\n );\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n\n // Solo mode suppression: drop non-chat events when mode is \"solo\".\n if (event.type !== \"chat:message\") {\n refreshMode(opts.tandemUrl, opts.logPrefix); // fire-and-forget\n if (getModeSync() === \"solo\") {\n console.error(`${opts.logPrefix} Solo mode: suppressed ${event.type} event`);\n if (eventId) cb.onEventId(eventId);\n continue;\n }\n }\n\n // Deliver the event. False-checkpoint guard: `cb.onEventId` MUST\n // stay below this so lastEventId never advances past an event that\n // didn't reach the consumer's delivery surface.\n try {\n await opts.onEvent(event, eventId);\n } catch (err) {\n console.error(`${opts.logPrefix} onEvent failed (transport broken?):`, err);\n throw err;\n }\n\n if (eventId) cb.onEventId(eventId);\n scheduleAwareness(event);\n }\n }\n } finally {\n // Single source of truth for timer cleanup — every exit path (success,\n // throw, reader.cancel) runs through here so awareness/inactivity\n // timers can't leak across reconnects.\n clearTimeout(stableTimer);\n clearInterval(watchdog);\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n shutdownTimers.awarenessTimer = null;\n shutdownTimers.clearAwarenessTimer = null;\n pendingAwareness = null;\n }\n}\n\n// --- Mode cache ---\n\ntype FetchModeResult = { ok: true; mode: TandemMode } | { ok: false; reason: string };\n\n/** Fetch + validate /api/mode. Callers apply their own failure policy. */\nasync function fetchMode(tandemUrl: string): Promise<FetchModeResult> {\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_MODE}`,\n {},\n CHANNEL_MODE_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) return { ok: false, reason: `status ${res.status}` };\n const body = (await res.json()) as { mode?: unknown };\n const parsed = TandemModeSchema.safeParse(body.mode);\n if (!parsed.success) return { ok: false, reason: `invalid mode ${JSON.stringify(body.mode)}` };\n return { ok: true, mode: parsed.data };\n } catch (err) {\n return { ok: false, reason: describeFetchError(err, API_MODE, CHANNEL_MODE_FETCH_TIMEOUT_MS) };\n }\n}\n\n/**\n * Get the current collaboration mode, with a 2s TTL cache.\n *\n * **Stale-preserving** on any failure: once a real mode has been fetched\n * successfully, a transient `/api/mode` failure (network error or non-OK)\n * NEVER changes the cached mode — `cachedMode` is left untouched and the last\n * known value is returned. The mode only ever changes when the server reports\n * a new mode, i.e. when the user actually toggles Solo/Tandem.\n *\n * `cachedModeAt === 0` is the cold-start sentinel (no successful fetch ever).\n * In that one case — and only that case — a failure falls back to the\n * documented `TANDEM_MODE_DEFAULT`. After the first success, `cachedModeAt`\n * is non-zero forever, so failures can never revert to the cold-start default.\n *\n * On failure, `cachedModeAt` is NOT updated, so the next call retries\n * immediately rather than waiting out MODE_CACHE_TTL_MS.\n */\nexport async function getCachedMode(\n tandemUrl: string,\n logPrefix = \"[Tandem]\",\n): Promise<TandemMode> {\n const now = Date.now();\n if (now - cachedModeAt < MODE_CACHE_TTL_MS && cachedModeAt !== 0) return cachedMode;\n\n const result = await fetchMode(tandemUrl);\n if (!result.ok) {\n // Stale-preserving: keep the last known mode. A failure must never\n // overwrite a successfully-observed mode. Only on a genuine cold start\n // (no successful fetch ever, cachedModeAt === 0) do we fall back to the\n // documented default. cachedModeAt is left untouched so the next call\n // retries immediately instead of serving a stale cache window.\n if (cachedModeAt !== 0) {\n console.error(\n `${logPrefix} Mode check failed (${result.reason}), preserving last known mode '${cachedMode}'`,\n );\n return cachedMode;\n }\n console.error(\n `${logPrefix} Mode check failed (${result.reason}), no prior mode — using cold-start default '${TANDEM_MODE_DEFAULT}'`,\n );\n cachedMode = TANDEM_MODE_DEFAULT; // propagate cold-start default to hot path; do NOT update cachedModeAt\n return TANDEM_MODE_DEFAULT;\n }\n cachedMode = result.mode;\n cachedModeAt = now;\n return cachedMode;\n}\n\n/** Sync reader — always returns the last known mode. Use this on the hot path. */\nexport function getModeSync(): TandemMode {\n return cachedMode;\n}\n\n/**\n * Background refresh — fire-and-forget, deduplicated.\n *\n * Leaves `cachedMode` UNCHANGED on failure (stale-preferred). getCachedMode\n * fails closed at startup because no baseline exists; refreshMode prefers\n * stale because flipping mid-session would randomly suppress events and\n * surprise the user.\n */\nfunction refreshMode(tandemUrl: string, logPrefix: string): void {\n if (_modeRefreshInFlight) return;\n const now = Date.now();\n if (now - cachedModeAt < MODE_CACHE_TTL_MS) return;\n // Rate-limit retries after a failure so a server returning 500 quickly\n // (or hanging up to MODE_FETCH_TIMEOUT_MS) doesn't spawn a new fetch on\n // every hot-path event.\n if (now - cachedModeFailedAt < MODE_CACHE_TTL_MS) return;\n\n // Fire-and-forget. fetchMode() converts network/parse errors into\n // { ok: false }, and the inner try/finally clears `_modeRefreshInFlight` on\n // both success and thrown rejection — so today, the outer .catch is\n // unreachable. It exists as a belt-and-suspenders guard.\n _modeRefreshInFlight = (async () => {\n try {\n const result = await fetchMode(tandemUrl);\n if (result.ok) {\n cachedMode = result.mode;\n cachedModeAt = Date.now();\n cachedModeFailedAt = 0;\n } else {\n cachedModeFailedAt = Date.now();\n console.error(\n `${logPrefix} Background mode refresh failed (${result.reason}), keeping cached`,\n );\n }\n } finally {\n _modeRefreshInFlight = null;\n }\n })().catch((err) => {\n console.error(`${logPrefix} refreshMode unexpected error:`, err);\n cachedModeFailedAt = Date.now();\n });\n}\n\n// --- Shutdown drain (used by the monitor's signal handler) ---\n\n/**\n * Drain any in-flight awareness POSTs and send a final shutdown\n * \"active: false\" so the server's last-observed awareness state is clean.\n *\n * Returns true on success (or no-op when no docId was ever scheduled),\n * false when the shutdown POST itself fails.\n */\nexport async function flushFinalAwareness(\n tandemUrl: string,\n logPrefix = \"[Tandem]\",\n): Promise<boolean> {\n if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);\n if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);\n if (outstandingAwareness.size > 0) {\n await Promise.allSettled(outstandingAwareness);\n }\n // If no awareness was ever scheduled for a document, skip the POST —\n // sending {documentId: null} is ambiguous and the server may reject it.\n if (shutdownTimers.lastDocumentId === null) return true;\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_AWARENESS}`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: shutdownTimers.lastDocumentId,\n status: \"idle\",\n active: false,\n }),\n },\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) {\n console.error(`${logPrefix} Shutdown awareness clear returned ${res.status}`);\n return false;\n }\n return true;\n } catch (err) {\n console.error(\n `${logPrefix} Shutdown awareness clear failed:`,\n describeFetchError(\n err,\n `${API_CHANNEL_AWARENESS} shutdown`,\n CHANNEL_AWARENESS_FETCH_TIMEOUT_MS,\n ),\n );\n return false;\n }\n}\n\n// --- Test-only helpers ---\n\n/** Testing-only. Resets module-level state so tests within a single file\n * don't contaminate each other. DO NOT call from production code. */\nexport function _resetSseConsumerStateForTests(): void {\n cachedMode = TANDEM_MODE_DEFAULT;\n cachedModeAt = 0;\n cachedModeFailedAt = 0;\n _modeRefreshInFlight = null;\n shutdownTimers.awarenessTimer = null;\n shutdownTimers.clearAwarenessTimer = null;\n shutdownTimers.lastDocumentId = null;\n outstandingAwareness.clear();\n}\n\n/** Testing-only — seeds the lastDocumentId that shutdown reads. */\nexport function _setLastDocumentIdForTests(id: string | null): void {\n shutdownTimers.lastDocumentId = id;\n}\n\n/** Testing-only — reads the last document id that shutdown would send. */\nexport function _getLastDocumentIdForTests(): string | null {\n return shutdownTimers.lastDocumentId;\n}\n\n/** Testing-only — seeds an outstanding awareness POST so the shutdown test\n * can assert the drain-before-exit behavior. */\nexport function _addOutstandingAwarenessForTests(p: Promise<unknown>): void {\n trackAwareness(p);\n}\n","/**\n * SSE event bridge: connects to Tandem server's /api/events endpoint and\n * pushes received events to Claude Code as channel notifications.\n *\n * All retry / SSE-frame / awareness / mode-cache logic lives in the shared\n * `src/shared/sse-consumer.ts` module (extracted in #282). This file is the\n * thin MCP-aware adapter that owns the delivery callback.\n */\n\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { formatEventContent, formatEventMeta } from \"../shared/events/types.js\";\nimport { runEventConsumer } from \"../shared/sse-consumer.js\";\nimport { CHANNEL_CONNECT_FAILED } from \"../shared/types.js\";\n\n/**\n * Stdio-mode SSE bridge. New push-path work should target src/monitor/.\n * This path remains active for stdio-mode Claude Code connections.\n */\nexport async function startEventBridge(mcp: Server, tandemUrl: string): Promise<void> {\n return runEventConsumer({\n tandemUrl,\n logPrefix: \"[Channel]\",\n errorCode: CHANNEL_CONNECT_FAILED,\n onEvent: (event) =>\n mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n }),\n });\n}\n","/**\n * Tandem Channel Shim — core runtime, shared by:\n * - src/channel/index.ts (standalone binary, used by the Desktop sidecar)\n * - src/cli/channel.ts (npm-delivered entry for the plugin `tandem-channel`)\n *\n * Bridges Tandem's SSE event stream → Claude Code channel notifications,\n * and exposes a `tandem_reply` tool for Claude to respond to chat messages.\n *\n * Uses the low-level MCP `Server` class (not `McpServer`) as required by\n * the Channels API spec.\n */\n\nimport { createConnection } from \"node:net\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\nimport { API_CHANNEL_PERMISSION, API_CHANNEL_REPLY } from \"../shared/api-paths.js\";\nimport {\n redirectConsoleToStderr,\n resolveTandemUrl,\n withClaudeSessionHeader,\n} from \"../shared/cli-runtime.js\";\nimport {\n CHANNEL_PERMISSION_FETCH_TIMEOUT_MS,\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n DEFAULT_MCP_PORT,\n} from \"../shared/constants.js\";\nimport {\n describeFetchError,\n fetchWithTimeout,\n isAbortOrTimeoutError,\n} from \"../shared/fetch-with-timeout.js\";\nimport { startEventBridge } from \"./event-bridge.js\";\n\nexport interface RunChannelOptions {\n /** Skip the non-fatal reachability probe. The CLI wrapper runs a strict\n * preflight upstream and we don't want to double-log \"server not reachable\"\n * noise. Defaults to false. */\n skipReachabilityLog?: boolean;\n}\n\nexport async function runChannel(opts: RunChannelOptions = {}): Promise<void> {\n redirectConsoleToStderr();\n\n const tandemUrl = resolveTandemUrl();\n\n const mcp = new Server(\n { name: \"tandem-channel\", version: \"0.1.0\" },\n {\n capabilities: {\n experimental: {\n \"claude/channel\": {},\n \"claude/channel/permission\": {},\n },\n tools: {},\n },\n instructions: [\n 'Events from Tandem arrive as <channel source=\"tandem-channel\" event_type=\"...\" document_id=\"...\">.',\n \"These are real-time push notifications of user actions in the collaborative document editor.\",\n \"Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,\",\n \"chat:message, document:opened, document:closed, document:switched.\",\n \"Chat messages may include a 'selection' field with buffered selection context.\",\n \"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_edit, etc.) to act on them.\",\n \"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.\",\n \"Do not reply to non-chat events — just act on them using tools.\",\n \"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback.\",\n ].join(\" \"),\n },\n );\n\n mcp.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: [\n {\n name: \"tandem_reply\",\n description: \"Reply to a chat message in Tandem\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n text: { type: \"string\", description: \"The reply message\" },\n documentId: {\n type: \"string\",\n description: \"Document ID from the channel event (optional)\",\n },\n replyTo: {\n type: \"string\",\n description: \"Message ID being replied to (optional)\",\n },\n },\n required: [\"text\"],\n },\n },\n ],\n }));\n\n mcp.setRequestHandler(CallToolRequestSchema, async (req) => {\n if (req.params.name === \"tandem_reply\") {\n const args = req.params.arguments as Record<string, unknown>;\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_REPLY}`,\n {\n method: \"POST\",\n headers: withClaudeSessionHeader({ \"Content-Type\": \"application/json\" }),\n body: JSON.stringify(args),\n },\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n );\n let data: unknown;\n try {\n data = await res.json();\n } catch (parseErr) {\n // Re-throw timeout/abort errors so they surface as structured\n // failures to Claude. AbortSignal.timeout fires DURING `res.json()`\n // (headers landed but body hung); without this re-throw, the bare\n // catch swallows AbortError and reports a fake-success \"Non-JSON\n // response\" payload — exactly the silent-failure pattern #364\n // exists to prevent.\n if (isAbortOrTimeoutError(parseErr)) throw parseErr;\n data = { message: \"Non-JSON response\" };\n }\n if (!res.ok) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Reply failed (${res.status}): ${JSON.stringify(data)}`,\n },\n ],\n isError: true,\n };\n }\n return { content: [{ type: \"text\" as const, text: JSON.stringify(data) }] };\n } catch (err) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Failed to send reply: ${describeFetchError(\n err,\n API_CHANNEL_REPLY,\n CHANNEL_REPLY_FETCH_TIMEOUT_MS,\n )}`,\n },\n ],\n isError: true,\n };\n }\n }\n throw new Error(`Unknown tool: ${req.params.name}`);\n });\n\n const PermissionRequestSchema = z.object({\n method: z.literal(\"notifications/claude/channel/permission_request\"),\n params: z.object({\n request_id: z.string(),\n tool_name: z.string(),\n description: z.string(),\n input_preview: z.string(),\n }),\n });\n\n mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {\n try {\n const res = await fetchWithTimeout(\n `${tandemUrl}${API_CHANNEL_PERMISSION}`,\n {\n method: \"POST\",\n headers: withClaudeSessionHeader({ \"Content-Type\": \"application/json\" }),\n body: JSON.stringify({\n requestId: params.request_id,\n toolName: params.tool_name,\n description: params.description,\n inputPreview: params.input_preview,\n }),\n },\n CHANNEL_PERMISSION_FETCH_TIMEOUT_MS,\n );\n if (!res.ok) {\n console.error(\n `[Channel] Permission relay got HTTP ${res.status} — browser may not see prompt`,\n );\n }\n } catch (err) {\n console.error(\n \"[Channel] Failed to forward permission request:\",\n describeFetchError(err, API_CHANNEL_PERMISSION, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS),\n );\n }\n });\n\n console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);\n\n if (!opts.skipReachabilityLog) {\n const reachable = await checkServerReachable(tandemUrl);\n if (!reachable) {\n console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);\n console.error(\"[Channel] Start it with: tandem start\");\n // Continue anyway — the event bridge will retry, and the server may start later\n }\n }\n\n const transport = new StdioServerTransport();\n await mcp.connect(transport);\n console.error(\"[Channel] Connected to Claude Code via stdio\");\n\n startEventBridge(mcp, tandemUrl).catch((err) => {\n console.error(\"[Channel] Event bridge failed unexpectedly:\", err);\n process.exit(1);\n });\n}\n\nasync function checkServerReachable(url: string, timeoutMs = 2000): Promise<boolean> {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n console.error(\n `[Channel] Invalid TANDEM_URL: \"${url}\" — expected format: http://127.0.0.1:3479`,\n );\n return false;\n }\n const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);\n return new Promise((resolve) => {\n const socket = createConnection({ port, host: parsed.hostname }, () => {\n socket.destroy();\n resolve(true);\n });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.on(\"error\", (err) => {\n console.error(`[Channel] Server probe failed: ${err.message}`);\n socket.destroy();\n resolve(false);\n });\n });\n}\n","/**\n * Tandem channel subcommand — npm-delivered entry for the plugin\n * `tandem-channel` MCP server. Runs the unified preflight, then hands off to\n * the shared channel shim runtime in src/channel/run.ts.\n */\n\nimport { runChannel } from \"../channel/run.js\";\nimport { ensureTandemServer } from \"./preflight.js\";\n\nexport async function runChannelCli(): Promise<void> {\n await ensureTandemServer();\n await runChannel({ skipReachabilityLog: true });\n}\n","/**\n * `store.lock` payload format + parsing, isolated from {@link ../store.ts} so\n * that lightweight consumers (the `tandem doctor` CLI) can read a lock without\n * pulling in the store's file-io / notifications / platform dependency graph.\n *\n * Two on-disk formats, both must stay readable:\n * - v2 (#1077): JSON `{pid, startedAtMs?, app}` — written by current versions.\n * - legacy: a bare PID string — written by older versions.\n */\n\n/** App identifier stamped into v2 lockfiles. */\nexport const LOCK_APP_ID = \"tandem\";\n\n/** Parsed contents of `store.lock` (v2 JSON or legacy raw-PID). */\nexport interface LockfileContents {\n pid: number;\n /** v2 only — epoch ms when the holder took the lock. */\n startedAtMs?: number;\n /** v2 only — always `\"tandem\"` when written by Tandem. */\n app?: string;\n}\n\n/** Serialize the v2 lockfile payload for the current process. */\nexport function lockfilePayload(): string {\n return JSON.stringify({ pid: process.pid, startedAtMs: Date.now(), app: LOCK_APP_ID });\n}\n\n/**\n * Parse `store.lock` contents. Two formats:\n * - v2 (#1077): JSON `{pid, startedAtMs?, app}` — written by current versions.\n * - legacy: a bare PID string — written by older versions; must stay readable.\n *\n * Returns `null` for garbage content (callers treat that as a stale lock).\n */\nexport function parseLockfile(raw: string): LockfileContents | null {\n const trimmed = raw.trim();\n if (trimmed.startsWith(\"{\")) {\n try {\n const parsed = JSON.parse(trimmed) as Record<string, unknown>;\n const pid = parsed.pid;\n if (typeof pid !== \"number\" || !Number.isInteger(pid) || pid <= 0) return null;\n return {\n pid,\n startedAtMs: typeof parsed.startedAtMs === \"number\" ? parsed.startedAtMs : undefined,\n app: typeof parsed.app === \"string\" ? parsed.app : undefined,\n };\n } catch {\n return null;\n }\n }\n // Legacy raw-PID format. parseInt (not Number()) preserves the historical\n // tolerance for trailing junk after the digits.\n const pid = Number.parseInt(trimmed, 10);\n if (!Number.isFinite(pid) || pid <= 0) return null;\n return { pid };\n}\n","/**\n * Tandem Doctor — diagnose common setup issues.\n *\n * This module is the importable core behind both `tandem doctor` (the bundled\n * CLI subcommand) and `npm run doctor` (the standalone `scripts/doctor.mjs`\n * shim). It is split into a PURE collector (`runDoctor`) and a thin printer +\n * exit-code wrapper (`runDoctorCli`):\n *\n * - `runDoctor()` reads NOTHING from `process.argv` and calls `process.exit`\n * NEVER. It returns a structured {@link DoctorReport} so callers and tests\n * can inspect results without side effects.\n * - `runDoctorCli({ json })` formats the report (human-readable TTY lines or a\n * single JSON document on stdout) and applies the shared exit code.\n *\n * BUNDLING RATIONALE (do not \"simplify\" this into a spawn): the diagnostics\n * logic MUST live in this TS module so tsup bundles it into `dist/cli`. The\n * `scripts/` directory is NOT shipped in the npm package (see package.json\n * `files`), so a dispatcher that spawned `scripts/doctor.mjs` would have\n * nothing to run inside a global install. Keeping the logic here is the only\n * correct path for `tandem doctor` to work after `npm install -g`.\n *\n * Pure Node.js built-ins only (no external dependencies) so the module bundles\n * cleanly and the standalone shim can mirror it.\n */\n\nimport { execFile } from \"node:child_process\";\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { request } from \"node:http\";\nimport { createConnection } from \"node:net\";\nimport { homedir, platform } from \"node:os\";\nimport { join } from \"node:path\";\nimport { parseLockfile } from \"../server/annotations/lockfile.js\";\nimport { DEFAULT_MCP_PORT, DEFAULT_WS_PORT } from \"../shared/constants.js\";\n\n// Injected by tsup into dist/cli. Absent in tsx dev / vitest (typeof-guarded at\n// use). This is the version the `npx` bridge entries are pinned to.\ndeclare const __TANDEM_VERSION__: string;\n\nexport type DoctorStatus = \"pass\" | \"warn\" | \"fail\";\n\nexport interface DoctorResult {\n check: string;\n status: DoctorStatus;\n message: string;\n fix?: string;\n data?: Record<string, unknown>;\n}\n\nexport interface DoctorReport {\n ok: boolean;\n crashed: boolean;\n failures: number;\n warnings: number;\n summary: string;\n error: string | null;\n results: DoctorResult[];\n}\n\n/**\n * Internal recorder shared by every check. Mirrors the recorder in the legacy\n * `scripts/doctor.mjs`: each check groups one or more results under a `name`.\n * No TTY output happens here — that's the wrapper's job, so the pure collector\n * stays side-effect-free.\n */\nclass Recorder {\n failures = 0;\n warnings = 0;\n readonly results: DoctorResult[] = [];\n private currentCheck = \"\";\n\n async check<T>(name: string, fn: () => T | Promise<T>): Promise<T> {\n const prev = this.currentCheck;\n this.currentCheck = name;\n try {\n return await fn();\n } finally {\n this.currentCheck = prev;\n }\n }\n\n private record(\n status: DoctorStatus,\n msg: string,\n fix?: string,\n fields?: Record<string, unknown>,\n ): void {\n const entry: DoctorResult = { check: this.currentCheck, status, message: msg };\n if (fix) entry.fix = fix;\n if (fields) entry.data = fields;\n this.results.push(entry);\n }\n\n pass(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.record(\"pass\", msg, fix, fields);\n }\n\n warn(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.warnings++;\n this.record(\"warn\", msg, fix, fields);\n }\n\n fail(msg: string, fix?: string, fields?: Record<string, unknown>): void {\n this.failures++;\n this.record(\"fail\", msg, fix, fields);\n }\n}\n\n// ── Check: Node.js version ──────────────────────────────────────────\n\nfunction checkNodeVersion(r: Recorder): void {\n const version = process.version;\n const major = Number.parseInt(version.slice(1), 10);\n if (major >= 22) {\n r.pass(`Node.js ${version} (>= 22 required)`);\n } else {\n r.fail(\n `Node.js ${version} — version 22+ required`,\n \"Install Node.js 22+ from https://nodejs.org\",\n );\n }\n}\n\n// ── Check: node_modules exists ──────────────────────────────────────\n\nfunction checkNodeModules(r: Recorder): void {\n if (existsSync(join(process.cwd(), \"node_modules\"))) {\n r.pass(\"node_modules/ exists\");\n } else {\n r.fail(\"node_modules/ not found\", \"npm install\");\n }\n}\n\n// ── Check: .mcp.json ────────────────────────────────────────────────\n\nfunction checkMcpJson(r: Recorder): void {\n const mcpPath = join(process.cwd(), \".mcp.json\");\n if (!existsSync(mcpPath)) {\n r.fail(\".mcp.json not found\", \"Restore it from git: git checkout .mcp.json\");\n return;\n }\n\n let raw: string;\n try {\n raw = readFileSync(mcpPath, \"utf-8\");\n } catch (err) {\n r.fail(`.mcp.json could not be read: ${errMsg(err)}`);\n return;\n }\n\n let config: {\n mcpServers?: Record<\n string,\n {\n type?: string;\n url?: string;\n command?: string;\n args?: string[];\n env?: Record<string, string>;\n }\n >;\n };\n try {\n config = JSON.parse(raw);\n } catch {\n // Deliberately no parse detail: V8 SyntaxErrors embed a snippet of the\n // source text, and this file carries auth-token headers. Doctor output\n // gets pasted into public issues.\n r.fail(\".mcp.json is not valid JSON\", \"Restore it from git: git checkout .mcp.json\");\n return;\n }\n\n const servers = config.mcpServers;\n if (!servers) {\n r.fail('.mcp.json missing \"mcpServers\" key');\n return;\n }\n\n // Check tandem (HTTP MCP) entry\n const tandem = servers.tandem;\n if (!tandem) {\n r.fail('.mcp.json missing \"tandem\" server entry');\n } else if (tandem.type !== \"http\" || !tandem.url?.includes(\"/mcp\")) {\n r.warn(`.mcp.json tandem: unexpected config — type=${tandem.type}, url=${tandem.url}`);\n } else {\n r.pass(`.mcp.json tandem → ${tandem.url}`);\n }\n\n // Check tandem-channel entry\n const channel = servers[\"tandem-channel\"];\n if (!channel) {\n r.warn(\n \".mcp.json missing tandem-channel — Claude will use polling instead of push notifications\",\n );\n } else {\n const cmd = channel.command;\n const args = (channel.args || []).join(\" \");\n\n if (cmd === \"cmd\" && args.includes(\"/c\")) {\n r.warn(\n `.mcp.json tandem-channel uses Windows-only \"cmd /c\" — won't work on macOS/Linux`,\n 'Change to: \"command\": \"npx\", \"args\": [\"tsx\", \"src/channel/index.ts\"]',\n );\n } else {\n r.pass(`.mcp.json tandem-channel → ${cmd} ${args}`);\n }\n\n if (!channel.env?.TANDEM_URL) {\n r.warn(\n \"tandem-channel missing TANDEM_URL env var\",\n 'Add \"env\": {\"TANDEM_URL\": \"http://127.0.0.1:3479\"}',\n );\n }\n }\n}\n\n// ── Check: user-level MCP config (global install path) ─────────────\n\nfunction checkUserMcpConfig(r: Recorder): void {\n const home = process.env.HOME || process.env.USERPROFILE || \"\";\n // Claude Code reads global MCP servers from ~/.claude.json (under\n // `mcpServers`), which is exactly where `tandem setup` writes them. The\n // legacy ~/.claude/mcp_settings.json is not the file Claude Code consults,\n // so checking it produced false warnings even on a correct install (#985).\n const claudeCodePath = join(home, \".claude.json\");\n\n if (!existsSync(claudeCodePath)) {\n r.warn(\n \"~/.claude.json not found\",\n \"Run: tandem setup (or ignore if using project-local .mcp.json)\",\n );\n return;\n }\n\n let config: { mcpServers?: Record<string, unknown> };\n try {\n config = JSON.parse(readFileSync(claudeCodePath, \"utf-8\"));\n } catch {\n // Deliberately no parse detail: V8 SyntaxErrors embed a snippet of the\n // source text, and ~/.claude.json carries bearer tokens / API keys. This\n // check survives the /api/diagnostics filter, so its message reaches the\n // Copy Diagnostics clipboard — destined for public issues.\n r.warn(\"~/.claude.json is malformed JSON\", \"Run: tandem setup to rewrite it\");\n return;\n }\n\n const servers = config?.mcpServers ?? {};\n if (!servers.tandem) {\n r.warn(\"tandem not registered in ~/.claude.json\", \"Run: tandem setup\");\n } else {\n r.pass(\"tandem registered in ~/.claude.json\");\n }\n if (!servers[\"tandem-channel\"]) {\n r.warn(\n \"tandem-channel not registered in ~/.claude.json — Claude Code will poll instead of receiving real-time push\",\n \"Run: tandem setup\",\n );\n } else {\n r.pass(\"tandem-channel registered in ~/.claude.json\");\n }\n}\n\n// ── Check: port status ──────────────────────────────────────────────\n\nfunction probePort(port: number, timeoutMs = 2000): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = createConnection({ port, host: \"127.0.0.1\" }, () => {\n socket.destroy();\n resolve(true);\n });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.on(\"error\", () => {\n socket.destroy();\n resolve(false);\n });\n });\n}\n\nasync function checkPorts(\n r: Recorder,\n wsPort: number,\n mcpPort: number,\n): Promise<{ ws: boolean; mcp: boolean }> {\n const [ws, mcp] = await Promise.all([probePort(wsPort), probePort(mcpPort)]);\n\n if (ws && mcp) {\n r.pass(`Ports ${wsPort} (WebSocket) + ${mcpPort} (MCP HTTP) in use`, undefined, { ws, mcp });\n } else if (!ws && !mcp) {\n r.fail(\n `Ports ${wsPort} + ${mcpPort} not listening — server not running`,\n \"npm run dev:standalone\",\n { ws, mcp },\n );\n } else {\n r.warn(\n `Partial: port ${wsPort} ${ws ? \"up\" : \"down\"}, port ${mcpPort} ${mcp ? \"up\" : \"down\"}`,\n \"Server may be starting up or partially crashed\",\n { ws, mcp },\n );\n }\n\n return { ws, mcp };\n}\n\n// ── Check: /health endpoint ─────────────────────────────────────────\n\ninterface HttpGetResult {\n status?: number;\n data?: { version?: string; transport?: string; hasSession?: boolean } | null;\n error?: string;\n}\n\nfunction httpGet(url: string, timeoutMs = 3000): Promise<HttpGetResult | null> {\n return new Promise((resolve) => {\n const req = request(url, { timeout: timeoutMs }, (res) => {\n let body = \"\";\n res.on(\"data\", (chunk) => {\n body += chunk;\n });\n res.on(\"end\", () => {\n try {\n resolve({ status: res.statusCode, data: JSON.parse(body) });\n } catch {\n resolve({ status: res.statusCode, data: null });\n }\n });\n });\n req.on(\"error\", (err: Error) => resolve({ error: err.message }));\n req.on(\"timeout\", () => {\n req.destroy();\n resolve(null);\n });\n req.end();\n });\n}\n\nasync function checkHealth(r: Recorder, mcpPort: number): Promise<boolean> {\n const result = await httpGet(`http://127.0.0.1:${mcpPort}/health`);\n\n if (!result) {\n r.fail(`Server not responding on 127.0.0.1:${mcpPort}`, \"npm run dev:standalone\");\n return false;\n }\n\n if (result.error) {\n r.fail(\n `Server not responding on 127.0.0.1:${mcpPort} (${result.error})`,\n \"npm run dev:standalone\",\n );\n return false;\n }\n\n if (result.status !== 200) {\n r.fail(`/health returned status ${result.status}`);\n return false;\n }\n\n const d = result.data;\n if (d) {\n const session = d.hasSession ? \"session active\" : \"no MCP session\";\n r.pass(`Server healthy (v${d.version}, ${d.transport}, ${session})`, undefined, {\n version: d.version,\n transport: d.transport,\n hasSession: !!d.hasSession,\n });\n if (!d.hasSession) {\n r.warn(\"No active MCP session — Claude Code hasn't connected yet\");\n }\n } else {\n r.pass(\"Server responded on /health (could not parse body)\");\n }\n return true;\n}\n\n// ── Check: SSE event stream ─────────────────────────────────────────\n\nfunction checkSseEndpoint(r: Recorder, mcpPort: number): Promise<void> {\n return new Promise((resolve) => {\n const req = request(`http://127.0.0.1:${mcpPort}/api/events`, { timeout: 2000 }, (res) => {\n // SSE endpoint responds with 200 and text/event-stream\n req.destroy(); // don't hold the connection open\n const ct = res.headers[\"content-type\"] || \"\";\n if (res.statusCode === 200 && ct.includes(\"text/event-stream\")) {\n r.pass(\"SSE event stream reachable (/api/events)\");\n } else {\n r.warn(`/api/events responded with status ${res.statusCode}, content-type: ${ct}`);\n }\n resolve();\n });\n req.on(\"error\", (err: Error) => {\n r.warn(`/api/events not reachable: ${err.message}`);\n resolve();\n });\n req.on(\"timeout\", () => {\n req.destroy();\n r.warn(\"/api/events timed out\");\n resolve();\n });\n req.end();\n });\n}\n\n// ── Check: annotation store health ──────────────────────────────────\n\n/** Mirror of `env-paths(\"tandem\").data` for the current OS. */\nfunction resolveAppDataDir(): string {\n const override = process.env.TANDEM_APP_DATA_DIR;\n if (override && override.length > 0) return override;\n\n const home = homedir();\n switch (platform()) {\n case \"win32\":\n return join(process.env.LOCALAPPDATA || join(home, \"AppData\", \"Local\"), \"tandem\", \"Data\");\n case \"darwin\":\n return join(home, \"Library\", \"Application Support\", \"tandem\");\n default:\n return join(process.env.XDG_DATA_HOME || join(home, \".local\", \"share\"), \"tandem\");\n }\n}\n\n/** Cross-platform test that a PID currently points at a live process. */\nfunction isPidLive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch (err) {\n return (err as NodeJS.ErrnoException)?.code === \"EPERM\";\n }\n}\n\nfunction formatBytes(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction checkAnnotationStore(r: Recorder): void {\n const dir = join(resolveAppDataDir(), \"annotations\");\n if (!existsSync(dir)) {\n r.pass(`Annotation store dir not yet created (${dir}) — first open will create it`, undefined, {\n dir,\n docCount: 0,\n totalBytes: 0,\n corruptCount: 0,\n exists: false,\n });\n return;\n }\n\n let entries: string[];\n try {\n entries = readdirSync(dir);\n } catch (err) {\n r.fail(`Annotation store dir unreadable: ${errMsg(err)}`, `Check permissions on ${dir}`);\n return;\n }\n\n const jsonFiles = entries.filter((f) => f.endsWith(\".json\") && !f.endsWith(\".corrupt.json\"));\n const corruptFiles = entries.filter((f) => f.includes(\".corrupt.\"));\n\n let totalBytes = 0;\n let newest: { name: string | null; mtime: number } = { name: null, mtime: 0 };\n let sampleSchemaVersion: number | null = null;\n\n for (const f of jsonFiles) {\n try {\n const s = statSync(join(dir, f));\n totalBytes += s.size;\n if (s.mtimeMs > newest.mtime) {\n newest = { name: f, mtime: s.mtimeMs };\n }\n if (sampleSchemaVersion === null) {\n try {\n const parsed = JSON.parse(readFileSync(join(dir, f), \"utf-8\"));\n if (typeof parsed?.schemaVersion === \"number\") {\n sampleSchemaVersion = parsed.schemaVersion;\n }\n } catch {\n // malformed individual file — counted under corruptFiles check below\n }\n }\n } catch {\n // file vanished between readdir and stat — ignore\n }\n }\n\n r.pass(\n `Annotation store: ${jsonFiles.length} doc(s), ${formatBytes(totalBytes)} total`,\n undefined,\n {\n dir,\n docCount: jsonFiles.length,\n totalBytes,\n corruptCount: corruptFiles.length,\n },\n );\n\n if (newest.name) {\n const ageMs = Date.now() - newest.mtime;\n const ageStr =\n ageMs < 60_000 ? `${Math.floor(ageMs / 1000)}s` : `${Math.floor(ageMs / 60_000)}m`;\n r.pass(`Most recent annotation write: ${newest.name} (${ageStr} ago)`, undefined, {\n name: newest.name,\n mtimeMs: newest.mtime,\n ageMs,\n });\n }\n\n if (sampleSchemaVersion !== null) {\n r.pass(`Annotation schema version: ${sampleSchemaVersion}`, undefined, {\n schemaVersion: sampleSchemaVersion,\n });\n }\n\n if (corruptFiles.length > 0) {\n r.warn(\n `${corruptFiles.length} quarantined annotation file(s) in ${dir}`,\n \"Safe to delete after inspection; kept 7d by design.\",\n {\n corruptCount: corruptFiles.length,\n dir,\n },\n );\n }\n\n // Lock status\n const lockPath = join(dir, \"store.lock\");\n if (!existsSync(lockPath)) {\n r.pass(\"Annotation store lock: not held (no running writer)\", undefined, { lockHeld: false });\n return;\n }\n\n try {\n const raw = readFileSync(lockPath, \"utf-8\").trim();\n // Current locks are v2 JSON (`{pid, startedAtMs, app}`, #1077); older ones\n // are a bare PID. parseLockfile reads both and returns null for true garbage.\n const lock = parseLockfile(raw);\n if (lock === null) {\n r.warn(\n `Annotation store lock at ${lockPath} has unparseable content: \"${raw}\"`,\n \"Restart Tandem or delete the lock file if no server is running.\",\n { lockHeld: true, lockPath, lockContent: raw },\n );\n return;\n }\n const { pid } = lock;\n if (isPidLive(pid)) {\n r.pass(`Annotation store lock held by live PID ${pid}`, undefined, {\n lockHeld: true,\n pid,\n pidLive: true,\n });\n } else {\n r.warn(\n `Annotation store lock at ${lockPath} points to dead PID ${pid}`,\n \"The next server start will reclaim the stale lock automatically.\",\n { lockHeld: true, pid, pidLive: false },\n );\n }\n } catch (err) {\n r.warn(`Could not read annotation store lock: ${errMsg(err)}`);\n }\n}\n\nfunction errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n// ── Pure collector ──────────────────────────────────────────────────\n\n/**\n * Three-tier summary line shared by `runDoctor` and the `/api/diagnostics`\n * route's filtered recomputation — keep wording in one place.\n */\nexport function summarizeDoctorResults(failures: number, warnings: number): string {\n if (failures > 0) return `${failures} issue(s) found.`;\n if (warnings > 0)\n return `${warnings} warning(s) — Tandem should work, but check the items above.`;\n return \"All checks passed. Tandem is ready.\";\n}\n\n// ── Check: stale global tandem-editor ───────────────────────────────\n//\n// The `tandem` MCP bridge is launched via `npx -y tandem-editor@<v> mcp-stdio`.\n// A globally-installed `tandem-editor` whose version predates the `mcp-stdio`\n// subcommand USED to be silently reused by `npx` (the exact \"Server disconnected\"\n// failure). The version pin now bypasses it, but a stale/foreign global can still\n// bite a hand-typed `npx tandem-editor` — so surface it. Runs inside the\n// synchronous Copy-Diagnostics path, so it MUST be time-bounded and non-fatal:\n// npm being absent (bundled-node Tauri sidecar), unreachable, or slow is a SKIP,\n// never a fail.\n\n/** Resolve a global `tandem-editor` version, or null when it can't be determined. */\nexport function globalTandemEditorVersion(): Promise<string | null> {\n return new Promise((resolve) => {\n // shell:true so Windows resolves the `npm.cmd` shim (bare execFile(\"npm\")\n // ENOENTs there). Args are all static — no injection surface.\n execFile(\n \"npm\",\n [\"ls\", \"-g\", \"--depth=0\", \"--json\", \"tandem-editor\"],\n { shell: true, windowsHide: true, timeout: 4000, maxBuffer: 8 * 1024 * 1024 },\n (_err, stdout) => {\n // `npm ls` exits non-zero on unrelated global peer issues but still\n // prints JSON to stdout, so parse stdout regardless of the exit code.\n if (!stdout || stdout.trim().length === 0) {\n resolve(null);\n return;\n }\n try {\n const parsed = JSON.parse(stdout) as {\n dependencies?: Record<string, { version?: string }>;\n };\n resolve(parsed.dependencies?.[\"tandem-editor\"]?.version ?? null);\n } catch {\n resolve(null);\n }\n },\n );\n });\n}\n\n/**\n * Pure decision step, split out of {@link checkStaleGlobal} so the\n * match/mismatch/nothing-to-report logic is directly unit-testable without\n * needing to fake the tsup-injected `__TANDEM_VERSION__` global or mock\n * `child_process` — see tests/cli/doctor.test.ts.\n */\nexport function evaluateStaleGlobal(\n bundled: string,\n globalVersion: string | null,\n): {\n status: \"pass\" | \"warn\";\n message: string;\n fix?: string;\n data?: Record<string, unknown>;\n} | null {\n if (globalVersion === null) {\n // No global install (the common, healthy case) or npm unavailable. Either\n // way there's nothing that can shadow the pinned npx spec.\n return null;\n }\n\n if (globalVersion === bundled) {\n return { status: \"pass\", message: `Global tandem-editor@${globalVersion} matches this build` };\n }\n\n return {\n status: \"warn\",\n message:\n `Global tandem-editor@${globalVersion} differs from this build (${bundled}) — ` +\n \"a stale global can break `npx tandem-editor` (e.g. Claude Desktop's MCP bridge).\",\n fix: \"npm uninstall -g tandem-editor (or: npm install -g tandem-editor@latest)\",\n data: { globalVersion, bundledVersion: bundled },\n };\n}\n\nasync function checkStaleGlobal(r: Recorder): Promise<void> {\n const bundled = typeof __TANDEM_VERSION__ !== \"undefined\" ? __TANDEM_VERSION__ : null;\n // Without a known bundled version (tsx dev / vitest) there's nothing to\n // compare against — skip silently rather than guess.\n if (!bundled) return;\n\n let globalVersion: string | null;\n try {\n globalVersion = await globalTandemEditorVersion();\n } catch {\n // npm absent / spawn failure / timeout — skip, never fail.\n return;\n }\n\n const result = evaluateStaleGlobal(bundled, globalVersion);\n if (!result) return;\n\n if (result.status === \"pass\") {\n r.pass(result.message);\n } else {\n r.warn(result.message, result.fix, result.data);\n }\n}\n\nexport interface RunDoctorOptions {\n /** WebSocket (Hocuspocus) port to probe. Defaults to {@link DEFAULT_WS_PORT}. */\n wsPort?: number;\n /** MCP HTTP port to probe. Defaults to {@link DEFAULT_MCP_PORT}. */\n mcpPort?: number;\n}\n\n/**\n * Run every diagnostic check and return a structured report. Performs NO\n * `process.argv` reads and NEVER calls `process.exit`. Safe to call from tests\n * and from both CLI entry points. Embedders that know their live ports (the\n * `/api/diagnostics` route on a `TANDEM_PORT`-overridden server) pass them via\n * `opts` so the self-probe doesn't report \"server not running\".\n */\nexport async function runDoctor(opts: RunDoctorOptions = {}): Promise<DoctorReport> {\n const wsPort = opts.wsPort ?? DEFAULT_WS_PORT;\n const mcpPort = opts.mcpPort ?? DEFAULT_MCP_PORT;\n const r = new Recorder();\n\n await r.check(\"node-version\", () => checkNodeVersion(r));\n await r.check(\"node-modules\", () => checkNodeModules(r));\n await r.check(\"mcp-json\", () => checkMcpJson(r));\n await r.check(\"user-mcp-config\", () => checkUserMcpConfig(r));\n await r.check(\"annotation-store\", () => checkAnnotationStore(r));\n await r.check(\"stale-global\", () => checkStaleGlobal(r));\n\n const { mcp } = await r.check(\"ports\", () => checkPorts(r, wsPort, mcpPort));\n\n if (mcp) {\n const healthy = await r.check(\"health\", () => checkHealth(r, mcpPort));\n if (healthy) {\n await r.check(\"sse\", () => checkSseEndpoint(r, mcpPort));\n }\n }\n\n return {\n ok: r.failures === 0,\n crashed: false,\n failures: r.failures,\n warnings: r.warnings,\n summary: summarizeDoctorResults(r.failures, r.warnings),\n error: null,\n results: r.results,\n };\n}\n\n// ── Printer + exit-code wrapper ─────────────────────────────────────\n\nexport interface RunDoctorCliOptions {\n json?: boolean;\n}\n\n/** ANSI-colored status tag for the human-readable TTY printer. */\nfunction colorTag(status: DoctorStatus): string {\n switch (status) {\n case \"pass\":\n return \"\\x1b[32m[PASS]\\x1b[0m\";\n case \"warn\":\n return \"\\x1b[33m[WARN]\\x1b[0m\";\n case \"fail\":\n return \"\\x1b[31m[FAIL]\\x1b[0m\";\n }\n}\n\n/**\n * Format the report and apply the shared exit code (0 pass, 1 failures,\n * 2 crash). In `--json` mode stdout is a SINGLE pure JSON document — human\n * lines are suppressed so the stream is machine-parseable. Both `tandem\n * doctor` and `npm run doctor` route through here.\n *\n * Note: writing JSON to stdout is correct for the CLI. Critical Rule #3\n * (\"stdout is reserved\") applies to the MCP stdio server, not this command —\n * `src/cli/index.ts` deliberately uses stdout for `--version`/`--help`.\n */\nexport async function runDoctorCli(opts: RunDoctorCliOptions = {}): Promise<number> {\n const json = opts.json ?? false;\n\n let report: DoctorReport;\n try {\n report = await runDoctor();\n } catch (err) {\n const message = errMsg(err);\n if (json) {\n const crashed: DoctorReport = {\n ok: false,\n crashed: true,\n failures: 0,\n warnings: 0,\n summary: `Tandem Doctor crashed unexpectedly: ${message}`,\n error: message,\n results: [],\n };\n process.stdout.write(`${JSON.stringify(crashed, null, 2)}\\n`);\n } else {\n process.stderr.write(`\\n Tandem Doctor crashed unexpectedly: ${message}\\n`);\n process.stderr.write(\n \" Please report this at https://github.com/bloknayrb/tandem/issues\\n\\n\",\n );\n }\n return 2;\n }\n\n const exitCode = report.failures > 0 ? 1 : 0;\n\n if (json) {\n process.stdout.write(`${JSON.stringify(report, null, 2)}\\n`);\n return exitCode;\n }\n\n // Human-readable TTY output.\n const out = (line: string) => process.stdout.write(`${line}\\n`);\n out(\"\");\n out(\" Tandem Doctor\");\n out(\" =============\");\n out(\"\");\n\n for (const res of report.results) {\n out(` ${colorTag(res.status)} ${res.message}`);\n if (res.status !== \"pass\" && res.fix) {\n out(` Fix: ${res.fix}`);\n }\n }\n\n out(\"\");\n if (report.failures > 0) {\n out(` ${report.failures} issue(s) found. Fix the items above and re-run: tandem doctor`);\n } else if (report.warnings > 0) {\n out(` ${report.warnings} warning(s) — Tandem should work, but check the items above.`);\n } else {\n out(\" All checks passed. Tandem is ready.\");\n }\n out(\"\");\n\n return exitCode;\n}\n","import envPaths from \"env-paths\";\nimport fs from \"fs\";\nimport path from \"path\";\nimport { TOKEN_FILE_NAME } from \"../constants.js\";\n\nexport function getTokenFilePath(): string {\n return path.join(envPaths(\"tandem\", { suffix: \"\" }).data, TOKEN_FILE_NAME);\n}\n\nexport async function readTokenFromFile(): Promise<string | null> {\n const filePath = getTokenFilePath();\n try {\n const content = await fs.promises.readFile(filePath, \"utf8\");\n // Remediate insecure permissions if a previous chmod failed (e.g., process crashed).\n if (process.platform !== \"win32\") {\n try {\n const stat = await fs.promises.stat(filePath);\n if ((stat.mode & 0o077) !== 0) {\n console.error(\"[tandem] auth token file has insecure permissions; attempting chmod 0600\");\n await fs.promises.chmod(filePath, 0o600);\n }\n } catch {\n // Non-fatal: stat/chmod failure doesn't invalidate the token we already read\n }\n }\n const trimmed = content.trim();\n return trimmed.length > 0 ? trimmed : null;\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw err;\n }\n}\n","import { createHash, randomBytes } from \"node:crypto\";\nimport { promises as fsPromises } from \"node:fs\";\nimport path from \"node:path\";\nimport { applyConfigWithToken } from \"../server/integrations/apply.js\";\nimport { API_ROTATE_TOKEN } from \"../shared/api-paths.js\";\nimport { getTokenFilePath, readTokenFromFile } from \"../shared/auth/token-file.js\";\nimport { resolveAuthTokenCandidate, resolveTandemUrl } from \"../shared/cli-runtime.js\";\n\n/** SHA-256 fingerprint — first 8 hex chars. Never logs the full token value. */\nfunction fingerprint(token: string): string {\n return createHash(\"sha256\").update(token, \"utf8\").digest(\"hex\").slice(0, 8);\n}\n\nfunction generateToken(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\nexport async function rotateToken(): Promise<void> {\n console.error(\"\\n[tandem] Rotating auth token...\\n\");\n\n // Refuse to rotate when token comes from env — Tauri injects TANDEM_AUTH_TOKEN\n // before sidecar spawn, and Claude Code's plugin host injects\n // CLAUDE_PLUGIN_OPTION_AUTH_TOKEN from userConfig. In either case we have no\n // way to update the launcher; rotating the file would desync with what's\n // re-injected on the next launch.\n const { source: envAuthSource } = resolveAuthTokenCandidate();\n if (\n envAuthSource === \"TANDEM_AUTH_TOKEN\" ||\n envAuthSource === \"CLAUDE_PLUGIN_OPTION_AUTH_TOKEN\"\n ) {\n console.error(\n `[tandem] Error: ${envAuthSource} is set in the environment.\\n` +\n \" Token rotation is not supported in env-token mode (used by Tauri\\n\" +\n \" and Claude Code's plugin host). Unset the variable and let Tandem\\n\" +\n \" manage the token file, or rotate via the launcher's token management.\",\n );\n process.exit(1);\n }\n\n const oldToken = await readTokenFromFile();\n if (!oldToken) {\n console.error(\n \"[tandem] Error: no token file found. Start the server once with `tandem` — it creates the token on first launch.\",\n );\n process.exit(1);\n }\n\n // writeTokenToFile uses O_EXCL; bypass it here — rotation is an intentional overwrite.\n // Use atomic write: write to a temp file first, then rename() into place.\n // rename() is atomic on the same filesystem — power-loss mid-write cannot leave an empty file.\n const newToken = generateToken();\n const tokenPath = getTokenFilePath();\n const dir = path.dirname(tokenPath);\n const tmpPath = path.join(dir, `.auth-token-tmp-${randomBytes(4).toString(\"hex\")}`);\n try {\n await fsPromises.writeFile(tmpPath, newToken, { encoding: \"utf8\", mode: 0o600 });\n await fsPromises.rename(tmpPath, tokenPath);\n } catch (err) {\n await fsPromises.unlink(tmpPath).catch(() => {});\n throw err;\n }\n\n const serverUrl = resolveTandemUrl();\n\n // Three distinct outcomes:\n // graceWindowActive = true → server accepted the rotation; grace window is live\n // serverRejected = true → server reachable but returned non-2xx\n // (neither) → fetch threw; server was not running\n let graceWindowActive = false;\n let serverRejected = false;\n let serverRejectedStatus = 0;\n try {\n const resp = await fetch(`${serverUrl}${API_ROTATE_TOKEN}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${oldToken}`,\n },\n body: JSON.stringify({}),\n signal: AbortSignal.timeout(5000),\n });\n if (resp.ok) {\n graceWindowActive = true;\n } else {\n serverRejected = true;\n serverRejectedStatus = resp.status;\n }\n } catch {\n console.error(\n \"[tandem] Warning: server is not reachable. The new token is written to disk.\\n\" +\n \" Restart the server to activate the grace window; reconnect Claude Code after.\",\n );\n }\n\n let updatedCount = 0;\n let configErrors: string[] = [];\n try {\n const result = await applyConfigWithToken(newToken);\n updatedCount = result.updated;\n configErrors = result.errors;\n } catch (err) {\n console.error(\n `[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n // TODO(v0.8.1): After rotation, re-walk Cowork workspaces to rewrite\n // env.TANDEM_AUTH_TOKEN so post-rotation Cowork sessions don't 401\n // (security invariant §6 — silent-failure H1). The Tauri IPC dynamic import\n // approach is inert here: this CLI runs as a Node subprocess with no WebView,\n // so `@tauri-apps/api/core`'s `invoke()` has no bridge to Rust. The fix is\n // an HTTP bridge — add a POST /api/cowork-apply-token endpoint in the server\n // (guarded by the auth middleware) and call it from here after the server\n // accepts the rotation.\n\n if (serverRejected) {\n // Configs now reference the new token but the server still holds the old one.\n // Print a strong warning — do NOT print \"Rotated auth token\" as that implies success.\n console.error(\n `[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`,\n );\n if (updatedCount > 0) {\n console.error(\n ` ${updatedCount} config file(s) updated to the new token, but the server still\\n` +\n \" holds the old token. Restart the server to complete rotation.\",\n );\n }\n console.error(` Old fingerprint: ${fingerprint(oldToken)}`);\n console.error(` New fingerprint: ${fingerprint(newToken)}`);\n for (const e of configErrors) {\n console.error(` Warning: could not update config — ${e}`);\n }\n console.error(\"\");\n return;\n }\n\n console.error(\"[tandem] Rotated auth token.\");\n console.error(` Old fingerprint: ${fingerprint(oldToken)}`);\n console.error(` New fingerprint: ${fingerprint(newToken)}`);\n console.error(` Updated ${updatedCount} config file(s).`);\n\n for (const e of configErrors) {\n console.error(` Warning: could not update config — ${e}`);\n }\n\n if (graceWindowActive) {\n console.error(\n \" Old token remains valid for 60 seconds; reconnect Claude Code within that window.\",\n );\n } else {\n console.error(\n \" Server was not running — start it with `tandem` and reconnect Claude Code with the new token.\",\n );\n }\n\n console.error(\"\");\n}\n","// The license gate ships DARK behind this build flag (ADR-040 consequences).\n// `__LICENSE_GATE_ENABLED__` is injected by tsup (see tsup.config.ts) into every\n// bundle whose tree can import this module — today the server + cli bundles.\n// Default: false (v0.16.0). Flip the single tsup const to `true` at v1.0.\ndeclare const __LICENSE_GATE_ENABLED__: boolean;\n\n/**\n * Pure flag resolver (injectable for tests). The build-time `define` value wins;\n * absent a define (tsx dev / vitest), the `TANDEM_LICENSE_GATE=1` env var enables\n * the gate so both paths can be exercised without rebuilding.\n */\nexport function readGateFlag(deps: {\n defineValue: boolean | undefined;\n env: Record<string, string | undefined>;\n}): boolean {\n if (typeof deps.defineValue !== \"undefined\") return deps.defineValue;\n return deps.env.TANDEM_LICENSE_GATE === \"1\";\n}\n\nconst defineValue: boolean | undefined =\n typeof __LICENSE_GATE_ENABLED__ !== \"undefined\" ? __LICENSE_GATE_ENABLED__ : undefined;\n\n// Ship-dark guard: a production sidecar bundle MUST carry the define. If it\n// doesn't, we'd silently fall back to the env var and ship dark regardless of\n// the build const — warn loudly so a mis-built bundle is caught at boot.\nif (process.env.TANDEM_TAURI_SIDECAR === \"1\" && typeof defineValue === \"undefined\") {\n console.error(\n \"[license] WARNING: __LICENSE_GATE_ENABLED__ define missing in sidecar bundle — \" +\n \"gate flag is falling back to the TANDEM_LICENSE_GATE env var\",\n );\n}\n\nexport const GATE_ENABLED = readGateFlag({ defineValue, env: process.env });\n","/**\n * Shared offset math for the flat-text coordinate system.\n *\n * The server's extractText() builds a flat string from Y.Doc elements by:\n * 1. Prepending heading prefixes (\"# \", \"## \", \"### \") to heading content\n * 2. Joining elements with \"\\n\" separators\n *\n * Both server (Y.Doc → flat offsets) and client (ProseMirror positions ↔ flat offsets)\n * must agree on these conventions. This module is the single source of truth.\n */\n\n/** Flat-text separator between block elements. */\nexport const FLAT_SEPARATOR = \"\\n\";\n\n/**\n * Length of the heading prefix in flat text for a given heading level.\n * Level 1 → \"# \" (2 chars), level 2 → \"## \" (3 chars), etc.\n * Returns 0 for non-heading nodes (level null/undefined/0).\n */\nexport function headingPrefixLength(level: number | null | undefined): number {\n if (!level) return 0;\n return level + 1;\n}\n\n/**\n * Build the heading prefix string for a given level.\n * Level 1 → \"# \", level 2 → \"## \", etc.\n */\nexport function headingPrefix(level: number): string {\n return \"#\".repeat(level) + \" \";\n}\n","import type { AlignType, PhrasingContent, Root, RootContent, Table } from \"mdast\";\nimport * as Y from \"yjs\";\nimport { serializeMdastBlock, serializeMdastInline } from \"./markdown.js\";\n\nconst MARKDOWN_HTML_ATTR = \"markdownHtml\";\n/**\n * Marks a `paragraph` whose Y.XmlText holds the verbatim markdown source of a\n * construct Tandem has no first-class editor node for (footnote/reference\n * definitions, unknown blocks). Re-emitted as an mdast `html` node on save so\n * it round-trips byte-exact, and surfaced to the editor as `data-markdown-raw`\n * for the show/hide toggle. Sibling to MARKDOWN_HTML_ATTR. See #981 / ADR-042.\n */\nconst MARKDOWN_RAW_ATTR = \"markdownRaw\";\n/**\n * Delta-attribute key for an inline run holding verbatim markdown source\n * (footnoteReference, linkReference, imageReference, inline image, inline html).\n * MUST byte-match the Tiptap `rawMarkdown` Mark name. Listed in ALL_MARKS so\n * `buildAttrs` emits it; read back in `deltaToPhrasingContent`.\n */\nconst RAW_MARKDOWN_MARK = \"rawMarkdown\";\n\n/**\n * Convert an MDAST tree into Y.Doc XmlFragment elements.\n * Block nodes become Y.XmlElements with Tiptap-compatible nodeNames.\n * Inline content becomes formatted Y.XmlText within those elements.\n *\n * Elements are attached to the doc BEFORE text is populated — Yjs\n * requires this for correct insert ordering on Y.XmlText.\n */\nexport function mdastToYDoc(doc: Y.Doc, tree: Root): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Clear existing content in a single operation\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n insertBlocks(doc, tree, 0);\n}\n\n/**\n * Append an MDAST tree's blocks to the END of the Y.Doc's XmlFragment without\n * clearing existing content. Reuses the same two-pass build as `mdastToYDoc`.\n *\n * Append is offset-safe: new elements land after every existing top-level\n * element, so existing flat offsets (and the annotation / authorship ranges\n * anchored to them) are unchanged. Used by the `tandem_appendContent` MCP tool.\n * The caller is responsible for the origin-tagged transaction wrapper.\n */\nexport function appendMdast(doc: Y.Doc, tree: Root): void {\n insertBlocks(doc, tree, doc.getXmlFragment(\"default\").length);\n}\n\n/**\n * Build the tree's blocks and insert them at `index` in the `default` fragment.\n * Two-pass: build detached elements (pass 1), attach via `fragment.insert`,\n * then populate text (pass 2) — Yjs requires Y.XmlText to be attached before\n * insert for correct ordering. `blockToYxml` is fragment-pure, so `index` (read\n * by the caller against the live fragment length) stays valid across pass 1.\n */\nfunction insertBlocks(doc: Y.Doc, tree: Root, index: number): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Pass 1 collects deferred text operations while building the element tree.\n const deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }> = [];\n const allElements: Y.XmlElement[] = [];\n for (const node of tree.children) {\n allElements.push(...blockToYxml(node, deferred));\n }\n\n // Attach all elements to the doc (pass 1 complete)\n if (allElements.length > 0) {\n fragment.insert(index, allElements);\n }\n\n // Pass 2: populate text now that elements are attached to the Y.Doc\n for (const { xmlText, nodes, plainText } of deferred) {\n if (nodes) {\n processInline(xmlText, nodes, {});\n } else if (plainText != null) {\n xmlText.insert(0, plainText);\n }\n }\n}\n\n/** Convert a block-level MDAST node to one or more Y.XmlElements */\nfunction blockToYxml(\n node: RootContent,\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement[] {\n switch (node.type) {\n case \"heading\": {\n const el = new Y.XmlElement(\"heading\");\n el.setAttribute(\"level\", node.depth as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: node.children });\n return [el];\n }\n\n case \"paragraph\": {\n // A standalone markdown image (`![alt](url)`) parses as `paragraph > image`\n // (image is phrasing content). Promote any top-level image children to\n // block-level `image` Y.XmlElements (issue #153) so they render via\n // Tiptap's block Image node. Inline text runs around an image stay as\n // their own paragraphs. Block images live as top-level fragment children\n // with empty getElementText(), preserving flat-offset alignment.\n if (node.children.some((c) => c.type === \"image\")) {\n return splitParagraphImages(node.children, deferred);\n }\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: node.children });\n return [el];\n }\n\n case \"blockquote\": {\n const el = new Y.XmlElement(\"blockquote\");\n let insertIndex = 0;\n for (const child of node.children) {\n const childEls = blockToYxml(child, deferred);\n for (const c of childEls) {\n el.insert(insertIndex, [c]);\n insertIndex++;\n }\n }\n return [el];\n }\n\n case \"list\": {\n const nodeName = node.ordered ? \"orderedList\" : \"bulletList\";\n const el = new Y.XmlElement(nodeName);\n if (node.ordered && node.start != null && node.start !== 1) {\n el.setAttribute(\"start\", node.start as any);\n }\n let listIndex = 0;\n for (const item of node.children) {\n const listItem = new Y.XmlElement(\"listItem\");\n // GFM task list: a list item with non-null `checked` carries the\n // tri-state as an attribute on the ordinary listItem (#982). `null`\n // (plain bullet) stores no attribute so the editor's PM-default `null`\n // reconciles without a phantom transaction. Stored as a real boolean —\n // the same representation y-prosemirror writes when a user toggles the\n // checkbox — so it round-trips byte-identically (yjs ContentAny).\n if (item.checked != null) {\n listItem.setAttribute(\"checked\", item.checked as any);\n }\n let itemIndex = 0;\n for (const child of item.children) {\n const childEls = blockToYxml(child, deferred);\n for (const c of childEls) {\n listItem.insert(itemIndex, [c]);\n itemIndex++;\n }\n }\n el.insert(listIndex, [listItem]);\n listIndex++;\n }\n return [el];\n }\n\n case \"code\": {\n const el = new Y.XmlElement(\"codeBlock\");\n if (node.lang) {\n el.setAttribute(\"language\", node.lang);\n }\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n\n case \"thematicBreak\": {\n return [new Y.XmlElement(\"horizontalRule\")];\n }\n\n case \"table\": {\n // GFM table: first MDAST tableRow becomes a row of tableHeader cells;\n // subsequent rows become tableCell rows. Column alignment is stored as\n // a JSON-stringified table-level \"align\" attribute (matches MDAST's\n // table-level storage and avoids per-cell alignment plumbing).\n //\n // Per CLAUDE.md \"Y.XmlText must be attached before populating\": each\n // cell's paragraph + Y.XmlText is attached to the tree first, and the\n // inline children are pushed to deferred[] for the second pass — same\n // pattern used by paragraph/heading above.\n const tableEl = new Y.XmlElement(\"table\");\n const align: (AlignType | null | undefined)[] = node.align ?? [];\n tableEl.setAttribute(\"align\", JSON.stringify(align) as any);\n node.children.forEach((row, rowIdx) => {\n const rowEl = new Y.XmlElement(\"tableRow\");\n const cellNodeName = rowIdx === 0 ? \"tableHeader\" : \"tableCell\";\n for (const cell of row.children) {\n const cellEl = new Y.XmlElement(cellNodeName);\n // Tiptap wraps cell content in a paragraph; mirror that here so the\n // Y.Doc structure matches what the editor would produce.\n const para = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n // Attach in order: row → cell → paragraph → text. Each child is\n // attached to its parent before deferring inline population.\n cellEl.insert(0, [para]);\n para.insert(0, [text]);\n rowEl.insert(rowEl.length, [cellEl]);\n deferred.push({ xmlText: text, nodes: cell.children });\n }\n tableEl.insert(tableEl.length, [rowEl]);\n });\n return [tableEl];\n }\n\n case \"image\": {\n return [imageToYxml(node)];\n }\n\n case \"html\": {\n const el = new Y.XmlElement(\"paragraph\");\n el.setAttribute(MARKDOWN_HTML_ATTR, true as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n\n // Footnote/reference definitions and any other structured block Tandem has\n // no first-class node for: store the verbatim markdown source in a\n // `paragraph[markdownRaw]` and re-emit as an mdast `html` node on save so\n // nothing is silently dropped (the historical bug — these carry no `.value`\n // so the old default returned []). See #981 / ADR-042.\n case \"footnoteDefinition\":\n case \"definition\":\n return [rawBlockParagraph(serializeMdastBlock(node), deferred)];\n\n default: {\n if (\"value\" in node && typeof node.value === \"string\") {\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: node.value });\n return [el];\n }\n // Unknown structured block: preserve verbatim rather than drop it.\n const serialized = serializeMdastBlock(node);\n return serialized.length > 0 ? [rawBlockParagraph(serialized, deferred)] : [];\n }\n }\n}\n\n/**\n * Build a `paragraph[markdownRaw]` carrying verbatim markdown source as text.\n * Mirrors the `markdownHtml` block pattern: text is deferred to pass 2 so the\n * Y.XmlText is attached before population (CLAUDE.md two-pass rule).\n */\nfunction rawBlockParagraph(\n source: string,\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement {\n const el = new Y.XmlElement(\"paragraph\");\n el.setAttribute(MARKDOWN_RAW_ATTR, true as any);\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, plainText: source });\n return el;\n}\n\n/** Build a block-level `image` Y.XmlElement from an MDAST image node. */\nfunction imageToYxml(node: Extract<PhrasingContent, { type: \"image\" }>): Y.XmlElement {\n const el = new Y.XmlElement(\"image\");\n el.setAttribute(\"src\", node.url);\n if (node.alt) el.setAttribute(\"alt\", node.alt);\n if (node.title) el.setAttribute(\"title\", node.title);\n return el;\n}\n\n/**\n * Split a paragraph's phrasing children into block-level `image` elements and\n * paragraphs for the surrounding inline content. Used when a markdown paragraph\n * contains one or more images (issue #153). Each top-level image becomes its own\n * block; runs of non-image phrasing between/around images become paragraphs.\n * Whitespace-only inline runs adjacent to an image are dropped so a lone image\n * doesn't leave an empty paragraph behind; boundary whitespace on real runs is\n * trimmed to avoid serializer escape noise.\n */\nfunction splitParagraphImages(\n children: PhrasingContent[],\n deferred: Array<{ xmlText: Y.XmlText; nodes?: PhrasingContent[]; plainText?: string }>,\n): Y.XmlElement[] {\n const result: Y.XmlElement[] = [];\n let inlineRun: PhrasingContent[] = [];\n\n const flushInline = () => {\n if (inlineRun.length === 0) return;\n const run = inlineRun;\n inlineRun = [];\n // Trim whitespace at the run boundaries (where it abutted an image) so the\n // serializer doesn't emit `&#x20;` escape noise around the split.\n const first = run[0];\n if (first?.type === \"text\") first.value = first.value.replace(/^\\s+/, \"\");\n const last = run[run.length - 1];\n if (last?.type === \"text\") last.value = last.value.replace(/\\s+$/, \"\");\n const hasContent = run.some((n) => n.type !== \"text\" || n.value.length > 0);\n if (hasContent) {\n const el = new Y.XmlElement(\"paragraph\");\n const text = new Y.XmlText();\n el.insert(0, [text]);\n deferred.push({ xmlText: text, nodes: run });\n result.push(el);\n }\n };\n\n for (const child of children) {\n if (child.type === \"image\") {\n flushInline();\n result.push(imageToYxml(child));\n } else {\n inlineRun.push(child);\n }\n }\n flushInline();\n\n return result;\n}\n\n/** All mark names that can appear on inline text */\nconst ALL_MARKS = [\"bold\", \"italic\", \"strike\", \"code\", \"link\", RAW_MARKDOWN_MARK] as const;\n\n/**\n * Build Yjs insert attributes from the current mark stack.\n * Explicitly sets null for inactive marks to prevent Yjs from\n * inheriting formatting from adjacent formatted segments.\n */\nfunction buildAttrs(marks: Record<string, object>): Record<string, object | null> {\n const attrs: Record<string, object | null> = {};\n for (const name of ALL_MARKS) {\n attrs[name] = name in marks ? marks[name] : null;\n }\n return attrs;\n}\n\n/**\n * Insert verbatim markdown source as a `rawMarkdown`-marked text run.\n * MUST use insert-with-attributes (never `insertEmbed`, never a fresh\n * Y.XmlText): the source stays as real text so every character counts 1-for-1\n * in `getElementText()`, keeping flat annotation offsets aligned. An embed\n * would collapse the run to flat-length 1 and desync every later anchor (#981).\n */\nfunction insertRaw(xmlText: Y.XmlText, source: string, marks: Record<string, object>): void {\n if (source.length === 0) return;\n xmlText.insert(xmlText.length, source, buildAttrs({ ...marks, [RAW_MARKDOWN_MARK]: {} }));\n}\n\n/**\n * Process inline/phrasing MDAST nodes into a single Y.XmlText with marks.\n * Uses insert-with-attributes (not insert + format) because Yjs requires\n * the Y.XmlText to be attached to a doc for format() to preserve order.\n */\nfunction processInline(\n xmlText: Y.XmlText,\n nodes: PhrasingContent[],\n marks: Record<string, object>,\n): void {\n for (const node of nodes) {\n switch (node.type) {\n case \"text\": {\n xmlText.insert(xmlText.length, node.value, buildAttrs(marks));\n break;\n }\n\n case \"strong\":\n processInline(xmlText, node.children, { ...marks, bold: {} });\n break;\n\n case \"emphasis\":\n processInline(xmlText, node.children, { ...marks, italic: {} });\n break;\n\n case \"delete\":\n processInline(xmlText, node.children, { ...marks, strike: {} });\n break;\n\n case \"inlineCode\": {\n xmlText.insert(xmlText.length, node.value, buildAttrs({ ...marks, code: {} }));\n break;\n }\n\n case \"link\":\n processInline(xmlText, node.children, {\n ...marks,\n link: { href: node.url, ...(node.title ? { title: node.title } : {}) },\n });\n break;\n\n case \"break\": {\n const embed = new Y.XmlElement(\"hardBreak\");\n xmlText.insertEmbed(xmlText.length, embed);\n break;\n }\n\n case \"image\": {\n // Truly-inline images (standalone images are promoted to block-level\n // `image` nodes before reaching here, see the paragraph case). Preserve\n // the full `![alt](url \"title\")` source as a raw run so the URL/title\n // survive the round-trip instead of degrading to alt-text only (#981).\n insertRaw(xmlText, serializeMdastInline(node) || node.alt || node.url, marks);\n break;\n }\n\n // footnoteReference / linkReference / imageReference carry no `.value`;\n // serialize each to its verbatim markdown source and store as a raw run so\n // the construct round-trips (the historical silent drop). See #981.\n case \"footnoteReference\":\n case \"linkReference\":\n case \"imageReference\":\n insertRaw(xmlText, serializeMdastInline(node), marks);\n break;\n\n // Inline (phrasing) HTML. mdast emits one `html` node per tag, so paired\n // tags like <span>…</span> arrive as separate nodes around real prose;\n // mark each tag's value as raw — the prose between stays normal text.\n case \"html\":\n insertRaw(xmlText, node.value, marks);\n break;\n\n default: {\n // Unreachable for the static PhrasingContent union (all variants are\n // cased above) — kept as a runtime net for any plugin-added node type.\n // `node` is `never` here, so widen through `unknown` before inspecting.\n const widened = node as unknown as PhrasingContent & { value?: string };\n const src = serializeMdastInline(widened);\n if (src.length > 0) {\n insertRaw(xmlText, src, marks);\n } else if (typeof widened.value === \"string\") {\n xmlText.insert(xmlText.length, widened.value, buildAttrs(marks));\n }\n break;\n }\n }\n }\n}\n\n/**\n * Convert a Y.Doc's XmlFragment back to an MDAST Root tree.\n */\nexport function yDocToMdast(doc: Y.Doc): Root {\n const fragment = doc.getXmlFragment(\"default\");\n const children: RootContent[] = [];\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n const mdastNode = yxmlToMdast(node);\n if (mdastNode) children.push(mdastNode);\n }\n }\n\n return { type: \"root\", children };\n}\n\n/** Convert a Y.XmlElement back to an MDAST block node */\nfunction yxmlToMdast(el: Y.XmlElement): RootContent | null {\n switch (el.nodeName) {\n case \"heading\": {\n const depth = Number(el.getAttribute(\"level\") ?? 1) as 1 | 2 | 3 | 4 | 5 | 6;\n return { type: \"heading\", depth, children: deltaToPhrasingContent(el) };\n }\n\n case \"paragraph\":\n // Both markdownRaw (footnote/reference defs, unknown blocks) and\n // markdownHtml (raw HTML blocks) re-emit as an mdast `html` node — its\n // value serializes verbatim, reproducing the original source exactly.\n if (el.getAttribute(MARKDOWN_RAW_ATTR) || el.getAttribute(MARKDOWN_HTML_ATTR)) {\n return { type: \"html\", value: getElementPlainText(el) } as any;\n }\n return { type: \"paragraph\", children: deltaToPhrasingContent(el) };\n\n case \"blockquote\": {\n const children: RootContent[] = [];\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlElement) {\n const m = yxmlToMdast(child);\n if (m) children.push(m);\n }\n }\n // blockquote.children is BlockContent[] but RootContent covers it\n return { type: \"blockquote\", children: children as any };\n }\n\n case \"bulletList\":\n case \"orderedList\": {\n const ordered = el.nodeName === \"orderedList\";\n const start = ordered ? Number(el.getAttribute(\"start\")) || 1 : undefined;\n const listItems: any[] = [];\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlElement && child.nodeName === \"listItem\") {\n const itemChildren: any[] = [];\n for (let j = 0; j < child.length; j++) {\n const grandchild = child.get(j);\n if (grandchild instanceof Y.XmlElement) {\n const m = yxmlToMdast(grandchild);\n if (m) itemChildren.push(m);\n }\n }\n // GFM task list (#982): re-emit the per-item `checked` tri-state so\n // remark-gfm stringifies `- [ ]` / `- [x]`. Absent attribute → plain\n // bullet (no `checked` field). Read tolerantly (boolean from\n // y-prosemirror / server writes, or a string from any future writer).\n const checkedAttr = child.getAttribute(\"checked\") as boolean | string | undefined;\n const listItemNode: any = { type: \"listItem\", spread: false, children: itemChildren };\n if (checkedAttr != null) {\n listItemNode.checked = checkedAttr === true || checkedAttr === \"true\";\n }\n listItems.push(listItemNode);\n }\n }\n return {\n type: \"list\",\n ordered,\n spread: false,\n ...(ordered && start !== 1 ? { start } : {}),\n children: listItems,\n } as any;\n }\n\n case \"codeBlock\": {\n const lang = el.getAttribute(\"language\") as string | undefined;\n let value = \"\";\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlText) {\n value += child.toString();\n }\n }\n return { type: \"code\", lang: lang || null, value } as any;\n }\n\n case \"horizontalRule\":\n return { type: \"thematicBreak\" };\n\n case \"table\": {\n // Read column alignment from the table-level \"align\" attribute (stored\n // as JSON). Default to [] if missing or unparseable — alignment is\n // optional in GFM, body-row alignment attrs are ignored.\n let align: (AlignType | null)[] = [];\n const rawAlign = el.getAttribute(\"align\");\n if (typeof rawAlign === \"string\" && rawAlign.length > 0) {\n try {\n const parsed = JSON.parse(rawAlign);\n if (Array.isArray(parsed)) align = parsed as (AlignType | null)[];\n } catch {\n // fall through with empty align\n }\n }\n const rows: any[] = [];\n for (let i = 0; i < el.length; i++) {\n const rowChild = el.get(i);\n if (!(rowChild instanceof Y.XmlElement) || rowChild.nodeName !== \"tableRow\") {\n continue;\n }\n const cells: any[] = [];\n for (let j = 0; j < rowChild.length; j++) {\n const cellChild = rowChild.get(j);\n if (\n cellChild instanceof Y.XmlElement &&\n (cellChild.nodeName === \"tableHeader\" || cellChild.nodeName === \"tableCell\")\n ) {\n cells.push({ type: \"tableCell\", children: cellToPhrasingContent(cellChild) });\n }\n }\n rows.push({ type: \"tableRow\", children: cells });\n }\n return { type: \"table\", align, children: rows } as Table;\n }\n\n case \"image\": {\n // MDAST `image` is phrasing content, not a valid direct root child.\n // Wrap it in a paragraph so remark-stringify emits proper block\n // separation (a bare root-level image serializes with no surrounding\n // newlines, mangling the document on save). Mirrors how remark-parse\n // produces `paragraph > image` for a standalone `![alt](url)` (#153).\n const image = {\n type: \"image\",\n url: (el.getAttribute(\"src\") as string) || \"\",\n alt: (el.getAttribute(\"alt\") as string) || undefined,\n title: (el.getAttribute(\"title\") as string) || null,\n };\n return { type: \"paragraph\", children: [image] } as any;\n }\n\n // Unknown node types — try to extract text content as a paragraph\n default: {\n const phrasing = deltaToPhrasingContent(el);\n if (phrasing.length > 0) {\n return { type: \"paragraph\", children: phrasing };\n }\n return null;\n }\n }\n}\n\n/**\n * Strip y-prosemirror hash suffixes from attribute keys.\n * y-prosemirror appends \"--<hash>\" to mark names in delta attributes.\n */\nfunction stripHashSuffix(key: string): string {\n const dashIdx = key.indexOf(\"--\");\n return dashIdx >= 0 ? key.slice(0, dashIdx) : key;\n}\n\nfunction getElementPlainText(el: Y.XmlElement): string {\n let value = \"\";\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n if (child instanceof Y.XmlText) value += child.toString();\n }\n return value;\n}\n\n/**\n * Convert Y.XmlText delta segments into MDAST phrasing content.\n * Handles marks (bold, italic, strike, code, link) and hardBreak embeds.\n */\nfunction deltaToPhrasingContent(el: Y.XmlElement): PhrasingContent[] {\n const result: PhrasingContent[] = [];\n\n for (let i = 0; i < el.length; i++) {\n const child = el.get(i);\n\n if (child instanceof Y.XmlText) {\n const delta = child.toDelta();\n for (const op of delta) {\n // Embedded elements (hardBreak, etc.)\n if (typeof op.insert !== \"string\") {\n if (op.insert instanceof Y.XmlElement && op.insert.nodeName === \"hardBreak\") {\n result.push({ type: \"break\" });\n }\n continue;\n }\n\n const text = op.insert;\n if (text.length === 0) continue;\n\n // Collect marks from delta attributes\n const attrs = op.attributes || {};\n const marks = new Map<string, any>();\n for (const [key, value] of Object.entries(attrs)) {\n marks.set(stripHashSuffix(key), value);\n }\n\n // A `rawMarkdown` run is verbatim markdown source (footnote/reference\n // refs, inline image, inline html). Emit as an inline `html` node — it\n // serializes byte-exact, bypassing the `text` escaper (PhrasingContent\n // includes Html, so the cast is structural only). It then flows through\n // the SAME link/strike/italic/bold wrapping below as ordinary text, so:\n // (a) an outer mark on the run is preserved (e.g. bold around a\n // footnote ref), and\n // (b) crucially, a raw inline IMAGE stays wrapped inside its mark\n // rather than becoming a bare paragraph-child image — which the\n // #153 `splitParagraphImages` promotion would otherwise turn into\n // a block image on reload, collapsing the inline run's flat length\n // and desyncing every later annotation offset.\n // Two adjacent UNMARKED raw runs (e.g. `[^1][^2]`) stay separate: `html`\n // has no wrapper, so `coalescePhrasing`'s `sameWrapper` never merges them.\n //\n // `code` is a leaf-level mark: the segment is either an inlineCode leaf\n // or a plain-text leaf. link/strike/italic/bold then each wrap whatever\n // `node` is — inlineCode is valid PhrasingContent inside all of them, so\n // a code span keeps its mark even when combined with bold/italic/etc.\n let node: PhrasingContent = marks.has(RAW_MARKDOWN_MARK)\n ? ({ type: \"html\", value: text } as any)\n : marks.has(\"code\")\n ? { type: \"inlineCode\", value: text }\n : { type: \"text\", value: text };\n\n // Wrap from innermost to outermost: link, then strike, italic, bold.\n if (marks.has(\"link\")) {\n const linkAttrs = marks.get(\"link\") || {};\n node = {\n type: \"link\",\n url: linkAttrs.href || \"\",\n ...(linkAttrs.title ? { title: linkAttrs.title } : {}),\n children: [node],\n };\n }\n if (marks.has(\"strike\")) {\n node = { type: \"delete\", children: [node] } as any;\n }\n if (marks.has(\"italic\")) {\n node = { type: \"emphasis\", children: [node] };\n }\n if (marks.has(\"bold\")) {\n node = { type: \"strong\", children: [node] };\n }\n\n result.push(node);\n }\n } else if (child instanceof Y.XmlElement) {\n // Non-text child elements embedded in a block (shouldn't happen often)\n if (child.nodeName === \"hardBreak\") {\n result.push({ type: \"break\" });\n }\n }\n }\n\n return coalescePhrasing(result);\n}\n\n/**\n * Merge adjacent phrasing nodes that share the same wrapper (strong/emphasis/\n * delete, or a link with identical url+title) into one node, recursing into\n * children. Each delta segment is wrapped independently above, so a bold run\n * containing a code span produces a `strong > text` node adjacent to a\n * `strong > inlineCode` node. Left un-merged, remark-stringify pads the two\n * adjacent emphasis runs with `&#x20;` / doubled `**`, corrupting the file on\n * save. Y.js `toDelta()` already collapses runs with identical attributes, so\n * the only adjacent same-wrapper nodes here differ in some inner mark.\n */\nfunction coalescePhrasing(nodes: PhrasingContent[]): PhrasingContent[] {\n const out: PhrasingContent[] = [];\n for (const node of nodes) {\n const prev = out[out.length - 1];\n if (prev && sameWrapper(prev, node)) {\n const merged = prev as Extract<PhrasingContent, { children: PhrasingContent[] }>;\n merged.children = coalescePhrasing([...merged.children, ...(node as typeof merged).children]);\n } else {\n out.push(node);\n }\n }\n return out;\n}\n\nfunction sameWrapper(a: PhrasingContent, b: PhrasingContent): boolean {\n if (a.type !== b.type) return false;\n switch (a.type) {\n case \"strong\":\n case \"emphasis\":\n case \"delete\":\n return true;\n case \"link\": {\n const al = a as Extract<PhrasingContent, { type: \"link\" }>;\n const bl = b as Extract<PhrasingContent, { type: \"link\" }>;\n return al.url === bl.url && al.title === bl.title;\n }\n default:\n return false;\n }\n}\n\nfunction cellToPhrasingContent(cell: Y.XmlElement): PhrasingContent[] {\n const chunks: PhrasingContent[][] = [];\n\n for (let i = 0; i < cell.length; i++) {\n const child = cell.get(i);\n if (child instanceof Y.XmlElement) {\n const chunk =\n child.nodeName === \"paragraph\"\n ? deltaToPhrasingContent(child)\n : plainTextToPhrasingContent(plainTextFromElement(child));\n if (isNonEmptyPhrasing(chunk)) chunks.push(chunk);\n } else if (child instanceof Y.XmlText) {\n const direct = deltaToPhrasingContent(cell);\n if (isNonEmptyPhrasing(direct)) chunks.push(direct);\n break;\n }\n }\n\n const result: PhrasingContent[] = [];\n chunks.forEach((chunk, index) => {\n if (index > 0) result.push({ type: \"text\", value: \" \" });\n result.push(...chunk);\n });\n return result;\n}\n\nfunction isNonEmptyPhrasing(nodes: PhrasingContent[]): boolean {\n return nodes.some((node) => phrasingPlainText(node).trim().length > 0);\n}\n\nfunction phrasingPlainText(node: PhrasingContent): string {\n switch (node.type) {\n case \"text\":\n case \"inlineCode\":\n return node.value;\n case \"break\":\n return \"\\n\";\n case \"strong\":\n case \"emphasis\":\n case \"delete\":\n case \"link\":\n return node.children.map(phrasingPlainText).join(\"\");\n case \"image\":\n return node.alt ?? \"\";\n default:\n return \"value\" in node && typeof node.value === \"string\" ? node.value : \"\";\n }\n}\n\nfunction plainTextToPhrasingContent(text: string): PhrasingContent[] {\n return text.trim().length > 0 ? [{ type: \"text\", value: text }] : [];\n}\n\nfunction plainTextFromElement(element: Y.XmlElement): string {\n const parts: string[] = [];\n let hasPriorContent = false;\n\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n parts.push(xmlTextToPlainText(child));\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (child.nodeName === \"hardBreak\") {\n parts.push(\"\\n\");\n hasPriorContent = true;\n } else {\n if (hasPriorContent) parts.push(\"\\n\");\n parts.push(plainTextFromElement(child));\n hasPriorContent = true;\n }\n }\n }\n\n return parts.join(\"\");\n}\n\nfunction xmlTextToPlainText(xmlText: Y.XmlText): string {\n let text = \"\";\n for (const op of xmlText.toDelta()) {\n text += typeof op.insert === \"string\" ? op.insert : \"\\n\";\n }\n return text;\n}\n","import type { PhrasingContent, Root, RootContent } from \"mdast\";\nimport remarkGfm from \"remark-gfm\";\nimport remarkParse from \"remark-parse\";\nimport remarkStringify from \"remark-stringify\";\nimport { unified } from \"unified\";\nimport { visit } from \"unist-util-visit\";\nimport * as Y from \"yjs\";\nimport { mdastToYDoc, yDocToMdast } from \"./mdast-ydoc.js\";\n\n/**\n * Normalize a reference label to mdast's canonical form (whitespace collapsed,\n * trimmed, lowercased). Matches what `mdast-util-from-markdown` stores in\n * `definition.identifier`. We don't use `micromark-util-normalize-identifier`\n * because that utility ends in `.toUpperCase()` while mdast's stored\n * `identifier` is lowercase.\n */\nfunction normalizeLabel(s: string): string {\n return s\n .replace(/[\\t\\n\\r ]+/g, \" \")\n .trim()\n .toLowerCase();\n}\n\n/**\n * Host shape (anchored at the char after `@`) that can re-form a GFM email\n * autolink-literal: a run of host chars (`[A-Za-z0-9._-]`) containing a dot\n * followed by a letter-bearing final label. Deliberately conservative — it\n * KEEPS the `\\@` escape for anything host-shaped and only un-escapes positions\n * that provably cannot autolink:\n * - no dot at all (`user@host`) -> safe to un-escape\n * - numeric-only final label (`user@a.1`) -> safe to un-escape\n * - `@` not followed by a host run -> safe to un-escape\n * It intentionally matches a few non-autolinking shapes (e.g. `host..com`,\n * which has an empty middle label) — over-keeping leaves harmless escape noise,\n * whereas under-keeping would re-form a link. Verified zero false-negatives\n * (no autolink-forming host is un-escaped) against the GFM autolink boundary,\n * including the leading-dot host case `user@.com`. See the `text` handler step\n * 5. The classes don't nest with overlapping quantifiers, so matching is linear\n * on adversarial input.\n */\nconst HOST_AFTER_AT = /^[A-Za-z0-9._-]*\\.[A-Za-z0-9_-]*[A-Za-z]/;\n\n/** Markdown parser shared by production and tests. */\nexport const mdParser = unified().use(remarkParse).use(remarkGfm).freeze();\n\nconst stringifyOptions = {\n bullet: \"-\",\n emphasis: \"*\",\n strong: \"*\",\n listItemIndent: \"one\",\n rule: \"-\",\n} as const;\n\n/** Parse markdown string and populate a Y.Doc's XmlFragment */\nexport function loadMarkdown(doc: Y.Doc, markdown: string): void {\n const tree = mdParser.parse(markdown) as Root;\n mdastToYDoc(doc, tree);\n}\n\n/** Serialize a Y.Doc's XmlFragment back to markdown */\nexport function saveMarkdown(doc: Y.Doc): string {\n return serializeMdast(yDocToMdast(doc));\n}\n\n/**\n * Ref-def identifiers for the tree currently being serialized. Module-level so\n * the frozen `mdStringifier` below can be built once (not rebuilt per call — the\n * serializer is invoked per raw-construct node on document load, #981); set\n * synchronously before each `mdStringifier.stringify(...)` call. Safe because\n * stringify is synchronous, single-threaded, and never re-entrant.\n */\nlet activeRefDefs = new Set<string>();\n\n/**\n * The project's configured markdown stringifier, frozen once (mirrors the\n * `mdParser` pattern). The custom `text` handler reads `activeRefDefs` at call\n * time, so the same frozen processor serves every tree.\n */\nconst mdStringifier = unified()\n .use(remarkGfm)\n .use(remarkStringify, {\n ...stringifyOptions,\n handlers: {\n // Call state.safe() first (mirroring the default text handler) so\n // block-context escapes (line-leading `# `, `- `, `> `, fence runs,\n // table pipes, setext underlines) remain intact, then selectively\n // un-escape intra-text noise that the default `unsafe` table over-flags.\n //\n // GFM extensions (autolink-literal `@`/`.`/`:`, strikethrough `~~`,\n // table `|`) register no `text` handler and contribute `unsafe` entries\n // that flow through safe(). Of those, `~` and (conditionally) `@` are\n // un-escaped below — `~` because GFM strikethrough requires `~~`, and\n // `@` only when the following text cannot re-form an email autolink.\n text(node, _parent, state, info) {\n let s = state.safe(node.value, info);\n\n // Will the next sibling's serialized output start with `[`? If yes,\n // a `\\[label]` at the end of our text node must stay escaped — the\n // un-escaped `[label][...]` would be parsed as a full reference link.\n const nextStartsBracket = typeof info.after === \"string\" && info.after.startsWith(\"[\");\n\n // 1. `\\[label]`: un-escape only when `label` does NOT match any\n // `definition` identifier in this tree (otherwise the un-escaped\n // form would re-parse as a collapsed/full reference link).\n // `label` is normalized per CommonMark before comparison.\n // Negative lookahead also rejects an immediately following `[`\n // inside this text node.\n // The label character class excludes `\\` to keep matching linear\n // on adversarial input like `\\[\\[\\[\\[\\[…`.\n s = s.replace(/\\\\\\[([^\\\\\\]\\n`]+)\\](?!\\s*[:([])/g, (match, label, offset) => {\n const atEnd = offset + match.length === s.length;\n if (atEnd && nextStartsBracket) return match;\n return activeRefDefs.has(normalizeLabel(label)) ? match : `[${label}]`;\n });\n\n // 2. `\\_` strictly between word chars: intra-word underscores never\n // open emphasis in CommonMark/GFM. Punctuation-flanked `_` (e.g.\n // `(\\_foo\\_)`) stays escaped — those flanks CAN form emphasis.\n s = s.replace(/(?<=\\w)\\\\_(?=\\w)/g, \"_\");\n\n // 3. `` \\` `` standalone, not adjacent to another backtick. Real code\n // spans round-trip through the `inlineCode` handler, never `text`.\n s = s.replace(/(?<![`\\\\])\\\\`(?!`)/g, \"`\");\n\n // 4. `\\~` not followed by another `~`. GFM strikethrough needs `~~`\n // so a lone `~` is unambiguous prose (e.g. `~4500 tokens`).\n s = s.replace(/\\\\~(?!~)/g, \"~\");\n\n // 5. `\\@` only where the following text is NOT host-shaped. remark-gfm\n // escapes `@` whenever a word-ish local-part char precedes it\n // (`user\\@host.tld`, `user\\@host`), so the local side is implicit\n // and the decision turns on what FOLLOWS `@` (see HOST_AFTER_AT).\n // Where a host shape follows, keep the escape — that is the\n // position a GFM email autolink-literal occupies, so un-escaping\n // there would re-emit prose that *appears* to invite the autolink,\n // mirroring the chain's conservative posture for `\\[`/`\\_`.\n // (CommonMark un-escapes `\\@`→`@` at parse time and the autolink\n // forms from the bare `@` regardless, so the escape is cosmetic at\n // parser level; the point is to strip escape noise only where it is\n // unambiguously safe.)\n s = s.replace(/\\\\@/g, (match, offset) =>\n HOST_AFTER_AT.test(s.slice(offset + match.length)) ? match : \"@\",\n );\n\n return s;\n },\n },\n })\n .freeze();\n\n/**\n * Serialize an mdast Root tree to markdown using the project's configured\n * serializer. Exposed for tests and any future code path that has an mdast\n * tree but no Y.Doc. Reuses the frozen `mdStringifier`; the per-tree ref-def\n * set is published to `activeRefDefs` before stringify (already lowercase +\n * whitespace-collapsed by mdast/from-markdown; labels pulled from text nodes\n * are re-normalized before comparison in the handler).\n */\nexport function serializeMdast(tree: Root): string {\n activeRefDefs = new Set<string>();\n visit(tree, \"definition\", (node) => {\n activeRefDefs.add(node.identifier);\n });\n return mdStringifier.stringify(tree);\n}\n\n/**\n * Serialize a single block-level MDAST node to its verbatim markdown source.\n * Used by the raw-construct passthrough path (#981): constructs Tandem has no\n * first-class editor node for (footnote/reference definitions, etc.) are stored\n * as their literal markdown text and re-emitted as an mdast `html` node, which\n * `remark-stringify` writes verbatim (bypassing the custom `text` escaper). The\n * block node is wrapped in a `root` directly. `trimEnd()` strips the trailing\n * newline the serializer appends so no stray `\\n` enters the paragraph's\n * Y.XmlText (which would desync flat offsets).\n */\nexport function serializeMdastBlock(node: RootContent): string {\n return serializeMdast({ type: \"root\", children: [node] }).trimEnd();\n}\n\n/**\n * Serialize a single inline/phrasing MDAST node (footnoteReference,\n * linkReference, imageReference, inline image, etc.) to its markdown source.\n * Phrasing nodes are not valid root children, so they are wrapped in a\n * `paragraph`. The result is the gfm-canonical form for the node's\n * `referenceType` (full `[t][ref]`, collapsed `[ref][]`, shortcut `[ref]`).\n */\nexport function serializeMdastInline(node: PhrasingContent): string {\n return serializeMdast({\n type: \"root\",\n children: [{ type: \"paragraph\", children: [node] }],\n }).trim();\n}\n","import path from \"path\";\nimport * as Y from \"yjs\";\nimport {\n FLAT_SEPARATOR,\n headingPrefix,\n headingPrefixLength as sharedHeadingPrefixLength,\n} from \"../../shared/offsets.js\";\nimport { saveMarkdown } from \"../file-io/markdown.js\";\n\n/**\n * Detect file format from extension.\n */\nexport function detectFormat(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase();\n switch (ext) {\n case \".md\":\n return \"md\";\n case \".txt\":\n return \"txt\";\n case \".html\":\n case \".htm\":\n return \"html\";\n case \".docx\":\n return \"docx\";\n default:\n return \"txt\";\n }\n}\n\n/**\n * Generate a stable, readable document ID from a file path.\n * Used as both the map key and the Hocuspocus room name.\n */\nexport function docIdFromPath(filePath: string): string {\n const normalized = filePath.replace(/\\\\/g, \"/\").toLowerCase();\n let hash = 0;\n for (let i = 0; i < normalized.length; i++) {\n hash = ((hash << 5) - hash + normalized.charCodeAt(i)) | 0;\n }\n const name = path\n .basename(normalized, path.extname(normalized))\n .replace(/[^a-zA-Z0-9]/g, \"-\")\n .replace(/-+/g, \"-\")\n .slice(0, 16);\n return `${name}-${Math.abs(hash).toString(36).slice(0, 6)}`;\n}\n\n/** Insert text content into a Y.Doc's XmlFragment as paragraphs */\nexport function populateYDoc(doc: Y.Doc, text: string): void {\n const fragment = doc.getXmlFragment(\"default\");\n\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n if (text === \"\") return;\n\n const lines = text.split(\"\\n\");\n for (const line of lines) {\n if (line === \"\") {\n const empty = new Y.XmlElement(\"paragraph\");\n empty.insert(0, [new Y.XmlText(\"\")]);\n fragment.insert(fragment.length, [empty]);\n continue;\n }\n\n let element: Y.XmlElement;\n\n if (line.startsWith(\"### \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 3 as any);\n element.insert(0, [new Y.XmlText(line.slice(4))]);\n } else if (line.startsWith(\"## \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 2 as any);\n element.insert(0, [new Y.XmlText(line.slice(3))]);\n } else if (line.startsWith(\"# \")) {\n element = new Y.XmlElement(\"heading\");\n element.setAttribute(\"level\", 1 as any);\n element.insert(0, [new Y.XmlText(line.slice(2))]);\n } else {\n element = new Y.XmlElement(\"paragraph\");\n element.insert(0, [new Y.XmlText(line)]);\n }\n\n fragment.insert(fragment.length, [element]);\n }\n}\n\n/**\n * Extract plain text from a Y.XmlElement by recursively collecting Y.XmlText content.\n * Inserts FLAT_SEPARATOR between nested XmlElement children so offsets are consistent\n * with the document-level separator convention (e.g., list items and table cells\n * get \\n between them).\n *\n * Separator contract (must stay in sync with getElementTextLength):\n * every gap between nested block/container XmlElement children contributes one\n * FLAT_SEPARATOR character. Offset helpers account for that as a one-character\n * between-element gap.\n */\nexport function getElementText(element: Y.XmlElement): string {\n const parts: string[] = [];\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n for (const op of child.toDelta()) {\n if (typeof op.insert === \"string\") {\n parts.push(op.insert);\n } else {\n // Embed (hardBreak, etc.) — emit \\n to keep flat offset aligned\n // with Y.XmlText internal index (embeds count as 1 in xmlText.length)\n parts.push(\"\\n\");\n }\n }\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) parts.push(FLAT_SEPARATOR);\n parts.push(getElementText(child));\n hasPriorContent = true;\n }\n }\n return parts.join(\"\");\n}\n\n/**\n * Compute the flat text length of a Y.XmlElement without building the string.\n * Uses the same one-character separator invariant as getElementText().\n */\nexport function getElementTextLength(element: Y.XmlElement): number {\n let len = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n len += child.length;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) len += 1;\n len += getElementTextLength(child);\n hasPriorContent = true;\n }\n }\n return len;\n}\n\n/**\n * Find the Y.XmlText that contains a given flat text offset within a Y.XmlElement.\n * Returns the XmlText and the offset within it, or null if the offset falls on a\n * separator character or cannot be resolved.\n */\nexport function findXmlTextAtOffset(\n element: Y.XmlElement,\n textOffset: number,\n): { xmlText: Y.XmlText; offsetInXmlText: number } | null {\n let accumulated = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n const len = child.length;\n if (accumulated + len > textOffset) {\n return { xmlText: child, offsetInXmlText: textOffset - accumulated };\n }\n accumulated += len;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) {\n if (textOffset === accumulated) {\n // Offset lands ON the separator — return null (between-element gap)\n return null;\n }\n accumulated += 1;\n }\n const childTextLen = getElementTextLength(child);\n if (accumulated + childTextLen > textOffset) {\n return findXmlTextAtOffset(child, textOffset - accumulated);\n }\n accumulated += childTextLen;\n hasPriorContent = true;\n }\n }\n // Handle end-of-element: offset equals total length\n if (textOffset === accumulated) {\n // Walk backwards to find the last XmlText\n for (let i = element.length - 1; i >= 0; i--) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n return { xmlText: child, offsetInXmlText: child.length };\n } else if (child instanceof Y.XmlElement) {\n return findXmlTextAtOffset(child, getElementTextLength(child));\n }\n }\n }\n return null;\n}\n\n/**\n * Collect all Y.XmlText nodes in a Y.XmlElement with their flat offsets from the\n * element's start. Uses the same one-character separator invariant as getElementText().\n */\nexport function collectXmlTexts(\n element: Y.XmlElement,\n): Array<{ xmlText: Y.XmlText; offsetFromStart: number }> {\n const results: Array<{ xmlText: Y.XmlText; offsetFromStart: number }> = [];\n let accumulated = 0;\n let hasPriorContent = false;\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n results.push({ xmlText: child, offsetFromStart: accumulated });\n accumulated += child.length;\n hasPriorContent = true;\n } else if (child instanceof Y.XmlElement) {\n if (hasPriorContent) accumulated += 1;\n for (const nested of collectXmlTexts(child)) {\n results.push({\n xmlText: nested.xmlText,\n offsetFromStart: accumulated + nested.offsetFromStart,\n });\n }\n accumulated += getElementTextLength(child);\n hasPriorContent = true;\n }\n }\n return results;\n}\n\n/** Extract plain text from a Y.Doc's XmlFragment */\nexport function extractText(doc: Y.Doc): string {\n const fragment = doc.getXmlFragment(\"default\");\n const lines: string[] = [];\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n const text = getElementText(node);\n if (node.nodeName === \"heading\") {\n const level = Number(node.getAttribute(\"level\") ?? 1);\n lines.push(headingPrefix(level) + text);\n } else {\n lines.push(text);\n }\n }\n }\n\n return lines.join(FLAT_SEPARATOR);\n}\n\n/**\n * Extract readable markdown from a Y.Doc via remark serialization.\n * NOT used by resolveToElement or tandem_edit (those use extractText).\n */\nexport function extractMarkdown(doc: Y.Doc): string {\n return saveMarkdown(doc).trimEnd();\n}\n\n/**\n * Get the heading prefix length for a Y.XmlElement.\n * Delegates to shared headingPrefixLength for the actual math.\n */\nexport function getHeadingPrefixLength(node: Y.XmlElement): number {\n if (node.nodeName === \"heading\") {\n const level = Number(node.getAttribute(\"level\") ?? 1);\n return sharedHeadingPrefixLength(level);\n }\n return 0;\n}\n\n// -- Range staleness detection ------------------------------------------------\n\nexport type RangeVerifyResult =\n | { valid: true }\n | { valid: false; gone: true }\n | { valid: false; gone: false; resolvedFrom: number; resolvedTo: number };\n\n/**\n * Check whether [from, to] still contains textSnapshot. If not, search the\n * full document and return the relocated range or { gone: true }.\n */\nexport function verifyAndResolveRange(\n doc: Y.Doc,\n from: number,\n to: number,\n textSnapshot: string | undefined,\n): RangeVerifyResult {\n if (!textSnapshot) return { valid: true };\n const fullText = extractText(doc);\n if (fullText.slice(from, to) === textSnapshot) return { valid: true };\n const candidates: number[] = [];\n let searchFrom = 0;\n while (true) {\n const idx = fullText.indexOf(textSnapshot, searchFrom);\n if (idx === -1) break;\n candidates.push(idx);\n searchFrom = idx + 1;\n }\n if (candidates.length === 0) return { valid: false, gone: true };\n const best = candidates.reduce((a, b) => (Math.abs(a - from) <= Math.abs(b - from) ? a : b));\n return { valid: false, gone: false, resolvedFrom: best, resolvedTo: best + textSnapshot.length };\n}\n\n/**\n * Find the first Y.XmlText child of a Y.XmlElement (read-only).\n * Returns null if no XmlText child exists.\n */\nexport function findXmlText(element: Y.XmlElement): Y.XmlText | null {\n for (let i = 0; i < element.length; i++) {\n const child = element.get(i);\n if (child instanceof Y.XmlText) {\n return child;\n }\n }\n return null;\n}\n\nexport const TEXTBLOCK_NODES = new Set([\"paragraph\", \"heading\", \"codeBlock\"]);\n\n/**\n * Merge all delta segments from `source` into `target` at `offset`,\n * preserving inline formatting and embeds. XmlElement embeds (hardBreak)\n * are cloned to avoid moving attached nodes out of `source`.\n */\nexport function mergeXmlTextDelta(target: Y.XmlText, source: Y.XmlText, offset: number): void {\n let pos = offset;\n for (const seg of source.toDelta()) {\n // Pass {} (not undefined) — Y.js insert(pos, str, undefined) inherits\n // formatting from the preceding character; insert(pos, str, {}) terminates it.\n if (typeof seg.insert === \"string\") {\n target.insert(pos, seg.insert, seg.attributes ?? {});\n pos += seg.insert.length;\n } else {\n const embed = seg.insert instanceof Y.XmlElement ? seg.insert.clone() : { ...seg.insert };\n target.insertEmbed(pos, embed, seg.attributes ?? {});\n pos += 1;\n }\n }\n}\n\n/**\n * Return the XmlText child of a textblock element, creating one if empty.\n * Throws on non-textblock nodes (containers like blockquote, bulletList, etc.).\n */\nexport function getOrCreateXmlText(element: Y.XmlElement): Y.XmlText {\n if (!TEXTBLOCK_NODES.has(element.nodeName)) {\n throw new Error(\n `Cannot create XmlText on \"${element.nodeName}\" — only textblock elements ` +\n `(paragraph, heading, codeBlock) should have direct XmlText children. ` +\n `Edit a specific paragraph or list item instead.`,\n );\n }\n return (\n findXmlText(element) ??\n (() => {\n const textNode = new Y.XmlText(\"\");\n element.insert(0, [textNode]);\n return textNode;\n })()\n );\n}\n","// HTML → Y.Doc conversion: htmlparser2 DOM traversal → Yjs XmlFragment\n\nimport type { ChildNode, Element, Text } from \"domhandler\";\nimport * as htmlparser2 from \"htmlparser2\";\nimport * as Y from \"yjs\";\nimport { DOCX_INLINE_MARKS } from \"../../shared/constants.js\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\n\n/** All marks that can appear on inline text (superset of mdast-ydoc) */\nconst ALL_MARKS = DOCX_INLINE_MARKS;\n\n/** Map HTML tag names to the mark they apply */\nconst INLINE_MARK_TAGS: Record<string, (el: Element) => Record<string, object>> = {\n strong: () => ({ bold: {} }),\n b: () => ({ bold: {} }),\n em: () => ({ italic: {} }),\n i: () => ({ italic: {} }),\n u: () => ({ underline: {} }),\n s: () => ({ strike: {} }),\n del: () => ({ strike: {} }),\n sup: () => ({ superscript: {} }),\n sub: () => ({ subscript: {} }),\n a: (el) => {\n const href = el.attribs.href || \"\";\n const safeHref = /^https?:\\/\\//i.test(href) || href.startsWith(\"mailto:\") ? href : \"\";\n return { link: { href: safeHref } };\n },\n};\n\n/** Tags that represent block-level elements */\nconst BLOCK_TAGS = new Set([\n \"h1\",\n \"h2\",\n \"h3\",\n \"h4\",\n \"h5\",\n \"h6\",\n \"p\",\n \"ul\",\n \"ol\",\n \"li\",\n \"blockquote\",\n \"table\",\n \"tr\",\n \"td\",\n \"th\",\n \"pre\",\n \"img\",\n \"hr\",\n \"br\",\n \"div\",\n]);\n\ntype DeferredText = { xmlText: Y.XmlText; children: ChildNode[]; marks: Record<string, object> };\n\n// -- Footnote reconstruction (#1123 Tier-A #3 PR 2) ---------------------------\n//\n// mammoth renders a Word footnote as an inline\n// <sup><a href=\"#footnote-N\" id=\"footnote-ref-N\">[N]</a></sup>\n// plus a trailing\n// <ol><li id=\"footnote-N\"><p>body <a href=\"#footnote-ref-N\">↑</a></p></li></ol>\n// (N is the OOXML footnote id, mirrored in the href). Endnotes use the disjoint\n// `#endnote-N` / `id=\"endnote-N\"` namespace, so the footnote patterns below never\n// match them — endnotes keep degrading to a visible list (CRITICAL-2).\n\nconst FOOTNOTE_REF_HREF = /^#footnote-(\\d+)$/;\nconst FOOTNOTE_LI_ID = /^footnote-(\\d+)$/;\n\n/** If `<a>` is a footnote inline reference, its id; else null. */\nfunction footnoteRefId(el: Element): string | null {\n const match = (el.attribs.href || \"\").match(FOOTNOTE_REF_HREF);\n return match ? match[1] : null;\n}\n\n/**\n * If `<li>` is a mammoth footnote list item — `id=\"footnote-N\"` AND a back-link\n * `<a href=\"#footnote-ref-N\">` inside — its id; else null. The back-link is\n * required so a coincidental author-authored `id=\"footnote-5\"` is never mistaken\n * for a flattened footnote (false-removal guard).\n */\nfunction footnoteListItemId(li: Element): string | null {\n const match = (li.attribs.id || \"\").match(FOOTNOTE_LI_ID);\n if (!match) return null;\n const backLink = `#footnote-ref-${match[1]}`;\n const stack: ChildNode[] = [...li.children];\n while (stack.length > 0) {\n const node = stack.pop();\n if (node && isElement(node)) {\n if (node.tagName.toLowerCase() === \"a\" && (node.attribs.href || \"\") === backLink) {\n return match[1];\n }\n stack.push(...node.children);\n }\n }\n return null;\n}\n\n/** Collect every footnote ref id (A) and footnote list-item id (B) in the DOM. */\nfunction collectFootnoteSignals(nodes: ChildNode[]): {\n refIds: Set<string>;\n listIds: Set<string>;\n} {\n const refIds = new Set<string>();\n const listIds = new Set<string>();\n const walk = (ns: ChildNode[]): void => {\n for (const node of ns) {\n if (!isElement(node)) continue;\n const tag = node.tagName.toLowerCase();\n if (tag === \"a\") {\n const id = footnoteRefId(node);\n if (id !== null) refIds.add(id);\n } else if (tag === \"li\") {\n const id = footnoteListItemId(node);\n if (id !== null) listIds.add(id);\n }\n walk(node.children);\n }\n };\n walk(nodes);\n return { refIds, listIds };\n}\n\n/**\n * RECONCILIATION INVARIANT (CRITICAL-3): a footnote id is reconstructed only\n * when it has an inline mark target (A) AND a removable trailing `<li>` (B) AND\n * a captured body (C). Any id where these disagree (a mammoth-format drift) is\n * left to degrade to a visible list — no mark, no `<li>` removal, export emits\n * nothing for it — converting a half-state into the lesser evil\n * (degraded-but-present), never silent loss or duplication.\n */\nfunction reconcileFootnotes(\n nodes: ChildNode[],\n footnoteBodies: Record<string, FootnoteBody>,\n): Set<string> {\n const { refIds, listIds } = collectFootnoteSignals(nodes);\n const bodyIds = new Set(Object.keys(footnoteBodies));\n const approved = new Set<string>();\n for (const id of new Set([...refIds, ...listIds, ...bodyIds])) {\n if (refIds.has(id) && listIds.has(id) && bodyIds.has(id)) {\n approved.add(id);\n } else {\n console.error(\n `[docx-footnotes] footnote id=${id} failed reconciliation ` +\n `(inline-ref=${refIds.has(id)} list-item=${listIds.has(id)} body=${bodyIds.has(id)}); ` +\n \"degrading to a visible list for this id (no reconstruction).\",\n );\n }\n }\n return approved;\n}\n\n/**\n * Compute which CAPTURED footnote ids will reconstruct vs be dropped, for a\n * given mammoth HTML + captured bodies, WITHOUT mutating a doc or logging. The\n * import honesty line (computed in `parse`, before `apply` runs the transform)\n * calls this so it reflects the SAME reconciliation `htmlToYDoc` performs in\n * `apply` — identical inputs (`loaded.html` + bodies) → identical partition — so\n * a footnote that fails reconciliation (an orphaned definition with no inline\n * ref, or a future mammoth-format drift) is reported as a real loss instead of\n * being silently claimed \"preserved\". Logging stays in `reconcileFootnotes` (the\n * apply path) so a discrepancy is recorded exactly once.\n */\nexport function reconcileFootnoteIds(\n html: string,\n footnoteBodies: Record<string, FootnoteBody>,\n): { reconstructed: string[]; dropped: string[] } {\n const bodyIds = Object.keys(footnoteBodies);\n if (bodyIds.length === 0) return { reconstructed: [], dropped: [] };\n if (!html.trim()) return { reconstructed: [], dropped: bodyIds };\n const { refIds, listIds } = collectFootnoteSignals(htmlparser2.parseDocument(html).children);\n const reconstructed: string[] = [];\n const dropped: string[] = [];\n for (const id of bodyIds) {\n (refIds.has(id) && listIds.has(id) ? reconstructed : dropped).push(id);\n }\n return { reconstructed, dropped };\n}\n\n/**\n * Detector B: remove approved footnotes' trailing `<li>`s so the reconstructed\n * body doesn't ALSO survive as a visible list. Operates at `<li>` granularity\n * (leaves endnote / non-approved items in place) and drops an enclosing `<ol>`\n * only when no `<li>` survives. Returns the rewritten children array.\n */\nfunction pruneFootnoteListItems(nodes: ChildNode[], approved: Set<string>): ChildNode[] {\n const out: ChildNode[] = [];\n for (const node of nodes) {\n if (isElement(node)) {\n if (node.tagName.toLowerCase() === \"ol\") {\n const kept = node.children.filter((child) => {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n const id = footnoteListItemId(child);\n if (id !== null && approved.has(id)) return false;\n }\n return true;\n });\n const survivingLi = kept.some(\n (child) => isElement(child) && child.tagName.toLowerCase() === \"li\",\n );\n if (!survivingLi) continue; // list emptied by removal → drop it entirely\n node.children = pruneFootnoteListItems(kept, approved);\n out.push(node);\n continue;\n }\n node.children = pruneFootnoteListItems(node.children, approved);\n }\n out.push(node);\n }\n return out;\n}\n\n/**\n * Build Yjs insert attributes from the current mark stack.\n * Explicitly sets null for inactive marks to prevent Yjs mark inheritance.\n */\nfunction buildAttrs(marks: Record<string, object>): Record<string, object | null> {\n const attrs: Record<string, object | null> = {};\n for (const name of ALL_MARKS) {\n attrs[name] = name in marks ? marks[name] : null;\n }\n return attrs;\n}\n\nfunction isElement(node: ChildNode): node is Element {\n return node.type === \"tag\";\n}\n\nfunction isText(node: ChildNode): node is Text {\n return node.type === \"text\";\n}\n\n/**\n * Convert parsed HTML into Y.Doc XmlFragment elements.\n * Two-pass pattern per ADR-009: build element tree first, then populate text.\n *\n * `footnoteBodies` (#1123 Tier-A #3 PR 2) are the footnote bodies captured from\n * `word/footnotes.xml`, keyed by OOXML id. When provided, footnotes that pass\n * reconciliation get a `footnote-ref` mark on their inline `[N]` text and have\n * their trailing `<li>` pruned. Returns the RECONCILED subset (only ids that\n * actually reconstructed) — the caller persists exactly these to\n * Y_MAP_FOOTNOTE_BODIES so a stale id from a prior reload can't linger.\n */\nexport function htmlToYDoc(\n doc: Y.Doc,\n html: string,\n footnoteBodies: Record<string, FootnoteBody> = {},\n): Record<string, FootnoteBody> {\n const fragment = doc.getXmlFragment(\"default\");\n\n // Clear existing content\n if (fragment.length > 0) {\n fragment.delete(0, fragment.length);\n }\n\n if (!html.trim()) return {};\n\n const parsed = htmlparser2.parseDocument(html);\n\n // Footnote reconciliation: which ids have a mark target (A), a removable\n // trailing <li> (B), AND a captured body (C). Then prune the approved <li>s\n // from the DOM BEFORE the transform so the body doesn't double as a list.\n const approvedFootnotes = reconcileFootnotes(parsed.children, footnoteBodies);\n parsed.children = pruneFootnoteListItems(parsed.children, approvedFootnotes);\n\n const deferred: DeferredText[] = [];\n const allElements: Y.XmlElement[] = [];\n\n // Pass 1: build element tree, collect deferred text ops\n for (const child of parsed.children) {\n allElements.push(...domNodeToYxml(child, deferred));\n }\n\n // Attach all elements to the doc\n if (allElements.length > 0) {\n fragment.insert(0, allElements);\n }\n\n // Pass 2: populate text now that elements are attached to Y.Doc (Detector A\n // attaches footnote-ref marks for approved ids).\n for (const { xmlText, children, marks } of deferred) {\n processInlineNodes(xmlText, children, marks, approvedFootnotes);\n }\n\n const reconciled: Record<string, FootnoteBody> = {};\n for (const id of approvedFootnotes) reconciled[id] = footnoteBodies[id];\n return reconciled;\n}\n\n/** Convert a DOM node to Y.XmlElement(s). Inline-only containers become paragraphs. */\nfunction domNodeToYxml(node: ChildNode, deferred: DeferredText[]): Y.XmlElement[] {\n if (isText(node)) {\n // Top-level text node — wrap in paragraph\n const text = node.data;\n if (!text.trim()) return [];\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: [node], marks: {} });\n return [el];\n }\n\n if (!isElement(node)) return [];\n\n const tag = node.tagName.toLowerCase();\n\n // Heading\n const headingMatch = tag.match(/^h([1-6])$/);\n if (headingMatch) {\n const el = new Y.XmlElement(\"heading\");\n el.setAttribute(\"level\", parseInt(headingMatch[1]) as any);\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n switch (tag) {\n case \"p\": {\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n case \"blockquote\": {\n const el = new Y.XmlElement(\"blockquote\");\n const blockChildren = collectBlockChildren(node.children, deferred);\n for (const child of blockChildren) {\n el.insert(el.length, [child]);\n }\n return [el];\n }\n\n case \"ul\": {\n const el = new Y.XmlElement(\"bulletList\");\n for (const child of node.children) {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n el.insert(el.length, [buildListItem(child, deferred)]);\n }\n }\n return [el];\n }\n\n case \"ol\": {\n const el = new Y.XmlElement(\"orderedList\");\n const start = parseInt(node.attribs.start || \"1\");\n if (start !== 1) {\n el.setAttribute(\"start\", start as any);\n }\n for (const child of node.children) {\n if (isElement(child) && child.tagName.toLowerCase() === \"li\") {\n el.insert(el.length, [buildListItem(child, deferred)]);\n }\n }\n return [el];\n }\n\n case \"table\": {\n const el = new Y.XmlElement(\"table\");\n // Walk tbody/thead/tfoot or direct tr children\n const rows = collectTableRows(node);\n for (const row of rows) {\n el.insert(el.length, [buildTableRow(row, deferred)]);\n }\n return [el];\n }\n\n case \"pre\": {\n const el = new Y.XmlElement(\"codeBlock\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n // Collect all text content from pre (which may contain a <code> child)\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n\n case \"img\": {\n const el = new Y.XmlElement(\"image\");\n el.setAttribute(\"src\", node.attribs.src || \"\");\n if (node.attribs.alt) el.setAttribute(\"alt\", node.attribs.alt);\n if (node.attribs.title) el.setAttribute(\"title\", node.attribs.title);\n return [el];\n }\n\n case \"hr\": {\n return [new Y.XmlElement(\"horizontalRule\")];\n }\n\n case \"br\": {\n // Top-level <br> — produce an empty paragraph\n const el = new Y.XmlElement(\"paragraph\");\n el.insert(0, [new Y.XmlText(\"\")]);\n return [el];\n }\n\n case \"div\": {\n // Recurse into div, treating it as a transparent container\n const results: Y.XmlElement[] = [];\n for (const child of node.children) {\n results.push(...domNodeToYxml(child, deferred));\n }\n return results;\n }\n\n default: {\n // Unknown block tag or inline-as-block: wrap in paragraph\n if (hasBlockChildren(node)) {\n // Contains blocks — recurse\n const results: Y.XmlElement[] = [];\n for (const child of node.children) {\n results.push(...domNodeToYxml(child, deferred));\n }\n return results;\n }\n // Pure inline content — wrap in paragraph\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: node.children, marks: {} });\n return [el];\n }\n }\n}\n\n/** Check if a node has any block-level element children */\nfunction hasBlockChildren(node: Element): boolean {\n return node.children.some(\n (child) => isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase()),\n );\n}\n\n/** Collect block children from a list of DOM nodes, wrapping stray text in paragraphs */\nfunction collectBlockChildren(children: ChildNode[], deferred: DeferredText[]): Y.XmlElement[] {\n const result: Y.XmlElement[] = [];\n let inlineBuffer: ChildNode[] = [];\n\n const flushInline = () => {\n if (inlineBuffer.length === 0) return;\n // Only flush if there's non-whitespace content\n const hasContent = inlineBuffer.some((n) => (isText(n) ? n.data.trim().length > 0 : true));\n if (hasContent) {\n const el = new Y.XmlElement(\"paragraph\");\n const xmlText = new Y.XmlText();\n el.insert(0, [xmlText]);\n deferred.push({ xmlText, children: inlineBuffer, marks: {} });\n result.push(el);\n }\n inlineBuffer = [];\n };\n\n for (const child of children) {\n if (isElement(child) && BLOCK_TAGS.has(child.tagName.toLowerCase())) {\n flushInline();\n result.push(...domNodeToYxml(child, deferred));\n } else {\n inlineBuffer.push(child);\n }\n }\n flushInline();\n\n // Ensure at least one paragraph (Tiptap requires content in block containers)\n if (result.length === 0) {\n const el = new Y.XmlElement(\"paragraph\");\n el.insert(0, [new Y.XmlText(\"\")]);\n result.push(el);\n }\n\n return result;\n}\n\n/** Build a listItem Y.XmlElement from an <li> DOM node */\nfunction buildListItem(li: Element, deferred: DeferredText[]): Y.XmlElement {\n const listItem = new Y.XmlElement(\"listItem\");\n const blockChildren = collectBlockChildren(li.children, deferred);\n for (const child of blockChildren) {\n listItem.insert(listItem.length, [child]);\n }\n return listItem;\n}\n\n/** Collect all <tr> elements from a <table>, walking through tbody/thead/tfoot */\nfunction collectTableRows(table: Element): Element[] {\n const rows: Element[] = [];\n for (const child of table.children) {\n if (!isElement(child)) continue;\n const tag = child.tagName.toLowerCase();\n if (tag === \"tr\") {\n rows.push(child);\n } else if (tag === \"thead\" || tag === \"tbody\" || tag === \"tfoot\") {\n for (const grandchild of child.children) {\n if (isElement(grandchild) && grandchild.tagName.toLowerCase() === \"tr\") {\n rows.push(grandchild);\n }\n }\n }\n }\n return rows;\n}\n\n/** Build a tableRow Y.XmlElement from a <tr> */\nfunction buildTableRow(tr: Element, deferred: DeferredText[]): Y.XmlElement {\n const row = new Y.XmlElement(\"tableRow\");\n for (const child of tr.children) {\n if (!isElement(child)) continue;\n const tag = child.tagName.toLowerCase();\n if (tag === \"td\" || tag === \"th\") {\n const nodeName = tag === \"th\" ? \"tableHeader\" : \"tableCell\";\n const cell = new Y.XmlElement(nodeName);\n\n // Copy colspan/rowspan\n if (child.attribs.colspan && child.attribs.colspan !== \"1\") {\n cell.setAttribute(\"colspan\", parseInt(child.attribs.colspan) as any);\n }\n if (child.attribs.rowspan && child.attribs.rowspan !== \"1\") {\n cell.setAttribute(\"rowspan\", parseInt(child.attribs.rowspan) as any);\n }\n\n // Tiptap requires cells to contain block elements (content: 'block+')\n const cellBlocks = collectBlockChildren(child.children, deferred);\n for (const block of cellBlocks) {\n cell.insert(cell.length, [block]);\n }\n\n row.insert(row.length, [cell]);\n }\n }\n return row;\n}\n\n/**\n * Process inline DOM nodes into a Y.XmlText with marks.\n * Uses insert-with-attributes per ADR-009.\n */\nfunction processInlineNodes(\n xmlText: Y.XmlText,\n nodes: ChildNode[],\n marks: Record<string, object>,\n approvedFootnotes: Set<string>,\n): void {\n for (const node of nodes) {\n if (isText(node)) {\n const text = node.data;\n if (text.length > 0) {\n xmlText.insert(xmlText.length, text, buildAttrs(marks));\n }\n continue;\n }\n\n if (!isElement(node)) continue;\n\n const tag = node.tagName.toLowerCase();\n\n // Hard break\n if (tag === \"br\") {\n const embed = new Y.XmlElement(\"hardBreak\");\n xmlText.insertEmbed(xmlText.length, embed);\n continue;\n }\n\n // Detector A: a reconstructed footnote's inline reference. Attach the\n // `footnote-ref` mark ONLY — DROP the inherited superscript (export's\n // FootnoteReferenceRun renders superscript natively) and the now-empty link,\n // so gen1/gen2 mark-key sets match. Read the id from the RAW href before the\n // `a` mark factory below sanitizes \"#footnote-N\" to \"\".\n if (tag === \"a\") {\n const fnId = footnoteRefId(node);\n if (fnId !== null && approvedFootnotes.has(fnId)) {\n processInlineNodes(\n xmlText,\n node.children,\n { \"footnote-ref\": { id: fnId, kind: \"footnote\" } },\n approvedFootnotes,\n );\n continue;\n }\n }\n\n // Inline mark tag?\n const markFactory = INLINE_MARK_TAGS[tag];\n if (markFactory) {\n const newMarks = { ...marks, ...markFactory(node) };\n processInlineNodes(xmlText, node.children, newMarks, approvedFootnotes);\n continue;\n }\n\n // Code element inside pre — just extract text\n if (tag === \"code\") {\n processInlineNodes(xmlText, node.children, marks, approvedFootnotes);\n continue;\n }\n\n // Unknown inline element — recurse (best effort)\n processInlineNodes(xmlText, node.children, marks, approvedFootnotes);\n }\n}\n","// .docx import: mammoth.js → HTML → Y.Doc. Editing is held in the Y.Doc and\n// written back on explicit save (#576); annotations persist via the session\n// system.\n\nimport mammoth from \"mammoth\";\nimport * as Y from \"yjs\";\nimport type { Annotation } from \"../../shared/types.js\";\nimport { getElementText } from \"../mcp/document-model.js\";\n\n// Re-export for backward compatibility — consumers can import from either module\nexport { htmlToYDoc, reconcileFootnoteIds } from \"./docx-html.js\";\n\n/**\n * Convert a .docx buffer to HTML via mammoth.js.\n * Warnings logged to stderr (stdout reserved for MCP).\n */\nexport async function loadDocx(content: Buffer): Promise<string> {\n const { html } = await loadDocxWithWarnings(content);\n return html;\n}\n\n/**\n * Convert a .docx buffer to HTML via mammoth.js, returning both the HTML and a\n * deduped, human-readable summary of what mammoth could NOT faithfully import\n * (#576 fidelity warnings). mammoth is a lossy importer — it silently drops\n * footnotes, headers/footers, tracked changes, and unsupported styles. The\n * `.docx` write-back path can only re-export what mammoth preserved, so we\n * surface these so the user understands the round-trip ceiling BEFORE editing.\n *\n * Individual mammoth messages are still logged verbatim to stderr; the returned\n * `warnings` are a collapsed, user-facing subset (one line per distinct kind of\n * loss, capped so a pathological document can't flood the toast).\n */\nexport async function loadDocxWithWarnings(\n content: Buffer,\n): Promise<{ html: string; warnings: string[] }> {\n // styleMap is the SECOND arg of convertToHtml(input, options) — merging it into\n // the input object silently no-ops. mammoth IGNORES underline by default (its\n // docs: underline \"can be easily confused with hyperlinks in HTML\"), so without\n // this map a <w:u> run reaches htmlToYDoc as plain text and the underline mark\n // is lost on round-trip. \"u => u\" emits a literal <u> (which docx-html.ts maps\n // to the underline mark) — NOT \"u => em\", which would silently re-code underline\n // as italic. Additive: includeDefaultStyleMap stays true, so headings/bold/lists\n // still resolve via mammoth's defaults. (Underline style/color — double/dotted/\n // wavy/colored — is flattened to a boolean by mammoth upstream, so it round-trips\n // as a single black underline; an accepted import ceiling, undetectable to warn on.)\n const result = await mammoth.convertToHtml({ buffer: content }, { styleMap: [\"u => u\"] });\n\n for (const msg of result.messages) {\n console.error(`[mammoth] ${msg.type}: ${msg.message}`);\n }\n\n return { html: result.value, warnings: summarizeMammothMessages(result.messages) };\n}\n\n/** Max distinct fidelity warnings surfaced to the user (avoid toast flooding). */\nconst MAX_FIDELITY_WARNINGS = 8;\n\n/**\n * Max characters per emitted warning line. The quote-stripping below redacts\n * the variable payload mammoth quotes (style names), but the report these feed\n * is now a PERSISTENT surface (#1145), not a 4s toast — so clamp each line as a\n * content-oracle backstop: a future mammoth message that surfaces user text\n * unquoted can leak at most this many chars, not an unbounded paragraph.\n */\nconst MAX_WARNING_LINE_LENGTH = 120;\n\n/**\n * Collapse mammoth's per-occurrence messages into a small set of distinct,\n * user-readable phrases. mammoth emits one message PER dropped element (e.g. a\n * line per unrecognized style run), which would be unreadable surfaced raw.\n */\nexport function summarizeMammothMessages(\n messages: Array<{ type: string; message: string }>,\n): string[] {\n const seen = new Set<string>();\n for (const msg of messages) {\n // Only warnings/errors matter for fidelity — mammoth has no \"info\" today,\n // but guard defensively so a future info-level message isn't surfaced.\n if (msg.type !== \"warning\" && msg.type !== \"error\") continue;\n // Normalize \"Unrecognised paragraph style: 'Foo' (Style ID: Bar)\" and\n // similar to a single bucket so 200 runs collapse to one line.\n // Redact every slot mammoth can put user-authored text in. Style/font\n // names can be sensitive (client names, project codenames, foundry-licensed\n // brand fonts), and these strings now feed a PERSISTENT banner (#1145), so\n // redaction must cover mammoth's actual message forms (enumerated from its\n // warning() emitters), not just the quoted one:\n // - \"Unrecognised paragraph style: 'Name' (Style ID: ID)\" → quotes + (…)\n // - \"…style with ID ID was referenced but not defined…\" → bare UNQUOTED\n // `with ID <token>` slot.\n // - \"…w:sym… ignored: char F0A7 in font <Brand Font>\" → the trailing\n // `in font <name>` slot (a name CAN contain spaces, so anchor to EOL).\n // The MAX_WARNING_LINE_LENGTH clamp below is the forward backstop for any\n // slot a future mammoth version introduces.\n const normalized = msg.message\n .replace(/['\"][^'\"]*['\"]/g, \"…\")\n .replace(/\\(Style ID:[^)]*\\)/gi, \"\")\n .replace(/\\bwith ID \\S+/gi, \"with ID …\")\n .replace(/\\bin font \\S.*$/i, \"in font …\")\n .replace(/\\s+/g, \" \")\n .trim();\n // Clamp per-line (defense-in-depth, see MAX_WARNING_LINE_LENGTH). Dedup on\n // the clamped form so two messages differing only past the cap collapse.\n seen.add(\n normalized.length > MAX_WARNING_LINE_LENGTH\n ? `${normalized.slice(0, MAX_WARNING_LINE_LENGTH - 1)}…`\n : normalized,\n );\n }\n return [...seen].slice(0, MAX_FIDELITY_WARNINGS);\n}\n\n// -- Annotation export --\n\n/**\n * Human-readable author label for the exported Markdown review summary (#438).\n *\n * The durable `author` enum (`\"claude\"`) is an internal role discriminator, not\n * a display label — emitting it verbatim leaks \"(claude)\" into a report a\n * GPT/Gemini user generated. The server has no access to the browser's Models\n * registry, so it can't name the specific model; it maps to a neutral\n * \"Assistant\" instead. Imported Word comments surface their real reviewer.\n */\nfunction exportAuthorLabel(ann: Annotation): string {\n if (ann.author === \"user\") return \"You\";\n if (ann.author === \"import\") return ann.importSource?.author?.trim() || \"Imported\";\n return \"Assistant\";\n}\n\n/**\n * Generate a Markdown summary of all annotations, grouped by type.\n * Includes a text snippet from the document for context.\n */\nexport function exportAnnotations(doc: Y.Doc, annotations: Annotation[]): string {\n // Defense-in-depth (ADR-027): notes are user-private and must never appear in\n // an export, regardless of what the caller passes. The MCP tool already\n // filters them out, but this function is privacy-safe on its own.\n const visible = annotations.filter((a) => a.type !== \"note\");\n if (visible.length === 0) {\n return \"# Document Review\\n\\nNo annotations found.\";\n }\n\n const fragment = doc.getXmlFragment(\"default\");\n const fullText = extractFullText(fragment);\n\n // Group by derived category using field presence, not raw type.\n // Notes are already filtered out above (ADR-027), so there is no notes group.\n type GroupKey = \"highlights\" | \"comments\" | \"suggestions\";\n const groups: Partial<Record<GroupKey, Annotation[]>> = {};\n for (const ann of visible) {\n let key: GroupKey;\n if (ann.type === \"highlight\") key = \"highlights\";\n else if (ann.suggestedText !== undefined) key = \"suggestions\";\n else key = \"comments\";\n if (!groups[key]) groups[key] = [];\n groups[key]?.push(ann);\n }\n\n const lines: string[] = [\"# Document Review\", \"\"];\n\n const groupLabels: Record<GroupKey, string> = {\n highlights: \"Highlights\",\n comments: \"Comments\",\n suggestions: \"Suggestions\",\n };\n\n const groupOrder: GroupKey[] = [\"highlights\", \"comments\", \"suggestions\"];\n\n for (const key of groupOrder) {\n const anns = groups[key];\n if (!anns) continue;\n lines.push(`## ${groupLabels[key]}`, \"\");\n\n for (const ann of anns) {\n const snippet = safeSlice(fullText, ann.range.from, ann.range.to);\n const truncated = snippet.length > 80 ? snippet.slice(0, 77) + \"...\" : snippet;\n\n lines.push(`- **\"${truncated}\"** (${exportAuthorLabel(ann)})`);\n\n if (ann.suggestedText !== undefined) {\n lines.push(` - Replace with: \"${ann.suggestedText}\"`);\n if (ann.content) lines.push(` - Reason: ${ann.content}`);\n } else if (ann.content) {\n lines.push(` - ${ann.content}`);\n }\n\n if (ann.color) {\n lines.push(` - Color: ${ann.color}`);\n }\n\n lines.push(\"\");\n }\n }\n\n return lines.join(\"\\n\").trimEnd();\n}\n\n/** Extract full flat text from a Y.Doc fragment (simplified — no heading prefixes) */\nfunction extractFullText(fragment: Y.XmlFragment): string {\n const parts: string[] = [];\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) {\n parts.push(getElementText(node));\n }\n }\n return parts.join(\"\\n\");\n}\n\n/** Safe string slice that handles out-of-bounds gracefully */\nfunction safeSlice(text: string, from: number, to: number): string {\n const start = Math.max(0, Math.min(from, text.length));\n const end = Math.max(start, Math.min(to, text.length));\n return text.slice(start, end);\n}\n","/**\n * Origin-tagged Y.Doc transaction wrappers (ADR-031).\n *\n * Every Y.Doc write — server-side or browser-side — MUST go through one of\n * the five helpers below. Direct `*.transact()` calls outside this file are\n * surfaced (warn-only) by the `.claude/hooks/check-raw-transact.sh` PostToolUse\n * hook and the `npm run audit:origins` static walk — there is no blocking\n * pre-commit hook or Biome rule. The wrapper\n * choice is the contract: the rest of the system reads `txn.origin` and\n * decides whether to project events, persist to disk, record tombstones,\n * etc.\n *\n * | Origin | Channel event queue | Durable-sync observer | Tombstone observer |\n * |------------|---------------------|-----------------------|--------------------|\n * | `mcp` | skip | persist | record |\n * | `file-sync`| skip | skip | record |\n * | `internal` | skip | skip | record |\n * | `reload` | skip | persist | record |\n * | `browser` | emit | persist | record |\n *\n * Picking the wrong helper is a silent bug. See ADR-031 for the full\n * \"how to choose\" enumeration with worked examples.\n */\n\nimport type * as Y from \"yjs\";\n\n// ---------------------------------------------------------------------------\n// Origin constants\n// ---------------------------------------------------------------------------\n\n/** Origin for Claude-initiated writes from MCP tool handlers. */\nexport const MCP_ORIGIN = \"mcp\";\n\n/** Origin for durable-annotation file-writer echoes (JSON → Y.Map sync). */\nexport const FILE_SYNC_ORIGIN = \"file-sync\";\n\n/**\n * Origin for server-internal setup writes. See ADR-031's `withInternal`\n * worked examples — session restore, file population, tutorial / scratchpad\n * seeding, clear-and-reload (user-initiated force-reload), cleanup-after-\n * failure paths, server metadata broadcasts on CTRL_ROOM.\n */\nexport const INTERNAL_ORIGIN = \"internal\";\n\n/**\n * Origin for the file-watcher mid-session `reloadFromDisk` flow. Channel\n * skips (not a user action), durable-sync persists (we want the re-anchored\n * relRanges saved), tombstone observer records.\n */\nexport const RELOAD_ORIGIN = \"reload\";\n\n/** Origin for user edits originating in the browser (no current observer\n * filters on this — explicit label preserves the universal rule). */\nexport const BROWSER_ORIGIN = \"browser\";\n\nexport type TandemOrigin =\n | typeof MCP_ORIGIN\n | typeof FILE_SYNC_ORIGIN\n | typeof INTERNAL_ORIGIN\n | typeof RELOAD_ORIGIN\n | typeof BROWSER_ORIGIN;\n\n// ---------------------------------------------------------------------------\n// Skip-set predicates\n// ---------------------------------------------------------------------------\n\n/**\n * Origins that channel-event observers must skip — every internal-purpose\n * origin. Only `browser` produces channel events today.\n */\nconst CHANNEL_SKIP: ReadonlySet<unknown> = new Set([\n MCP_ORIGIN,\n FILE_SYNC_ORIGIN,\n INTERNAL_ORIGIN,\n RELOAD_ORIGIN,\n]);\n\n/** Origins that the durable-annotation sync observer must skip. */\nconst DURABLE_SKIP: ReadonlySet<unknown> = new Set([FILE_SYNC_ORIGIN, INTERNAL_ORIGIN]);\n\nexport function shouldSkipChannel(origin: unknown): boolean {\n return CHANNEL_SKIP.has(origin);\n}\n\nexport function shouldSkipDurableSync(origin: unknown): boolean {\n return DURABLE_SKIP.has(origin);\n}\n\n// ---------------------------------------------------------------------------\n// Wrapper helpers\n// ---------------------------------------------------------------------------\n\nfunction runTransact<T>(doc: Y.Doc, fn: () => T, origin: TandemOrigin): T {\n let result: T | undefined;\n let captured = false;\n // biome-ignore lint/suspicious/noExplicitAny: Y.Doc.transact's second arg is `unknown`; passing a typed string is safe.\n (doc as any).transact(() => {\n result = fn();\n captured = true;\n }, origin);\n if (!captured) {\n // Should be unreachable — Y.Doc.transact invokes the callback synchronously.\n throw new Error(`origins: transact callback did not run (origin=${origin})`);\n }\n return result as T;\n}\n\n/** Wrap user-intent writes from MCP tool handlers. */\nexport function withMcp<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, MCP_ORIGIN);\n}\n\n/** Wrap echoes from the durable-annotation file-writer / file-watcher\n * reload path. The channel skips, the durable-sync observer skips. The\n * tombstone observer RECORDS (not skips) — see sync.ts observer comment. */\nexport function withFileSync<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, FILE_SYNC_ORIGIN);\n}\n\n/** Wrap server-internal setup writes — see the `INTERNAL_ORIGIN` doc\n * comment for the worked-example list. */\nexport function withInternal<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, INTERNAL_ORIGIN);\n}\n\n/** Wrap mid-session `reloadFromDisk` writes. Distinct from `withFileSync`:\n * the durable-sync observer PERSISTS reload writes so the re-anchored\n * relRanges land on disk. */\nexport function withReload<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, RELOAD_ORIGIN);\n}\n\n/** Wrap user edits originating in the browser. */\nexport function withBrowser<T>(doc: Y.Doc, fn: () => T): T {\n return runTransact(doc, fn, BROWSER_ORIGIN);\n}\n\n// ---------------------------------------------------------------------------\n// Test helper\n// ---------------------------------------------------------------------------\n\n/**\n * Test-only transact wrapper for synthetic Y.Docs. Tagged with a sentinel\n * origin so observers and lint can distinguish from production transacts.\n * Allowlisted in the `block-raw-transact` hook via the helpers-file\n * exception.\n */\nexport const TEST_ORIGIN = \"test\";\n\nexport function transactForTest<T>(doc: Y.Doc, fn: () => T): T {\n let result: T | undefined;\n // biome-ignore lint/suspicious/noExplicitAny: Y.Doc.transact's second arg is `unknown`.\n (doc as any).transact(() => {\n result = fn();\n }, TEST_ORIGIN);\n return result as T;\n}\n","/**\n * Once-per-(doc, kind) logging for legacy annotation migrations.\n *\n * Several read/write paths silently rewrite v0 annotations into the v1\n * model — flag→note, directedAt strip, unknown-type → comment coercion. The\n * rewrites are correct, but a silent rewrite destroys the v0→v1 forensic\n * trail: an operator investigating \"where did my flags go?\" sees no trace.\n *\n * This module gives those paths a shared dedup mechanism keyed by\n * `${docHash}:${kind}` so each lossy upgrade fires exactly once per doc per\n * kind, regardless of which path triggered it (parseAnnotationDoc, the\n * sync.ts fast-path strip, migrateToV1, etc.).\n *\n * Module placement: `sync.ts` already imports from `schema.ts`, so adding\n * a sync→schema log import would create a cycle. This module is the\n * shared dependency-free home both can import from.\n */\n\nimport type { SanitizationEvent } from \"../../shared/sanitize.js\";\n\nexport type LegacyMigrationKind =\n | \"flag\"\n | \"directedAt\"\n | \"legacy-type\"\n | \"flag-to-note\"\n | \"question-to-comment\"\n | \"malformed-suggestion-json\"\n | \"unknown-type\"\n | \"import-note-to-comment\"\n | \"audience-conflict-resolved\";\n\n/** Dedup state — `${docHash}:${kind}`. Cleared on doc close via `forgetDoc`. */\nconst loggedLegacyMigrations = new Set<string>();\n\n/** Sentinel docHash for `migrateToV1`, whose envelope has `docHash: \"\"`. */\nexport const MIGRATE_TO_V1_DOC_HASH = \"<migrateToV1>\";\n\n/**\n * Log a legacy-migration event the first time it's seen for `(docHash, kind)`.\n * Subsequent calls with the same pair are silent. `docHash === undefined`\n * skips dedup entirely (logs every call) — used in test paths and as a\n * defensive default.\n */\nexport function logLegacyMigration(docHash: string | undefined, kind: LegacyMigrationKind): void {\n if (docHash === undefined) {\n console.error(`[ANNOTATION-STORE] legacy migration: ${kind} (no docHash)`);\n return;\n }\n const key = `${docHash}:${kind}`;\n if (loggedLegacyMigrations.has(key)) return;\n loggedLegacyMigrations.add(key);\n console.error(`[ANNOTATION-STORE] legacy migration: ${kind} in ${docHash}`);\n}\n\n/** Drop dedup state for a specific doc — call on doc close so a reopen logs again. */\nexport function forgetDoc(docHash: string): void {\n for (const key of loggedLegacyMigrations) {\n if (key.startsWith(`${docHash}:`)) loggedLegacyMigrations.delete(key);\n }\n}\n\n/** Reset all dedup state. Tests only. */\nexport function resetMigrationLog(): void {\n loggedLegacyMigrations.clear();\n}\n\n/**\n * Server-side relay for `sanitizeAnnotation`'s `onLossy` callback. Maps the\n * shared `SanitizationEvent` discriminated union to a `LegacyMigrationKind`\n * and routes through the dedup'd `logLegacyMigration` channel so silent\n * sanitize coercions become visible in the migration trail.\n *\n * Imported lazily by callers that already have a docHash/docName in hand.\n * Callers without one pass `undefined` and accept un-deduped logging.\n */\n\nexport function relaySanitizationEvent(\n docHash: string | undefined,\n event: SanitizationEvent,\n): void {\n switch (event.kind) {\n case \"flag-to-note\":\n case \"question-to-comment\":\n case \"malformed-suggestion-json\":\n case \"unknown-type\":\n case \"import-note-to-comment\":\n case \"audience-conflict-resolved\":\n logLegacyMigration(docHash, event.kind);\n return;\n default: {\n // Compile-time exhaustiveness: adding a new SanitizationEvent kind without a\n // matching case here becomes a TypeScript error. Never remove this arm.\n const _exhaustive: never = event;\n console.error(\n `[ANNOTATION-STORE] unhandled SanitizationEvent kind: ${(_exhaustive as SanitizationEvent).kind}`,\n );\n }\n }\n}\n","/**\n * v1 → v2 annotation-envelope migration.\n *\n * **Proof-of-shape, not a real schema change.** The on-disk annotation\n * envelope is still `schemaVersion: 1` in production (`SCHEMA_VERSION` in\n * `../schema.ts` is unchanged). This migration exists to prove the framework\n * end-to-end and to give the first *real* v2 a place to land: when a future\n * PR bumps `SCHEMA_VERSION` to 2, the load path begins running this function\n * automatically with no further wiring.\n *\n * The transform is an identity over the record payload — it only re-stamps\n * `schemaVersion` to 2. No fields are added, removed, or reshaped, so a v1\n * envelope is already a structurally valid v2 payload. The frozen v1 Zod\n * schema below is the input contract (mirrors `integrations/migrations.ts`):\n * we refuse to migrate garbage even though the transform is otherwise a no-op.\n */\n\nimport { z } from \"zod\";\n\nimport { AnnotationDocSchemaV1 } from \"../schema.js\";\n\nimport type { MigrationFn } from \"./runner.js\";\n\nexport const migrateV1ToV2: MigrationFn = (input) => {\n // Build the frozen input contract LAZILY, inside the function — never at\n // module top level. There is an import cycle\n // (schema.ts → migrations/index.ts → runner.ts → v1_to_v2.ts → schema.ts);\n // evaluating `AnnotationDocSchemaV1.extend(...)` during module init would run\n // before schema.ts finishes defining `AnnotationDocSchemaV1`, throwing a TDZ\n // \"Cannot access 'AnnotationDocSchemaV1' before initialization\" that crashes\n // the load path on import. Deferring to call time (the same reason the\n // transform itself is an arrow function) sidesteps the cycle: by the time any\n // migration runs, schema.ts is fully initialized.\n //\n // The contract locks `schemaVersion` to the numeric literal `1`, NOT the live\n // `SCHEMA_VERSION` constant. `AnnotationDocSchemaV1` validates `schemaVersion`\n // against `z.literal(SCHEMA_VERSION)` — the *current* version. The moment a\n // future PR bumps `SCHEMA_VERSION` to 2, that schema starts requiring\n // `schemaVersion === 2`, and this migration — which by definition receives\n // genuine v1 files (`schemaVersion: 1`) — would reject every one of them,\n // quarantining all annotations as `corrupt` on the first load after the\n // upgrade. A migration's input version must be pinned to a literal forever;\n // every subsequent migration must follow the same rule.\n const FrozenV1InputSchema = AnnotationDocSchemaV1.extend({\n schemaVersion: z.literal(1),\n });\n const parsed = FrozenV1InputSchema.parse(input);\n // `.passthrough()` on the schema means `parsed` already carries any\n // forward-compatible extra fields verbatim; spread preserves them and the\n // explicit `schemaVersion` override wins over the parsed `1`.\n return {\n ...parsed,\n schemaVersion: 2,\n };\n};\n","/**\n * Versioned migration framework for the on-disk annotation envelope\n * (`<annotationsDir>/<docHash>.json`).\n *\n * Modeled on `src/server/integrations/migrations.ts`: an ordered\n * `MigrationFn[]` chain plus a `migrateUp(input, fromVersion, toVersion)`\n * runner. `migrations[i]` migrates v(i+1) → v(i+2).\n *\n * **Contract for migration authors:** the `input` parameter is typed\n * `unknown` and the framework does NOT validate the v_n shape before passing\n * it to your function. Validate with Zod against the v_n schema inside the\n * migration — do NOT use `as` casts. The `unknown` input type is the\n * compile-time signal that runtime validation is required.\n *\n * **Current state:** the production envelope is still `SCHEMA_VERSION = 1`\n * (see `../schema.ts`). The v1 → v2 entry below is a dormant proof-of-shape:\n * because `migrateUp` is always called with `toVersion === SCHEMA_VERSION`,\n * it never fires during a normal load while `SCHEMA_VERSION` is 1. When a\n * future PR bumps `SCHEMA_VERSION` to 2, the load path begins running it\n * automatically with no further wiring.\n */\n\nimport { migrateV1ToV2 } from \"./v1_to_v2.js\";\n\nexport type MigrationFn = (input: unknown) => unknown;\n\n/**\n * Ordered migration chain. `migrations[i]` migrates v(i+1) → v(i+2).\n * Module-local — exposed only via `migrateUp` so external code cannot inject\n * a migration at runtime.\n */\nconst migrations: ReadonlyArray<MigrationFn> = [migrateV1ToV2];\n\n/**\n * Run the migration chain forward from `fromVersion` to `toVersion`. The\n * caller is responsible for Zod-validating the result. A missing migration\n * throws — silent default behavior would mask a corrupt or future-version\n * file.\n *\n * Returns `input` unchanged (by reference) when `fromVersion === toVersion`,\n * so the common already-current case allocates nothing.\n */\nexport function migrateUp(input: unknown, fromVersion: number, toVersion: number): unknown {\n if (toVersion < fromVersion) {\n throw new Error(`Cannot migrate down: from v${fromVersion} to v${toVersion}`);\n }\n let current = input;\n for (let v = fromVersion; v < toVersion; v++) {\n const m = migrations[v - 1];\n if (!m) {\n throw new Error(`No migration registered from v${v} to v${v + 1}`);\n }\n current = m(current);\n }\n return current;\n}\n","/**\n * Public entry point for the annotation-envelope migration framework.\n * See `./runner.ts` for the runner contract and `./v1_to_v2.ts` for the\n * first (dormant, proof-of-shape) migration.\n */\n\nexport { type MigrationFn, migrateUp } from \"./runner.js\";\nexport { migrateV1ToV2 } from \"./v1_to_v2.js\";\n","/**\n * On-disk schema for Tandem's durable annotation envelope.\n *\n * ## Unknown-field policy\n *\n * All object schemas use `.passthrough()` (overriding Zod's default strip).\n * Forward-compatibility is the goal: a future version might add fields\n * (e.g. `pinnedBy`, `severity`) that a pre-upgrade Tandem install should\n * *preserve* when rewriting the file, not silently drop. Strict rejection\n * would turn a harmless additive change into a \"corrupt\" error and trip the\n * `.json.future` fallback path unnecessarily. Breaking version jumps\n * (`schemaVersion > 1`) are still handled explicitly via\n * `parseAnnotationDoc` returning `{ ok: false, error: \"future\" }`.\n */\n\nimport { z } from \"zod\";\nimport {\n AnnotationStatusSchema,\n AnnotationTypeSchema,\n AuthorSchema,\n type HighlightColor,\n HighlightColorSchema,\n ReplyAuthorSchema,\n} from \"../../shared/types.js\";\nimport { logLegacyMigration, MIGRATE_TO_V1_DOC_HASH } from \"./migration-log.js\";\nimport { migrateUp } from \"./migrations/index.js\";\n\n/** On-disk envelope version. Bump when making breaking changes to the file shape. */\nexport const SCHEMA_VERSION = 1 as const;\n\n/**\n * Reply size bounds (#1000 security review R2). `REPLY_TEXT_MAX` is a generous\n * durable-schema sanity ceiling — deliberately large so it can NEVER drop a\n * legitimate existing user/claude reply on load (`normalizeReply` discards rows\n * that fail `safeParse`). The tight, product-sensible truncation of untrusted\n * imported Word reply bodies happens at the injection door\n * (`IMPORT_REPLY_BODY_CAP`), well under this ceiling. `IMPORT_AUTHOR_MAX` only\n * ever bounds our own injection-written field, so it is safe to validate tightly.\n */\nexport const REPLY_TEXT_MAX = 100_000;\nexport const IMPORT_REPLY_BODY_CAP = 4_000;\nexport const IMPORT_AUTHOR_MAX = 128;\n\n/**\n * Compute the next revision for a user-intent write. Returns `1` for a brand\n * new record (no `prev`), otherwise `prev.rev + 1`. Pre-T6 records that lack\n * `rev` are treated as `rev: 0` so the first write after migration lands at\n * `rev: 1`.\n *\n * Used at every server-side creation and mutation site so the `?? 0` fallback\n * and increment live in exactly one place — the invariant is part of the\n * schema module's domain, not the call sites'.\n */\nexport function nextRev(prev?: { rev?: number }): number {\n return (prev?.rev ?? 0) + 1;\n}\n\n// ---------------------------------------------------------------------------\n// Primitive sub-schemas\n// ---------------------------------------------------------------------------\n\n/**\n * JSON form of a Yjs RelativePosition (output of `Y.relativePositionToJSON`).\n * All four fields are optional — Yjs omits nulls on serialization. Passthrough\n * because the Yjs internals are opaque and we shouldn't break on schema drift\n * in the upstream library.\n */\nconst SerializedRelPosSchema = z\n .object({\n type: z.unknown().optional(),\n tname: z.string().optional(),\n item: z.unknown().optional(),\n assoc: z.number().optional(),\n })\n .passthrough();\n\nconst DocumentRangeSchema = z\n .object({\n from: z.number().int().nonnegative(),\n to: z.number().int().nonnegative(),\n })\n .passthrough();\n\nconst RelativeRangeSchema = z\n .object({\n fromRel: SerializedRelPosSchema,\n toRel: SerializedRelPosSchema,\n })\n .passthrough();\n\n// ---------------------------------------------------------------------------\n// Annotation + reply per-record schemas\n// ---------------------------------------------------------------------------\n\n/**\n * Per-annotation envelope record. Largely mirrors `AnnotationBase` from\n * `src/shared/types.ts`, plus the optional type-discriminator fields\n * (`color` / `suggestedText`), plus the required `rev`. ADR-027 removed\n * `directedAt` from the model; the schema enforces its absence via a\n * `.refine()` so any caller that skips migration is caught at validation time.\n * Fields not listed here (e.g. `heldInSolo`) are preserved via `.passthrough()`.\n */\nexport const AnnotationRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n author: AuthorSchema,\n type: AnnotationTypeSchema,\n range: DocumentRangeSchema,\n relRange: RelativeRangeSchema.optional(),\n content: z.string(),\n status: AnnotationStatusSchema,\n timestamp: z.number(),\n textSnapshot: z.string().optional(),\n editedAt: z.number().optional(),\n // Type-specific optional fields. Not cross-validated against `type` — the\n // TS discriminated union enforces that invariant at construction time\n // (see `src/shared/types.ts`). Here we only gate shape.\n color: HighlightColorSchema.optional(),\n suggestedText: z.string().optional(),\n // New for v1 envelope: monotonically-increasing revision counter used for\n // last-writer-wins merge between in-memory Y.Map state and on-disk state.\n rev: z.number().int().nonnegative(),\n })\n .passthrough()\n // ADR-027: directedAt is removed from the model. All production read paths\n // (parseAnnotationDoc, migrateToV1) run migrateFlagAndDirectedAt() before\n // reaching this schema, so the field is already gone. This refine catches\n // any caller that bypasses migration and passes a stale record directly.\n .refine((rec) => !(\"directedAt\" in rec), {\n message: \"directedAt is removed in ADR-027; run migrateFlagAndDirectedAt before validation\",\n path: [\"directedAt\"],\n });\n\n/**\n * Reply record (existing `AnnotationReply` shape + `rev`).\n */\nexport const AnnotationReplyRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n annotationId: z.string().min(1),\n author: ReplyAuthorSchema,\n // Bounded to defend against pathological/oversized .docx reply bodies that\n // bypass the SNAPSHOT_CAP path (#1000 security review R2).\n text: z.string().max(REPLY_TEXT_MAX),\n timestamp: z.number(),\n editedAt: z.number().optional(),\n rev: z.number().int().nonnegative(),\n // #1000: user-private (note-authored or imported Word) reply — never sent\n // to Claude. Optional; absent ⇒ surfaces normally on comment parents.\n private: z.boolean().optional(),\n // #1000: original Word reviewer name for `author: \"import\"` replies.\n importAuthor: z.string().max(IMPORT_AUTHOR_MAX).optional(),\n })\n .passthrough();\n\n/**\n * Tombstone for a deleted annotation. `rev` is the rev the annotation carried\n * when it was deleted, so merge logic can decide whether an incoming add from\n * a stale peer is older (drop it) or newer (resurrect).\n */\nexport const TombstoneRecordSchemaV1 = z\n .object({\n id: z.string().min(1),\n rev: z.number().int().nonnegative(),\n deletedAt: z.number(),\n })\n .passthrough();\n\n// ---------------------------------------------------------------------------\n// Top-level envelope\n// ---------------------------------------------------------------------------\n\nconst MetaSchema = z\n .object({\n filePath: z.string(),\n lastUpdated: z.number(),\n // Additive (no schema-version bump — MetaSchema is .passthrough()).\n // SHA-256 of `extractText(doc)` recomputed on EVERY durable write, used by\n // the rename-recovery path (#313) to re-associate an orphaned envelope with\n // a renamed-but-byte-identical document. Optional so pre-#313 envelopes\n // (which lack it) still parse; recovery simply skips them.\n contentHash: z.string().optional(),\n })\n .passthrough();\n\n/**\n * The full on-disk JSON envelope.\n * Passthrough at every layer — see module header for the rationale.\n */\nexport const AnnotationDocSchemaV1 = z\n .object({\n schemaVersion: z.literal(SCHEMA_VERSION),\n docHash: z.string(),\n meta: MetaSchema,\n annotations: z.array(AnnotationRecordSchemaV1),\n tombstones: z.array(TombstoneRecordSchemaV1),\n replies: z.array(AnnotationReplyRecordSchemaV1),\n })\n .passthrough();\n\nexport type AnnotationDocV1 = z.infer<typeof AnnotationDocSchemaV1>;\nexport type AnnotationRecordV1 = z.infer<typeof AnnotationRecordSchemaV1>;\nexport type AnnotationReplyRecordV1 = z.infer<typeof AnnotationReplyRecordSchemaV1>;\nexport type TombstoneRecordV1 = z.infer<typeof TombstoneRecordSchemaV1>;\n\n// ---------------------------------------------------------------------------\n// Color migration helpers\n// ---------------------------------------------------------------------------\n\n// Align legacy color remap with the v7 design handoff palette\n// (docs/designs/handoff/tandem/project/calm-v7.css):\n// - red → pink (warm-family remap; the prior red→yellow remap predated the\n// v7 palette decision and silently collapsed two visually distinct\n// highlights into one)\n// - purple → blue (cool-family remap; unchanged)\nconst LEGACY_COLOR_MAP: Record<\"red\" | \"purple\", HighlightColor> = {\n red: \"pink\",\n purple: \"blue\",\n};\n\nfunction migrateHighlightColor(ann: Record<string, unknown>): void {\n const color = ann.color;\n if (typeof color === \"string\" && color in LEGACY_COLOR_MAP) {\n ann.color = LEGACY_COLOR_MAP[color as keyof typeof LEGACY_COLOR_MAP];\n }\n}\n\n// ADR-027: flag→note migration + directedAt removal\n// ---------------------------------------------------------------------------\n\nfunction migrateFlagAndDirectedAt(ann: Record<string, unknown>, docHash?: string): void {\n if (ann.type === \"flag\") {\n ann.type = \"note\";\n logLegacyMigration(docHash, \"flag\");\n }\n if (\"directedAt\" in ann) {\n delete ann.directedAt;\n logLegacyMigration(docHash, \"directedAt\");\n }\n}\n\n// ---------------------------------------------------------------------------\n// Parse + migrate\n// ---------------------------------------------------------------------------\n\n/**\n * Discriminated result of `parseAnnotationDoc`. The `ok` flag is the\n * discriminant; we intentionally avoid keying on `error` because the v1 schema\n * uses `.passthrough()` (see module header) and any alive doc could, in\n * principle, carry a passthrough field named `error`. `ok` is our own\n * invariant, outside the schema's namespace, so narrowing is unambiguous.\n */\nexport type ParseAnnotationDocResult =\n | { ok: true; doc: AnnotationDocV1 }\n | { ok: false; error: \"corrupt\" }\n | { ok: false; error: \"future\"; schemaVersion: number };\n\n/**\n * Validate an on-disk annotation doc.\n *\n * Accepts either a parsed object *or* a raw JSON string (for readability at\n * call sites). On any parse/validation failure returns\n * `{ ok: false, error: \"corrupt\" }`. If the payload looks well-formed but\n * carries `schemaVersion > 1`, returns\n * `{ ok: false, error: \"future\", schemaVersion }` so the caller can rename the\n * file to `<hash>.json.future` and fall back to in-memory state.\n */\nexport function parseAnnotationDoc(raw: unknown): ParseAnnotationDocResult {\n // Optional JSON-string convenience: callers that read the file as text can\n // pass the string straight through.\n let candidate: unknown = raw;\n if (typeof candidate === \"string\") {\n try {\n candidate = JSON.parse(candidate);\n } catch (err) {\n console.error(\"[parseAnnotationDoc] JSON.parse failed:\", err);\n return { ok: false, error: \"corrupt\" };\n }\n }\n\n if (candidate === null || typeof candidate !== \"object\") {\n console.error(\n `[parseAnnotationDoc] expected object, got ${candidate === null ? \"null\" : typeof candidate}`,\n );\n return { ok: false, error: \"corrupt\" };\n }\n\n // Check schemaVersion *before* full validation so we can return `future`\n // without treating a newer-but-otherwise-valid file as corrupt.\n const schemaVersion = (candidate as { schemaVersion?: unknown }).schemaVersion;\n if (\n typeof schemaVersion === \"number\" &&\n Number.isInteger(schemaVersion) &&\n schemaVersion > SCHEMA_VERSION\n ) {\n return { ok: false, error: \"future\", schemaVersion };\n }\n\n // Migrate legacy highlight colors before validation. Clone each annotation\n // so the caller's input objects are not mutated as a side effect of parsing.\n const cand = candidate as { annotations?: unknown[]; docHash?: unknown };\n const candDocHash = typeof cand.docHash === \"string\" ? cand.docHash : undefined;\n if (Array.isArray(cand.annotations)) {\n for (let i = 0; i < cand.annotations.length; i++) {\n const ann = cand.annotations[i];\n if (ann && typeof ann === \"object\") {\n const cloned = { ...(ann as Record<string, unknown>) };\n migrateHighlightColor(cloned);\n migrateFlagAndDirectedAt(cloned, candDocHash);\n cand.annotations[i] = cloned;\n }\n }\n }\n\n // Run the versioned migration framework forward to the current schema\n // version. Today `SCHEMA_VERSION` is 1, so any well-formed file is already\n // at-or-below current and `migrateUp` returns the input unchanged — the\n // wiring is dormant but live. When `SCHEMA_VERSION` is bumped to 2, the\n // registered v1 → v2 migration begins running here with no further changes.\n // A migration that throws (e.g. a record that fails the v_n input contract)\n // is treated as corruption rather than crashing the load path.\n const fromVersion =\n typeof schemaVersion === \"number\" && Number.isInteger(schemaVersion) ? schemaVersion : 1;\n let migrated: unknown;\n try {\n migrated = migrateUp(candidate, fromVersion, SCHEMA_VERSION);\n } catch (err) {\n console.error(\"[parseAnnotationDoc] migration failed:\", err);\n return { ok: false, error: \"corrupt\" };\n }\n\n const result = AnnotationDocSchemaV1.safeParse(migrated);\n if (!result.success) {\n console.error(\"[parseAnnotationDoc] schema validation failed:\", result.error.issues);\n return { ok: false, error: \"corrupt\" };\n }\n return { ok: true, doc: result.data };\n}\n\n/** Result of `migrateToV1`. Drop counts let callers surface lossy upgrades. */\nexport interface MigrationResult {\n doc: AnnotationDocV1;\n /** Count of annotation records skipped during migration (non-object input or schema rejection). */\n droppedAnnotations: number;\n /** Count of reply records skipped during migration (same criteria). */\n droppedReplies: number;\n}\n\n/**\n * Best-effort migration from a legacy session-blob–shaped object (no `rev`,\n * no `tombstones`, no `docHash`, no `meta`) into the v1 envelope shape.\n *\n * Populates sensible defaults:\n * - `rev: 0` on every annotation and reply\n * - `tombstones: []`\n * - `docHash: \"\"` (caller fills in with the real hash of the current document)\n * - `meta: { filePath: \"\", lastUpdated: 0 }` (caller overrides)\n *\n * Expects `raw` to be roughly `{ annotations?: unknown[]; replies?: unknown[] }`.\n * Anything unrecognized is coerced; invalid records are skipped and tallied\n * in `droppedAnnotations` / `droppedReplies` so callers can surface data loss\n * rather than silently discarding records. As a second line of defense, a\n * single `console.error` fires when any records are dropped — without it a\n * caller that forgets to destructure the counts would lose the data-loss\n * signal entirely. The full v1 → vN migration framework is deferred.\n */\nexport function migrateToV1(raw: unknown): MigrationResult {\n const src = (raw && typeof raw === \"object\" ? raw : {}) as Record<string, unknown>;\n\n const annotationsIn = Array.isArray(src.annotations) ? src.annotations : [];\n const repliesIn = Array.isArray(src.replies) ? src.replies : [];\n\n let droppedAnnotations = 0;\n let droppedReplies = 0;\n\n const annotations: AnnotationRecordV1[] = [];\n for (const ann of annotationsIn) {\n if (!ann || typeof ann !== \"object\") {\n droppedAnnotations++;\n continue;\n }\n const withRev = { rev: 0, ...(ann as object) };\n migrateHighlightColor(withRev as Record<string, unknown>);\n migrateFlagAndDirectedAt(withRev as Record<string, unknown>, MIGRATE_TO_V1_DOC_HASH);\n const parsed = AnnotationRecordSchemaV1.safeParse(withRev);\n if (parsed.success) {\n annotations.push(parsed.data);\n } else {\n droppedAnnotations++;\n const annId = (withRev as { id?: unknown }).id ?? \"<missing>\";\n console.error(\n `[ANNOTATION-STORE] migrateToV1: dropping annotation id=${String(annId)}:`,\n parsed.error.issues,\n );\n }\n }\n\n const replies: AnnotationReplyRecordV1[] = [];\n for (const r of repliesIn) {\n if (!r || typeof r !== \"object\") {\n droppedReplies++;\n continue;\n }\n const withRev = { rev: 0, ...(r as object) };\n const parsed = AnnotationReplyRecordSchemaV1.safeParse(withRev);\n if (parsed.success) {\n replies.push(parsed.data);\n } else {\n droppedReplies++;\n const replyId = (withRev as { id?: unknown }).id ?? \"<missing>\";\n console.error(\n `[ANNOTATION-STORE] migrateToV1: dropping reply id=${String(replyId)}:`,\n parsed.error.issues,\n );\n }\n }\n\n if (droppedAnnotations > 0 || droppedReplies > 0) {\n console.error(\n `[ANNOTATION-STORE] migrateToV1 dropped ${droppedAnnotations} annotation(s) and ${droppedReplies} reply/replies as malformed`,\n );\n }\n\n return {\n doc: {\n schemaVersion: SCHEMA_VERSION,\n docHash: \"\",\n meta: { filePath: \"\", lastUpdated: 0 },\n annotations,\n tombstones: [],\n replies,\n },\n droppedAnnotations,\n droppedReplies,\n };\n}\n","export type {\n AnchoredRangeResult,\n DocumentRange,\n ElementPosition,\n FlatOffset,\n PmPos,\n PmRangeResult,\n RangeValidation,\n RefreshResult,\n RelativeRange,\n ResolutionMethod,\n SerializedRelPos,\n} from \"./types.js\";\n\nexport {\n toFlatOffset,\n toPmPos,\n toSerializedRelPos,\n} from \"./types.js\";\n","/**\n * Server-side position module.\n *\n * Consolidates all flat-offset, Y.Doc element resolution, RelativePosition,\n * and range validation logic into caller-optimized functions.\n *\n * High-level (use these):\n * - validateRange() — validate + stale-check a flat-offset range\n * - anchoredRange() — validate + create both flat and CRDT-anchored range\n * - refreshRange() — resolve relRange → flat offsets (or lazily attach)\n * - refreshAllRanges() — batch version in a Y.Doc transaction\n *\n * Low-level (escape hatches):\n * - resolveToElement() — flat offset → Y.Doc element position\n * - flatOffsetToRelPos() — flat offset → serialized RelativePosition\n * - relPosToFlatOffset() — serialized RelativePosition → flat offset\n */\n\nimport * as Y from \"yjs\";\nimport { withMcp } from \"../shared/origins.js\";\nimport type {\n AnchoredRangeResult,\n DocumentRange,\n ElementPosition,\n FlatOffset,\n RangeValidation,\n RefreshResult,\n RelativeRange,\n SerializedRelPos,\n} from \"../shared/positions/index.js\";\nimport { toFlatOffset, toSerializedRelPos } from \"../shared/positions/index.js\";\nimport type { Annotation } from \"../shared/types.js\";\nimport {\n collectXmlTexts,\n extractText,\n findXmlTextAtOffset,\n getElementTextLength,\n getHeadingPrefixLength,\n} from \"./mcp/document-model.js\";\n\n// ---------------------------------------------------------------------------\n// Low-level: element resolution\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a flat character offset to a Y.Doc element position.\n * Needed by tandem_edit for cross-element deletion logic.\n */\nexport function resolveToElement(\n fragment: Y.XmlFragment,\n charOffset: FlatOffset,\n): ElementPosition | null {\n let accumulated = 0;\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n\n const prefixLen = getHeadingPrefixLength(node);\n const textLen = getElementTextLength(node);\n const fullLen = prefixLen + textLen;\n\n if (accumulated + fullLen > charOffset) {\n const offsetInFull = charOffset - accumulated;\n const clampedFromPrefix = offsetInFull < prefixLen && prefixLen > 0;\n const textOffset = Math.max(0, offsetInFull - prefixLen);\n return { elementIndex: i, textOffset, clampedFromPrefix };\n }\n\n accumulated += fullLen;\n\n if (i < fragment.length - 1) {\n accumulated += 1; // \\n separator\n if (accumulated > charOffset) {\n return { elementIndex: i, textOffset: textLen, clampedFromPrefix: false };\n }\n }\n }\n\n if (fragment.length > 0) {\n const lastNode = fragment.get(fragment.length - 1);\n if (lastNode instanceof Y.XmlElement) {\n return {\n elementIndex: fragment.length - 1,\n textOffset: getElementTextLength(lastNode),\n clampedFromPrefix: false,\n };\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Low-level: RelativePosition conversion\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a flat text offset to a JSON-serialized Yjs RelativePosition.\n * Returns null if the offset falls in a heading prefix or can't be resolved.\n *\n * Sole mint of `SerializedRelPos` — no other code path constructs the wire\n * shape. Readers (`relPosToFlatOffset` here + `relRangeToPmPositions` in\n * `src/client/positions.ts`) must tolerate `Y.createRelativePositionFromJSON`\n * throwing on stale items after `reloadFromDisk` replaces the Y.Doc content;\n * see `docs/lessons-learned.md` \"Dead CRDT RelativePositions Must Be Stripped,\n * Not Preserved\" for why the throw is expected rather than a bug.\n */\nexport function flatOffsetToRelPos(\n doc: Y.Doc,\n offset: FlatOffset,\n assoc: 0 | -1,\n): SerializedRelPos | null {\n const fragment = doc.getXmlFragment(\"default\");\n const resolved = resolveToElement(fragment, offset);\n if (!resolved || resolved.clampedFromPrefix) return null;\n\n const node = fragment.get(resolved.elementIndex);\n if (!(node instanceof Y.XmlElement)) return null;\n\n let found = findXmlTextAtOffset(node, resolved.textOffset);\n // If the offset lands exactly on an intra-element separator (between nested block children),\n // fall back based on assoc: -1 (stick left) → try offset-1; 0 (stick right) → try offset+1.\n if (!found && assoc === -1 && resolved.textOffset > 0) {\n found = findXmlTextAtOffset(node, resolved.textOffset - 1);\n if (found) {\n // Advance offsetInXmlText to end of this XmlText to stick to the left boundary\n found = { xmlText: found.xmlText, offsetInXmlText: found.xmlText.length };\n }\n } else if (!found && assoc === 0) {\n const nodeLen = getElementTextLength(node);\n if (resolved.textOffset + 1 <= nodeLen) {\n found = findXmlTextAtOffset(node, resolved.textOffset + 1);\n }\n }\n if (!found) return null;\n const rpos = Y.createRelativePositionFromTypeIndex(found.xmlText, found.offsetInXmlText, assoc);\n return toSerializedRelPos(Y.relativePositionToJSON(rpos));\n}\n\n/**\n * Resolve a JSON-serialized Yjs RelativePosition back to a flat text offset.\n * Returns null if the referenced content was deleted.\n */\nexport function relPosToFlatOffset(doc: Y.Doc, relPosJson: SerializedRelPos): FlatOffset | null {\n let absPos;\n try {\n const rpos = Y.createRelativePositionFromJSON(relPosJson);\n absPos = Y.createAbsolutePositionFromRelativePosition(rpos, doc);\n } catch (err) {\n if (!(err instanceof TypeError) && !(err instanceof SyntaxError)) {\n console.error(\"[positions] relPosToFlatOffset: unexpected error resolving relRange:\", err);\n }\n return null;\n }\n if (!absPos) return null;\n\n const fragment = doc.getXmlFragment(\"default\");\n let accumulated = 0;\n\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n\n const prefixLen = getHeadingPrefixLength(node);\n\n const xmlTexts = collectXmlTexts(node);\n for (const { xmlText, offsetFromStart } of xmlTexts) {\n if (xmlText === absPos.type) {\n return toFlatOffset(accumulated + prefixLen + offsetFromStart + absPos.index);\n }\n }\n\n accumulated += prefixLen + getElementTextLength(node);\n if (i < fragment.length - 1) {\n accumulated += 1;\n }\n }\n\n console.error(\n \"[positions] relPosToFlatOffset: absPos resolved but no matching XmlText found in traversal\",\n );\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// High-level: range validation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a flat-offset range against a Y.Doc.\n *\n * Checks: ordering, textSnapshot staleness (with relocation), and optionally\n * heading-prefix overlap. Returns a structured RangeValidation.\n *\n */\nexport function validateRange(\n ydoc: Y.Doc,\n from: FlatOffset,\n to: FlatOffset,\n opts?: {\n textSnapshot?: string;\n rejectHeadingOverlap?: boolean;\n },\n): RangeValidation {\n const rejectHeadingOverlap = opts?.rejectHeadingOverlap ?? false;\n\n if (from > to) {\n return {\n ok: false,\n code: \"INVALID_RANGE\",\n message: `Invalid range: from (${from}) must be <= to (${to}).`,\n };\n }\n\n // Staleness check\n if (opts?.textSnapshot) {\n const fullText = extractText(ydoc);\n if (fullText.slice(from, to) !== opts.textSnapshot) {\n const candidates: number[] = [];\n let searchFrom = 0;\n while (true) {\n const idx = fullText.indexOf(opts.textSnapshot, searchFrom);\n if (idx === -1) break;\n candidates.push(idx);\n searchFrom = idx + 1;\n }\n if (candidates.length === 0) {\n return { ok: false, code: \"RANGE_GONE\" };\n }\n const best = candidates.reduce((a, b) => (Math.abs(a - from) <= Math.abs(b - from) ? a : b));\n return {\n ok: false,\n code: \"RANGE_MOVED\",\n resolvedFrom: toFlatOffset(best),\n resolvedTo: toFlatOffset(best + opts.textSnapshot.length),\n };\n }\n }\n\n // Heading overlap check\n if (rejectHeadingOverlap) {\n const fragment = ydoc.getXmlFragment(\"default\");\n const startPos = resolveToElement(fragment, from);\n const endPos = resolveToElement(fragment, to);\n if (!startPos || !endPos) {\n return {\n ok: false,\n code: \"INVALID_RANGE\",\n message: `Cannot resolve offset range [${from}, ${to}] in document.`,\n };\n }\n if (startPos.clampedFromPrefix || endPos.clampedFromPrefix) {\n return { ok: false, code: \"HEADING_OVERLAP\" };\n }\n }\n\n return { ok: true, range: { from, to } };\n}\n\n// ---------------------------------------------------------------------------\n// High-level: anchored range creation\n// ---------------------------------------------------------------------------\n\n/**\n * Validate a range and create both flat and CRDT-anchored positions in one call.\n * Pass `opts.rejectHeadingOverlap: true` to also reject ranges that overlap\n * heading prefixes (same guard used by `tandem_edit`).\n *\n * Sole assembler of `RelativeRange` at annotation birth — `refreshRange`'s\n * lazy-attach and dead-relRange repair branches are the only other sites that\n * assemble the `{fromRel, toRel}` shape, and both live in this file. Wire-shape\n * changes to `SerializedRelPos` require updating `SerializedRelPosSchema`,\n * both readers, and any on-disk JSON predating the change.\n */\nexport function anchoredRange(\n ydoc: Y.Doc,\n from: FlatOffset,\n to: FlatOffset,\n textSnapshot?: string,\n opts?: { rejectHeadingOverlap?: boolean },\n): AnchoredRangeResult | (RangeValidation & { ok: false }) {\n const validation = validateRange(ydoc, from, to, {\n textSnapshot,\n rejectHeadingOverlap: opts?.rejectHeadingOverlap,\n });\n if (!validation.ok) return validation;\n\n const range: DocumentRange = { from, to };\n\n // Create CRDT-anchored positions\n const fromRel = flatOffsetToRelPos(ydoc, from, 0); // assoc 0: stick right\n const toRel = flatOffsetToRelPos(ydoc, to, -1); // assoc -1: stick left\n const relRange: RelativeRange | undefined = fromRel && toRel ? { fromRel, toRel } : undefined;\n\n if (!relRange) {\n const fragment = ydoc.getXmlFragment(\"default\");\n const fromEl = resolveToElement(fragment, from);\n const toEl = resolveToElement(fragment, to);\n if (fromEl && !fromEl.clampedFromPrefix && toEl && !toEl.clampedFromPrefix) {\n console.error(`[positions] anchoredRange: relRange creation failed for [${from}, ${to}]`);\n }\n }\n\n if (relRange) {\n return { ok: true, fullyAnchored: true, range, relRange };\n }\n return { ok: true, fullyAnchored: false, range };\n}\n\n// ---------------------------------------------------------------------------\n// High-level: annotation range refresh\n// ---------------------------------------------------------------------------\n\n/**\n * Refresh an annotation's flat offsets from its relRange, or lazily attach\n * relRange if missing. Returns a tagged `RefreshResult` (ADR-032) so\n * callers can distinguish healthy / updated / attached / repaired /\n * degraded / failed paths instead of treating every outcome as success.\n * If `map` is provided, persists changes back to the Y.Map.\n *\n * The lazy-attach and dead-relRange repair branches below are the two\n * intentional `{fromRel, toRel}` re-assembly sites referenced by\n * `anchoredRange`'s JSDoc — both repair existing annotations rather than\n * minting new ones, so the shape duplication is deliberate, not a DRY gap.\n */\nexport function refreshRange(ann: Annotation, ydoc: Y.Doc, map?: Y.Map<unknown>): RefreshResult {\n if (!ann.relRange) {\n // Lazy attachment: compute relRange from current flat offsets\n const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);\n const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);\n if (!fromRel || !toRel) return { kind: \"degraded\", annotation: ann };\n const updated = { ...ann, relRange: { fromRel, toRel } };\n if (map) map.set(ann.id, updated);\n return { kind: \"attached\", annotation: updated };\n }\n\n // Resolve relRange to current flat offsets\n const newFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);\n const newTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);\n if (newFrom === null || newTo === null) {\n if (newFrom !== null || newTo !== null) {\n console.error(\n `[positions] refreshRange: partial CRDT resolution for ${ann.id} ` +\n `(from: ${newFrom !== null ? \"ok\" : \"dead\"}, to: ${newTo !== null ? \"ok\" : \"dead\"})`,\n );\n }\n // CRDT resolution failed (items deleted after content replacement).\n // Strip the dead relRange and attempt re-anchoring from flat offsets.\n const fromRel = flatOffsetToRelPos(ydoc, ann.range.from, 0);\n const toRel = flatOffsetToRelPos(ydoc, ann.range.to, -1);\n if (fromRel && toRel) {\n const updated: Annotation = { ...ann, relRange: { fromRel, toRel } };\n if (map) map.set(ann.id, updated);\n return { kind: \"repaired\", annotation: updated };\n }\n // Can't re-anchor — strip dead relRange so lazy path works next time\n const stripped: Annotation = { ...ann };\n delete stripped.relRange;\n if (map) map.set(ann.id, stripped);\n return { kind: \"degraded\", annotation: stripped };\n }\n if (newFrom > newTo) {\n console.error(\n `[positions] refreshRange: inverted CRDT range for annotation ${ann.id}: ` +\n `resolved [${newFrom}, ${newTo}] from flat [${ann.range.from}, ${ann.range.to}]`,\n );\n return { kind: \"failed\", annotation: ann };\n }\n if (newFrom === ann.range.from && newTo === ann.range.to) {\n return { kind: \"ok\", annotation: ann };\n }\n\n const updated = { ...ann, range: { from: newFrom, to: newTo } };\n if (map) map.set(ann.id, updated);\n return { kind: \"updated\", annotation: updated };\n}\n\n/**\n * Refresh all annotations in a batch, wrapping Y.Map writes in a transaction.\n *\n * When `skipTransact` is true, writes happen inline without wrapping a\n * `ydoc.transact`. The caller is responsible for providing an outer\n * transaction with the appropriate origin. Used by `reloadFromDisk` to merge\n * this pass with the subsequent textSnapshot relocation pass into a single\n * `MCP_ORIGIN` transaction (closes the two-write crash window — GH #622).\n */\nexport function refreshAllRanges(\n annotations: Annotation[],\n ydoc: Y.Doc,\n map: Y.Map<unknown>,\n opts?: { skipTransact?: boolean },\n): RefreshResult[] {\n const results: RefreshResult[] = [];\n const run = () => {\n for (const ann of annotations) {\n results.push(refreshRange(ann, ydoc, map));\n }\n };\n if (opts?.skipTransact) {\n run();\n } else {\n withMcp(ydoc, run);\n }\n\n // PR #705 review observability: surface CRDT corruption (`failed` kind —\n // inverted CRDT range) at the aggregator boundary. The individual\n // refreshRange already logs via console.error; this lifts a count + IDs\n // above the per-annotation noise so a batched reload makes the corruption\n // visible without log-scraping.\n const failed = results.filter((r) => r.kind === \"failed\");\n if (failed.length > 0) {\n console.warn(\n `[positions] refreshAllRanges: ${failed.length} annotation(s) failed CRDT refresh: ${failed\n .map((r) => r.annotation.id)\n .join(\", \")}`,\n );\n }\n\n return results;\n}\n\n/**\n * Exhaustive-match helper. Use in `switch (result.kind)` defaults so future\n * additions to the `RefreshResult` discriminator produce a compile error\n * at every call site that should branch on the new kind.\n */\nexport function assertNeverRefreshResult(value: never): never {\n throw new Error(`Unexpected RefreshResult kind: ${JSON.stringify(value)}`);\n}\n","// Shared canonical-`w:id` predicate for the Word-comment round-trip.\n//\n// A leaf module (no project imports) so both the import side\n// (`docx-comments.ts`) and the export side (`docx-comment-export.ts`) depend on\n// a single source of truth for \"is this Word `w:id` safe to trust as a stable\n// identity\" rather than duplicating the predicate. The export side previously\n// carried this check inline (`reusableCommentId`); #1150 adds the import\n// drift-dedup index as a second consumer, so it was extracted here.\n//\n// Canonical means: a non-negative decimal short enough to survive the\n// `IMPORT_COMMENT_ID_MAX` (32) slice un-truncated, with no non-canonical form\n// (\"01\") that would re-serialize differently. The 9-digit cap is well inside\n// both OOXML's int32 `w:id` range and the slice width. Two consumers:\n// - import drift-dedup index (#1150) trusts only ids passing this gate; a\n// sliced or non-canonical id could collapse two distinct comments into one\n// bucket, so those degrade to the (accepted) duplicate-on-drift behavior\n// rather than a silent cross-comment content swap.\n// - export id-reuse (`reusableCommentId`) reuses the original numeric id on\n// a promote → save → re-open cycle so `importAnnotationId` stays stable.\n\n/** True when a Word `w:id` is a canonical non-negative decimal (see module doc). */\nexport function isCanonicalWordId(raw: string | undefined): raw is string {\n return !!raw && /^\\d{1,9}$/.test(raw) && String(Number(raw)) === raw;\n}\n","// Walk word/document.xml counting flat-text offsets.\n//\n// Shared between comment extraction (docx-comments.ts) and suggestion\n// application (docx-apply.ts). The walker's flat-text output must match\n// `extractText(htmlToYDoc(mammoth(docx)))` for any document.\n//\n// Key invariant: <w:del> subtrees are skipped (mammoth excludes deleted\n// tracked-change text), while <w:ins> subtrees are traversed normally\n// (mammoth includes inserted text).\n\nimport type { ChildNode, Element } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport { headingPrefixLength } from \"../../shared/offsets.js\";\n\n// ---------------------------------------------------------------------------\n// DOM helpers (lightweight — avoids adding domutils as a direct dependency)\n// ---------------------------------------------------------------------------\n\nexport function isElement(node: ChildNode): node is Element {\n return node.type === \"tag\";\n}\n\nexport function getAttr(el: Element, name: string): string | undefined {\n return el.attribs?.[name];\n}\n\n/** Recursively collect text content from a DOM node. */\nexport function getTextContent(node: ChildNode): string {\n if (node.type === \"text\") return (node as { data: string }).data;\n if (!isElement(node)) return \"\";\n return node.children.map(getTextContent).join(\"\");\n}\n\n/** Recursively find all elements with a given name. */\nexport function findAllByName(name: string, nodes: ChildNode[]): Element[] {\n const results: Element[] = [];\n for (const node of nodes) {\n if (isElement(node)) {\n if (node.name === name) results.push(node);\n results.push(...findAllByName(name, node.children));\n }\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Heading detection\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether a <w:p> has a heading paragraph style.\n * Returns the heading level (1–6) or 0 if not a heading.\n *\n * Word heading styles appear as:\n * <w:p><w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>...</w:p>\n *\n * mammoth maps these to <h1>–<h6>, and htmlToYDoc maps those to\n * Y.XmlElement(\"heading\") with a `level` attribute.\n */\nexport function detectHeadingLevel(paragraph: Element): number {\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:pPr\") continue;\n for (const prop of child.children) {\n if (!isElement(prop) || prop.name !== \"w:pStyle\") continue;\n const val = getAttr(prop, \"w:val\") || \"\";\n // Match \"Heading1\" through \"Heading6\" (case-insensitive)\n const match = val.match(/^heading\\s*(\\d)$/i);\n if (match) {\n const level = parseInt(match[1], 10);\n if (level >= 1 && level <= 6) return level;\n }\n }\n }\n return 0;\n}\n\n// ---------------------------------------------------------------------------\n// Walker types\n// ---------------------------------------------------------------------------\n\nexport interface TextHit {\n /** The <w:r> run element containing this text node. */\n run: Element;\n /** The <w:t> element itself. */\n textNode: Element;\n /** Flat-text offset where this text node starts. */\n offsetStart: number;\n /** The text content of this node. */\n text: string;\n /** The enclosing <w:p> paragraph element. */\n paragraph: Element;\n /** w14:paraId attribute from the paragraph, if present. */\n paragraphId: string | undefined;\n}\n\nexport interface CommentStartHit {\n commentId: string;\n offset: number;\n paragraph: Element;\n paragraphId: string | undefined;\n}\n\nexport interface WalkerCallbacks {\n onText?(hit: TextHit): void;\n onCommentStart?(hit: CommentStartHit): void;\n onCommentEnd?(commentId: string, offset: number): void;\n}\n\nexport interface WalkerResult {\n totalLength: number;\n flatText: string;\n}\n\n// ---------------------------------------------------------------------------\n// Single-character elements that mammoth maps to one character\n// ---------------------------------------------------------------------------\n\nconst SINGLE_CHAR_ELEMENTS = new Set([\"w:tab\", \"w:br\", \"w:noBreakHyphen\", \"w:softHyphen\", \"w:sym\"]);\n\n// ---------------------------------------------------------------------------\n// Walker\n// ---------------------------------------------------------------------------\n\n/**\n * Walk `<w:body>` children in document.xml, counting flat-text offsets and\n * firing callbacks for text nodes and comment range markers.\n *\n * Skips `<w:del>` subtrees (mammoth excludes deleted tracked-change text).\n * Traverses `<w:ins>` subtrees normally (mammoth includes inserted text).\n * Skips `<w:instrText>` (field instruction text).\n */\nexport function walkDocumentBody(xml: string, callbacks: WalkerCallbacks = {}): WalkerResult {\n const doc = parseDocument(xml, { xmlMode: true });\n\n let offset = 0;\n let firstParagraph = true;\n const textParts: string[] = [];\n\n // Current paragraph context — set when entering a <w:p>\n let currentParagraph: Element | undefined;\n let currentParagraphId: string | undefined;\n\n // Current run context — set when entering a <w:r>\n let currentRun: Element | undefined;\n\n function walk(nodes: ChildNode[]): void {\n for (const node of nodes) {\n if (!isElement(node)) continue;\n\n if (node.name === \"w:p\") {\n // Paragraph separator (except for first paragraph)\n if (!firstParagraph) {\n offset += 1; // \\n\n textParts.push(\"\\n\");\n }\n firstParagraph = false;\n\n // Set paragraph context\n const prevParagraph = currentParagraph;\n const prevParagraphId = currentParagraphId;\n currentParagraph = node;\n currentParagraphId = getAttr(node, \"w14:paraId\");\n\n // Detect heading style → add prefix length to offset\n const headingLevel = detectHeadingLevel(node);\n if (headingLevel > 0) {\n const prefixLen = headingPrefixLength(headingLevel);\n offset += prefixLen;\n textParts.push(\"#\".repeat(headingLevel) + \" \");\n }\n\n walk(node.children);\n\n // Restore paragraph context\n currentParagraph = prevParagraph;\n currentParagraphId = prevParagraphId;\n } else if (node.name === \"w:del\") {\n // Skip deleted tracked-change text — mammoth excludes it\n } else if (node.name === \"w:commentRangeStart\") {\n const id = getAttr(node, \"w:id\");\n if (id) {\n callbacks.onCommentStart?.({\n commentId: id,\n offset,\n paragraph: currentParagraph!,\n paragraphId: currentParagraphId,\n });\n }\n } else if (node.name === \"w:commentRangeEnd\") {\n const id = getAttr(node, \"w:id\");\n if (id) {\n callbacks.onCommentEnd?.(id, offset);\n }\n } else if (node.name === \"w:instrText\") {\n // Skip field instruction text\n } else if (node.name === \"w:t\") {\n const text = getTextContent(node);\n if (callbacks.onText && currentRun && currentParagraph) {\n callbacks.onText({\n run: currentRun,\n textNode: node,\n offsetStart: offset,\n text,\n paragraph: currentParagraph,\n paragraphId: currentParagraphId,\n });\n }\n offset += text.length;\n textParts.push(text);\n } else if (SINGLE_CHAR_ELEMENTS.has(node.name)) {\n offset += 1;\n textParts.push(\" \"); // placeholder character\n } else if (node.name === \"w:r\") {\n // Track current run for onText callback\n const prevRun = currentRun;\n currentRun = node;\n walk(node.children);\n currentRun = prevRun;\n } else {\n // Recurse into w:ins, w:hyperlink, w:pPr children, etc.\n walk(node.children);\n }\n }\n }\n\n // Find <w:body> and walk its children\n const bodyElements = findAllByName(\"w:body\", doc.children);\n if (bodyElements.length === 0) {\n return { totalLength: 0, flatText: \"\" };\n }\n walk(bodyElements[0].children);\n\n const flatText = textParts.join(\"\");\n return { totalLength: offset, flatText };\n}\n","// Extract Word comments from .docx ZIP and inject as Tandem annotations.\n//\n// Comments are parsed from word/comments.xml; anchor ranges are calculated\n// by walking word/document.xml and tracking w:commentRangeStart/End markers\n// alongside character offsets. Heading prefix offsets are accounted for so\n// flat-text positions match Tandem's coordinate system after mammoth → htmlToYDoc.\n\nimport * as crypto from \"node:crypto\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport * as Y from \"yjs\";\nimport { Y_MAP_ANNOTATION_REPLIES, Y_MAP_ANNOTATIONS } from \"../../shared/constants.js\";\nimport { withInternal } from \"../../shared/origins.js\";\nimport type { Annotation, AnnotationReply, FlatOffset } from \"../../shared/types.js\";\nimport { toFlatOffset } from \"../../shared/types.js\";\nimport { IMPORT_AUTHOR_MAX, IMPORT_REPLY_BODY_CAP, nextRev } from \"../annotations/schema.js\";\nimport { anchoredRange } from \"../positions.js\";\nimport { isCanonicalWordId } from \"./docx-comment-id.js\";\nimport {\n findAllByName,\n getAttr,\n getTextContent,\n isElement,\n walkDocumentBody,\n} from \"./docx-walker.js\";\n\n/**\n * Deterministic annotation id for an imported Word comment.\n *\n * Inputs (commentId + range + comment body) are stable across repeated imports\n * of the same .docx, so re-opening or force-reloading the file produces the\n * same id — which lets the injection loop dedupe against the existing map\n * instead of accumulating duplicates in the durable annotation store.\n */\nexport function importAnnotationId(\n commentId: string,\n from: number,\n to: number,\n bodyText: string,\n): string {\n const hash = crypto\n .createHash(\"sha256\")\n .update(`${commentId}\u0000${from}\u0000${to}\u0000${bodyText}`)\n .digest(\"hex\")\n .slice(0, 12);\n return `import-${hash}`;\n}\n\n/**\n * Deterministic id for an imported Word comment reply (#1000). Stable across\n * re-imports of the same .docx (root id + reply id + body), so re-opening or\n * force-reloading dedupes against the existing replies map rather than\n * accumulating duplicates. Distinct prefix from `importAnnotationId` so a reply\n * id can never collide with a note id.\n */\nexport function importReplyId(\n rootCommentId: string,\n replyCommentId: string,\n bodyText: string,\n): string {\n const hash = crypto\n .createHash(\"sha256\")\n .update(`${rootCommentId} ${replyCommentId} ${bodyText}`)\n .digest(\"hex\")\n .slice(0, 12);\n return `import-reply-${hash}`;\n}\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface DocxComment {\n commentId: string;\n authorName: string;\n bodyText: string;\n from: FlatOffset;\n to: FlatOffset;\n date?: string;\n /**\n * Threaded Word comment replies (#1000), reconstructed from\n * `commentsExtended.xml`. Present only on root comments that have replies;\n * absent for non-threaded documents (backward compatible). Replies inherit\n * the root's anchor range and are injected as private import replies.\n */\n replies?: DocxReply[];\n}\n\n/** A threaded Word comment reply. Inherits its root comment's anchor range. */\nexport interface DocxReply {\n commentId: string;\n authorName: string;\n bodyText: string;\n date?: string;\n}\n\n/**\n * Cycle/runaway guard for the thread-parent walk. Word comment threads are flat\n * (one root + N replies), so this is generous; a crafted `commentsExtended.xml`\n * with a deep/cyclic `paraIdParent` chain degrades to treating the node as a\n * root rather than hanging (#1000 security review R3).\n */\nconst MAX_THREAD_DEPTH = 64;\n\n/**\n * Length cap for the original Word `w:id` stored in `importSource.commentId`\n * (#1068). Real Word ids are short decimal strings; the cap only bounds a\n * crafted/hostile attribute. Export-side reuse additionally validates the\n * stored value is a canonical non-negative decimal before emitting it.\n */\nexport const IMPORT_COMMENT_ID_MAX = 32;\n\n// ---------------------------------------------------------------------------\n// Top-level extraction\n// ---------------------------------------------------------------------------\n\n/**\n * Extract comments and their document ranges from a .docx buffer.\n * Returns an empty array when the document has no comments.\n */\nexport async function extractDocxComments(buffer: Buffer): Promise<DocxComment[]> {\n const zip = await JSZip.loadAsync(buffer);\n\n const commentsXml = await zip.file(\"word/comments.xml\")?.async(\"text\");\n if (!commentsXml) return [];\n\n const documentXml = await zip.file(\"word/document.xml\")?.async(\"text\");\n if (!documentXml) return [];\n\n const commentMap = parseCommentMetadata(commentsXml);\n if (commentMap.size === 0) return [];\n\n const ranges = calculateCommentRanges(documentXml);\n\n // Thread reconstruction (#1000). Absent commentsExtended.xml ⇒ empty threading\n // ⇒ every comment resolves to itself as a root ⇒ identical to pre-#1000.\n const extendedXml = await zip.file(\"word/commentsExtended.xml\")?.async(\"text\");\n const threading = extendedXml ? parseCommentThreading(extendedXml) : new Map<string, string>();\n\n // paraId (lowercased) → commentId, to resolve a reply's parent paraId back to\n // a comment.\n const paraIdToCommentId = new Map<string, string>();\n for (const [id, meta] of commentMap) {\n if (meta.lastParaId) paraIdToCommentId.set(meta.lastParaId, id);\n }\n\n // Walk up paraIdParent links to the thread root. Cycle/self-parent/unresolved/\n // over-depth all terminate by treating the current node as a root.\n const resolveRoot = (startId: string): string => {\n let currentId = startId;\n const visited = new Set<string>();\n for (let depth = 0; depth < MAX_THREAD_DEPTH; depth++) {\n if (visited.has(currentId)) {\n console.error(`[docx-comments] Comment thread cycle at ${currentId}; treating as root`);\n return currentId;\n }\n visited.add(currentId);\n const paraId = commentMap.get(currentId)?.lastParaId;\n if (!paraId) return currentId;\n const parentParaId = threading.get(paraId);\n if (!parentParaId) return currentId;\n const parentId = paraIdToCommentId.get(parentParaId);\n if (!parentId || parentId === currentId) {\n if (!parentId) {\n console.error(\n `[docx-comments] Reply ${currentId} references unresolved parent paraId ${parentParaId}; treating as root`,\n );\n }\n return currentId;\n }\n currentId = parentId;\n }\n console.error(\n `[docx-comments] Comment thread exceeded depth ${MAX_THREAD_DEPTH} at ${startId}; treating as root`,\n );\n return currentId;\n };\n\n // Partition into roots and reply buckets (document order preserved by Map\n // iteration, which is the parse/document order).\n const rootIds: string[] = [];\n const replyBuckets = new Map<string, DocxReply[]>();\n for (const [id, meta] of commentMap) {\n const root = resolveRoot(id);\n if (root === id) {\n rootIds.push(id);\n } else {\n const bucket = replyBuckets.get(root) ?? [];\n bucket.push({\n commentId: id,\n authorName: meta.authorName,\n bodyText: meta.bodyText,\n date: meta.date,\n });\n replyBuckets.set(root, bucket);\n }\n }\n\n const result: DocxComment[] = [];\n for (const id of rootIds) {\n const meta = commentMap.get(id)!;\n const range = ranges.get(id);\n if (!range) {\n console.error(\n `[docx-comments] Comment ${id} has no range markers in document.xml — skipping`,\n );\n continue;\n }\n const replies = replyBuckets.get(id);\n if (replies) {\n // Order replies chronologically; stable sort keeps document order for\n // equal/absent dates.\n replies.sort((a, b) => (a.date ? Date.parse(a.date) : 0) - (b.date ? Date.parse(b.date) : 0));\n }\n result.push({\n commentId: id,\n authorName: meta.authorName,\n bodyText: meta.bodyText,\n from: range.from,\n to: range.to,\n date: meta.date,\n ...(replies && replies.length > 0 ? { replies } : {}),\n });\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Comment metadata (word/comments.xml)\n// ---------------------------------------------------------------------------\n\ninterface CommentMeta {\n authorName: string;\n bodyText: string;\n date?: string;\n /**\n * Lowercased `w14:paraId` of the LAST `<w:p>` in the comment. This is what\n * `commentsExtended.xml`'s `w15:commentEx/@paraId` references (OOXML\n * CT_CommentEx §2.5.39), so it is the join key for thread reconstruction.\n */\n lastParaId?: string;\n}\n\n/** Parse comment id, author, body text, optional date, and last-paragraph paraId. */\nexport function parseCommentMetadata(xml: string): Map<string, CommentMeta> {\n const doc = parseDocument(xml, { xmlMode: true });\n const map = new Map<string, CommentMeta>();\n\n for (const comment of findAllByName(\"w:comment\", doc.children)) {\n const id = getAttr(comment, \"w:id\");\n if (!id) continue;\n\n const author = getAttr(comment, \"w:author\") || \"Unknown\";\n const date = getAttr(comment, \"w:date\");\n\n // Collect text from <w:t> elements within the comment body\n const textNodes = findAllByName(\"w:t\", comment.children);\n const bodyText = textNodes.map((t) => getTextContent(t)).join(\"\");\n\n // The comment's last paragraph paraId is the thread join key.\n // Shallow filter — only direct children of <w:comment>; a recursive\n // `findAllByName` would include <w:p> inside embedded tables, picking the\n // wrong last paraId. CT_CommentEx @paraId references the last top-level\n // paragraph (OOXML §2.5.39).\n const paragraphs = comment.children.filter(isElement).filter((c) => c.name === \"w:p\");\n const lastParaId =\n paragraphs.length > 0\n ? getAttr(paragraphs[paragraphs.length - 1], \"w14:paraId\")?.toLowerCase()\n : undefined;\n\n map.set(id, { authorName: author, bodyText, date, lastParaId });\n }\n return map;\n}\n\n/**\n * Parse `word/commentsExtended.xml` into a `childParaId → parentParaId` map\n * (both lowercased). Only entries with a `paraIdParent` (i.e. replies) are\n * included. Absent file ⇒ empty map ⇒ every comment is treated as a root\n * (backward compatible with non-threaded documents). #1000.\n */\nexport function parseCommentThreading(xml: string): Map<string, string> {\n const doc = parseDocument(xml, { xmlMode: true });\n const map = new Map<string, string>();\n for (const ex of findAllByName(\"w15:commentEx\", doc.children)) {\n const paraId = getAttr(ex, \"w15:paraId\")?.toLowerCase();\n const parent = getAttr(ex, \"w15:paraIdParent\")?.toLowerCase();\n if (paraId && parent) map.set(paraId, parent);\n }\n return map;\n}\n\n// ---------------------------------------------------------------------------\n// Range calculation (word/document.xml)\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the document body, counting flat-text characters (including heading\n * prefixes), and record start/end offsets for each comment range marker.\n *\n * Delegates to the shared `walkDocumentBody` walker which also skips\n * `<w:del>` subtrees (mammoth excludes deleted tracked-change text).\n */\nexport function calculateCommentRanges(\n xml: string,\n): Map<string, { from: FlatOffset; to: FlatOffset }> {\n const ranges = new Map<string, { from: FlatOffset; to: FlatOffset }>();\n const openRanges = new Map<string, number>(); // commentId → startOffset\n\n walkDocumentBody(xml, {\n onCommentStart({ commentId, offset }) {\n openRanges.set(commentId, offset);\n },\n onCommentEnd(commentId, offset) {\n if (openRanges.has(commentId)) {\n ranges.set(commentId, {\n from: toFlatOffset(openRanges.get(commentId)!),\n to: toFlatOffset(offset),\n });\n openRanges.delete(commentId);\n }\n },\n });\n\n if (openRanges.size > 0) {\n console.error(\n `[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(\", \")}`,\n );\n }\n\n return ranges;\n}\n\n// ---------------------------------------------------------------------------\n// Annotation injection\n// ---------------------------------------------------------------------------\n\n/**\n * Inject extracted comments into a Y.Doc's annotation map.\n * Must be called AFTER htmlToYDoc has populated the document content,\n * so that anchoredRange can create CRDT-anchored positions.\n *\n * Imports land as **private notes** (`type: \"note\"`, `audience: \"private\"`,\n * `author: \"import\"`) per the v7 W8 batch-promote flow. They carry the\n * reviewer attribution in `importSource: { author, file }` rather than\n * inlining `[author]` in the content body, so the UI can render a \"From:\n * <author>\" byline. The user batch-promotes notes to comments via\n * `BatchPromoteBar`, which flips `audience: \"private\"` → `\"outbound\"`,\n * `author: \"import\"` → `\"user\"`, and `type: \"note\"` → `\"comment\"`. Only\n * after that promotion do they surface to Claude via channel events or\n * `tandem_getAnnotations`.\n *\n * The fileName argument is best-effort — uploads and force-reload paths\n * that don't have a meaningful file name fall back to \"unknown\".\n */\nexport function injectCommentsAsAnnotations(\n doc: Y.Doc,\n comments: DocxComment[],\n fileName?: string,\n): number {\n if (comments.length === 0) return 0;\n\n const map = doc.getMap(Y_MAP_ANNOTATIONS);\n const repliesMap = doc.getMap(Y_MAP_ANNOTATION_REPLIES);\n const sourceFile = fileName ?? \"unknown\";\n let injected = 0;\n let migrated = 0;\n let reanchored = 0;\n let injectedReplies = 0;\n\n // Secondary dedup axis (#1150): index existing imported records by their stable\n // Word `commentId`, so a comment whose flat offsets drifted between imports is\n // updated in place instead of duplicated under a new offset-derived key. One\n // read-only O(n) pass before the transact. Two record kinds are indexed:\n // - `author: \"import\"` notes → candidates for in-place drift-update.\n // - promoted-from-import records (`promotedFrom: \"note\"`, which keep their\n // `importSource` per annotation-actions.ts) → candidates for SKIP, so a\n // drifted comment the user already promoted doesn't re-inject a ghost note\n // (which would double-write the .docx on the next export). The update filter\n // is `author === \"import\"` exactly — NOT `importSource != null`, which\n // survives promotion (ADR-027: a blind rewrite would silently un-promote).\n // Only canonical-decimal ids are trusted (see isCanonicalWordId). Two stored\n // records can legitimately share one `commentId` only as a legacy duplicate\n // (e.g. a pre-#1150 ghost note alongside its promoted record). When that\n // happens, deterministically prefer the PROMOTED record so the skip branch\n // wins regardless of Y.Map iteration order, and log the collision — it's a\n // real inconsistency, not something to silently iteration-order away.\n const byCommentId = new Map<string, { key: string; ann: Annotation }>();\n for (const [key, val] of map as Iterable<[string, Annotation]>) {\n const cid = val?.importSource?.commentId;\n if (!isCanonicalWordId(cid)) continue;\n const isPromoted = val.promotedFrom === \"note\";\n if (val.author !== \"import\" && !isPromoted) continue;\n const existing = byCommentId.get(cid);\n if (!existing) {\n byCommentId.set(cid, { key, ann: val });\n } else if (isPromoted && existing.ann.promotedFrom !== \"note\") {\n byCommentId.set(cid, { key, ann: val });\n console.error(\n `[docx-comments] Duplicate imported commentId ${cid}: preferred promoted record ${key} over import note ${existing.key}.`,\n );\n } else {\n console.error(\n `[docx-comments] Duplicate imported commentId ${cid}: kept ${existing.key}, ignored ${key}.`,\n );\n }\n }\n\n // `withInternal` here is the authoritative origin. When callers invoke\n // this function inside an outer `withInternal` or `withReload` transact\n // (as file-opener.ts does), Y.js nested transactions inherit the outermost\n // origin — so the effective origin becomes whatever the outer call used.\n // This is intentional: a reload path calling this inside `withReload` wants\n // reload semantics (durable-sync persists, channel skips).\n withInternal(doc, () => {\n for (const comment of comments) {\n const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));\n if (!result.ok) {\n console.error(\n `[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] — ${result.code}`,\n );\n continue;\n }\n\n const offsetId = importAnnotationId(\n comment.commentId,\n comment.from,\n comment.to,\n comment.bodyText,\n );\n\n // The map key under which this comment's root note lives — the offset id on\n // the stable/new paths, or an existing key when offsets drifted (#1150). The\n // reply loop below anchors `annotationId` to this so replies follow the root.\n let effectiveKey = offsetId;\n\n // Provenance written on every fresh/updated note for this comment. Built\n // once per iteration; only one map.set branch below runs, so no two records\n // ever alias it.\n const importSource = {\n author: comment.authorName,\n file: sourceFile,\n commentId: comment.commentId.slice(0, IMPORT_COMMENT_ID_MAX),\n };\n\n // Dedup: idempotent re-import. Same .docx → same id → leave the existing\n // note as-is. Legacy records stored under the pre-W8 model as\n // `type: \"comment\"` with content prefix `[author] ` are migrated in place\n // to the new private-note shape. Unlike the pre-#1000 code we do NOT early\n // `continue` here — reply injection below must run for existing/migrated\n // roots too (dedup is per-reply), so a pre-#1000 imported note picks up\n // its threaded replies on the next open.\n if (map.has(offsetId)) {\n const existing = map.get(offsetId) as Annotation | undefined;\n if (\n existing &&\n existing.author === \"import\" &&\n (existing.type === \"comment\" || existing.audience !== \"private\")\n ) {\n map.set(offsetId, {\n ...existing,\n type: \"note\" as const,\n audience: \"private\" as const,\n content: comment.bodyText,\n importSource,\n rev: nextRev(existing),\n });\n migrated++;\n } else if (\n existing &&\n existing.author === \"import\" &&\n existing.importSource &&\n existing.importSource.commentId === undefined\n ) {\n // #1068 backfill: pre-commentId import notes (already note-shaped,\n // so the migration branch above skipped them) gain the original\n // Word id so a later promote → save reuses it. One-shot write —\n // guarded on the field being absent.\n map.set(offsetId, {\n ...existing,\n importSource: { ...existing.importSource, commentId: importSource.commentId },\n rev: nextRev(existing),\n });\n }\n } else {\n // Offset-id miss. Before injecting, consult the commentId index — a miss\n // here may be drift (same Word comment, moved/edited), not a new comment.\n const drift = isCanonicalWordId(comment.commentId)\n ? byCommentId.get(comment.commentId)\n : undefined;\n\n if (drift && drift.ann.author === \"import\") {\n // Drift: re-anchor the existing note IN PLACE under its existing key\n // instead of duplicating. Destructure out the stale `relRange` (a\n // RelativePosition into pre-reload content htmlToYDoc just deleted) and\n // re-add it ONLY when the fresh anchor is fully anchored — otherwise the\n // record would carry a fresh flat range glued to a dead CRDT anchor,\n // which refreshRange can resolve to garbage offsets (#1150 C1). Strip a\n // stale `textSnapshot` for the same reason and by the same symmetry —\n // it's a pre-reload anchor the reload's relocation pass would chase\n // against the old text. Import notes don't carry one today, so this is\n // defense-in-depth against a future writer.\n effectiveKey = drift.key;\n const { relRange: _staleRel, textSnapshot: _staleSnap, ...existingRest } = drift.ann;\n map.set(drift.key, {\n ...existingRest,\n type: \"note\" as const,\n audience: \"private\" as const,\n content: comment.bodyText,\n range: { from: result.range.from, to: result.range.to },\n importSource,\n rev: nextRev(drift.ann),\n ...(result.fullyAnchored ? { relRange: result.relRange } : {}),\n });\n reanchored++;\n } else if (drift && drift.ann.promotedFrom === \"note\") {\n // The user already promoted this Word comment to an outbound comment.\n // Re-injecting a private note for it would create a ghost that\n // double-writes the .docx on export, so we write no note and leave the\n // promotion (its content, range, and anchor) untouched. But DON'T skip\n // the reply loop: an import reply is private by its own durable property\n // regardless of its root, so a Word reply added AFTER promotion must\n // still land — point it at the promoted record via effectiveKey so it\n // threads correctly and round-trips. (An edited body is intentionally\n // not applied; promotion makes the content user-owned.)\n effectiveKey = drift.key;\n } else {\n const annotation: Annotation = {\n id: offsetId,\n author: \"import\" as const,\n type: \"note\" as const,\n audience: \"private\" as const,\n range: { from: result.range.from, to: result.range.to },\n content: comment.bodyText,\n status: \"pending\" as const,\n timestamp: comment.date ? new Date(comment.date).getTime() : Date.now(),\n rev: nextRev(),\n importSource,\n ...(result.fullyAnchored ? { relRange: result.relRange } : {}),\n };\n\n map.set(offsetId, annotation);\n injected++;\n }\n }\n\n // Inject threaded Word replies as PRIVATE import replies (#1000). They\n // inherit the root note's anchor (no separate range) and never reach\n // Claude (private + the channel/read-path guards). Deterministic\n // `importReplyId` dedupes on the replies map itself — independent of the\n // parent note's existence — so re-import after a cascade delete recreates\n // them without duplicates. Untrusted body/author are length-bounded.\n for (const reply of comment.replies ?? []) {\n const replyId = importReplyId(comment.commentId, reply.commentId, reply.bodyText);\n if (repliesMap.has(replyId)) continue;\n const replyRecord: AnnotationReply = {\n id: replyId,\n annotationId: effectiveKey,\n author: \"import\",\n text: reply.bodyText.slice(0, IMPORT_REPLY_BODY_CAP),\n timestamp: reply.date ? new Date(reply.date).getTime() : Date.now(),\n rev: nextRev(),\n private: true,\n importAuthor: reply.authorName.slice(0, IMPORT_AUTHOR_MAX),\n };\n repliesMap.set(replyId, replyRecord);\n injectedReplies++;\n }\n }\n });\n\n if (injected > 0 || migrated > 0 || reanchored > 0 || injectedReplies > 0) {\n console.error(\n `[docx-comments] Imported ${injected}/${comments.length} Word comments as private notes` +\n (injectedReplies > 0 ? ` + ${injectedReplies} threaded replies` : \"\") +\n (migrated > 0 ? ` (migrated ${migrated} legacy records to note shape)` : \"\") +\n (reanchored > 0 ? ` (re-anchored ${reanchored} drifted notes)` : \"\"),\n );\n }\n\n return injected;\n}\n","import type { Annotation } from \"./types.js\";\n\n/** Raw annotation from Y.Map — may contain legacy `suggestion`/`question` types. */\nexport type RawAnnotation = Omit<Annotation, \"type\"> & { type: string };\n\n/**\n * Discriminated union describing a lossy rewrite performed by\n * `sanitizeAnnotation`. Reported via the required `onLossy` callback so\n * callers can route the event to their own observability sink (server:\n * migration-log; client: dev console).\n *\n * NEW kinds added here MUST be handled in `relaySanitizationEvent`\n * (`src/server/annotations/migration-log.ts`) — extend\n * `LegacyMigrationKind` in lockstep.\n */\nexport type SanitizationEvent =\n | { kind: \"flag-to-note\"; id: string }\n | { kind: \"question-to-comment\"; id: string }\n | { kind: \"malformed-suggestion-json\"; id: string }\n | { kind: \"unknown-type\"; id: string; rawType: string }\n /**\n * @deprecated Never emitted by `sanitizeAnnotation` since Wave 8 (PR #756)\n * reversed the import-note→comment rewrite. Retained in the union so that\n * log-parsing tools and `relaySanitizationEvent` don't break on event streams\n * that pre-date W8. Do not add new call sites.\n */\n | { kind: \"import-note-to-comment\"; id: string }\n | { kind: \"audience-conflict-resolved\"; id: string };\n\n/**\n * Required callback invoked once per lossy rewrite. Sync only — Promise\n * returns are forbidden at the type level. Errors thrown from the callback\n * are caught inside `sanitizeAnnotation` and logged to stderr; sanitize\n * never aborts mid-`.map()` because of a faulty relay.\n */\nexport type OnLossy = (event: SanitizationEvent) => void;\n\n/**\n * Normalize a legacy annotation into the unified shape.\n * - `suggestion` → `comment` with `suggestedText` + `content` (parsed from JSON)\n * - `question` → `comment` (directedAt removed per ADR-027)\n * - `flag` → `note` (ADR-027: audience-based model)\n * - Strips stray `color` from non-highlight entries (#245)\n * - Strips `directedAt` from comments (ADR-027)\n * - Preserves `rev` (the durable-annotation last-writer-wins counter — added\n * by the on-disk schema, see `src/server/annotations/schema.ts`). `rev` is\n * a server-internal durability concept, not a client-facing annotation\n * field, so it doesn't appear on the `Annotation` union type. Passthrough\n * here is load-bearing: without it every sanitize-then-write cycle in the\n * MCP tools would reset `rev` to undefined and the sync observer would\n * serialize `rev: 0` forever.\n *\n * `onLossy` is REQUIRED. Lossy rewrites — `flag→note`, `question→comment`,\n * malformed-suggestion-JSON, unknown-type → comment — fire one event each.\n * Making the callback required is the TS-level enforcement that prevents a\n * forgotten callsite from silently regressing observability.\n */\nexport function sanitizeAnnotation(\n input: Annotation | RawAnnotation,\n onLossy: OnLossy,\n): Annotation {\n const ann = input as RawAnnotation;\n\n const emit = (event: SanitizationEvent): void => {\n try {\n onLossy(event);\n } catch (err) {\n // Never abort sanitize because of a faulty relay. The whole point of\n // the callback is observability — if it throws, log to stderr (which\n // the server redirects from console.warn) and move on.\n console.warn(`[sanitizeAnnotation] onLossy threw for ${event.kind}:`, err);\n }\n };\n\n // AR1: derive audience before `base` is built so it flows through all early-return paths.\n // \"flag\" is explicit — it hasn't been mutated to \"note\" at this point.\n // Import annotations are always private initially; users triage Word comments before Claude sees them.\n // Computing a default is normative behavior, not a lossy migration — no event emitted.\n const derivedAudience: \"private\" | \"outbound\" =\n ann.audience === \"private\" || ann.audience === \"outbound\"\n ? ann.audience\n : ann.author === \"import\" ||\n ann.type === \"highlight\" ||\n ann.type === \"note\" ||\n ann.type === \"flag\"\n ? \"private\"\n : \"outbound\";\n\n // Build a base with only AnnotationBase fields (strip legacy type-specific fields)\n const base = {\n id: ann.id,\n author: ann.author,\n range: ann.range,\n content: ann.content,\n status: ann.status,\n timestamp: ann.timestamp,\n ...(ann.relRange !== undefined ? { relRange: ann.relRange } : {}),\n ...(ann.textSnapshot !== undefined ? { textSnapshot: ann.textSnapshot } : {}),\n ...(ann.editedAt !== undefined ? { editedAt: ann.editedAt } : {}),\n ...(typeof ann.rev === \"number\" ? { rev: ann.rev } : {}),\n audience: derivedAudience,\n ...(ann.promotedFrom !== undefined ? { promotedFrom: ann.promotedFrom } : {}),\n ...(ann.importSource !== undefined ? { importSource: ann.importSource } : {}),\n };\n\n // Guard: user-authored notes, highlights, and flags must never be outbound.\n // Flags are included because the flag-to-note migration below preserves audience;\n // without this guard a flag with explicit audience:\"outbound\" would become a\n // note with audience:\"outbound\", violating ADR-027.\n // Only author:\"user\" is guarded; import-promoted comments (author:\"import\") remain\n // outbound-eligible after their own type rewrite below.\n if (\n base.audience === \"outbound\" &&\n ann.author === \"user\" &&\n (ann.type === \"note\" || ann.type === \"highlight\" || ann.type === \"flag\")\n ) {\n base.audience = \"private\";\n emit({ kind: \"audience-conflict-resolved\", id: ann.id });\n }\n\n if (ann.type === \"suggestion\") {\n let suggestedText: string | undefined;\n let content: string;\n try {\n const parsed = JSON.parse(ann.content) as { newText?: string; reason?: string };\n suggestedText = parsed.newText;\n content = parsed.reason ?? \"\";\n } catch {\n emit({ kind: \"malformed-suggestion-json\", id: ann.id });\n content = ann.content;\n }\n return { ...base, type: \"comment\", content, suggestedText } as Annotation;\n }\n\n if (ann.type === \"question\") {\n emit({ kind: \"question-to-comment\", id: ann.id });\n return { ...base, type: \"comment\" } as Annotation;\n }\n\n if (ann.type === \"highlight\") {\n return {\n ...base,\n type: \"highlight\",\n color: (ann as Annotation & { color?: string }).color,\n } as Annotation;\n }\n\n // W8 (PR #756) reverses the #482 policy: imported Word reviewer comments\n // are now first-class private notes (`author: \"import\", type: \"note\",\n // audience: \"private\"`) until the user batch-promotes them via the\n // BatchPromoteBar. The previous import-note→comment rewrite leaked\n // un-promoted imports to Claude on every MCP read; falling through to\n // the note branch below keeps them in the private bucket.\n\n if (ann.type === \"flag\" || ann.type === \"note\") {\n if (ann.type === \"flag\") {\n emit({ kind: \"flag-to-note\", id: ann.id });\n }\n return { ...base, type: \"note\" } as Annotation;\n }\n\n if (ann.type === \"comment\") {\n return {\n ...base,\n type: \"comment\",\n ...(ann.suggestedText !== undefined ? { suggestedText: ann.suggestedText } : {}),\n } as Annotation;\n }\n\n // Truly unknown type — coerce to comment\n emit({ kind: \"unknown-type\", id: ann.id, rawType: ann.type });\n return { ...base, type: \"comment\" } as Annotation;\n}\n","// Annotation → Word-comment export gate (#1068, #576 v1.1).\n//\n// Decides WHICH annotations become Word comments on .docx save and resolves\n// their current document ranges. The OOXML emission itself (CommentRangeStart/\n// End markers + comments.xml) lives in `docx-export.ts`; this module is the\n// privacy and correctness boundary in front of it.\n//\n// ADR-027 GATE (must be preserved by any future change):\n// ADR-027 governs CLAUDE visibility, not the .docx file round-trip. Two kinds\n// of annotation reach this file: (A) user/Claude comments destined for Claude,\n// and (B) imports — Word comments that CAME FROM the source .docx, stored as\n// private notes (Claude-invisible) but written back to the same file on save.\n// Writing an import back to its own file is content preservation, NOT Claude\n// exposure — the Claude-facing surfaces (`tandem_getAnnotations`,\n// `tandem_exportAnnotations`, channel) are untouched by this module.\n//\n// - An ANNOTATION is exported when EITHER:\n// (A) `type === \"comment\"` AND `audience !== \"private\"` AND\n// `status === \"pending\"` (the user/Claude comment path), OR\n// (B) it is an IMPORT ROUND-TRIP — `isImportRoundtrip(ann)`: `author ===\n// \"import\"` AND a populated `importSource` (see the predicate). Imports\n// bypass the type, audience, AND status gates: they are file content,\n// not Claude-facing, and Bryan's directive is \"imported comments\n// should not be dropped\" — even an accepted/dismissed import round-trips\n// (status is Tandem's review state, not the file's content). Only an\n// explicit DELETE (removal from the annotation map) drops an import.\n// - The import predicate (annotations AND replies) keys on `author ===\n// \"import\"` AND a corroborating field (`importSource` / `importAuthor`),\n// never on `author` alone: the `.passthrough()` durable envelope\n// enum-validates `author` but does NOT cross-validate it against the import\n// metadata, so `author:\"import\"` alone must not bypass the gate. A genuine\n// import always populates the corroborating field.\n// - User-authored `note`/`highlight` and user-authored `private` replies\n// NEVER satisfy the import predicate, so they are never exported.\n// - Replies: `private` replies are exported ONLY when they are import replies\n// (`author === \"import\"` AND a populated `importAuthor`) — imported Word\n// reply threads round-trip back to the file; note-authored and other private\n// replies never export. Privacy is a durable property of the reply.\n//\n// Range resolution mirrors the read paths: `refreshRange` resolves the CRDT\n// `relRange` first and falls back to flat offsets (read-only here — no Y.Map\n// writes, no transactions; a .docx save must not mutate the Y.Doc). Ranges\n// that no longer resolve are skipped with a stderr warning instead of\n// failing the save.\n//\n// Threaded replies: docx@9.6 cannot emit `commentsExtended.xml` (the part\n// Word uses for reply threading), so exportable replies are FLATTENED into\n// the comment body as attributed paragraphs. See the #1068 PR for the\n// empirical evidence and trade-offs.\n\nimport type * as Y from \"yjs\";\nimport { Y_MAP_ANNOTATION_REPLIES, Y_MAP_ANNOTATIONS } from \"../../shared/constants.js\";\nimport { sanitizeAnnotation } from \"../../shared/sanitize.js\";\nimport type { Annotation, AnnotationReply } from \"../../shared/types.js\";\nimport { extractText } from \"../mcp/document-model.js\";\nimport { refreshRange } from \"../positions.js\";\nimport { isCanonicalWordId } from \"./docx-comment-id.js\";\n\n/** A privacy-gated, range-resolved comment ready for OOXML emission. */\nexport interface ExportComment {\n /** Numeric Word `w:id`. Unique within one export. */\n id: number;\n /** Word comment author display name. */\n author: string;\n /** Word comment initials (derived from `author`). */\n initials: string;\n /** Comment creation date (from the annotation timestamp). */\n date: Date;\n /** Resolved flat-offset range start (current Y.Doc coordinates). */\n from: number;\n /** Resolved flat-offset range end (current Y.Doc coordinates). */\n to: number;\n /**\n * Comment body, one entry per Word comment paragraph. The FIRST entries are\n * always the annotation content verbatim (split on newlines) so a promoted\n * import without suggestion/replies round-trips to an identical\n * `importAnnotationId` body hash. Suggestion text and flattened replies\n * append AFTER the content.\n */\n bodyParagraphs: string[];\n}\n\n/**\n * Import round-trip predicate: an annotation that ORIGINATED from the source\n * .docx (a Word comment) and must be written back to it on save.\n *\n * Keyed on `author === \"import\"` AND a populated `importSource.author`, never on\n * `author` alone. The durable store's `.passthrough()` envelope (annotations/\n * schema.ts) enum-validates `author` but does NOT cross-validate it against\n * `importSource`, so a tampered/legacy `<hash>.json` record carrying\n * `author:\"import\"` + user content but no `importSource` must not be enough to\n * bypass the privacy gate and leak into a shared file. A genuine import always\n * populates `importSource` (docx-comments.ts injection); requiring it restores\n * belt-and-suspenders alongside the (now import-bypassed) type and audience\n * gates. (A determined local attacker who hand-edits the at-rest JSON can forge\n * both fields — but that is not an escalation: they can already edit the target\n * .docx directly.) `importSource.commentId` is deliberately NOT required — it is\n * about w:id stability, not provenance, and pre-#1068 import notes lack it.\n */\nfunction isImportRoundtrip(ann: Annotation): boolean {\n return (\n ann.author === \"import\" &&\n typeof ann.importSource?.author === \"string\" &&\n ann.importSource.author.length > 0\n );\n}\n\n/**\n * Reply analogue of `isImportRoundtrip`: an imported Word reply that round-trips\n * back to its source file. Same corroboration rationale — `author === \"import\"`\n * alone is insufficient under the `.passthrough()` envelope; require a populated\n * `importAuthor`, which the genuine injection path always sets (reply author\n * defaults to \"Unknown\", never empty — `parseCommentMetadata`). This keeps the\n * reply gate symmetric with the annotation gate.\n */\nfunction isImportReply(reply: AnnotationReply): boolean {\n return (\n reply.author === \"import\" &&\n typeof reply.importAuthor === \"string\" &&\n reply.importAuthor.length > 0\n );\n}\n\n/**\n * Returns the original Word comment id as a number when it can be reused\n * verbatim, else null. Reusing the original id keeps `importAnnotationId`\n * stable across a promote → save → re-open cycle. The canonical-form check\n * (which also rejects \"01\", whose re-imported id would differ) is the shared\n * `isCanonicalWordId` predicate — the import drift-dedup index (#1150) trusts\n * the same gate.\n */\nfunction reusableCommentId(raw: string | undefined): number | null {\n return isCanonicalWordId(raw) ? Number(raw) : null;\n}\n\nfunction authorLabel(ann: Annotation): string {\n const imported = ann.importSource?.author?.trim();\n if (imported) return imported;\n if (ann.author === \"claude\") return \"Claude\";\n if (ann.author === \"user\") return \"User\";\n return \"Imported\";\n}\n\nfunction initialsFor(label: string): string {\n const initials = label\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 3)\n .map((part) => part[0].toUpperCase())\n .join(\"\");\n return initials || \"T\";\n}\n\nfunction replyAuthorLabel(reply: AnnotationReply): string {\n if (reply.author === \"claude\") return \"Claude\";\n if (reply.author === \"user\") return \"User\";\n return reply.importAuthor?.trim() || \"Imported\";\n}\n\n/** Minimal structural guard for raw Y.Map values before sanitize/refresh. */\nfunction isAnnotationShaped(value: unknown): value is Annotation {\n if (typeof value !== \"object\" || value === null) return false;\n const v = value as Record<string, unknown>;\n const range = v.range as Record<string, unknown> | undefined;\n return (\n typeof v.id === \"string\" &&\n typeof v.content === \"string\" &&\n typeof range === \"object\" &&\n range !== null &&\n typeof range.from === \"number\" &&\n typeof range.to === \"number\"\n );\n}\n\n/**\n * Collect exportable (non-private) replies for an annotation, oldest first.\n */\nfunction exportableReplies(repliesMap: Y.Map<unknown>, annotationId: string): AnnotationReply[] {\n const out: AnnotationReply[] = [];\n repliesMap.forEach((value) => {\n if (typeof value !== \"object\" || value === null) return;\n const reply = value as AnnotationReply;\n if (reply.annotationId !== annotationId) return;\n // ADR-027/#1000: private replies never reach Claude. The .docx file\n // round-trip is a separate boundary: an imported Word reply (isImportReply)\n // is written back to the file it came from even though it's private. A\n // user-authored private reply (note-authored, or a private reply on an\n // imported comment) never exports, and an `author:\"import\"` reply lacking\n // the corroborating `importAuthor` is treated as untrusted (fail closed).\n if (reply.private === true && !isImportReply(reply)) return;\n if (typeof reply.id !== \"string\") return;\n if (typeof reply.text !== \"string\" || reply.text.length === 0) return;\n out.push(reply);\n });\n out.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0) || a.id.localeCompare(b.id));\n return out;\n}\n\n/** Split annotation/reply text into Word comment paragraphs. */\nfunction toParagraphLines(text: string): string[] {\n const lines = text.replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n return lines.length > 0 ? lines : [\"\"];\n}\n\n/**\n * Build the privacy-gated, range-resolved Word comment list for a .docx save.\n *\n * READ-ONLY on the Y.Doc: range resolution uses `refreshRange` without a map\n * argument, so no Y.Map writes and no transactions occur during export.\n *\n * Annotations whose ranges no longer resolve (CRDT anchors dead AND flat\n * offsets out of bounds/inverted) are skipped with a stderr warning — a save\n * must never fail because one annotation went stale.\n */\nexport function prepareExportComments(doc: Y.Doc): ExportComment[] {\n const map = doc.getMap(Y_MAP_ANNOTATIONS);\n if (map.size === 0) return [];\n const repliesMap = doc.getMap(Y_MAP_ANNOTATION_REPLIES);\n const docLength = extractText(doc).length;\n\n const candidates: Annotation[] = [];\n map.forEach((value) => {\n if (!isAnnotationShaped(value)) return;\n const ann = sanitizeAnnotation(value, (event) => {\n console.error(`[docx-comment-export] sanitize rewrote ${event.id}: ${event.kind}`);\n });\n // ADR-027 gate — see module header. Import round-trips (author:\"import\" +\n // importSource) bypass the type/audience/status gates: they are file content\n // written back to their own .docx, Claude-invisible throughout. User notes/\n // highlights and user-private content never satisfy the predicate, so they\n // stay excluded by every clause. Sanitize runs FIRST (above) so this sees\n // the canonicalized record; the admitted import set is exactly\n // {author:\"import\" + importSource} × {note, private-comment}.\n const importRoundtrip = isImportRoundtrip(ann);\n if (ann.type !== \"comment\" && !importRoundtrip) return; // never user notes/highlights\n if (ann.audience === \"private\" && !importRoundtrip) return; // defense-in-depth\n if (ann.status !== \"pending\" && !importRoundtrip) return; // resolved user comments drop\n candidates.push(ann);\n });\n if (candidates.length === 0) return [];\n\n // Resolve each candidate's CURRENT range: relRange first, flat fallback\n // (refreshRange does both, read-only without a map argument).\n const resolved: Array<{ ann: Annotation; from: number; to: number }> = [];\n for (const ann of candidates) {\n const refreshed = refreshRange(ann, doc);\n if (refreshed.kind === \"failed\") {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: CRDT range resolution failed`,\n );\n continue;\n }\n const { from, to } = refreshed.annotation.range;\n if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from > to) {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: invalid range [${from}, ${to}]`,\n );\n continue;\n }\n if (to > docLength) {\n console.error(\n `[docx-comment-export] Skipping comment ${ann.id}: range [${from}, ${to}] ` +\n `exceeds document length ${docLength}`,\n );\n continue;\n }\n resolved.push({ ann: refreshed.annotation, from, to });\n }\n if (resolved.length === 0) return [];\n\n // Stable output order: document position, then id.\n resolved.sort((a, b) => a.from - b.from || a.to - b.to || a.ann.id.localeCompare(b.ann.id));\n\n // Allocate w:id values. Promoted imports reuse their original Word id\n // (importAnnotationId stability); everything else gets the next free id.\n const usedIds = new Set<number>();\n const reserved = new Map<string, number>();\n for (const { ann } of resolved) {\n const original = reusableCommentId(ann.importSource?.commentId);\n if (original !== null && !usedIds.has(original)) {\n usedIds.add(original);\n reserved.set(ann.id, original);\n }\n }\n let nextId = 1;\n const allocate = (): number => {\n while (usedIds.has(nextId)) nextId++;\n usedIds.add(nextId);\n return nextId;\n };\n\n const out: ExportComment[] = [];\n for (const { ann, from, to } of resolved) {\n const label = authorLabel(ann);\n const bodyParagraphs = toParagraphLines(ann.content);\n if (ann.type === \"comment\" && ann.suggestedText) {\n bodyParagraphs.push(\"\", `Suggested replacement: ${ann.suggestedText}`);\n }\n for (const reply of exportableReplies(repliesMap, ann.id)) {\n const replyLines = toParagraphLines(reply.text);\n bodyParagraphs.push(\"\", `Reply from ${replyAuthorLabel(reply)}: ${replyLines[0]}`);\n bodyParagraphs.push(...replyLines.slice(1));\n }\n out.push({\n id: reserved.get(ann.id) ?? allocate(),\n author: label,\n initials: initialsFor(label),\n date: new Date(Number.isFinite(ann.timestamp) ? ann.timestamp : Date.now()),\n from,\n to,\n bodyParagraphs,\n });\n }\n return out;\n}\n","// Y.Doc -> .docx export (#576: v1.0 body, #1068: v1.1 Word comments).\n//\n// Production write-back engine using the `docx` npm package. Walks\n// `Y.Doc.getXmlFragment(\"default\")` and maps Tiptap node names onto the\n// `docx` package's Paragraph / Table constructors, flattening Y.XmlText\n// deltas into TextRuns with marks.\n//\n// SCOPE: body content + Word comments + footnotes. Tandem `comment`-type\n// annotations are emitted as Word comments (`comments.xml` +\n// CommentRangeStart/End markers). The privacy gate and range resolution live in\n// `docx-comment-export.ts` (ADR-027: notes and highlights are NEVER exported).\n// Footnotes captured on import (#1123 Tier-A #3) are re-emitted as real\n// `<w:footnote>` parts + `FootnoteReferenceRun`s from the off-fragment\n// Y_MAP_FOOTNOTE_BODIES map; body FORMATTING is flattened to plain text (honestly\n// reported on import), and a marked ref with no captured body falls back to a\n// plain `[N]` superscript (never a corrupt bodyless reference). NOT exported here:\n// - Tracked changes — requires a Y.Doc authorship-diff layer (deferred).\n// - Threaded comment replies — docx@9.x has no `commentsExtended.xml`\n// support; exportable replies are flattened into the comment body.\n// - Inline images — degraded to alt text (mdast-ydoc imports images as\n// inline phrasing content, not top-level <image> nodes; see the docx-npm\n// spike). Top-level <image> nodes ARE exported when present.\n//\n// COMMENT ANCHORING. Comment ranges are flat-offset based (the annotation\n// coordinate system: `extractText` semantics — heading prefixes count,\n// top-level blocks join with \\n, nested blocks separate with \\n, hardBreak\n// embeds count 1). The emitter threads a cursor (`EmitCtx.pos`) through the\n// block walk that advances EXACTLY like `extractText`/`getElementText`, and\n// splits TextRuns at comment boundaries so `w:commentRangeStart/End` land at\n// offsets the import-side walker (`docx-walker.ts#walkDocumentBody`)\n// recomputes identically. Content the exporter drops or rewrites (image alt\n// placeholders, unknown nested blocks) still advances the cursor by its\n// ORIGINAL flat length so later anchors stay aligned.\n//\n// TRUST BOUNDARY (must be preserved by any future change). A .docx Tandem\n// produces must never carry hostile relationships out into the filesystem or\n// network:\n// 1. NO r:link external image references — only inline `data:` images are\n// embedded as raw bytes via `ImageRun({ data: Buffer })`. We never pass a\n// URL / remote-image reference to `docx`.\n// 2. NO `<w:object>` embedded objects — `docx` has no public OLE API and we\n// never call any embed-object method.\n// 3. NO `targetMode=\"External\"` relationships pointing to `file://` or UNC\n// paths — hyperlinks are scrubbed to `http`/`https`/`mailto` only.\n// 4. Unknown Y.XmlElement node names fall through to a text-only paragraph,\n// never to a passthrough that could inject markup.\n//\n// All Tiptap node-name strings in this file mirror those produced by\n// `mdast-ydoc.ts` / `docx-html.ts` and consumed by the editor; they are NOT\n// Y.Map keys, so they do not require the Y_MAP_* constants (Critical Rule #1).\n\nimport {\n AlignmentType,\n CommentRangeEnd,\n CommentRangeStart,\n CommentReference,\n Document,\n ExternalHyperlink,\n FootnoteReferenceRun,\n HeadingLevel,\n type ICommentOptions,\n ImageRun,\n Packer,\n Paragraph,\n type ParagraphChild,\n ShadingType,\n Table,\n TableCell,\n TableRow,\n TextRun,\n WidthType,\n} from \"docx\";\nimport * as Y from \"yjs\";\nimport { Y_MAP_DOCUMENT_META, Y_MAP_FOOTNOTE_BODIES } from \"../../shared/constants.js\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\nimport {\n extractText,\n getElementTextLength,\n getHeadingPrefixLength,\n} from \"../mcp/document-model.js\";\nimport { type ExportComment, prepareExportComments } from \"./docx-comment-export.js\";\n\n// -- Trust-boundary helpers ---------------------------------------------------\n\nconst SAFE_LINK_PROTOCOLS = new Set([\"http:\", \"https:\", \"mailto:\"]);\n\n/**\n * Returns the URL if it is safe to embed as an ExternalHyperlink target,\n * otherwise null. Blocks `file://`, UNC paths, Windows drive paths, and any\n * non-http(s)/mailto scheme so no exported docx can carry a\n * `targetMode=\"External\"` relationship pointing at a local filesystem resource.\n */\nexport function safeHyperlinkUrl(raw: string | null | undefined): string | null {\n if (!raw) return null;\n const trimmed = raw.trim();\n if (!trimmed) return null;\n // UNC: `\\\\server\\share\\path`\n if (trimmed.startsWith(\"\\\\\\\\\")) return null;\n // Windows drive paths: `C:\\...`\n if (/^[a-zA-Z]:[\\\\/]/.test(trimmed)) return null;\n try {\n const u = new URL(trimmed);\n if (!SAFE_LINK_PROTOCOLS.has(u.protocol)) return null;\n return u.toString();\n } catch {\n // Relative or malformed -- drop it (no implicit base).\n return null;\n }\n}\n\n/**\n * Image embed gate. Only inline `data:` URIs are accepted; everything else\n * (http, https, file, UNC, relative paths) is dropped so we never produce an\n * `r:link` external image reference.\n */\nexport function safeImageEmbed(\n src: string | null | undefined,\n): { data: Buffer; type: \"png\" | \"jpg\" | \"gif\" | \"bmp\" } | null {\n if (!src) return null;\n const match = /^data:image\\/(png|jpe?g|gif|bmp);base64,([A-Za-z0-9+/=]+)$/.exec(src.trim());\n if (!match) return null;\n const rawType = match[1].toLowerCase();\n const type = (rawType === \"jpeg\" ? \"jpg\" : rawType) as \"png\" | \"jpg\" | \"gif\" | \"bmp\";\n try {\n const data = Buffer.from(match[2], \"base64\");\n if (data.length === 0) return null;\n return { data, type };\n } catch {\n return null;\n }\n}\n\n// -- Comment marker events ------------------------------------------------------\n\n/**\n * A comment range marker scheduled at a flat-text offset. `order` breaks ties\n * at equal offsets: real range ends (0) close before new ranges open (1);\n * collapsed (zero-width) ranges keep start-before-end (their end sorts at 2).\n */\ninterface CommentEvent {\n offset: number;\n order: 0 | 1 | 2;\n components: ParagraphChild[];\n}\n\n/**\n * Mutable emission context threaded through the block walk. `pos` is the\n * flat-text cursor (extractText coordinates); `events` is sorted by\n * (offset, order); `idx` is the next unflushed event.\n */\ninterface EmitCtx {\n pos: number;\n events: CommentEvent[];\n idx: number;\n /** Reconstructed footnote bodies keyed by id (read-only input, #1123). */\n footnoteBodies: Record<string, FootnoteBody>;\n /** Accumulated footnotes map for the Document constructor, keyed by numeric\n * id (output — populated as `FootnoteReferenceRun`s are emitted). */\n footnotesMap: Record<number, { children: Paragraph[] }>;\n}\n\nfunction buildCommentEvents(comments: ExportComment[]): CommentEvent[] {\n const events: CommentEvent[] = [];\n for (const c of comments) {\n events.push({ offset: c.from, order: 1, components: [new CommentRangeStart(c.id)] });\n events.push({\n offset: c.to,\n order: c.from === c.to ? 2 : 0,\n // OOXML requires the reference run (which binds the comment bubble to\n // the range) immediately after the range end marker.\n components: [\n new CommentRangeEnd(c.id),\n new TextRun({ children: [new CommentReference(c.id)] }),\n ],\n });\n }\n events.sort((a, b) => a.offset - b.offset || a.order - b.order);\n return events;\n}\n\n/** Emit every pending marker whose offset the cursor has reached. */\nfunction flushCommentEvents(emit: EmitCtx, out: ParagraphChild[]): void {\n while (emit.idx < emit.events.length && emit.events[emit.idx].offset <= emit.pos) {\n out.push(...emit.events[emit.idx].components);\n emit.idx++;\n }\n}\n\n// -- Tiptap -> docx runtime conversion ----------------------------------------\n\ninterface MarkState {\n bold?: boolean;\n italic?: boolean;\n strike?: boolean;\n code?: boolean;\n underline?: boolean;\n superscript?: boolean;\n subscript?: boolean;\n link?: { href: string; title?: string } | null;\n /** Footnote reference marker (#1123 Tier-A #3 PR 2). Emitted atomically as a\n * `FootnoteReferenceRun`; mutually exclusive with other formatting in\n * practice (the import attaches it ALONE on the `[N]` text). */\n footnoteRef?: { id: string; kind: \"footnote\" | \"endnote\" };\n}\n\ninterface InlineRun {\n text: string;\n marks: MarkState;\n}\n\n/** Walk a Y.XmlText, flattening deltas into typed runs. */\nfunction flattenXmlText(xt: Y.XmlText): InlineRun[] {\n const runs: InlineRun[] = [];\n const delta = xt.toDelta() as Array<{\n insert?: string | Record<string, unknown>;\n attributes?: Record<string, unknown>;\n }>;\n for (const op of delta) {\n if (typeof op.insert === \"string\") {\n const attrs = op.attributes ?? {};\n const marks: MarkState = {\n bold: attrs.bold != null,\n italic: attrs.italic != null,\n strike: attrs.strike != null,\n code: attrs.code != null,\n underline: attrs.underline != null,\n superscript: attrs.superscript != null,\n subscript: attrs.subscript != null,\n };\n const link = attrs.link as { href?: string; title?: string } | undefined;\n if (link?.href) {\n marks.link = { href: link.href, ...(link.title ? { title: link.title } : {}) };\n }\n const footnote = attrs[\"footnote-ref\"] as { id?: string; kind?: string } | undefined;\n if (footnote?.id) {\n marks.footnoteRef = {\n id: footnote.id,\n kind: footnote.kind === \"endnote\" ? \"endnote\" : \"footnote\",\n };\n }\n runs.push({ text: op.insert, marks });\n } else if (op.insert && typeof op.insert === \"object\") {\n // hardBreak embed -- represented as `\\n`; the emitter turns it into a\n // dedicated `<w:br/>` run (1 flat character).\n runs.push({ text: \"\\n\", marks: {} });\n }\n }\n return runs;\n}\n\nfunction makeTextRun(text: string, marks: MarkState): TextRun {\n return new TextRun({\n text,\n bold: marks.bold,\n italics: marks.italic,\n strike: marks.strike,\n underline: marks.underline ? {} : undefined,\n superScript: marks.superscript,\n subScript: marks.subscript,\n // `docx` lacks a first-class inline-code style; approximate with a\n // monospace font + light shading.\n font: marks.code ? \"Consolas\" : undefined,\n shading: marks.code ? { type: ShadingType.CLEAR, color: \"auto\", fill: \"F1F3F5\" } : undefined,\n });\n}\n\n/**\n * Emit `text` as one or more TextRuns, splitting at comment marker offsets.\n * Advances the cursor by `text.length`. Hyperlinked segments each get their\n * own ExternalHyperlink wrapper (matches the pre-#1068 one-wrapper-per-run\n * behavior; a split link still navigates from every segment).\n */\nfunction emitTextSegments(\n text: string,\n marks: MarkState,\n emit: EmitCtx,\n out: ParagraphChild[],\n): void {\n let s = text;\n while (s.length > 0) {\n flushCommentEvents(emit, out);\n const nextOffset = emit.idx < emit.events.length ? emit.events[emit.idx].offset : Infinity;\n // flush guarantees nextOffset > pos, so take >= 1 and the loop terminates.\n const take = Math.min(s.length, nextOffset - emit.pos);\n const run = makeTextRun(s.slice(0, take), marks);\n if (marks.link) {\n const safeUrl = safeHyperlinkUrl(marks.link.href);\n if (safeUrl) {\n out.push(new ExternalHyperlink({ children: [run], link: safeUrl }));\n } else {\n // Drop unsafe hyperlink, keep the text.\n out.push(run);\n }\n } else {\n out.push(run);\n }\n emit.pos += take;\n s = s.slice(take);\n }\n}\n\n/**\n * Emit a footnote reference marker (#1123 Tier-A #3 PR 2). A\n * `FootnoteReferenceRun` is atomic OOXML and the cursor must advance by the\n * marker's full flat length, so this is handled OUTSIDE the comment-split loop:\n * markers due before the glyph flush first, the single reference run emits, the\n * cursor advances by the marker text, then any marker that fell INSIDE the span\n * snaps to AFTER it (Word can't anchor inside a footnote glyph).\n */\nfunction emitFootnoteRef(r: InlineRun, emit: EmitCtx, out: ParagraphChild[]): void {\n flushCommentEvents(emit, out);\n // biome-ignore lint/style/noNonNullAssertion: caller guards r.marks.footnoteRef.\n const ref = r.marks.footnoteRef!;\n const body = ref.kind === \"footnote\" ? emit.footnoteBodies[ref.id] : undefined;\n const numId = Number(ref.id);\n if (body && Number.isInteger(numId) && numId > 0) {\n // docx auto-prepends the footnote-number run; we supply only the body text.\n emit.footnotesMap[numId] = { children: [new Paragraph(body.text)] };\n out.push(new FootnoteReferenceRun(numId));\n } else {\n // CRITICAL-1: NEVER emit a bodyless FootnoteReferenceRun — it saves fine but\n // corrupts on reopen. Fall back to the verbatim marker as a superscript run:\n // offset-neutral, lossless, and re-importable as a plain `[N]`.\n console.error(\n `[docx-footnotes] footnote ref id=${ref.id} has no reconstructable body; ` +\n \"exporting the marker as plain superscript text.\",\n );\n out.push(makeTextRun(r.text, { superscript: true }));\n }\n emit.pos += r.text.length;\n flushCommentEvents(emit, out);\n}\n\n/**\n * Emit InlineRuns, honoring marks, hyperlinks, hardBreaks, and comment\n * markers. A hardBreak (`\\n`) becomes a dedicated `<w:br/>` run AFTER the\n * preceding text — `TextRun({ text, break: 1 })` renders the break BEFORE its\n * text, which inverted hardBreak order in the v1.0 exporter (fixed here).\n */\nfunction emitInlineRuns(runs: InlineRun[], emit: EmitCtx, out: ParagraphChild[]): void {\n for (const r of runs) {\n // Footnote reference: emit atomically (never split by \\n or comment markers).\n if (r.marks.footnoteRef) {\n emitFootnoteRef(r, emit, out);\n continue;\n }\n const parts = r.text.split(\"\\n\");\n parts.forEach((part, idx) => {\n if (idx > 0) {\n // The `\\n` occupies one flat character; markers at its offset go\n // before the <w:br/> run.\n flushCommentEvents(emit, out);\n out.push(new TextRun({ break: 1 }));\n emit.pos += 1;\n }\n emitTextSegments(part, r.marks, emit, out);\n });\n }\n flushCommentEvents(emit, out);\n}\n\n/**\n * Build the ParagraphChild list for a leaf inline container (paragraph,\n * heading, …). Direct Y.XmlText children are emitted; nested Y.XmlElement\n * children are NOT exported at inline level (pre-existing behavior) but the\n * cursor still advances past their flat text (+ the 1-char separator\n * `getElementText` would insert) so later comment anchors stay aligned.\n */\nfunction inlineChildren(el: Y.XmlElement, emit: EmitCtx): ParagraphChild[] {\n const out: ParagraphChild[] = [];\n let hasPrior = false;\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) {\n emitInlineRuns(flattenXmlText(c), emit, out);\n hasPrior = true;\n } else if (c instanceof Y.XmlElement) {\n if (hasPrior) emit.pos += 1;\n emit.pos += getElementTextLength(c);\n hasPrior = true;\n }\n }\n flushCommentEvents(emit, out);\n return out;\n}\n\nconst HEADING_BY_LEVEL: Record<number, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {\n 1: HeadingLevel.HEADING_1,\n 2: HeadingLevel.HEADING_2,\n 3: HeadingLevel.HEADING_3,\n 4: HeadingLevel.HEADING_4,\n 5: HeadingLevel.HEADING_5,\n 6: HeadingLevel.HEADING_6,\n};\n\n/**\n * Node names this exporter knows how to serialize faithfully. Used by the\n * fidelity pre-flight (`detectExportFidelityIssues`) so the caller can warn\n * the user before overwriting their `.docx` with content we'd downgrade.\n * `image` is \"known\" structurally but degrades to alt text unless the src is\n * an inline `data:` URI — the fidelity check flags that separately.\n */\nconst KNOWN_BLOCK_NODES = new Set([\n \"heading\",\n \"paragraph\",\n \"blockquote\",\n \"bulletList\",\n \"orderedList\",\n \"listItem\",\n \"codeBlock\",\n \"horizontalRule\",\n \"image\",\n \"table\",\n \"tableRow\",\n \"tableCell\",\n \"tableHeader\",\n]);\n\nconst BULLET_REF = \"tandem-bullet\";\nconst NUMBERED_REF = \"tandem-numbered\";\n\ninterface BlockCtx {\n numberingDepth: number;\n blockquoteDepth: number;\n}\n\nfunction emptyCtx(): BlockCtx {\n return { numberingDepth: 0, blockquoteDepth: 0 };\n}\n\nfunction blockToDocx(el: Y.XmlElement, ctx: BlockCtx, emit: EmitCtx): Array<Paragraph | Table> {\n const name = el.nodeName;\n switch (name) {\n case \"heading\": {\n const level = Number(el.getAttribute(\"level\") ?? 1);\n const heading = HEADING_BY_LEVEL[level] ?? HeadingLevel.HEADING_1;\n // Flat offsets include the markdown-style heading prefix (\"## \").\n // Markers can't render inside the virtual prefix; any event there\n // flushes at the heading text start (the import walker also counts the\n // prefix before the first run, so a clamp is the closest valid anchor).\n // getHeadingPrefixLength is the SAME function the coordinate system\n // uses (extractText/resolveToElement) — keep the cursor consistent.\n emit.pos += getHeadingPrefixLength(el);\n return [new Paragraph({ heading, children: inlineChildren(el, emit) })];\n }\n case \"paragraph\": {\n return [\n new Paragraph({\n children: inlineChildren(el, emit),\n indent: ctx.blockquoteDepth > 0 ? { left: 720 * ctx.blockquoteDepth } : undefined,\n }),\n ];\n }\n case \"blockquote\": {\n const out: Array<Paragraph | Table> = [];\n const childCtx = { ...ctx, blockquoteDepth: ctx.blockquoteDepth + 1 };\n let hasPrior = false;\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) {\n // Direct text inside a blockquote isn't exported (pre-existing);\n // advance the cursor past it.\n emit.pos += c.length;\n hasPrior = true;\n } else if (c instanceof Y.XmlElement) {\n if (hasPrior) emit.pos += 1;\n out.push(...blockToDocx(c, childCtx, emit));\n hasPrior = true;\n }\n }\n return out;\n }\n case \"bulletList\":\n case \"orderedList\": {\n const kind: \"bullet\" | \"number\" = name === \"orderedList\" ? \"number\" : \"bullet\";\n const out: Array<Paragraph | Table> = [];\n let hasPriorItem = false;\n for (let i = 0; i < el.length; i++) {\n const item = el.get(i);\n if (item instanceof Y.XmlText) {\n emit.pos += item.length;\n hasPriorItem = true;\n continue;\n }\n if (!(item instanceof Y.XmlElement)) continue;\n if (hasPriorItem) emit.pos += 1;\n hasPriorItem = true;\n if (item.nodeName !== \"listItem\") {\n // Skipped in output (pre-existing); cursor still advances.\n emit.pos += getElementTextLength(item);\n continue;\n }\n let hasPriorChild = false;\n for (let j = 0; j < item.length; j++) {\n const child = item.get(j);\n if (child instanceof Y.XmlText) {\n emit.pos += child.length;\n hasPriorChild = true;\n continue;\n }\n if (!(child instanceof Y.XmlElement)) continue;\n if (hasPriorChild) emit.pos += 1;\n hasPriorChild = true;\n if (child.nodeName === \"paragraph\") {\n out.push(\n new Paragraph({\n children: inlineChildren(child, emit),\n numbering: {\n reference: kind === \"number\" ? NUMBERED_REF : BULLET_REF,\n level: ctx.numberingDepth,\n },\n }),\n );\n } else if (child.nodeName === \"bulletList\" || child.nodeName === \"orderedList\") {\n out.push(\n ...blockToDocx(child, { ...ctx, numberingDepth: ctx.numberingDepth + 1 }, emit),\n );\n } else {\n out.push(...blockToDocx(child, ctx, emit));\n }\n }\n }\n return out;\n }\n case \"codeBlock\": {\n const inner = readXmlTextChild(el);\n const lines = inner.split(\"\\n\");\n const codeMarks: MarkState = { code: true };\n return lines.map((line, idx) => {\n if (idx > 0) emit.pos += 1; // the `\\n` between lines\n const children: ParagraphChild[] = [];\n emitTextSegments(line, codeMarks, emit, children);\n flushCommentEvents(emit, children);\n return new Paragraph({ children });\n });\n }\n case \"horizontalRule\": {\n const children: ParagraphChild[] = [];\n flushCommentEvents(emit, children);\n return [\n new Paragraph({\n border: { bottom: { color: \"auto\", space: 1, style: \"single\", size: 6 } },\n children,\n }),\n ];\n }\n case \"image\": {\n // Images contribute 0 flat characters (no XmlText); flush any markers\n // due at this position into the emitted paragraph.\n const markers: ParagraphChild[] = [];\n flushCommentEvents(emit, markers);\n const src = el.getAttribute(\"src\");\n const embed = safeImageEmbed(src);\n if (!embed) {\n // Trust boundary: drop image rather than emit r:link.\n const alt = el.getAttribute(\"alt\") ?? \"\";\n return [\n new Paragraph({\n children: [\n ...markers,\n new TextRun({ text: alt ? `[image: ${alt}]` : \"[image]\", italics: true }),\n ],\n }),\n ];\n }\n return [\n new Paragraph({\n alignment: AlignmentType.CENTER,\n children: [\n ...markers,\n new ImageRun({\n data: embed.data,\n transformation: { width: 480, height: 360 },\n type: embed.type,\n }),\n ],\n }),\n ];\n }\n case \"table\":\n return [tableToDocx(el, emit)];\n default: {\n // Unknown node name — emit text-only, never a passthrough (trust rule #4).\n return [new Paragraph({ children: inlineChildren(el, emit) })];\n }\n }\n}\n\n/**\n * Read a table-cell span attribute (`colspan`/`rowspan`) as a docx span count.\n * Returns undefined for absent/≤1/non-integer values so the caller can omit the\n * option entirely (a span of 1 is the default and must not be emitted).\n */\nfunction readSpanAttr(cell: Y.XmlElement, attr: \"colspan\" | \"rowspan\"): number | undefined {\n const raw = cell.getAttribute(attr);\n if (raw == null) return undefined;\n const n = Number(raw);\n return Number.isInteger(n) && n > 1 ? n : undefined;\n}\n\nfunction tableToDocx(tableEl: Y.XmlElement, emit: EmitCtx): Table {\n const rows: TableRow[] = [];\n let hasPriorRow = false;\n for (let i = 0; i < tableEl.length; i++) {\n const row = tableEl.get(i);\n if (row instanceof Y.XmlText) {\n emit.pos += row.length;\n hasPriorRow = true;\n continue;\n }\n if (!(row instanceof Y.XmlElement)) continue;\n if (hasPriorRow) emit.pos += 1;\n hasPriorRow = true;\n if (row.nodeName !== \"tableRow\") {\n emit.pos += getElementTextLength(row);\n continue;\n }\n const cells: TableCell[] = [];\n let hasPriorCell = false;\n for (let j = 0; j < row.length; j++) {\n const cell = row.get(j);\n if (cell instanceof Y.XmlText) {\n emit.pos += cell.length;\n hasPriorCell = true;\n continue;\n }\n if (!(cell instanceof Y.XmlElement)) continue;\n if (hasPriorCell) emit.pos += 1;\n hasPriorCell = true;\n const cellChildren: Paragraph[] = [];\n let hasPriorBlock = false;\n for (let k = 0; k < cell.length; k++) {\n const c = cell.get(k);\n if (c instanceof Y.XmlText) {\n emit.pos += c.length;\n hasPriorBlock = true;\n continue;\n }\n if (!(c instanceof Y.XmlElement)) continue;\n if (hasPriorBlock) emit.pos += 1;\n hasPriorBlock = true;\n if (c.nodeName === \"paragraph\") {\n cellChildren.push(new Paragraph({ children: inlineChildren(c, emit) }));\n } else {\n // Skipped in output (pre-existing); cursor still advances.\n emit.pos += getElementTextLength(c);\n }\n }\n if (cellChildren.length === 0) cellChildren.push(new Paragraph({ children: [] }));\n // Carry a horizontal merge through to Word. The import preserves `colspan`\n // on the cell element; without `columnSpan` here the export silently\n // un-merges the cell (the priority loss the 0d scoreboard pinned).\n // `rowspan` is intentionally NOT carried yet — docx vertical merge needs\n // continuation cells on the rows below, a separate change.\n const columnSpan = readSpanAttr(cell, \"colspan\");\n cells.push(new TableCell({ children: cellChildren, ...(columnSpan ? { columnSpan } : {}) }));\n }\n rows.push(new TableRow({ children: cells }));\n }\n return new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } });\n}\n\nfunction readXmlTextChild(el: Y.XmlElement): string {\n let out = \"\";\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlText) out += c.toString();\n }\n return out;\n}\n\n// -- Fidelity pre-flight ------------------------------------------------------\n\n/**\n * Inspect the top-level document body and report fidelity concerns the caller\n * should surface to the user BEFORE overwriting their `.docx`. The export is\n * body + comments: anything mammoth dropped on import (footnotes,\n * headers/footers, tracked changes) is already gone from the Y.Doc and will\n * not be re-exported, and a couple of supported nodes are approximated. We\n * flag:\n *\n * - unknown node names (downgraded to plain text)\n * - non-`data:` images (downgraded to alt text — trust rule #1)\n *\n * Returns a deduped, human-readable list of warnings (empty = clean export).\n */\nexport function detectExportFidelityIssues(doc: Y.Doc): string[] {\n const warnings = new Set<string>();\n const fragment = doc.getXmlFragment(\"default\");\n const walk = (el: Y.XmlElement): void => {\n if (!KNOWN_BLOCK_NODES.has(el.nodeName)) {\n warnings.add(`unsupported \"${el.nodeName}\" block (exported as plain text)`);\n }\n if (el.nodeName === \"image\") {\n const src = el.getAttribute(\"src\");\n if (!safeImageEmbed(src)) {\n warnings.add(\"an image without embedded data (exported as a text placeholder)\");\n }\n }\n for (let i = 0; i < el.length; i++) {\n const c = el.get(i);\n if (c instanceof Y.XmlElement) walk(c);\n }\n };\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (node instanceof Y.XmlElement) walk(node);\n }\n return [...warnings];\n}\n\n// -- Public API ---------------------------------------------------------------\n\n/**\n * Read the reconstructed footnote bodies the import wrote off-fragment to\n * Y_MAP_FOOTNOTE_BODIES (#1123 Tier-A #3 PR 2). Defensive shape validation: the\n * map is server-written but this read path stays robust to a malformed value\n * (an unreconstructable id simply produces no footnote — the emitter's\n * bodyless-ref fallback then keeps the marker as plain text).\n */\nfunction readFootnoteBodies(doc: Y.Doc): Record<string, FootnoteBody> {\n const raw = doc.getMap(Y_MAP_DOCUMENT_META).get(Y_MAP_FOOTNOTE_BODIES);\n if (!raw || typeof raw !== \"object\") return {};\n const out: Record<string, FootnoteBody> = {};\n for (const [id, value] of Object.entries(raw as Record<string, unknown>)) {\n if (value && typeof value === \"object\" && typeof (value as FootnoteBody).text === \"string\") {\n out[id] = {\n text: (value as FootnoteBody).text,\n hadFormatting: Boolean((value as FootnoteBody).hadFormatting),\n };\n }\n }\n return out;\n}\n\nfunction toCommentOptions(c: ExportComment): ICommentOptions {\n return {\n id: c.id,\n author: c.author,\n initials: c.initials,\n date: c.date,\n children: c.bodyParagraphs.map(\n (text) => new Paragraph({ children: text.length > 0 ? [new TextRun(text)] : [] }),\n ),\n };\n}\n\n/**\n * Convert a Tandem Y.Doc into a `.docx` byte buffer (body + Word comments).\n *\n * `comment`-type annotations stored in the doc's annotation map are emitted\n * as Word comments anchored to their CURRENT ranges (relRange-first\n * resolution; see `docx-comment-export.ts` for the ADR-027 privacy gate —\n * notes and highlights are never exported). External hyperlinks, file paths,\n * and remote images are filtered by the trust-boundary helpers so the output\n * cannot exfiltrate references. Tracked changes and authorship coloring are\n * intentionally NOT emitted — see the module header.\n *\n * READ-ONLY on the Y.Doc: no Y.Map writes, no transactions.\n */\nexport async function exportYDocToDocx(doc: Y.Doc): Promise<Buffer> {\n const comments = prepareExportComments(doc);\n const emit: EmitCtx = {\n pos: 0,\n events: buildCommentEvents(comments),\n idx: 0,\n footnoteBodies: readFootnoteBodies(doc),\n footnotesMap: {},\n };\n\n const fragment = doc.getXmlFragment(\"default\");\n const children: Array<Paragraph | Table> = [];\n const ctx = emptyCtx();\n let first = true;\n for (let i = 0; i < fragment.length; i++) {\n const node = fragment.get(i);\n if (!(node instanceof Y.XmlElement)) continue;\n if (!first) emit.pos += 1; // top-level \\n separator (extractText join)\n first = false;\n children.push(...blockToDocx(node, ctx, emit));\n }\n\n // Defensive drain: prepareExportComments bounds-checks every range against\n // the document length, so leftovers indicate cursor drift. Emit them in a\n // trailing paragraph anyway — a comment listed in comments.xml without its\n // body markers would be structurally broken — and warn loudly.\n if (emit.idx < emit.events.length) {\n const leftovers: ParagraphChild[] = [];\n while (emit.idx < emit.events.length) {\n leftovers.push(...emit.events[emit.idx].components);\n emit.idx++;\n }\n console.error(\n `[docx-export] ${leftovers.length} comment marker component(s) fell past the end of ` +\n \"the document; appended to a trailing paragraph. Comment anchors may be misplaced.\",\n );\n children.push(new Paragraph({ children: leftovers }));\n }\n\n // Cheap invariant check (only when anchoring mattered): the emission cursor\n // must land exactly on the flat-text length, or anchors drifted.\n if (comments.length > 0) {\n const expected = extractText(doc).length;\n if (emit.pos !== expected) {\n console.error(\n `[docx-export] comment-anchor cursor drift: walked ${emit.pos} chars but the ` +\n `document flat text is ${expected} — exported comment anchors may be misplaced`,\n );\n }\n }\n\n // `docx` requires at least one section child; emit an empty paragraph for a\n // blank document so Packer doesn't produce a malformed file.\n if (children.length === 0) children.push(new Paragraph({ children: [] }));\n\n const document = new Document({\n creator: \"Tandem\",\n ...(comments.length > 0 ? { comments: { children: comments.map(toCommentOptions) } } : {}),\n ...(Object.keys(emit.footnotesMap).length > 0 ? { footnotes: emit.footnotesMap } : {}),\n numbering: {\n config: [\n {\n reference: BULLET_REF,\n levels: [0, 1, 2, 3, 4, 5].map((lvl) => ({\n level: lvl,\n format: \"bullet\",\n text: \"•\",\n alignment: AlignmentType.LEFT,\n style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },\n })),\n },\n {\n reference: NUMBERED_REF,\n levels: [0, 1, 2, 3, 4, 5].map((lvl) => ({\n level: lvl,\n format: \"decimal\",\n text: `%${lvl + 1}.`,\n alignment: AlignmentType.LEFT,\n style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } },\n })),\n },\n ],\n },\n sections: [{ properties: {}, children }],\n });\n\n return Packer.toBuffer(document);\n}\n","// Footnote/endnote CAPTURE for the import honesty + reconstruction layers\n// (Tier-A #3). mammoth flattens Word footnotes/endnotes to a trailing <ol> and\n// emits NO warning, so the degradation is otherwise SILENT. This module reads\n// the real notes directly from the .docx ZIP (the same JSZip + htmlparser2 path\n// the comment importer uses) so the import path can BOTH surface an honest\n// FidelityReport line AND — for footnotes — capture the body text so the export\n// can re-emit a real `<w:footnote>` (PR 2 reconstruction).\n//\n// It only READS — no rels, no external refs, no writes (security posture\n// identical to the comment path; ratified in the security review of\n// .claude/plans/docx-footnote-honesty.md). Footnote BODY TEXT captured here is\n// document content and is threaded into the Y.Doc, but is NEVER routed into\n// `footnoteLossLines` (see the redaction note there).\n\nimport type { ChildNode, Element } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport type { FootnoteBody } from \"../../shared/types.js\";\nimport { findAllByName, getAttr, getTextContent, isElement } from \"./docx-walker.js\";\n\n/**\n * Footnote bodies (keyed by OOXML footnote id — the same id mammoth puts in its\n * `#footnote-N` href) plus the endnote count. Footnotes carry bodies (PR 2\n * reconstructs them); endnotes are count-only (still degrade to a list, honestly\n * reported — endnote reconstruction is a deferred fast-follow).\n */\nexport interface DocxNotes {\n footnotes: Record<string, FootnoteBody>;\n endnotes: number;\n}\n\n// Word ALWAYS embeds these structural sentinel notes in word/footnotes.xml and\n// word/endnotes.xml even when the document has ZERO real footnotes (empirically\n// confirmed: the docx package emits BOTH parts, each with a `separator` and a\n// `continuationSeparator` note, for every document). A \"real\" note is one whose\n// `w:type` is NONE of these — i.e. the attribute is absent (defaults to the\n// \"normal\" type per OOXML ST_FtnEdn) or carries a non-structural value.\n//\n// We EXCLUDE this set rather than allow-list \"normal\"/absent so an unknown or\n// future structural type is treated as a REAL note (over-warn) instead of being\n// silently swallowed — honesty is the priority. A future OOXML structural type\n// would surface a (harmless) \"footnotes flattened\" warning; do NOT \"fix\" that by\n// flipping to an allowlist, which would risk silently dropping a real footnote.\nconst STRUCTURAL_NOTE_TYPES = new Set([\"separator\", \"continuationSeparator\", \"continuationNotice\"]);\n\n// Body-formatting markers we DROP on import (the body is flattened to plain\n// text in PR 2). Presence drives the count-only honesty line; rich-body\n// fidelity is a deferred fast-follow. `<w:rStyle>` (the footnote-number style)\n// is deliberately NOT here — it's structural, not body formatting.\nconst FORMATTING_ELEMENTS = new Set([\"w:b\", \"w:i\", \"w:u\", \"w:hyperlink\"]);\n\n/**\n * Collect real (non-structural) note Elements from one notes part, or [] if the\n * part is absent/unreadable. Shared by footnote + endnote extraction.\n */\nasync function collectRealNotes(\n zip: JSZip,\n partPath: string,\n elementName: string,\n): Promise<Element[]> {\n const file = zip.file(partPath);\n if (!file) return []; // Common clean case: a doc with no notes omits the part.\n try {\n const xml = await file.async(\"text\");\n // xmlMode preserves the `w:` prefix on both element names and attributes\n // (same trusted parser as docx-walker / docx-apply); htmlparser2 does not\n // resolve DOCTYPE/external entities, so there is no XXE surface.\n const doc = parseDocument(xml, { xmlMode: true });\n return findAllByName(elementName, doc.children).filter((note) => {\n const type = getAttr(note, \"w:type\");\n return type === undefined || !STRUCTURAL_NOTE_TYPES.has(type);\n });\n } catch (err) {\n // Present but unreadable. Degrade to [] (never block import) but leave a\n // breadcrumb — an honesty feature must not silently conflate \"couldn't read\n // the notes part\" with \"no notes\". htmlparser2 is non-throwing, so this is\n // nearly unreachable (only a corrupt ZIP entry trips it) and the user still\n // sees the flattened content, so a user-facing line for this case is deferred.\n console.error(`[docx-footnotes] failed to analyze ${partPath}:`, err);\n return [];\n }\n}\n\n/** Whether a note subtree carries body formatting we flatten to plain text. */\nfunction noteHadFormatting(note: Element): boolean {\n let paragraphs = 0;\n let formatted = false;\n const walk = (nodes: ChildNode[]): void => {\n for (const node of nodes) {\n if (!isElement(node)) continue;\n if (node.name === \"w:p\") paragraphs++;\n if (FORMATTING_ELEMENTS.has(node.name)) formatted = true;\n walk(node.children);\n }\n };\n walk(note.children);\n return formatted || paragraphs > 1;\n}\n\n/**\n * Parse real footnote bodies + count real endnotes from an UNTRUSTED .docx\n * buffer. Never throws: a non-ZIP/corrupt buffer degrades to empty (mammoth, run\n * in parallel, surfaces a genuinely-broken file — note honesty is moot when the\n * import itself fails).\n */\nexport async function parseDocxFootnotes(buffer: Buffer): Promise<DocxNotes> {\n let zip: JSZip;\n try {\n zip = await JSZip.loadAsync(buffer);\n } catch (err) {\n console.error(\"[docx-footnotes] could not open .docx archive:\", err);\n return { footnotes: {}, endnotes: 0 };\n }\n const [footnoteEls, endnoteEls] = await Promise.all([\n collectRealNotes(zip, \"word/footnotes.xml\", \"w:footnote\"),\n collectRealNotes(zip, \"word/endnotes.xml\", \"w:endnote\"),\n ]);\n const footnotes: Record<string, FootnoteBody> = {};\n for (const note of footnoteEls) {\n const id = getAttr(note, \"w:id\");\n if (id === undefined) continue; // a real footnote always carries an id\n footnotes[id] = { text: getTextContent(note), hadFormatting: noteHadFormatting(note) };\n }\n return { footnotes, endnotes: endnoteEls.length };\n}\n\n/**\n * The reconstruction partition for the captured footnotes (from\n * `reconcileFootnoteIds`): ids that WILL reconstruct as real footnotes vs ids\n * that won't (an orphaned definition with no inline ref, or a mammoth-format\n * drift). Drives an honest loss line per outcome.\n */\nexport interface FootnoteReconciliation {\n reconstructed: string[];\n dropped: string[];\n}\n\n/**\n * Honest, user-facing FidelityReport lines for notes.\n *\n * INPUTS ARE COUNTS/FLAGS/IDS ONLY — never thread the captured footnote body\n * text through here. These lines bypass BOTH `summarizeMammothMessages`'\n * redaction AND the `MAX_WARNING_LINE_LENGTH` clamp (docx.ts), which is safe\n * ONLY because every line below is a fixed string plus an integer count.\n * Threading body text would reintroduce the user-content-leak vector.\n *\n * Post-PR-2 contract (HIGH-2): footnotes that RECONSTRUCT round-trip as real\n * `<w:footnote>` parts, so they get NO structural-loss line — at most a\n * count-only body-FORMATTING line (we store plain text). A footnote that fails\n * reconciliation degrades (orphan → absent; drift → trailing list), so it gets\n * an honest structural line rather than being silently claimed \"preserved\" — the\n * partition is computed pre-`apply` from the same reconciliation `htmlToYDoc`\n * runs. Endnotes always degrade to a trailing list (reconstruction deferred).\n */\nexport function footnoteLossLines(\n notes: DocxNotes,\n reconciliation: FootnoteReconciliation,\n): string[] {\n const lines: string[] = [];\n // Reconstructed footnotes whose body carried formatting we flattened.\n const formattingFlattened = reconciliation.reconstructed.filter(\n (id) => notes.footnotes[id]?.hadFormatting,\n ).length;\n if (formattingFlattened > 0) {\n const n = formattingFlattened;\n lines.push(\n `${n === 1 ? \"1 footnote\" : `${n} footnotes`} preserved, but body formatting ` +\n `(bold/italic/links or multiple paragraphs) was simplified to plain text`,\n );\n }\n // Captured footnotes that won't reconstruct — degraded, NOT preserved.\n if (reconciliation.dropped.length > 0) {\n const n = reconciliation.dropped.length;\n lines.push(\n `${n === 1 ? \"1 footnote\" : `${n} footnotes`} couldn't be reconstructed and ` +\n `won't be preserved as footnotes on save`,\n );\n }\n if (notes.endnotes > 0) {\n const n = notes.endnotes;\n lines.push(\n `${n === 1 ? \"1 endnote\" : `${n} endnotes`} flattened to a trailing list — ` +\n `endnote markers and links aren't preserved on save`,\n );\n }\n return lines;\n}\n","// Apply accepted suggestions to a .docx as tracked changes (w:del + w:ins).\n//\n// Uses the shared walker (docx-walker.ts) to map flat-text offsets into\n// the XML DOM, then mutates the DOM in-place before serializing back to ZIP.\n\nimport render from \"dom-serializer\";\nimport type { ChildNode } from \"domhandler\";\nimport { Element, Text } from \"domhandler\";\nimport { parseDocument } from \"htmlparser2\";\nimport JSZip from \"jszip\";\nimport {\n findAllByName,\n getAttr,\n isElement,\n type TextHit,\n walkDocumentBody,\n} from \"./docx-walker.js\";\n\n/** Element names inside <w:r> that would produce malformed XML if split. */\nconst COMPLEX_RUN_ELEMENTS = new Set([\n \"w:footnoteReference\",\n \"w:endnoteReference\",\n \"w:drawing\",\n \"w:pict\",\n \"w:fldChar\",\n]);\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface OffsetEntry {\n run: Element;\n textNode: Element;\n /** Character index within the textNode's text content. */\n charIndex: number;\n paragraph: Element;\n paragraphId?: string;\n}\n\nexport interface OffsetMap {\n get(offset: number): OffsetEntry | undefined;\n flatText: string;\n totalLength: number;\n /** The parsed <w:body> element — same DOM tree as all OffsetEntry references. */\n body: Element;\n /** The full parsed document (parent of body). */\n doc: ReturnType<typeof parseDocument>;\n}\n\nexport interface SuggestionInput {\n from: number;\n to: number;\n newText: string;\n author: string;\n date: string;\n revisionId: number;\n}\n\nexport interface ApplyResult {\n ok: boolean;\n reason?: string;\n}\n\nexport interface AcceptedSuggestion {\n id: string;\n from: number;\n to: number;\n newText: string;\n textSnapshot?: string;\n /** Word comment ID if this suggestion overlaps an imported comment. */\n importCommentId?: string;\n}\n\nexport interface ApplyOptions {\n author: string;\n ydocFlatText: string;\n date?: string;\n}\n\nexport interface ApplyOutput {\n buffer: Buffer;\n applied: number;\n rejected: number;\n rejectedDetails: Array<{ id: string; reason: string }>;\n commentsResolved: number;\n}\n\n// ---------------------------------------------------------------------------\n// buildOffsetMap\n// ---------------------------------------------------------------------------\n\n/**\n * Walk the document body and build a lookup from flat-text offset to the\n * corresponding XML DOM position (run, text node, char index within node).\n */\nexport function buildOffsetMap(xml: string, targetOffsets: Set<number>): OffsetMap {\n const entries = new Map<number, OffsetEntry>();\n\n // Collect text hits so we can resolve offsets after the walk\n const hits: TextHit[] = [];\n\n const { totalLength, flatText } = walkDocumentBody(xml, {\n onText(hit) {\n hits.push(hit);\n },\n });\n\n // For each target offset, find the text hit that contains it\n for (const offset of targetOffsets) {\n // Special case: end-of-document offset\n if (offset === totalLength && hits.length > 0) {\n const lastHit = hits[hits.length - 1];\n entries.set(offset, {\n run: lastHit.run,\n textNode: lastHit.textNode,\n charIndex: lastHit.text.length,\n paragraph: lastHit.paragraph,\n paragraphId: lastHit.paragraphId,\n });\n continue;\n }\n\n for (let i = 0; i < hits.length; i++) {\n const hit = hits[i];\n const start = hit.offsetStart;\n const end = start + hit.text.length;\n if (offset >= start && offset < end) {\n entries.set(offset, {\n run: hit.run,\n textNode: hit.textNode,\n charIndex: offset - start,\n paragraph: hit.paragraph,\n paragraphId: hit.paragraphId,\n });\n break;\n }\n // Allow offset === end for the last character boundary of a text node,\n // but only if no subsequent hit starts at that offset (prefer the start\n // of the next node).\n if (offset === end) {\n const nextHit = hits[i + 1];\n if (!nextHit || nextHit.offsetStart !== offset) {\n entries.set(offset, {\n run: hit.run,\n textNode: hit.textNode,\n charIndex: hit.text.length,\n paragraph: hit.paragraph,\n paragraphId: hit.paragraphId,\n });\n break;\n }\n }\n }\n }\n\n // Recover the walker's parsed DOM by walking up the parent chain from hits.\n // The walker parses internally; all hit nodes share the same DOM tree.\n let walkerBody: Element;\n let walkerDoc: ReturnType<typeof parseDocument>;\n if (hits.length > 0) {\n // Walk up from the first paragraph to find body and document\n let node = hits[0].paragraph.parent;\n while (node && isElement(node) && node.name !== \"w:body\") {\n node = node.parent;\n }\n walkerBody = node as Element;\n if (!walkerBody || walkerBody.name !== \"w:body\") {\n throw new Error(\"Could not recover w:body from walker's parsed DOM\");\n }\n walkerDoc = walkerBody.parent as unknown as ReturnType<typeof parseDocument>;\n } else {\n // No hits — parse ourselves (no entries reference it anyway)\n const doc = parseDocument(xml, { xmlMode: true });\n const bodyElements = findAllByName(\"w:body\", doc.children);\n walkerBody = bodyElements[0] ?? new Element(\"w:body\", {});\n walkerDoc = doc;\n }\n\n return {\n get(offset: number) {\n return entries.get(offset);\n },\n flatText,\n totalLength,\n body: walkerBody,\n doc: walkerDoc,\n };\n}\n\n// ---------------------------------------------------------------------------\n// DOM helpers\n// ---------------------------------------------------------------------------\n\n/** Get the text content of a w:t or w:delText element. */\nfunction getNodeText(node: Element): string {\n for (const child of node.children) {\n if (child.type === \"text\") return (child as unknown as Text).data;\n }\n return \"\";\n}\n\n/** Set the text content of a w:t or w:delText element. */\nfunction setNodeText(node: Element, text: string): void {\n const textChild = new Text(text);\n (textChild as ChildNode).parent = node;\n node.children = [textChild];\n // Preserve spaces if needed\n if (text.startsWith(\" \") || text.endsWith(\" \")) {\n node.attribs[\"xml:space\"] = \"preserve\";\n } else {\n delete node.attribs[\"xml:space\"];\n }\n}\n\n/** Clone a w:rPr element by serializing and re-parsing. */\nfunction cloneRPr(rPr: Element): Element {\n const serialized = render(rPr, { xmlMode: true });\n const doc = parseDocument(serialized, { xmlMode: true });\n return doc.children[0] as Element;\n}\n\n/** Find the w:rPr child of a w:r run, if any. */\nfunction findRPr(run: Element): Element | undefined {\n for (const child of run.children) {\n if (isElement(child) && child.name === \"w:rPr\") return child;\n }\n return undefined;\n}\n\n/** Build a new w:r element with optional rPr and a text element. */\nfunction buildRun(\n textElementName: \"w:t\" | \"w:delText\",\n text: string,\n rPrSource?: Element,\n): Element {\n const children: ChildNode[] = [];\n\n if (rPrSource) {\n const cloned = cloneRPr(rPrSource);\n children.push(cloned);\n }\n\n const textChild = new Text(text);\n const attribs: Record<string, string> = {};\n if (text.startsWith(\" \") || text.endsWith(\" \")) {\n attribs[\"xml:space\"] = \"preserve\";\n }\n const textNode = new Element(textElementName, attribs, [textChild]);\n (textChild as ChildNode).parent = textNode;\n children.push(textNode);\n\n const run = new Element(\"w:r\", {}, children);\n for (const child of children) {\n (child as ChildNode).parent = run;\n }\n return run;\n}\n\n/** Insert a node into a parent's children array at a given index. */\nfunction insertChild(parent: Element, index: number, node: ChildNode): void {\n parent.children.splice(index, 0, node);\n node.parent = parent;\n}\n\n/** Remove a node from its parent's children array. */\nfunction removeChild(node: ChildNode): void {\n if (!node.parent) return;\n const parent = node.parent as Element;\n const idx = parent.children.indexOf(node);\n if (idx >= 0) parent.children.splice(idx, 1);\n node.parent = null;\n}\n\n// ---------------------------------------------------------------------------\n// applySingleSuggestion\n// ---------------------------------------------------------------------------\n\n/**\n * Apply a single tracked-change suggestion to the document body.\n *\n * Wraps the original text in `<w:del>` (using `<w:delText>`) and inserts\n * `<w:ins>` with the replacement. Inherits `<w:rPr>` from the first deleted run.\n */\nexport function applySingleSuggestion(\n offsetMap: OffsetMap,\n suggestion: SuggestionInput,\n): ApplyResult {\n const { from, to, newText, author, date, revisionId } = suggestion;\n\n const fromEntry = offsetMap.get(from);\n const toEntry = offsetMap.get(to);\n\n if (!fromEntry || !toEntry) {\n return { ok: false, reason: `Could not resolve offsets: from=${from} to=${to}` };\n }\n\n if (fromEntry.paragraph !== toEntry.paragraph) {\n return { ok: false, reason: \"Cross-paragraph suggestions not yet supported\" };\n }\n\n const paragraph = fromEntry.paragraph;\n\n // Collect all w:r runs between from and to within this paragraph.\n // We need to find runs that contain text in the [from, to) range.\n // Strategy: split boundary runs if needed, then wrap interior runs.\n\n // Step 1: Split the \"from\" run if charIndex > 0\n if (fromEntry.charIndex > 0) {\n splitRun(fromEntry.run, fromEntry.textNode, fromEntry.charIndex, paragraph);\n // After split, fromEntry.run is the \"before\" part; the text we want\n // starts in the newly created run (next sibling).\n const idx = paragraph.children.indexOf(fromEntry.run);\n const nextRun = paragraph.children[idx + 1];\n if (!nextRun || !isElement(nextRun) || nextRun.name !== \"w:r\") {\n return { ok: false, reason: \"Split failed: no next run after from-split\" };\n }\n // Update fromEntry to point to the new run\n fromEntry.run = nextRun;\n const nextTextNode = findTextNode(nextRun);\n if (!nextTextNode) {\n return { ok: false, reason: \"Run contains no text element\" };\n }\n fromEntry.textNode = nextTextNode;\n fromEntry.charIndex = 0;\n }\n\n // Step 2: Split the \"to\" run if charIndex < text length\n const toText = getNodeText(toEntry.textNode);\n if (toEntry.charIndex < toText.length && toEntry.charIndex > 0) {\n splitRun(toEntry.run, toEntry.textNode, toEntry.charIndex, paragraph);\n // After split, toEntry.run has text [0..charIndex), and the rest is in a new run.\n // We want to include toEntry.run in the deletion (it has the first part).\n // toEntry now correctly points to the run ending at the split point.\n } else if (toEntry.charIndex === 0) {\n // The \"to\" offset is at the start of this run — don't include this run.\n // We delete everything up to but not including toEntry.run.\n }\n\n // Step 3: Collect all runs between fromEntry.run and toEntry.run (inclusive/exclusive)\n const runsToDelete: Element[] = [];\n let collecting = false;\n\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") {\n if (collecting && child === toEntry.run) break;\n continue;\n }\n\n if (child === fromEntry.run) {\n collecting = true;\n }\n\n if (collecting) {\n if (toEntry.charIndex === 0 && child === toEntry.run) {\n // Don't include this run — \"to\" is at its start\n break;\n }\n runsToDelete.push(child);\n if (child === toEntry.run) {\n break;\n }\n }\n }\n\n if (runsToDelete.length === 0) {\n // Edge case: from === to (insertion only, no deletion)\n if (from === to && newText.length > 0) {\n const insertionPoint = paragraph.children.indexOf(fromEntry.run);\n const rPr = findRPr(fromEntry.run);\n const insRun = buildRun(\"w:t\", newText, rPr);\n const ins = new Element(\n \"w:ins\",\n {\n \"w:id\": String(revisionId + 1),\n \"w:author\": author,\n \"w:date\": date,\n },\n [insRun],\n );\n (insRun as ChildNode).parent = ins;\n insertChild(paragraph, insertionPoint, ins);\n return { ok: true };\n }\n return { ok: false, reason: \"No runs found in deletion range\" };\n }\n\n // Step 4: Build w:del element with w:delText runs\n const rPrSource = findRPr(runsToDelete[0]);\n const delChildren: ChildNode[] = [];\n\n for (const run of runsToDelete) {\n const tn = findTextNode(run);\n if (!tn) continue; // skip tab-only or break-only runs with no <w:t>\n const text = getNodeText(tn);\n const delRun = buildRun(\"w:delText\", text, findRPr(run));\n delChildren.push(delRun);\n }\n\n const del = new Element(\n \"w:del\",\n {\n \"w:id\": String(revisionId),\n \"w:author\": author,\n \"w:date\": date,\n },\n delChildren,\n );\n for (const child of delChildren) {\n (child as ChildNode).parent = del;\n }\n\n // Step 5: Build w:ins element if newText is non-empty\n let ins: Element | undefined;\n if (newText.length > 0) {\n const insRun = buildRun(\"w:t\", newText, rPrSource);\n ins = new Element(\n \"w:ins\",\n {\n \"w:id\": String(revisionId + 1),\n \"w:author\": author,\n \"w:date\": date,\n },\n [insRun],\n );\n (insRun as ChildNode).parent = ins;\n }\n\n // Step 6: Replace the original runs with del (+ ins)\n const firstRunIndex = paragraph.children.indexOf(runsToDelete[0]);\n\n // Remove original runs\n for (const run of runsToDelete) {\n removeChild(run);\n }\n\n // Insert del at the position of the first removed run\n insertChild(paragraph, firstRunIndex, del);\n\n // Insert ins after del\n if (ins) {\n insertChild(paragraph, firstRunIndex + 1, ins);\n }\n\n return { ok: true };\n}\n\n/** Split a run at charIndex, creating a new run after it with the remainder. */\nfunction splitRun(run: Element, textNode: Element, charIndex: number, paragraph: Element): void {\n const fullText = getNodeText(textNode);\n const before = fullText.slice(0, charIndex);\n const after = fullText.slice(charIndex);\n\n // Update the original run's text\n setNodeText(textNode, before);\n\n // Create a new run for the remainder\n const rPr = findRPr(run);\n const newRun = buildRun(\"w:t\", after, rPr);\n\n // Insert after the original run in the paragraph\n const idx = paragraph.children.indexOf(run);\n insertChild(paragraph, idx + 1, newRun);\n}\n\n/** Find the w:t element within a run. */\nfunction findTextNode(run: Element): Element | undefined {\n for (const child of run.children) {\n if (isElement(child) && child.name === \"w:t\") return child;\n }\n return undefined;\n}\n\n// ---------------------------------------------------------------------------\n// applyTrackedChanges (orchestrator)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply accepted suggestions to a .docx buffer as tracked changes.\n *\n * Returns a new buffer with the modified document plus statistics.\n */\nexport async function applyTrackedChanges(\n docxBuffer: Buffer,\n suggestions: AcceptedSuggestion[],\n options: ApplyOptions,\n): Promise<ApplyOutput> {\n const zip = await JSZip.loadAsync(docxBuffer);\n const documentXml = await zip.file(\"word/document.xml\")?.async(\"text\");\n if (!documentXml) {\n throw new Error(\"Missing word/document.xml in .docx archive\");\n }\n\n const date = options.date ?? new Date().toISOString();\n\n // Collect all target offsets\n const targetOffsets = new Set<number>();\n for (const s of suggestions) {\n targetOffsets.add(s.from);\n targetOffsets.add(s.to);\n }\n\n // Build offset map (first pass — for comparison guard only)\n const offsetMap = buildOffsetMap(documentXml, targetOffsets);\n\n // Comparison guard\n if (offsetMap.flatText !== options.ydocFlatText) {\n throw new Error(\n \"Flat text mismatch: the .docx content does not match the Y.Doc flat text. \" +\n \"The file may have changed since it was loaded.\",\n );\n }\n\n // Sort descending by `from` so later edits don't shift earlier offsets\n const sorted = [...suggestions].sort((a, b) => b.from - a.from);\n\n // Validate ALL before mutating\n const valid: AcceptedSuggestion[] = [];\n const rejectedDetails: Array<{ id: string; reason: string }> = [];\n\n for (const s of sorted) {\n // textSnapshot check\n if (s.textSnapshot !== undefined) {\n const actual = offsetMap.flatText.slice(s.from, s.to);\n if (actual !== s.textSnapshot) {\n rejectedDetails.push({\n id: s.id,\n reason: `Text snapshot mismatch: expected \"${s.textSnapshot}\", got \"${actual}\"`,\n });\n continue;\n }\n }\n\n // Offset resolution check\n const fromEntry = offsetMap.get(s.from);\n const toEntry = offsetMap.get(s.to);\n if (!fromEntry || !toEntry) {\n rejectedDetails.push({\n id: s.id,\n reason: `Could not resolve offsets: from=${s.from} to=${s.to}`,\n });\n continue;\n }\n\n valid.push(s);\n }\n\n // Check for overlapping ranges among valid suggestions (already sorted desc by from)\n const validAfterOverlapCheck: AcceptedSuggestion[] = [];\n let lastFrom = Infinity;\n for (const s of valid) {\n if (s.to > lastFrom) {\n rejectedDetails.push({\n id: s.id,\n reason: `Overlapping range [${s.from}, ${s.to}) conflicts with another suggestion`,\n });\n continue;\n }\n lastFrom = s.from;\n validAfterOverlapCheck.push(s);\n }\n\n // Reject suggestions whose range contains complex (non-text) elements\n // that would produce malformed XML when the run is split or wrapped.\n const validAfterComplexCheck: AcceptedSuggestion[] = [];\n for (const s of validAfterOverlapCheck) {\n const fromEntry = offsetMap.get(s.from)!;\n const toEntry = offsetMap.get(s.to)!;\n const paragraph = fromEntry.paragraph;\n\n // Collect runs in the range [fromEntry.run .. toEntry.run]\n let collecting = false;\n let hasComplex = false;\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") continue;\n if (child === fromEntry.run) collecting = true;\n if (collecting) {\n for (const rc of child.children) {\n if (isElement(rc) && COMPLEX_RUN_ELEMENTS.has(rc.name)) {\n hasComplex = true;\n break;\n }\n }\n if (hasComplex) break;\n if (child === toEntry.run) break;\n }\n }\n\n if (hasComplex) {\n rejectedDetails.push({\n id: s.id,\n reason: \"Overlaps a complex element (footnote, drawing, or field) and couldn't be applied\",\n });\n } else {\n validAfterComplexCheck.push(s);\n }\n }\n\n // Reject suggestions that target the same <w:r> run — the first split would\n // invalidate the second suggestion's DOM references.\n const validAfterRunCheck: AcceptedSuggestion[] = [];\n const claimedRuns = new Map<Element, string>(); // run -> suggestion id that claimed it\n for (const s of validAfterComplexCheck) {\n const fromEntry = offsetMap.get(s.from)!;\n const toEntry = offsetMap.get(s.to)!;\n const paragraph = fromEntry.paragraph;\n\n // Collect all runs this suggestion touches\n const touchedRuns: Element[] = [];\n let collecting = false;\n for (const child of paragraph.children) {\n if (!isElement(child) || child.name !== \"w:r\") continue;\n if (child === fromEntry.run) collecting = true;\n if (collecting) {\n touchedRuns.push(child);\n if (child === toEntry.run) break;\n }\n }\n\n let conflict = false;\n for (const run of touchedRuns) {\n if (claimedRuns.has(run)) {\n rejectedDetails.push({\n id: s.id,\n reason: \"Targets the same text run as another suggestion\",\n });\n conflict = true;\n break;\n }\n }\n if (!conflict) {\n for (const run of touchedRuns) {\n claimedRuns.set(run, s.id);\n }\n validAfterRunCheck.push(s);\n }\n }\n\n // The offset map already parsed the XML — reuse its DOM for serialization\n const doc = offsetMap.doc;\n\n // Find max existing w:id to avoid collisions\n const idMatches = documentXml.match(/w:id=\"(\\d+)\"/g) || [];\n let maxId = 0;\n for (const m of idMatches) {\n const num = parseInt(m.match(/\\d+/)![0], 10);\n if (num > maxId) maxId = num;\n }\n\n // Apply each valid suggestion in reverse offset order.\n // ID allocation: maxId += 2 per suggestion because each tracked change\n // needs two revision IDs — one for the <w:del> element (revisionId) and\n // one for the <w:ins> element (revisionId + 1).\n let applied = 0;\n const appliedSuggestions: AcceptedSuggestion[] = [];\n for (const s of validAfterRunCheck) {\n maxId += 2;\n const result = applySingleSuggestion(offsetMap, {\n from: s.from,\n to: s.to,\n newText: s.newText,\n author: options.author,\n date,\n revisionId: maxId - 1,\n });\n if (result.ok) {\n applied++;\n appliedSuggestions.push(s);\n } else {\n rejectedDetails.push({ id: s.id, reason: result.reason ?? \"Unknown error\" });\n }\n }\n\n // Serialize back\n const serialized = render(doc, { xmlMode: true });\n zip.file(\"word/document.xml\", serialized);\n\n // Resolve Word comments for successfully applied suggestions only\n const commentsResolved = await resolveWordComments(zip, appliedSuggestions);\n\n const buffer = Buffer.from(await zip.generateAsync({ type: \"nodebuffer\" }));\n\n return {\n buffer,\n applied,\n rejected: rejectedDetails.length,\n rejectedDetails,\n commentsResolved,\n };\n}\n\n// ---------------------------------------------------------------------------\n// resolveWordComments\n// ---------------------------------------------------------------------------\n\nconst W15_NS = \"http://schemas.microsoft.com/office/word/2012/wordml\";\n\n/**\n * Read each Word comment's OWN last-paragraph `w14:paraId` from\n * `word/comments.xml`, keyed by comment id. This is the value OOXML\n * `CT_CommentEx/@paraId` references (§2.5.39) — the comment body's last\n * paragraph, NOT the document paragraph the comment is anchored to. Original\n * case is preserved so the written id matches both the comment's `<w:p>` and\n * any `commentEx` entry Word already wrote. See #1007.\n */\nasync function readCommentLastParaIds(zip: JSZip): Promise<Map<string, string>> {\n const map = new Map<string, string>();\n const xml = await zip.file(\"word/comments.xml\")?.async(\"text\");\n if (!xml) return map;\n const doc = parseDocument(xml, { xmlMode: true });\n for (const comment of findAllByName(\"w:comment\", doc.children)) {\n const id = getAttr(comment, \"w:id\");\n if (!id) continue;\n // Shallow filter — only direct children of <w:comment>. `findAllByName` is\n // recursive and would include <w:p> elements nested inside embedded tables,\n // yielding the wrong last-paragraph paraId. CT_CommentEx @paraId references\n // the comment body's last TOP-LEVEL paragraph (OOXML §2.5.39).\n const paragraphs = comment.children.filter(isElement).filter((c) => c.name === \"w:p\");\n if (paragraphs.length === 0) continue;\n const lastParaId = getAttr(paragraphs[paragraphs.length - 1], \"w14:paraId\");\n if (lastParaId) map.set(id, lastParaId);\n }\n return map;\n}\n\n/**\n * Mark Word comments as \"done\" in commentsExtended.xml.\n *\n * For each applied suggestion that has an `importCommentId`, looks up the\n * comment's own last-paragraph `w14:paraId` (from `word/comments.xml`) and\n * marks the matching `<w15:commentEx>` entry `w15:done=\"1\"` — updating an\n * existing entry in place, or creating one (and the part + relationship +\n * content-type) when `commentsExtended.xml` is absent.\n */\nexport async function resolveWordComments(\n zip: JSZip,\n appliedSuggestions: AcceptedSuggestion[],\n): Promise<number> {\n const commentLastParaIds = await readCommentLastParaIds(zip);\n\n // Collect comment IDs to resolve\n const toResolve: Array<{ commentId: string; paraId: string }> = [];\n for (const s of appliedSuggestions) {\n if (!s.importCommentId) continue;\n const paraId = commentLastParaIds.get(s.importCommentId);\n if (!paraId) {\n console.warn(\n `[docx-apply] No comment-paragraph id for comment ${s.importCommentId}; skipping resolution`,\n );\n continue;\n }\n toResolve.push({ commentId: s.importCommentId, paraId });\n }\n\n if (toResolve.length === 0) return 0;\n\n // Deduplicate by commentId\n const seen = new Set<string>();\n const unique = toResolve.filter((r) => {\n if (seen.has(r.commentId)) return false;\n seen.add(r.commentId);\n return true;\n });\n\n const existingXml = await zip.file(\"word/commentsExtended.xml\")?.async(\"text\");\n\n if (existingXml) {\n // Parse and append\n const doc = parseDocument(existingXml, { xmlMode: true });\n const root = doc.children.find((c) => isElement(c) && c.name === \"w15:commentsEx\") as\n | Element\n | undefined;\n if (root) {\n // Index existing commentEx entries by lowercased paraId. paraIds are hex\n // (ST_LongHexNumber); compare case-insensitively, but keep/write the\n // original case. A comment Word already tracks here must be UPDATED to\n // done=\"1\" in place, not duplicated.\n const existing = new Map<string, Element>();\n for (const child of root.children) {\n if (isElement(child) && child.name === \"w15:commentEx\") {\n const pid = getAttr(child, \"w15:paraId\");\n if (pid) existing.set(pid.toLowerCase(), child);\n }\n }\n\n for (const { paraId } of unique) {\n const found = existing.get(paraId.toLowerCase());\n if (found) {\n found.attribs[\"w15:done\"] = \"1\";\n } else {\n const entry = new Element(\"w15:commentEx\", {\n \"w15:paraId\": paraId,\n \"w15:done\": \"1\",\n });\n insertChild(root, root.children.length, entry);\n }\n }\n zip.file(\"word/commentsExtended.xml\", render(doc, { xmlMode: true }));\n }\n } else {\n // Create new commentsExtended.xml\n const entries = unique\n .map((r) => `<w15:commentEx w15:paraId=\"${r.paraId}\" w15:done=\"1\"/>`)\n .join(\"\");\n const newXml =\n `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>` +\n `<w15:commentsEx xmlns:w15=\"${W15_NS}\">${entries}</w15:commentsEx>`;\n zip.file(\"word/commentsExtended.xml\", newXml);\n\n // Add relationship in word/_rels/document.xml.rels\n const relsXml = await zip.file(\"word/_rels/document.xml.rels\")?.async(\"text\");\n if (relsXml) {\n const relsDoc = parseDocument(relsXml, { xmlMode: true });\n const relsRoot = relsDoc.children.find((c) => isElement(c) && c.name === \"Relationships\") as\n | Element\n | undefined;\n if (relsRoot) {\n // Find a unique rId\n const existingIds = findAllByName(\"Relationship\", relsRoot.children)\n .map((r) => getAttr(r, \"Id\") || \"\")\n .filter((id) => id.startsWith(\"rId\"))\n .map((id) => parseInt(id.slice(3), 10))\n .filter((n) => !isNaN(n));\n const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 100;\n\n const rel = new Element(\"Relationship\", {\n Id: `rId${nextId}`,\n Type: \"http://schemas.microsoft.com/office/2011/relationships/commentsExtended\",\n Target: \"commentsExtended.xml\",\n });\n insertChild(relsRoot, relsRoot.children.length, rel);\n zip.file(\"word/_rels/document.xml.rels\", render(relsDoc, { xmlMode: true }));\n }\n }\n\n // Add content type\n const ctXml = await zip.file(\"[Content_Types].xml\")?.async(\"text\");\n if (ctXml) {\n const ctDoc = parseDocument(ctXml, { xmlMode: true });\n const typesRoot = ctDoc.children.find((c) => isElement(c) && c.name === \"Types\") as\n | Element\n | undefined;\n if (typesRoot) {\n const override = new Element(\"Override\", {\n PartName: \"/word/commentsExtended.xml\",\n ContentType:\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml\",\n });\n insertChild(typesRoot, typesRoot.children.length, override);\n zip.file(\"[Content_Types].xml\", render(ctDoc, { xmlMode: true }));\n }\n }\n }\n\n return unique.length;\n}\n","import * as crypto from \"node:crypto\";\nimport fs from \"fs/promises\";\nimport path from \"path\";\nimport {\n Y_MAP_ANNOTATIONS,\n Y_MAP_DOCUMENT_META,\n Y_MAP_FOOTNOTE_BODIES,\n} from \"../../shared/constants.js\";\nimport { extractText, populateYDoc } from \"../mcp/document-model.js\";\nimport { htmlToYDoc, loadDocxWithWarnings, reconcileFootnoteIds } from \"./docx.js\";\nimport {\n type DocxComment,\n extractDocxComments,\n injectCommentsAsAnnotations,\n} from \"./docx-comments.js\";\nimport { exportYDocToDocx } from \"./docx-export.js\";\nimport { type DocxNotes, footnoteLossLines, parseDocxFootnotes } from \"./docx-footnotes.js\";\nimport { loadMarkdown, saveMarkdown } from \"./markdown.js\";\nimport type { FormatAdapter, LoadIssue, Prepared } from \"./types.js\";\n\nexport {\n type AcceptedSuggestion,\n type ApplyOptions,\n type ApplyOutput,\n applyTrackedChanges,\n} from \"./docx-apply.js\";\nexport type { ApplyContext, FormatAdapter, LoadIssue, Prepared } from \"./types.js\";\n\n// -- Adapter implementations (ADR-036 two-phase parse/apply) --\n\nconst markdownAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n return { format: \"md\", content: content as string, issues: [] };\n },\n apply(doc, prepared) {\n if (prepared.format !== \"md\") return [];\n loadMarkdown(doc, prepared.content);\n return [];\n },\n save(doc) {\n return saveMarkdown(doc);\n },\n};\n\nconst plaintextAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n const text = typeof content === \"string\" ? content : content.toString(\"utf-8\");\n return { format: \"other\", content: text, issues: [] };\n },\n apply(doc, prepared) {\n if (prepared.format !== \"other\") return [];\n populateYDoc(doc, prepared.content);\n return [];\n },\n save(doc) {\n return extractText(doc);\n },\n};\n\n/**\n * The .docx adapter provides `saveBinary` (#576) — .docx write-back holds edits\n * in the Y.Doc and serializes to a `.docx` buffer on EXPLICIT save only. This\n * supersedes ADR-004's read-only default; the protective layer is now \"never\n * overwrite without an explicit save\" rather than `contenteditable=false`.\n * Exports body + Word comments (#1068): user/Claude `comment`-type annotations\n * AND imported Word comments written back to their source file (private notes\n * that round-trip but stay Claude-invisible), per the gate in\n * `docx-comment-export.ts` — tracked changes stay deferred.\n *\n * - `parse` runs `loadDocxWithWarnings` + `extractDocxComments` in parallel.\n * mammoth import-fidelity warnings land as a `LoadIssue { kind: \"other\" }`\n * so the UI can tell the user what formatting mammoth dropped (and thus\n * what the round-trip cannot recover). Comment-extraction failures land as\n * `LoadIssue { kind: \"comments-failed\" }` rather than being swallowed.\n * - `apply` runs `htmlToYDoc` then `injectCommentsAsAnnotations`\n * synchronously inside the caller's transact. The snapshot/undo dance\n * around inject lives here because Yjs doesn't roll back inner-transact\n * writes when a callback throws.\n * - `saveBinary` serializes the current Y.Doc body + pending comment\n * annotations to a `.docx` buffer via `exportYDocToDocx`\n * (trust-boundary-gated). NOT wired into auto-save.\n */\nconst docxAdapter: FormatAdapter = {\n async parse(content): Promise<Prepared> {\n const buffer = content as Buffer;\n const issues: LoadIssue[] = [];\n const [loaded, comments, notes] = await Promise.all([\n loadDocxWithWarnings(buffer),\n extractDocxComments(buffer).catch((err) => {\n console.error(\n \"[docx-comments] Comment extraction failed; document will load without imported comments:\",\n err,\n );\n issues.push({ kind: \"comments-failed\", error: err });\n return [] as DocxComment[];\n }),\n // Footnote/endnote capture (#1123 Tier-A #3): mammoth flattens these to a\n // trailing list and emits NO warning. Read the real notes directly from\n // the ZIP so the import can BOTH surface an honest loss line AND capture\n // footnote bodies for reconstruction. parseDocxFootnotes never throws (it\n // catches per-part + per-archive); this .catch is last-resort defense.\n parseDocxFootnotes(buffer).catch((err) => {\n console.error(\"[docx-footnotes] parse failed unexpectedly:\", err);\n return { footnotes: {}, endnotes: 0 } satisfies DocxNotes;\n }),\n ]);\n // Note losses lead (the named, higher-impact loss). mammoth's per-occurrence\n // warnings are already deduped + capped inside summarizeMammothMessages; the\n // note lines are a bounded fixed set (≤3), so they ride on top of that cap\n // without re-flooding — and crucially this guard fires when EITHER source is\n // non-empty, because mammoth emits zero warnings for notes. The honesty line\n // is driven off the RECONCILED partition (same inputs `htmlToYDoc` reconciles\n // in `apply` → identical result), so a footnote that won't reconstruct (an\n // orphaned definition, or a mammoth-format drift) is reported as a loss, not\n // silently claimed \"preserved\".\n const reconciliation = reconcileFootnoteIds(loaded.html, notes.footnotes);\n const importLosses = [...footnoteLossLines(notes, reconciliation), ...loaded.warnings];\n if (importLosses.length > 0) {\n issues.push({\n kind: \"other\",\n error: undefined,\n message:\n \"Some Word formatting couldn't be imported and won't be preserved on save: \" +\n `${importLosses.join(\"; \")}.`,\n // Granular list for the persistent fidelity report (#1145); the joined\n // `message` above drives the transient open-time toast.\n importLosses,\n });\n }\n return { format: \"docx\", html: loaded.html, comments, footnoteBodies: notes.footnotes, issues };\n },\n async saveBinary(doc): Promise<Buffer> {\n return exportYDocToDocx(doc);\n },\n apply(doc, prepared, ctx) {\n if (prepared.format !== \"docx\") return [];\n const reconciledFootnotes = htmlToYDoc(doc, prepared.html, prepared.footnoteBodies);\n // Persist the reconstructed footnote bodies off-fragment so the exporter can\n // re-emit real <w:footnote> parts (#1123 Tier-A #3 PR 2). WHOLE-VALUE replace\n // (a reload with fewer footnotes must not leave stale ids). The caller's\n // transact is already origin-tagged; this documentMeta key has no observer\n // (server write-only, client/Claude-invisible) and sits OUTSIDE the comment-\n // inject rollback zone below, which only deletes newly-added annotation keys.\n doc.getMap(Y_MAP_DOCUMENT_META).set(Y_MAP_FOOTNOTE_BODIES, reconciledFootnotes);\n const out: LoadIssue[] = [];\n if (prepared.comments.length > 0) {\n // Snapshot-and-rollback: Yjs does NOT roll back inner-transact writes\n // when a callback throws, so we capture the key set before inject and\n // delete any newly-added keys on throw. Without this, a partial inject\n // would commit half the comments and re-open would hit importAnnotationId\n // dedup, permanently dropping the failing comment.\n const annotMap = doc.getMap(Y_MAP_ANNOTATIONS);\n const before = new Set(annotMap.keys());\n try {\n injectCommentsAsAnnotations(doc, prepared.comments, ctx?.fileName);\n } catch (err) {\n for (const k of annotMap.keys()) {\n if (!before.has(k)) annotMap.delete(k);\n }\n console.error(\n \"[docx-comments] inject failed mid-transact; document loads without imported comments:\",\n err,\n );\n out.push({ kind: \"inject-failed\", error: err });\n }\n }\n return out;\n },\n};\n\n// -- Registry --\n\nconst adapters: Record<string, FormatAdapter> = {\n md: markdownAdapter,\n other: plaintextAdapter, // covers txt, html, and any other plaintext-routed format\n docx: docxAdapter,\n};\n\n/** Look up the adapter for a given format string. Unknown formats fall back\n * to the plaintext adapter. */\nexport function getAdapter(format: string): FormatAdapter {\n return adapters[format] ?? plaintextAdapter;\n}\n\n// -- Shared helpers --\n\n/**\n * Atomic rename retry constants. Windows can throw EPERM/EACCES when another\n * process (AV scanner, file indexer, or a stale handle) is briefly holding the\n * destination file. A handful of short retries with exponential backoff clears\n * virtually all such contention in practice.\n */\nconst RENAME_MAX_RETRIES = 3;\nconst RENAME_RETRY_BASE_MS = 50;\n\nasync function renameWithRetry(tempPath: string, filePath: string): Promise<void> {\n for (let attempt = 0; attempt < RENAME_MAX_RETRIES; attempt++) {\n try {\n await fs.rename(tempPath, filePath);\n return;\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if ((code === \"EPERM\" || code === \"EACCES\") && attempt < RENAME_MAX_RETRIES - 1) {\n await new Promise((r) => setTimeout(r, RENAME_RETRY_BASE_MS * 2 ** attempt));\n continue;\n }\n await fs.unlink(tempPath).catch(() => {});\n throw err;\n }\n }\n}\n\n/**\n * Filename prefix for atomic-write temp siblings. Exported so the startup\n * reaper (`reaper.ts`) can build the exact same name shape it sweeps for.\n */\nexport const ATOMIC_TEMP_PREFIX = \".tandem-tmp-\";\n\n/**\n * Produce a unique temp filename in the same directory as `filePath`. Uses a\n * random suffix so concurrent writers to the same directory cannot collide on\n * a shared `Date.now()` millisecond (the annotation store writes multiple\n * files in the same directory in parallel).\n */\nexport function tempSiblingPath(filePath: string): string {\n const rand = crypto.randomBytes(6).toString(\"hex\");\n return path.join(path.dirname(filePath), `${ATOMIC_TEMP_PREFIX}${Date.now()}-${rand}`);\n}\n\n/**\n * Atomic file write: write to a temp file, then rename.\n * Prevents partial writes on crash. Retries the rename up to 3 times on\n * EPERM/EACCES (Windows file-handle contention) with exponential backoff.\n */\nexport async function atomicWrite(filePath: string, content: string): Promise<void> {\n const tempPath = tempSiblingPath(filePath);\n await fs.writeFile(tempPath, content, \"utf-8\");\n await renameWithRetry(tempPath, filePath);\n}\n\n/**\n * Atomic binary file write: write Buffer to a temp file, then rename.\n * Used for .docx (ZIP) output where UTF-8 encoding would corrupt binary data.\n * Shares the same EPERM/EACCES retry behaviour as `atomicWrite`.\n */\nexport async function atomicWriteBuffer(filePath: string, content: Buffer): Promise<void> {\n const tempPath = tempSiblingPath(filePath);\n await fs.writeFile(tempPath, content);\n await renameWithRetry(tempPath, filePath);\n}\n","import path from \"path\";\n\n/** Trial length in days (ADR-040 §5: under the 30-day BUSL eval ceiling). */\nexport const TRIAL_DAYS = 14;\n/** Trial length in milliseconds — epoch math only, never calendar arithmetic. */\nexport const TRIAL_MS = TRIAL_DAYS * 86_400_000;\n\n/** Path to the activated signed-license blob. */\nexport const licenseFilePath = (appDataDir: string): string =>\n path.join(appDataDir, \"license.json\");\n\n/** Path to the soft on-device trial clock. */\nexport const trialFilePath = (appDataDir: string): string => path.join(appDataDir, \"trial.json\");\n","// Pinned public key for Ed25519 license verification.\n// IMPORTANT: The corresponding private key must be stored securely by Bryan Kolb\n// (not committed to git). Store as the TANDEM_PRIVATE_KEY environment variable on the\n// webhook server. Re-generate with `npx tsx scripts/generate-keys.ts` before first use.\nexport const TANDEM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA7gm5YEEylcqDgxr1rQ/gA/K8ZzOPfqmvUBtE6CI8QCk=\n-----END PUBLIC KEY-----`;\n","/* eslint-disable @typescript-eslint/no-explicit-any */\nimport crypto from \"crypto\";\nimport type { LicenseMetadata, SignatureVerified, SignedLicense } from \"./license-types.js\";\nimport { TANDEM_PUBLIC_KEY } from \"./public-key.js\";\n\nexport function canonicalObject(obj: any): any {\n if (typeof obj !== \"object\" || obj === null) {\n return obj;\n }\n if (Array.isArray(obj)) {\n return obj.map(canonicalObject);\n }\n const sortedKeys = Object.keys(obj).sort();\n // Null-prototype accumulator so a `__proto__` (or `constructor`/`prototype`)\n // own-key is canonicalized as a normal key instead of mutating the prototype\n // (plain `{}` + `sortedObj[\"__proto__\"] = …` would silently drop it from the\n // signed bytes). Symmetric signer/verifier either way, but this removes the\n // foot-gun if the trusted field set ever grows.\n const sortedObj: any = Object.create(null);\n for (const key of sortedKeys) {\n sortedObj[key] = canonicalObject(obj[key]);\n }\n return sortedObj;\n}\n\n/**\n * Deterministically stringify an object by sorting its keys.\n * This guarantees consistent signatures across different platforms and runs.\n */\nexport function canonicalize(obj: any): string {\n return JSON.stringify(canonicalObject(obj));\n}\n\n/**\n * Verifies the Ed25519 signature of a base64-encoded signed license against the\n * embedded public key — signature ONLY, no expiry check.\n *\n * This is the correct check for the *run* gate (ADR-040 §3/§4): a paid license\n * lets you run the current version **forever**; `expiresAt` governs only the\n * update window, not the right to run. Callers that need the update-window\n * decision read `metadata.expiresAt` themselves (see `license-state.ts`).\n * Throws if the format is malformed or the signature is invalid.\n */\nexport function verifyLicenseSignature(licenseString: string): SignatureVerified {\n // Bound the input before any allocation — license blobs are small (<2KB).\n if (licenseString.length > 10_000) {\n throw new Error(\"License verification failed: input exceeds maximum length\");\n }\n try {\n // 1. Decode base64\n const decoded = Buffer.from(licenseString, \"base64\").toString(\"utf-8\");\n // Bound the DECODED payload too — a deeply-nested array can fit inside the\n // 10k input bound yet blow the recursion stack in canonicalObject. Real\n // blobs are <2KB; 4KB is a generous ceiling that rejects the pathological\n // case before any parse/recurse.\n if (decoded.length > 4096) {\n throw new Error(\"License verification failed: payload exceeds maximum length\");\n }\n const signedLicense = JSON.parse(decoded) as SignedLicense;\n\n if (!signedLicense.metadata || !signedLicense.signature) {\n throw new Error(\"Invalid license format: missing metadata or signature\");\n }\n\n // 2. Verify signature\n const data = Buffer.from(canonicalize(signedLicense.metadata));\n const signature = Buffer.from(signedLicense.signature, \"hex\");\n\n const verified = crypto.verify(null, data, TANDEM_PUBLIC_KEY, signature);\n\n if (!verified) {\n throw new Error(\"Signature verification failed\");\n }\n\n // Brand: this metadata is now signature-verified (run-gate may trust it).\n return signedLicense.metadata as SignatureVerified;\n } catch (error: any) {\n // Preserve already-wrapped messages to avoid double-wrapping.\n if (error instanceof Error && error.message.startsWith(\"License verification failed\")) {\n throw error;\n }\n throw new Error(`License verification failed: ${error.message}`, { cause: error });\n }\n}\n\n/**\n * Verifies signature AND rejects an expired license. Stricter than the run\n * gate needs — kept for callers that want a single \"valid & unexpired now\"\n * check. The on-device run/update gate uses {@link verifyLicenseSignature}\n * plus its own `expiresAt` reading instead.\n */\nexport function verifyLicense(licenseString: string): LicenseMetadata {\n const metadata = verifyLicenseSignature(licenseString);\n if (metadata.expiresAt) {\n const expires = new Date(metadata.expiresAt);\n if (expires.getTime() < Date.now()) {\n throw new Error(`License expired on ${metadata.expiresAt}`);\n }\n }\n return metadata;\n}\n","import fs from \"fs\";\nimport { atomicWrite } from \"../file-io/index.js\";\nimport { resolveAppDataDir } from \"../platform.js\";\nimport { GATE_ENABLED } from \"./gate-flag.js\";\nimport type { LicenseFile, LicenseState, SignatureVerified, TrialFile } from \"./license-types.js\";\nimport { licenseFilePath, TRIAL_MS, trialFilePath } from \"./paths.js\";\nimport { verifyLicenseSignature } from \"./verifier.js\";\n\n// Known license schema majors. The signed `version` field becomes load-bearing:\n// an unknown major is rejected rather than silently honored (review §12 L3).\nconst KNOWN_VERSION_MAJORS = new Set([\"1\"]);\nfunction knownVersion(v: string): boolean {\n return typeof v === \"string\" && KNOWN_VERSION_MAJORS.has(v.split(\".\")[0]);\n}\n\nfunction readJson<T>(filePath: string): T | null {\n try {\n return JSON.parse(fs.readFileSync(filePath, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Resolve on-device license state — computed FRESH on every call (no cache).\n * A cache caused the two-writer staleness + mid-session-expiry bugs the spec\n * reviews found, so the gate re-reads `license.json`/`trial.json` per dispatch.\n * Cost is a tiny file read + (at most) one Ed25519 verify.\n *\n * `verify` is injectable for tests; production uses signature-only verification\n * so an expired *update window* never drops a paid user to `restricted`\n * (ADR-040: run forever, updates windowed). The update window is read from\n * `expiresAt` into `updateWindowCurrent`.\n */\nexport function resolveLicenseState(deps: {\n appDataDir: string;\n now: () => number;\n gateEnabled: boolean;\n // Branded: only a signature-verifying function fits, so the expiry-checking\n // `verifyLicense` can't be wired here (it would lock out paid users past their\n // update window). See SignatureVerified in license-types.ts.\n verify?: (blob: string) => SignatureVerified;\n}): LicenseState {\n const { appDataDir, now, gateEnabled, verify = verifyLicenseSignature } = deps;\n\n if (!gateEnabled) {\n return { gateActive: false };\n }\n\n // One timestamp for the whole resolution — the licensed update-window check and\n // the trial-clock math must agree on a single \"now\".\n const nowMs = now();\n\n // 1. A signature-valid license of a known version ⇒ licensed (runs forever).\n const lf = readJson<LicenseFile>(licenseFilePath(appDataDir));\n if (lf?.blob) {\n try {\n const meta = verify(lf.blob);\n if (knownVersion(meta.version)) {\n const updateWindowCurrent =\n meta.expiresAt === null || new Date(meta.expiresAt).getTime() > nowMs;\n return {\n gateActive: true,\n status: \"licensed\",\n license: meta,\n licenseId: meta.id,\n updateWindowCurrent,\n };\n }\n } catch {\n // malformed / bad signature / unknown version — fall through to trial/restricted\n }\n }\n\n // 2. Trial clock (soft by design — ADR-040 §3). Absent file ⇒ day 0.\n const tf = readJson<TrialFile>(trialFilePath(appDataDir));\n const firstRunAt = tf?.firstRunAt ? new Date(tf.firstRunAt).getTime() : nowMs;\n const expiresAt = firstRunAt + TRIAL_MS;\n if (nowMs < expiresAt) {\n const daysRemaining = Math.max(0, Math.ceil((expiresAt - nowMs) / 86_400_000));\n return {\n gateActive: true,\n status: \"trial\",\n updateWindowCurrent: false,\n trial: {\n firstRunAt: new Date(firstRunAt).toISOString(),\n expiresAt: new Date(expiresAt).toISOString(),\n daysRemaining,\n },\n };\n }\n\n // 3. Trial expired, no license ⇒ restricted (read-only escape hatch).\n return { gateActive: true, status: \"restricted\", updateWindowCurrent: false };\n}\n\n/**\n * Production-wired `resolveLicenseState`: the single place the live deps (real\n * app-data dir, wall clock, build-time gate flag) are assembled. Shared by both\n * enforcement surfaces — Hocuspocus `onAuthenticate` (Surface A) and the MCP\n * `gatedTool` / `licenseGateMiddleware` (Surface B) — plus the status route, so\n * a future deps change lands in one spot. Still cache-free: every call re-reads disk.\n */\nexport function resolveLiveLicenseState(): LicenseState {\n return resolveLicenseState({\n appDataDir: resolveAppDataDir(),\n now: () => Date.now(),\n gateEnabled: GATE_ENABLED,\n });\n}\n\n/**\n * Start the trial clock on first boot of a gate-active build. Writes `trial.json`\n * once, with an exclusive create (`flag: \"wx\"`) so concurrent stdio+HTTP first\n * boots agree on a single `firstRunAt` (first writer wins). No-op when the gate\n * is dark — so the v1.0 flag-flip starts a clean 14-day trial.\n */\nexport async function ensureTrialStarted(\n appDataDir: string,\n now: () => number,\n gateEnabled: boolean,\n): Promise<void> {\n if (!gateEnabled) return;\n const filePath = trialFilePath(appDataDir);\n if (fs.existsSync(filePath)) return;\n const body: TrialFile = { version: 1, firstRunAt: new Date(now()).toISOString() };\n try {\n fs.writeFileSync(filePath, JSON.stringify(body), { flag: \"wx\" });\n } catch {\n // Lost the race to a concurrently-starting process — its file stands.\n }\n}\n\n/**\n * Activate a license: verify its signature + known version, persist atomically,\n * and return the freshly-resolved state. Does NOT reject an expired update\n * window — a user may activate an older license and still run forever; they\n * simply won't receive new updates until they renew.\n */\nexport async function activateLicense(\n appDataDir: string,\n blob: string,\n // Injectable for tests (sign with a temp keypair) — mirrors the seam on\n // resolveLicenseState. Production uses the pinned-key signature verifier.\n verify: (blob: string) => SignatureVerified = verifyLicenseSignature,\n): Promise<LicenseState> {\n const meta = verify(blob); // throws on malformed / bad signature\n if (!knownVersion(meta.version)) {\n throw new Error(`Unsupported license version: ${meta.version}`);\n }\n const body: LicenseFile = { version: 1, blob };\n await atomicWrite(licenseFilePath(appDataDir), JSON.stringify(body));\n return resolveLicenseState({ appDataDir, now: () => Date.now(), gateEnabled: true, verify });\n}\n","/**\n * `tandem activate <license-or-path>` and `tandem license` (#1116, ADR-040).\n *\n * Both operate on the LOCAL appData store directly — no running server required.\n * The server re-resolves license state per dispatch (no cache), so activating\n * here takes effect on the running server's next tool call / reconnect.\n */\nimport fs from \"fs\";\nimport { GATE_ENABLED } from \"../server/license/gate-flag.js\";\nimport { activateLicense, resolveLicenseState } from \"../server/license/license-state.js\";\nimport type { LicenseState } from \"../server/license/license-types.js\";\nimport { resolveAppDataDir } from \"../server/platform.js\";\n\n/**\n * Resolve the activate argument to a license blob. If it names a readable file\n * (e.g. a `.license` file emailed to a beta tester), read that file's contents;\n * otherwise treat the argument itself as the pasted blob. `fs.existsSync` never\n * throws — a base64 blob containing `/` simply isn't a real path and falls\n * through to the literal branch.\n */\nexport function resolveLicenseInput(\n arg: string,\n fileExists: (p: string) => boolean,\n readFile: (p: string) => string,\n): string {\n if (fileExists(arg)) return readFile(arg).trim();\n return arg.trim();\n}\n\n/**\n * Human-readable lines for `tandem license`. Pure (given an already-resolved\n * state) so the formatting is unit-testable without touching disk.\n */\nexport function formatLicenseStatus(state: LicenseState, enforcementOn: boolean): string[] {\n const lines: string[] = [\"\", \"Tandem license\", \"\"];\n lines.push(` Enforcement: ${enforcementOn ? \"on\" : \"off (activates at v1.0)\"}`);\n if (state.gateActive && state.status === \"trial\") {\n lines.push(` Status: trial (${state.trial.daysRemaining} days remaining)`);\n } else if (state.gateActive && state.status === \"restricted\") {\n lines.push(\" Status: restricted — trial ended; activate a license to keep editing\");\n } else {\n // licensed (gate-active) — or the dark arm, which `tandem license` never\n // produces (it forces gateEnabled:true), but the union requires the branch.\n lines.push(\" Status: licensed\");\n if (state.gateActive && state.status === \"licensed\") {\n lines.push(` Licensee: ${state.license.name} (${state.license.type})`);\n const window = state.updateWindowCurrent ? \"current\" : \"expired\";\n const through = state.license.expiresAt\n ? ` (through ${state.license.expiresAt.slice(0, 10)})`\n : \"\";\n lines.push(` Update window: ${window}${through}`);\n }\n }\n lines.push(\"\");\n return lines;\n}\n\n/**\n * `tandem license` — print the on-device license/trial state. Forces full\n * resolution (gateEnabled: true) so a beta tester can confirm an installed\n * license even while enforcement still ships dark; the \"Enforcement\" line\n * separately reports the real build flag.\n */\nexport async function runLicenseStatus(): Promise<void> {\n const state = resolveLicenseState({\n appDataDir: resolveAppDataDir(),\n now: () => Date.now(),\n gateEnabled: true,\n });\n for (const line of formatLicenseStatus(state, GATE_ENABLED)) console.log(line);\n}\n\n/**\n * `tandem activate <license-or-path>` — verify + persist a signed license.\n * Exits non-zero with a generic message on a bad license (no blob bytes echoed).\n */\nexport async function runActivate(args: string[]): Promise<void> {\n const input = args[1];\n if (!input) {\n console.error(\"Usage: tandem activate <license-string-or-path>\");\n process.exit(1);\n }\n const blob = resolveLicenseInput(input, fs.existsSync, (p) => fs.readFileSync(p, \"utf-8\"));\n try {\n const state = await activateLicense(resolveAppDataDir(), blob);\n const lic = state.gateActive && state.status === \"licensed\" ? state.license : null;\n const who = lic ? `${lic.name} (${lic.type})` : \"this device\";\n console.log(`\\n✓ License activated for ${who}.`);\n if (lic?.expiresAt) {\n console.log(` Updates included through ${lic.expiresAt.slice(0, 10)}.`);\n }\n console.log(\"\");\n } catch {\n console.error(\n \"\\n[Tandem] License activation failed: the license could not be verified.\\n\" +\n \"Check that you pasted the full license string (or gave the correct file path).\\n\",\n );\n process.exit(1);\n }\n}\n","import { spawn } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SERVER_DIST = resolve(__dirname, \"../server/index.js\");\n\nexport function runStart(): void {\n if (!existsSync(SERVER_DIST)) {\n console.error(`[Tandem] Server not found at ${SERVER_DIST}`);\n console.error(\"[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor\");\n process.exit(1);\n }\n\n console.error(\n \"[Tandem] Browser distribution is deprecated; the Tauri desktop app is the primary form factor.\",\n );\n console.error(\"[Tandem] See https://github.com/bloknayrb/tandem/issues/477 for context.\");\n console.error(\"[Tandem] Starting server...\");\n\n const proc = spawn(\"node\", [SERVER_DIST], {\n stdio: \"inherit\",\n env: process.env,\n });\n\n proc.on(\"error\", (err) => {\n console.error(`[Tandem] Failed to start server: ${err.message}`);\n process.exit(1);\n });\n\n proc.on(\"exit\", (code) => {\n process.exit(code ?? 0);\n });\n\n // Forward signals — proc.kill() with no argument uses SIGTERM on Unix\n // and TerminateProcess on Windows (correct cross-platform behavior).\n // On Windows SIGTERM is not emitted by the OS, but SIGINT (Ctrl+C) works.\n // Both are listed for Unix compatibility.\n for (const sig of [\"SIGINT\", \"SIGTERM\"] as const) {\n process.once(sig, () => proc.kill());\n }\n}\n","const MIN_NODE_MAJOR = 22;\n\n/**\n * Returns a user-facing error message when the running Node.js is below the\n * supported floor, or null when it's acceptable. Unparseable versions fail\n * open — the guard exists to turn a cryptic downstream crash into a clear\n * message, not to gate runtimes it can't identify.\n */\nexport function nodeVersionError(version: string): string | null {\n const major = Number.parseInt(version.replace(/^v/, \"\"), 10);\n if (Number.isNaN(major) || major >= MIN_NODE_MAJOR) {\n return null;\n }\n return [\n `[tandem] Node.js ${MIN_NODE_MAJOR}+ is required — you are running ${version}.`,\n \"[tandem] Install the current LTS from https://nodejs.org, then run tandem again.\",\n ].join(\"\\n\");\n}\n","/**\n * Tandem CLI — entry point for the `tandem` global command.\n * Shebang is added by tsup banner at build time.\n *\n * Usage:\n * tandem Start the Tandem server and open the editor\n * tandem setup Print first-run setup guidance (setup is wizard-driven)\n * tandem setup --apply Non-interactively write MCP config to detected clients\n * tandem doctor Diagnose setup issues (add --json for machine-readable output)\n * tandem --help Show this help\n * tandem --version Show version\n */\n\nimport { nodeVersionError } from \"./node-version.js\";\n\n// Must run before any Tandem code — npm's `engines` field only warns at\n// install time, so an old-Node user otherwise reaches a cryptic runtime\n// failure deep in the server. update-notifier is imported dynamically below\n// so it can't evaluate ahead of this guard. (In the bundled CLI, tsup's\n// splitting:false inlines the subcommand modules and hoists their external\n// deps — zod, the MCP SDK, env-paths — above this guard; those all load\n// cleanly on Node 18/20, so the guard still fires before anything breaks.)\nconst versionError = nodeVersionError(process.version);\nif (versionError) {\n console.error(versionError);\n process.exit(1);\n}\n\nprocess.once(\"uncaughtException\", (err: unknown) => {\n const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);\n try {\n process.stderr.write(`[tandem cli] uncaughtException: ${msg}\\n`);\n } catch {\n /* EPIPE */\n }\n process.exit(1);\n});\nprocess.once(\"unhandledRejection\", (reason: unknown) => {\n const detail = reason instanceof Error ? reason.message : String(reason);\n process.stderr.write(`[tandem cli] unhandledRejection: ${detail}\\n`);\n process.exit(1);\n});\n\n// Injected at build time by tsup define; declared here for TypeScript\ndeclare const __TANDEM_VERSION__: string;\nconst version = typeof __TANDEM_VERSION__ !== \"undefined\" ? __TANDEM_VERSION__ : \"0.0.0-dev\";\n\nconst args = process.argv.slice(2);\n\nif (args.includes(\"--help\") || args.includes(\"-h\")) {\n console.log(`tandem v${version}\n\nUsage:\n tandem Start Tandem server and open the editor\n tandem setup Print first-run setup guidance (setup is wizard-driven)\n tandem setup --apply Write MCP config to detected AI clients non-interactively\n tandem setup --apply --force Apply to default paths regardless of detection\n tandem setup --apply --target=claude-code|claude-desktop\n Restrict --apply to specific client(s)\n tandem setup --apply --with-channel-shim\n Also register the stdio channel shim (legacy opt-in)\n tandem doctor Diagnose setup issues (Node version, .mcp.json,\n ports, server health, annotation store)\n tandem doctor --json Same checks, emit a single JSON report on stdout\n tandem rotate-token Rotate the auth token with a 60-second grace window\n tandem activate <license|path> Activate a signed license (string or file path)\n tandem license Show the current license / trial status\n tandem --uninstall-scrub Remove Tandem's MCP entries, skill, and Cowork\n registration from Claude configs (run before\n uninstalling; the Windows uninstaller runs it)\n tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP\n (used by the plugin's Cowork bridge; requires\n tandem server running on the host)\n tandem channel Run the Tandem channel shim (stdio MCP)\n (used by the plugin's tandem-channel entry)\n tandem --version\n tandem --help\n`);\n process.exit(0);\n}\n\nif (args.includes(\"--version\") || args.includes(\"-v\")) {\n console.log(version);\n process.exit(0);\n}\n\n// --help/--version exit above without paying the import. Skipped for stdio\n// subcommands — the output is machine-consumed by Claude Desktop's plugin\n// loader, and any incidental write risks corrupting the MCP wire or producing\n// log noise no human will ever read.\nconst isStdioMode = args[0] === \"mcp-stdio\" || args[0] === \"channel\";\nif (!isStdioMode) {\n const { default: updateNotifier } = await import(\"update-notifier\");\n updateNotifier({ pkg: { name: \"tandem-editor\", version } }).notify();\n}\n\ntry {\n if (args[0] === \"--uninstall-scrub\") {\n // Invoked by the Tauri NSIS uninstaller hook on Windows, and manually on\n // any platform before removing the app. Removes Tandem's MCP config\n // entries + bundled skill everywhere; Cowork plugin entries + firewall\n // rules on Windows. Runs inside the already-signed tandem.exe (security\n // invariant §10 — prevents binary-planting during uninstall).\n const { runUninstallScrub } = await import(\"./uninstall-scrub.js\");\n const exitCode = await runUninstallScrub();\n process.exit(exitCode);\n } else if (args[0] === \"setup\") {\n const { runSetup, parseTargetArgs } = await import(\"./setup.js\");\n // `--target=claude-code` / `--target=claude-desktop`, repeatable. Warn on\n // unrecognized values so a typo doesn't silently become a confusing \"No\n // matching installations\" downstream.\n const { targets, unknown } = parseTargetArgs(args);\n for (const t of unknown) {\n console.error(\n `[tandem] Ignoring unrecognized --target value \"${t}\" (expected claude-code or claude-desktop).`,\n );\n }\n // If the user asked for targets but NONE resolved, fail closed rather than\n // falling through to \"no filter → apply to every detected client\" — writing\n // config to clients they didn't ask for is a surprising side effect on a\n // --apply write path.\n if (unknown.length > 0 && targets.length === 0) {\n console.error(\n \"[tandem] No valid --target values given (expected claude-code or claude-desktop); refusing to apply to all clients. Aborting.\",\n );\n process.exit(1);\n }\n await runSetup({\n apply: args.includes(\"--apply\"),\n force: args.includes(\"--force\"),\n withChannelShim: args.includes(\"--with-channel-shim\"),\n targets,\n });\n } else if (args[0] === \"mcp-stdio\") {\n const { runMcpStdio } = await import(\"./mcp-stdio.js\");\n await runMcpStdio();\n } else if (args[0] === \"channel\") {\n const { runChannelCli } = await import(\"./channel.js\");\n await runChannelCli();\n } else if (args[0] === \"doctor\") {\n // The doctor logic is bundled into dist/cli (imported, not spawned):\n // scripts/ is NOT shipped in the npm package, so a spawn would have\n // nothing to run in a global install. See src/cli/doctor.ts for the\n // full rationale. --json emits a single JSON report on stdout.\n const { runDoctorCli } = await import(\"./doctor.js\");\n const exitCode = await runDoctorCli({ json: args.includes(\"--json\") });\n process.exit(exitCode);\n } else if (args[0] === \"rotate-token\") {\n const { rotateToken } = await import(\"./rotate-token.js\");\n await rotateToken();\n } else if (args[0] === \"activate\") {\n const { runActivate } = await import(\"./license.js\");\n await runActivate(args);\n } else if (args[0] === \"license\") {\n const { runLicenseStatus } = await import(\"./license.js\");\n await runLicenseStatus();\n } else if (!args[0] || args[0] === \"start\") {\n const { runStart } = await import(\"./start.js\");\n runStart();\n } else {\n console.error(`Unknown command: ${args[0]}`);\n console.error(\"Run 'tandem --help' for usage.\");\n process.exit(1);\n }\n} catch (err) {\n console.error(`\\n[Tandem] Fatal error: ${err instanceof Error ? err.message : String(err)}`);\n console.error(\"If this persists, try reinstalling: npm install -g tandem-editor\\n\");\n process.exit(1);\n}\n"],"mappings":"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","names":["args","path","join","randomUUID","readFileSync","open","dirname","join","resolve","fileURLToPath","__dirname","path","backupPath","path","execFile","randomUUID","homedir","path","promisify","write","execFileAsync","existsSync","join","args","VALID_TOKEN_RE","detail","buffered","init_types","init_types","init_types","init_types","StdioServerTransport","z","args","resolve","pid","execFile","existsSync","readdirSync","readFileSync","statSync","createConnection","homedir","join","version","args","resolve","resolveAppDataDir","envPaths","fs","path","fsPromises","path","Y","Y","Y","z","z","init_types","init_types","Y","init_positions","parseDocument","parseDocument","init_types","init_positions","init_positions","Y","parseDocument","JSZip","parseDocument","JSZip","crypto","fs","path","atomicWrite","path","crypto","fs","atomicWrite","fs","readFile","args","existsSync","dirname","resolve","fileURLToPath","__dirname","version","runUninstallScrub","runSetup","parseTargetArgs","runMcpStdio","runChannelCli","runDoctorCli","rotateToken","runActivate","runLicenseStatus","runStart"]}