@prismshadow/penguin-server 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +1 -1
  2. package/dist/api/types.d.ts +402 -52
  3. package/dist/api/types.js +17 -3
  4. package/dist/auth-token.js +1 -1
  5. package/dist/{chunk-HMTT2PX4.js → chunk-2LZDP26O.js} +8 -5
  6. package/dist/chunk-2LZDP26O.js.map +1 -0
  7. package/dist/chunk-5YLLSHY3.js +1195 -0
  8. package/dist/chunk-5YLLSHY3.js.map +1 -0
  9. package/dist/{chunk-D3JNA6ZI.js → chunk-B3PSKR3D.js} +2 -2
  10. package/dist/chunk-B3PSKR3D.js.map +1 -0
  11. package/dist/{chunk-LPPVGHYD.js → chunk-BL4U7GYY.js} +2 -2
  12. package/dist/chunk-BL4U7GYY.js.map +1 -0
  13. package/dist/chunk-BRMNPLU6.js +37 -0
  14. package/dist/chunk-BRMNPLU6.js.map +1 -0
  15. package/dist/chunk-GGIINEEK.js +3401 -0
  16. package/dist/chunk-GGIINEEK.js.map +1 -0
  17. package/dist/{chunk-QFV3ERQO.js → chunk-OQZDL3TR.js} +2 -2
  18. package/dist/hmr/manifest.js +1 -1
  19. package/dist/index.js +13440 -8991
  20. package/dist/index.js.map +1 -1
  21. package/dist/machine-status.js +2 -2
  22. package/dist/model-catalog.d.ts +610 -0
  23. package/dist/model-catalog.js +108 -0
  24. package/dist/model-catalog.js.map +1 -0
  25. package/dist/plugin/index.d.ts +170 -74
  26. package/dist/reset-admin-password.js +2 -2
  27. package/dist/version-report.js +2 -2
  28. package/package.json +14 -9
  29. package/web-dist/assets/{abnfDiagram-VCTEODGH-0AKFiyig.js → abnfDiagram-VCTEODGH-C_Ej4MyZ.js} +1 -1
  30. package/web-dist/assets/{addon-clipboard-BovFnNVg.js → addon-clipboard-eONBCa8x.js} +1 -1
  31. package/web-dist/assets/{addon-fit-B1npgxQk.js → addon-fit-Dk6bkT8u.js} +1 -1
  32. package/web-dist/assets/{addon-web-links-D88QHj7A.js → addon-web-links-e0JpGvGv.js} +1 -1
  33. package/web-dist/assets/{arc-Beox1Cgf.js → arc-ytwqobEL.js} +1 -1
  34. package/web-dist/assets/{architectureDiagram-5GKGNRK7-CC1sLMY1.js → architectureDiagram-5GKGNRK7-oxT9SNOE.js} +1 -1
  35. package/web-dist/assets/{blockDiagram-I7D4REHJ-B8BY1PkZ.js → blockDiagram-I7D4REHJ-D8OXfpb9.js} +1 -1
  36. package/web-dist/assets/{c4Diagram-7LVT6UL2-yjY0BkxQ.js → c4Diagram-7LVT6UL2-BNYNzSWK.js} +1 -1
  37. package/web-dist/assets/channel-7Be_pU-b.js +1 -0
  38. package/web-dist/assets/{chunk-2Q5K7J3B-DdgIvETg.js → chunk-2Q5K7J3B-C2yNhFmh.js} +1 -1
  39. package/web-dist/assets/{chunk-5VM5RSS4-BE38k1Rs.js → chunk-5VM5RSS4-CAeuhwbB.js} +1 -1
  40. package/web-dist/assets/{chunk-F27PBJKO-D4YXmaqQ.js → chunk-F27PBJKO-DSuRiLxP.js} +1 -1
  41. package/web-dist/assets/{chunk-IMKFNOWR-CyJFdThc.js → chunk-IMKFNOWR-MStar4cf.js} +1 -1
  42. package/web-dist/assets/{chunk-JWPE2WC7-BAM_oEUU.js → chunk-JWPE2WC7-CIgQwRv4.js} +1 -1
  43. package/web-dist/assets/{chunk-POPQ4Y6H-BP3D0FVi.js → chunk-POPQ4Y6H-CA8evx9g.js} +1 -1
  44. package/web-dist/assets/{chunk-SVP7TREG-Clk8PdKZ.js → chunk-SVP7TREG-bR7-FT1W.js} +1 -1
  45. package/web-dist/assets/{chunk-TICWLB2K-EvWPnEGg.js → chunk-TICWLB2K-C0ZdCnvI.js} +1 -1
  46. package/web-dist/assets/{chunk-XXDRQBXY-6p5D0UB1.js → chunk-XXDRQBXY-CzozFLFI.js} +1 -1
  47. package/web-dist/assets/classDiagram-ZZMXUADV-BjVhvDIH.js +1 -0
  48. package/web-dist/assets/classDiagram-v2-VYDZK3BY-BjVhvDIH.js +1 -0
  49. package/web-dist/assets/{cose-bilkent-JH36ORCC-bpkI-tLJ.js → cose-bilkent-JH36ORCC-nkjrY6eI.js} +1 -1
  50. package/web-dist/assets/{cynefin-OW5HDTMX-srLaqJ4e.js → cynefin-OW5HDTMX-D2A0h062.js} +1 -1
  51. package/web-dist/assets/{cynefinDiagram-5FMLGOSQ-BPfpHSPc.js → cynefinDiagram-5FMLGOSQ-DFZQ7d_T.js} +1 -1
  52. package/web-dist/assets/{dagre-GXQ25YYZ-C4fF5-j7.js → dagre-GXQ25YYZ-DPYLLARd.js} +1 -1
  53. package/web-dist/assets/{diagram-S7CK7UJ4-BzBLb_9-.js → diagram-S7CK7UJ4-B204Nrur.js} +1 -1
  54. package/web-dist/assets/{diagram-UQ7AKVKN-BJuJ1o_T.js → diagram-UQ7AKVKN-DWORCZlS.js} +1 -1
  55. package/web-dist/assets/{diagram-VSXAHHWV-DFelN9i9.js → diagram-VSXAHHWV-Su6Bzm4Z.js} +1 -1
  56. package/web-dist/assets/{diagram-VX7I27RA-BgrEoPyp.js → diagram-VX7I27RA-B4p8ssYj.js} +1 -1
  57. package/web-dist/assets/{diagram-Z3DM3KII-C71KiMiX.js → diagram-Z3DM3KII-BgMz7jjW.js} +1 -1
  58. package/web-dist/assets/{ebnfDiagram-PWID7BFC-EYteyp1b.js → ebnfDiagram-PWID7BFC-DBCS3Cwc.js} +1 -1
  59. package/web-dist/assets/{erDiagram-RLTQ6QDP-B6n6-sz6.js → erDiagram-RLTQ6QDP-DEm39S5C.js} +1 -1
  60. package/web-dist/assets/{flowDiagram-HODETNUW-BtoMTdyT.js → flowDiagram-HODETNUW-CEUQGUza.js} +1 -1
  61. package/web-dist/assets/{ganttDiagram-EL5Y4UJY-CbOAa3vS.js → ganttDiagram-EL5Y4UJY-myYTgwFp.js} +1 -1
  62. package/web-dist/assets/{gitGraphDiagram-WWUBYQGX-B7-8sszH.js → gitGraphDiagram-WWUBYQGX-BAQ0Kl1C.js} +1 -1
  63. package/web-dist/assets/highlighter-DRTMVWxI.js +2 -0
  64. package/web-dist/assets/{highlighter-core-CMXoD8ze.js → highlighter-core-DgISzHrX.js} +1 -1
  65. package/web-dist/assets/{highlighter.worker-D6D9fPma.js → highlighter.worker-CKF6ok1g.js} +1 -1
  66. package/web-dist/assets/index-PA-IFoA6.js +1300 -0
  67. package/web-dist/assets/index-oCEyg9BT.css +1 -0
  68. package/web-dist/assets/{infoDiagram-27XIBGKW-BBjf6pkP.js → infoDiagram-27XIBGKW-B2WDMvX9.js} +1 -1
  69. package/web-dist/assets/{ishikawaDiagram-5VMMS53U-BBSvCv45.js → ishikawaDiagram-5VMMS53U-8qHTttjq.js} +1 -1
  70. package/web-dist/assets/{journeyDiagram-3NMN7TZE-CuHHl5zk.js → journeyDiagram-3NMN7TZE-DKE_xRCO.js} +1 -1
  71. package/web-dist/assets/{kanban-definition-UXKFOSKX-DJyP4Myw.js → kanban-definition-UXKFOSKX-EnJbHEmV.js} +1 -1
  72. package/web-dist/assets/{layout-Bqqhdwtt.js → layout-BlhCFmEZ.js} +1 -1
  73. package/web-dist/assets/{linear-BZfUSIf8.js → linear-Dw2VzU-D.js} +1 -1
  74. package/web-dist/assets/mermaid.core-CaXNMz57.js +310 -0
  75. package/web-dist/assets/{mindmap-definition-YA3MSWOX-BtyMD4wz.js → mindmap-definition-YA3MSWOX-DnTTfqbu.js} +1 -1
  76. package/web-dist/assets/{pegDiagram-XKGWAZYB-BUF_GJ0B.js → pegDiagram-XKGWAZYB-C3SArVd6.js} +1 -1
  77. package/web-dist/assets/{pieDiagram-E7YTZNPT-nweubbsU.js → pieDiagram-E7YTZNPT-CTOmO6ke.js} +1 -1
  78. package/web-dist/assets/powershell-DXrKVo3G.js +1 -0
  79. package/web-dist/assets/powershell-Dpen1YoG.js +1 -0
  80. package/web-dist/assets/{quadrantDiagram-AXDQQJYC-B_r4Wkxy.js → quadrantDiagram-AXDQQJYC-CbUsMwzO.js} +1 -1
  81. package/web-dist/assets/{railroadDiagram-O6MQD6OU-Dn7nm0MX.js → railroadDiagram-O6MQD6OU-Co2hsvaZ.js} +1 -1
  82. package/web-dist/assets/{requirementDiagram-BXWQKSXE-DaagYJ49.js → requirementDiagram-BXWQKSXE-BqFG0eYQ.js} +1 -1
  83. package/web-dist/assets/{sankeyDiagram-P5KCCOFB-CqEX6H-B.js → sankeyDiagram-P5KCCOFB-YgQHJlB7.js} +1 -1
  84. package/web-dist/assets/{sequenceDiagram-WJ2MYXX4-BzLfQO55.js → sequenceDiagram-WJ2MYXX4-CdDrP6O2.js} +1 -1
  85. package/web-dist/assets/{sizeCapture-INFHLROL-BcUuVH2L.js → sizeCapture-INFHLROL-CKwbEVhU.js} +1 -1
  86. package/web-dist/assets/{stateDiagram-D77RDMKH-BO3LWLvT.js → stateDiagram-D77RDMKH-kTJR3t2p.js} +1 -1
  87. package/web-dist/assets/stateDiagram-v2-MP3YSRHH-DzaSnZwj.js +1 -0
  88. package/web-dist/assets/{swimlanes-42K2YHIH-etP_hkIB.js → swimlanes-42K2YHIH-DCAO3rCQ.js} +1 -1
  89. package/web-dist/assets/swimlanesDiagram-VR7AAH4N-DNfCLejq.js +8 -0
  90. package/web-dist/assets/{timeline-definition-24CTP7MA-CTLt2XCT.js → timeline-definition-24CTP7MA-DEjnyzKw.js} +1 -1
  91. package/web-dist/assets/{vennDiagram-4TSXK5OY-DPO6ZSvH.js → vennDiagram-4TSXK5OY-DSihUvCq.js} +1 -1
  92. package/web-dist/assets/{wardleyDiagram-VM6X3IG4-DFofGg8b.js → wardleyDiagram-VM6X3IG4-CnAoMeCH.js} +1 -1
  93. package/web-dist/assets/{xterm-RdyTuaAH.js → xterm-3JwPT74G.js} +1 -1
  94. package/web-dist/assets/{xychartDiagram-S5SC5T6Z-Bgtb34Gu.js → xychartDiagram-S5SC5T6Z-43dHqZTr.js} +1 -1
  95. package/web-dist/index.html +6 -5
  96. package/web-dist/workbench-ui.css +164 -34
  97. package/dist/chunk-3SFJKLQU.js +0 -1016
  98. package/dist/chunk-3SFJKLQU.js.map +0 -1
  99. package/dist/chunk-D3JNA6ZI.js.map +0 -1
  100. package/dist/chunk-HMTT2PX4.js.map +0 -1
  101. package/dist/chunk-LPPVGHYD.js.map +0 -1
  102. package/dist/chunk-W7JNIJ25.js +0 -19
  103. package/dist/chunk-W7JNIJ25.js.map +0 -1
  104. package/web-dist/assets/channel-mfsAVJLE.js +0 -1
  105. package/web-dist/assets/classDiagram-ZZMXUADV-BPVO1m8i.js +0 -1
  106. package/web-dist/assets/classDiagram-v2-VYDZK3BY-BPVO1m8i.js +0 -1
  107. package/web-dist/assets/highlighter-Bj5l2XH_.js +0 -2
  108. package/web-dist/assets/index-BOCqRT4e.css +0 -1
  109. package/web-dist/assets/index-BrMpjp2s.js +0 -1286
  110. package/web-dist/assets/mermaid.core-BlUspEsI.js +0 -310
  111. package/web-dist/assets/stateDiagram-v2-MP3YSRHH-B-EuucjM.js +0 -1
  112. package/web-dist/assets/swimlanesDiagram-VR7AAH4N-0JbFdDbQ.js +0 -8
  113. /package/dist/{chunk-QFV3ERQO.js.map → chunk-OQZDL3TR.js.map} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/api/types.ts"],"sourcesContent":["/**\n * Web API DTO contract — request/response types shared between server routes and the\n * frontend SPA (single source of truth).\n *\n * These field definitions are authoritative for the Web API contract. Conventions:\n * - DTO fields use camelCase; an AMSP record keeps its own shape as-is (snake_case envelope),\n * no conversion;\n * - This file holds only types, no implementation; exposed to the frontend via package\n * exports `\"./api\"` for type-only import;\n * - Types are taken only from `@prismshadow/amsp` and core's pure `interfaces` subpath, so the\n * frontend can safely reference them.\n *\n * Docs: packages/docs/content/server-api.{zh,en}.md (site path /docs/server-api) is the\n * public route/SSE reference for this contract — keep it in sync when changing DTOs.\n */\nimport type { CompactionMode, AmspRecord, SessionSource, ToolCallPayload } from \"@prismshadow/amsp\";\nimport type {\n MCPServerConfig,\n ThinkingLevelName,\n ToolDefinitionConfig,\n} from \"@prismshadow/penguin-core/interfaces\";\n// Build/harness identity is not an interface contract — it ships from the barrel (core's version-info.ts).\nimport type { HarnessInfo, VersionReport, HarnessHistory } from \"@prismshadow/penguin-core\";\nimport type { IfacesDiff } from \"@prismshadow/penguin-hmr\";\nimport type { WorkbenchInfo } from \"../mechanisms/workbenches.js\";\n\n// ---------------------------------------------------------------------------\n// General\n// ---------------------------------------------------------------------------\n\n/**\n * Unified error response body: `code` is a machine-readable error code, `message` an English\n * user-facing message, and `details`, on the errors that have them, the facts a client acts on\n * beside the code (the Benchmark id a 409 `benchmark_exists` is about, say) — read from there,\n * never parsed out of the message.\n */\nexport interface ErrorBody {\n error: { code: string; message: string; details?: ErrorDetails };\n}\n\n/** An error's structured facts, by name; each code that carries them documents its own. */\nexport type ErrorDetails = Readonly<Record<string, string>>;\n\n/** Session approval mode (reuses the CLI enum). */\nexport type ApprovalMode = \"allow-all\" | \"deny-all\" | \"read-only\" | \"always-ask\";\n\n/** Session run status: idle / Task in progress / compacting. */\nexport type SessionStatus = \"idle\" | \"running\" | \"compacting\";\n\n/**\n * What kind of conversation a Session is (`user` / `api` / `schedule` / `subagent` / `cli` / `company` / `workbench`):\n * core's type, the one definition, re-exported for the DTOs below.\n */\nexport type { SessionSource };\n\n// ---------------------------------------------------------------------------\n// Authentication and users\n// ---------------------------------------------------------------------------\n\nexport interface UserInfo {\n /** Semantic id, i.e. login name: `^[a-z][a-z0-9_-]{1,31}$`, immutable after creation. */\n userId: string;\n /** Built-in admin (seeded at startup). */\n isAdmin: boolean;\n /** Still using the initial password (seeded/set by admin): frontend prompts the user to change it soon. */\n passwordIsInitial: boolean;\n /**\n * Nickname the account chose (1-32 characters), shown wherever the id would otherwise be.\n * Omitted when unset — every account that predates the Profile page starts without one, and\n * a surface with no value falls back to `userId`.\n */\n displayName?: string;\n /**\n * Avatar as a `data:image/...;base64,` URL, at most 131072 characters. Omitted when unset,\n * and a surface with no value draws the letter placeholder instead.\n */\n avatar?: string;\n createdAt: string;\n}\n\n/**\n * PUT /api/me/profile — a patch, not a replacement: an absent field keeps what is stored,\n * `null` clears it, a string sets it. A body naming neither field is a 400, since it can only\n * be a mistake.\n */\nexport interface UpdateProfileRequest {\n displayName?: string | null;\n avatar?: string | null;\n}\n\nexport interface UpdateProfileResponse {\n user: UserInfo;\n}\n\nexport interface AuthLoginRequest {\n userId: string;\n password: string;\n}\n\nexport interface AuthResponse {\n user: UserInfo;\n}\n\n/**\n * GET /api/install — the identity of the data root this server is serving (install-id.ts),\n * read by the web app before it mounts so state persisted against a DIFFERENT root can be\n * swept. Public, like the login route it sits next to.\n */\nexport interface InstallResponse {\n /**\n * Opaque per-data-root id, or null when the server could not establish one (an\n * unreadable or unwritable root). Null means \"unknown\", and a client must change\n * nothing on it — never treat it as a new install.\n */\n installId: string | null;\n}\n\nexport interface MeResponse {\n user: UserInfo;\n /**\n * Whether Workspace HTML previews open on a separate origin (the loopback\n * counterpart of the App host, or PENGUIN_PREVIEW_ORIGIN when set). False means this\n * deployment has no usable preview origin —\n * the App is reached on something other than a loopback name and\n * PENGUIN_PREVIEW_ORIGIN is unset — so previews fall back to the same-origin sandbox,\n * where `localStorage`, cookies and third-party embeds do not work. Computed per\n * request, since it depends on the host the caller is using.\n */\n previewIsolated: boolean;\n /**\n * Whether this server runs in desktop mode (spawned by the desktop shell with\n * PENGUIN_DESKTOP_TOKEN). The web app then hides the logout entry, the\n * initial-password banner and the self-update entry.\n */\n desktopMode: boolean;\n /**\n * How THIS session was established — distinct from desktopMode, which describes the\n * server: a browser signed into a desktop-mode server holds a \"password\" session.\n * \"desktop\" is the shell's own window (one-shot token); \"setup\" was claimed through the\n * first-login link on a server whose admin password has never been set. Both may set a\n * password without the old one (it is random and was never shown); only \"desktop\" opens\n * desktop-only routes. \"remote\" is the one-hour session Penguin Remote exchanged the local\n * API token for; it counts as the person's sign-in. \"token\" marks the local API token itself,\n * sent as a Bearer header; it cannot perform writes requiring a person's sign-in.\n */\n sessionVia: \"password\" | \"desktop\" | \"setup\" | \"remote\" | \"token\";\n /**\n * The upload limits currently in force, so the composer can refuse an oversize pick before\n * reading it and can name the real number in the message. They are admin-settable and ride\n * `/api/me` rather than `/api/admin/settings` because every user's composer needs them, not\n * just an admin's.\n */\n uploadLimits: UploadLimits;\n /**\n * Whether company mode is enabled server-wide (the admin switch in server settings, default\n * on). Off hides the mode switch in every client and 404s every organization route; the\n * user's own preference (`UiPrefs.companyMode`) only hides the switch for that user.\n */\n companyMode: boolean;\n}\n\n/**\n * Upload limits as the web app sees them: whole MB, the same unit the admin form uses, so the\n * number in the error message is the number the admin typed.\n */\nexport interface UploadLimits {\n /** Per-file cap for composer file attachments. */\n attachmentMaxMb: number;\n /** Per-message total of decoded attachment bytes. */\n attachmentTotalMb: number;\n /** Per-message file count. Fixed server-side, not admin-settable. */\n attachmentMaxCount: number;\n /**\n * Per-image cap for images that ride the conversation inline. Fixed server-side and\n * deliberately far below the attachment cap — an inline image enters the conversation and the\n * Trace, where its size is paid again on every history page and every resume.\n */\n imageMaxMb: number;\n /**\n * The range the two admin-settable limits may be set to. Carried here so the admin form states\n * the real bounds without compiling its own copy of them — the server is the only place that\n * decides how large an upload it can survive.\n */\n attachmentLimitMinMb: number;\n attachmentLimitMaxMb: number;\n}\n\nexport interface PasswordChangeRequest {\n /** Omitted only by a \"desktop\" or \"setup\" session (see {@link MeResponse.sessionVia}); required otherwise. */\n oldPassword?: string;\n /** At least 8 characters. */\n newPassword: string;\n}\n\n// ---------------------------------------------------------------------------\n// Admin user backend (admin only)\n// ---------------------------------------------------------------------------\n\nexport interface AdminUsersResponse {\n users: UserInfo[];\n}\n\nexport interface AdminUserCreateRequest {\n /** Username, i.e. user_id: `^[a-z][a-z0-9_-]{1,31}$`. */\n userId: string;\n /** Initial password (at least 8 characters), flagged as an initial password. */\n password: string;\n}\n\nexport interface AdminUserCreateResponse {\n user: UserInfo;\n}\n\nexport interface AdminPasswordResetRequest {\n /** New initial password (at least 8 characters); resets invalidate all of the user's sessions. */\n password: string;\n}\n\n/**\n * Admin-level server-global settings (SQLite server_settings):\n * two independent proxy switches sharing one optional explicit address. In every\n * on-state the effective NO_PROXY always includes localhost/127.0.0.1/::1 (loopback is\n * never proxied), and changes apply to newly initiated connections/spawns immediately —\n * no restart.\n */\nexport interface ServerSettings {\n /**\n * \"Application uses the proxy\" (default on): the server's own outbound traffic (LLM\n * requests, the update check, image fetches). On with `proxyUrl` set = that address\n * for both http and https; on without an address = the proxy environment variables\n * HTTP_PROXY / HTTPS_PROXY (both spellings); off = always direct.\n */\n proxyForApp: boolean;\n /**\n * \"Agent environment uses the proxy\" (default on): agent command subprocess\n * environments. On with `proxyUrl` set = HTTP_PROXY / HTTPS_PROXY (plus lowercase\n * twins) injected as that address with the merged NO_PROXY, overriding inherited\n * values; on without an address = the host environment passes through unchanged;\n * off = the proxy variables are stripped (NO_PROXY kept).\n */\n proxyForAgent: boolean;\n /**\n * The shared explicit proxy address (a canonical URL — http(s):// or socks5:// /\n * socks://), or null = follow the proxy environment variables. When set it takes\n * precedence over HTTP_PROXY / HTTPS_PROXY wherever the owning switch is on.\n */\n proxyUrl: string | null;\n /**\n * Per-file cap for composer file attachments, in whole MB (default 100). Applies to the very\n * next upload — the validators read it per request, nothing is snapshotted at boot.\n */\n attachmentMaxMb: number;\n /**\n * Per-message total of decoded attachment bytes, in whole MB (default 120). Never below\n * `attachmentMaxMb`, so a message may always carry one full-size attachment. The global request\n * body cap is derived from this value (base64 inflates it by 4/3, plus headroom for one inline\n * image and the JSON framing), which is why raising it needs no separate setting.\n */\n attachmentTotalMb: number;\n /**\n * Company mode master switch (default on). Off stops the organization scheduler (no\n * calendar event or chat mention fires, nothing is backfilled when it is turned on again),\n * every `/api/projects/:projectId/organizations` route answers 404, and `GET /api/me`\n * reports it so clients hide the mode switch. Organizations on disk are untouched.\n */\n companyMode: boolean;\n /**\n * Whether users may drive their own Chrome through the PenguinHarness Browser extension\n * (default on). Off closes every connected extension (close code 4009), refuses new\n * connections, and makes the chrome backend unavailable (`extension_disabled`); pairings are\n * kept, so turning it on again lets the extensions reconnect.\n */\n browserExtensionsEnabled: boolean;\n /**\n * Whether external programs may talk to Agents through the Agent API (default on). Off answers\n * every `/api/amsp/v1` request 403 `agent_api_disabled` (a CORS preflight excepted); every\n * Agent's API switch, approval mode and keys are kept, so turning it on again restores them.\n */\n agentApiEnabled: boolean;\n}\n\nexport interface ServerSettingsResponse {\n settings: ServerSettings;\n}\n\n/** PUT body: every field optional, omitted fields keep their current value (mirrors prefs). */\nexport interface ServerSettingsUpdateRequest {\n proxyForApp?: boolean;\n proxyForAgent?: boolean;\n /** Company mode master switch; see `ServerSettings.companyMode`. */\n companyMode?: boolean;\n /** Chrome extension switch; see `ServerSettings.browserExtensionsEnabled`. */\n browserExtensionsEnabled?: boolean;\n /** Agent API switch; see `ServerSettings.agentApiEnabled`. */\n agentApiEnabled?: boolean;\n /**\n * New proxy address. Accepted forms: any proxy URL undici's dispatcher takes —\n * `http://`, `https://`, `socks5://` / `socks://`, credentials allowed — or bare\n * `host[:port]` (normalized to `http://…`; only normalized values are stored, and the\n * response echoes the stored form). Empty/whitespace-only or null clears the address\n * (follow the environment variables); anything else is 400 `invalid_proxy_url`.\n */\n proxyUrl?: string | null;\n /**\n * New per-file attachment cap in whole MB. Must be an integer between 1 and 200; anything else\n * — a fraction, a string, 102400 for \"100GB\" — is 400 `invalid_attachment_limit` and writes\n * nothing.\n */\n attachmentMaxMb?: number;\n /**\n * New per-message total attachment cap in whole MB. Same 1..200 integer range, and additionally\n * must not be below the *effective* per-file cap (the value in the same PUT, or the stored one\n * when this PUT does not change it) — a total below the per-file cap would make a legal single\n * attachment unsendable. Violations are 400 `invalid_attachment_limit`.\n */\n attachmentTotalMb?: number;\n}\n\n/**\n * The endpoints the proxy reachability probe covers. A fixed list: the route takes a provider\n * id from this set and never a URL, so nothing a caller sends decides what the server fetches.\n *\n * GLM is two entries rather than one because it is two hosts: Z.AI serves the global endpoint\n * and BigModel the mainland one, they are reached over different routes, and a proxy can carry\n * one and not the other.\n */\nexport type ProxyProbeProvider =\n \"openai\" | \"anthropic\" | \"gemini\" | \"deepseek\" | \"zai\" | \"bigmodel\";\n\n/**\n * One probe's verdict. `reachable` means an HTTP answer arrived, whatever its status — a\n * rejected credential still proves the whole path works. The rest are transport failures,\n * named so a proxy that swallows connections can be told apart from one whose address does\n * not resolve: `timeout` (no answer within the probe's window), `dns` (the name never\n * became an address), `refused` (the connection was refused at the TCP level), `tls` (the\n * handshake or the certificate failed) and `network` (anything else).\n */\nexport type ProxyProbeOutcome = \"reachable\" | \"timeout\" | \"dns\" | \"refused\" | \"tls\" | \"network\";\n\n/**\n * One probe target. Served before any probe runs so the page can list what it is about to\n * request — the URLs are the concrete answer to \"what does no API key mean here\".\n */\nexport interface ProxyProbeTargetDto {\n provider: ProxyProbeProvider;\n /** The exact URL a probe requests, unauthenticated. */\n url: string;\n}\n\n/** What the probe endpoint would request, without requesting it. */\nexport interface ProxyProbeTargetsResponse {\n targets: ProxyProbeTargetDto[];\n}\n\n/** One provider's probe result. */\nexport interface ProxyProbeDto extends ProxyProbeTargetDto {\n outcome: ProxyProbeOutcome;\n /** Wall time in milliseconds until the answer's headers arrived, or until the attempt failed. */\n ms: number;\n /** The HTTP status, present only when `outcome` is `reachable`. */\n status?: number;\n}\n\n/**\n * One probe's answer. The route measures a single target per call: the page asks for all of\n * them at once and fills each row the moment its own answer lands, so one black-holed host\n * cannot hold every other result behind its timeout.\n */\nexport interface ProxyProbeResponse {\n probe: ProxyProbeDto;\n}\n\n/**\n * One draft-screen shortcut: a prompt the user wrote, filed under a name they chose. Clicking it\n * fills the composer exactly like a built-in example does, and sends nothing. Deliberately holds\n * no Skill list — a saved prompt is not authored against a known Skill catalog the way a shipped\n * example is, and the Agent it will run under is picked after the click.\n */\nexport interface DraftShortcut {\n /** Stable client-generated id: what an edit or a delete addresses the row by. Unique per user. */\n id: string;\n title: string;\n prompt: string;\n}\n\n/**\n * The account's keyboard shortcut overrides, one section per platform (the Web App's `Mod` token\n * is ⌘ on macOS and Ctrl elsewhere, so a deliberate macOS-only binding must not leak to Windows).\n * Each section maps a command id to a chord string (\"Mod+Shift+KeyW\") or null (explicitly\n * unbound); only rows that differ from the Web App's default are present. Bounded on write by\n * services/keybindings.ts.\n */\nexport interface StoredKeybindings {\n v: 1;\n mac?: Record<string, string | null>;\n windows?: Record<string, string | null>;\n linux?: Record<string, string | null>;\n}\n\n/** User UI preferences (SQLite ui_prefs, free-form JSON; known keys declared here). */\nexport interface UiPrefs {\n theme?: \"light\" | \"dark\";\n lastProjectId?: string;\n /** Whether the \"no API key configured\" guide has already been shown: once ever (on first visit to the chat page). */\n credentialGuideSeen?: boolean;\n /** The initial-password notice banner (app layout) was permanently dismissed by the user. */\n initialPasswordBannerDismissed?: boolean;\n /**\n * \"I have dealt with this\" markers for the dismissible to-do badges, per Project id and then\n * per trail (`skills` / `models` / `errors`). Each value is the SIGNATURE of what was waved\n * away rather than a hidden flag, so anything new raises the dot again (web's\n * `lib/todo-badges.ts`). Replaced whole on every write, like `draftShortcuts`: the merge is\n * shallow, so this whole map is one field.\n */\n todoDismissed?: Record<string, Record<string, string>>;\n /**\n * Notices the user closed, by notice id (a machine's model keys only this server's environment\n * holds: `machine-env-only:<machine id>`), each the SIGNATURE of what it named when closed — its\n * items sorted, one per line — so it comes back when a new item appears (web's\n * `lib/notice-dismissals.ts`). Replaced whole on every write, like `todoDismissed`.\n */\n noticesDismissed?: Record<string, string>;\n /**\n * The draft screen's user-defined shortcuts, in display order. Replaced whole on every write\n * (the merge is shallow, so the array is one field like any other) and bounded on write by\n * services/draft-shortcuts.ts — count, title length and prompt length — because this is the one\n * known key holding user-authored text rather than a flag or an id.\n */\n draftShortcuts?: DraftShortcut[];\n /** Keyboard shortcut overrides, replaced whole on every write and validated by services/keybindings.ts. */\n keybindings?: StoredKeybindings;\n /** Personal company-mode switch (default on): off only hides this user's mode switch; organizations keep running. */\n companyMode?: boolean;\n /** The work mode the user last chose in the shell: development (default) or company. */\n workMode?: \"dev\" | \"company\";\n /** The organization last opened in company mode, as `<projectId>/<orgId>`. */\n lastOrgKey?: string;\n /** The one group balance shown beside the user name (pinned on the models page); null when unpinned. */\n pinnedBalance?: PinnedBalance | null;\n /**\n * The browser this user's agents drive. Written only through PUT /api/builtin-browser/backend\n * (PUT /api/me/prefs refuses it): absent means built-in for an admin on the desktop, chrome\n * everywhere else.\n */\n browserBackend?: BrowserBackend;\n // ----- The Settings pages' preferences, which follow the account to every browser. Each is\n // the value the Web App keeps in that browser's cache (see its lib/account-sync), validated on\n // write by services/account-settings.ts for shape only: the Web App decides what a value\n // means and reads anything it does not know as its default. `null` reads as unset.\n /** Light / dark / follow the system. */\n themeMode?: string | null;\n /** Which theme renders the app (a theme id). */\n themeId?: string | null;\n /** The accent preset, or \"neutral\" for the theme's own. */\n accent?: string | null;\n /** The root text size step. */\n textSize?: string | null;\n /** The Latin face paired over the theme's own (\"theme\" = keep the theme's). */\n fontLatin?: string | null;\n /** The CJK face paired over the theme's own (\"theme\" = keep the theme's). */\n fontCjk?: string | null;\n /** Interface language: zh / en / system. */\n lang?: string | null;\n /** Display currency for prices. */\n currency?: string | null;\n /** The terminal's appearance: light / dark / follow the app. */\n terminalTheme?: string | null;\n /** Whether tool cards name the built-in tools by their short alias. */\n toolAliases?: boolean | null;\n /** Whether task-completion notifications were asked for (each browser still needs its own OS permission). */\n notifications?: boolean | null;\n /** Whether the floating dock launcher was put away. */\n launcherHidden?: boolean | null;\n /** Mid-run send mode: steer or queue as a follow-up. */\n steerMode?: string | null;\n /** The custom background's display settings; the image itself is GET|PUT /api/me/background-image. */\n background?: BackgroundSettingsDto | null;\n /**\n * Which background image the account holds, as an opaque id that changes whenever the image\n * does; absent or null when it holds none. Written only by PUT /api/me/background-image (PUT\n * /api/me/prefs refuses it): a browser compares it with the image it has cached and fetches the\n * image only when they differ.\n */\n backgroundImageId?: string | null;\n [key: string]: unknown;\n}\n\n/** The custom background's display settings, as the Web App stores them. */\nexport interface BackgroundSettingsDto {\n /** 0–100: how much of the page colour shows through the image. */\n transparency: number;\n mode: string;\n position: string;\n}\n\n/**\n * The background image cap, in characters of the data URL: the same number the Web App encodes\n * against, so the browser and the server cannot disagree about what \"too large\" means.\n */\nexport const BACKGROUND_IMAGE_MAX_CHARS = 1024 * 1024;\n\n/** GET /api/me/background-image. */\nexport interface BackgroundImageResponse {\n /** A PNG / JPEG / WebP data URL, or null when the account holds no image. */\n image: string | null;\n imageId: string | null;\n}\n\n/** PUT /api/me/background-image: the image to keep, or null to remove it. */\nexport interface BackgroundImageRequest {\n image: string | null;\n}\n\n/** PUT /api/me/background-image's answer: the id the image is now known by (null once removed). */\nexport interface BackgroundImagePutResponse {\n imageId: string | null;\n}\n\n/**\n * The kinds of draft the account keeps, as the first segment of a draft key\n * (`<kind>:<id>`). The id names what the draft belongs to, so the server can drop it with that\n * thing: `chat` — a Project's new-conversation draft (`chat:<projectId>`); `parked` — a\n * Project's parked draft conversations (`parked:<projectId>`); `org` — a Project's\n * create-organization form (`org:<projectId>`); `channel` — a company-mode channel's composer\n * (`channel:<projectId>.<orgId>.<channelId>`); `session` — a Session's composer\n * (`session:<sessionId>`); `a2ui` — a reply's form answers in progress\n * (`a2ui:<sessionId>:<block>`).\n */\nexport const DRAFT_KINDS = [\"chat\", \"parked\", \"org\", \"channel\", \"session\", \"a2ui\"] as const;\nexport type DraftKind = (typeof DRAFT_KINDS)[number];\n\n/** One draft's cap, in characters of its JSON text. */\nexport const DRAFT_VALUE_MAX_CHARS = 256 * 1024;\n\n/** Draft writes one PATCH may carry. */\nexport const DRAFTS_PATCH_MAX_ENTRIES = 50;\n\n/** GET /api/me/drafts: every draft the account keeps, by key, as the JSON each was written with. */\nexport interface DraftsResponse {\n drafts: Record<string, unknown>;\n}\n\n/**\n * PATCH /api/me/drafts: drafts to write, by key; `null` removes one. Applied together. When the\n * account then holds more than its bound, its least recently written drafts outside this patch\n * are dropped.\n */\nexport interface DraftsPatchRequest {\n drafts: Record<string, unknown>;\n}\n\n/** A group balance pinned beside the user name: which Project's key reads which group's balance. */\nexport interface PinnedBalance {\n projectId: string;\n provider: string;\n}\n\nexport interface PrefsResponse {\n prefs: UiPrefs;\n}\n\n// ---------------------------------------------------------------------------\n// Project and member authorization\n// ---------------------------------------------------------------------------\n\nexport type ProjectRole = \"owner\" | \"member\";\n\nexport interface ProjectSummary {\n projectId: string;\n /** Display name (the `name` in project_config.toml); frontend falls back to projectId when unset. */\n name?: string;\n /** Current user's role in this Project. */\n role: ProjectRole;\n ownerUserId: string;\n createdAt: string;\n}\n\nexport interface ProjectsResponse {\n projects: ProjectSummary[];\n}\n\nexport interface ProjectCreateRequest {\n /**\n * Semantic id, specified by the creator: `^[a-z][a-z0-9_-]{1,63}$`, immutable after creation.\n * Non-admins must prefix it with `<username>-` (the web input locks the prefix segment);\n * admins are unrestricted.\n */\n projectId: string;\n /** Display name; defaults to projectId. */\n name?: string;\n}\n\nexport interface ProjectCreateResponse {\n project: ProjectSummary;\n}\n\nexport interface ProjectUpdateRequest {\n /** New display name. The projectId itself is immutable — only this label can change. */\n name: string;\n}\n\nexport interface ProjectUpdateResponse {\n project: ProjectSummary;\n}\n\nexport interface MemberInfo {\n userId: string;\n role: ProjectRole;\n createdAt: string;\n}\n\nexport interface MembersResponse {\n members: MemberInfo[];\n}\n\nexport interface MemberAddRequest {\n /** Username of the user being granted access (owner invites by username). */\n userId: string;\n}\n\nexport interface MemberAddResponse {\n member: MemberInfo;\n}\n\n// ---------------------------------------------------------------------------\n// Model and credential config (single .project_config.toml file; credentials are inlined in it:\n// once per group in [providers.<id>], and on a model entry where that model overrides its group)\n// ---------------------------------------------------------------------------\n\n/**\n * Model reference DTO: `(provider, modelId)` pair.\n * `modelId` is the upstream request id, sent to MMSP as-is — `<provider>/<id>` string\n * concatenation is forbidden throughout the pipeline.\n */\nexport interface ModelRefDto {\n provider: string;\n modelId: string;\n}\n\n/** Three pricing buckets, in USD per million tokens (unit is fixed at usd_per_mtok; not carried in the DTO). */\nexport interface ModelPricingDto {\n cacheRead: number;\n cacheWrite: number;\n output: number;\n}\n\n/**\n * One group's `[providers.<id>]` connection as the page reads it: the values the GROUP stores\n * (never the catalog's), the key masked. An absent field is unset: a row with no value of its\n * own then gets the client's default.\n */\nexport interface ProviderConnectionDto {\n baseUrl?: string;\n clientType?: string;\n apiKeyMasked?: string;\n /** When the group key was written (ISO 8601). */\n createdAt?: string;\n}\n\n/** Where an effective connection value comes from: the row itself, its group, or nowhere (the client's default). */\nexport type ConnectionSourceDto = \"model\" | \"provider\" | \"none\";\n\n/**\n * What a row is actually used with, after core's effectiveConnection (row -> group -> none,\n * per field; the file's values only — the catalog is not consulted), and where each value\n * came from. The group's key counts only where it reaches the row (core's groupKeyReaches).\n * The row's own values stay in `clientType` / `credential` (what the form edits); this is for\n * display.\n */\nexport interface ModelEffectiveConnection {\n baseUrl?: string;\n baseUrlSource: ConnectionSourceDto;\n clientType?: string;\n clientTypeSource: ConnectionSourceDto;\n /**\n * `env` = no key in the file, and MMSP's routed client reads its own variable for the row's\n * endpoint (no base URL, or the client's default endpoint) — reported when that variable\n * holds a value here (`envKeyMasked`), `none` otherwise.\n */\n apiKeySource: \"model\" | \"provider\" | \"env\" | \"none\";\n /** Mask of the key the row would use, whichever source; absent when none. */\n apiKeyMasked?: string;\n}\n\n/** Read-only credential display: masked key and creation time; plaintext is never sent. */\nexport interface CredentialInfo {\n apiKeyMasked?: string;\n baseUrl?: string;\n createdAt?: string;\n}\n\nexport interface ModelInfo {\n /** Provider group id (anthropic / openai / …, see core's MODEL_PROVIDERS; custom models use `custom`). */\n provider: string;\n /** Upstream model id (the request id actually sent to MMSP); paired with `provider` forms the entry's unique key. */\n modelId: string;\n /**\n * Display name: explicit TOML field (user-edited) takes priority, then the built-in catalog;\n * falls back to unset (frontend shows modelId). The empty string is reported as such and\n * means the user cleared the name on a model the catalog does name — render it as modelId,\n * and send it back unchanged, since absent would ask for the catalog's name instead.\n */\n displayName?: string;\n contextWindow?: number;\n /**\n * The row's OWN MMSP client type (`openai-chat`, `openai-responses`, etc.) — an override of\n * its group's; absent = the row follows its group (see `effective`).\n */\n clientType?: string;\n /**\n * Whether image input (vision/multimodal) is supported: the TOML `vision` annotation only\n * (a new Project writes `false` where the catalog says so); absent = unset, treated as\n * supported. The catalog is not consulted.\n */\n vision?: boolean;\n /**\n * Per-model max output tokens (TOML `max_tokens` annotation; user-only, never preset by the\n * built-in catalog): when set it wins over the Agent's `system_config.model.max_tokens`;\n * unset = inherit the Agent value. Lets a small-context model cap its output below the\n * seeded per-Agent default (32000), which cannot fit into e.g. a 32k context window.\n */\n maxTokens?: number;\n /**\n * Per-model fast mode (TOML `fast_mode` annotation; user-only, never preset by the\n * built-in catalog): when true, session requests opt into the provider's faster serving\n * tier at premium pricing (MMSP UniConfig `fast_mode`). Only `true` is reported;\n * unset = off. Models without a fast tier reject requests carrying it.\n */\n fastMode?: boolean;\n pricing?: ModelPricingDto;\n /** Running promotion for this row — a fraction in (0, 1) off `pricing`, which is the list price — read from web.db. Absent when the row has none. */\n discount?: number;\n /**\n * Environment variable name the entry falls back to when api_key is empty (e.g.\n * ANTHROPIC_API_KEY): only for a row with no key in the file whose routed MMSP client reads\n * that variable for its endpoint — no base URL on the row or its group, or the client's own\n * default endpoint. Unset for a row with a key (its own or a group key that reaches it), a row\n * sent to any other base URL (a gateway, Penguin Go, ModelScope, a vendor's second host or a\n * proxy), or an id nothing routes.\n */\n envKey?: string;\n /**\n * Masked preview (same rule as `credential.apiKeyMasked`) of the value the server process\n * currently holds for `envKey` — the plaintext is never serialized. Absent = the variable is\n * unset or empty, or the entry gets no fallback. Reported only when no row or group key\n * reaches the row.\n */\n envKeyMasked?: string;\n /** The row's OWN key (masked) and base URL — overrides of its group's; see `effective`. */\n credential?: CredentialInfo;\n /** The connection the row is actually used with, and where each value comes from. */\n effective: ModelEffectiveConnection;\n isDefault: boolean;\n}\n\nexport interface ModelsResponse {\n /** Paired reference to the default Model. */\n defaultModel?: ModelRefDto;\n /** Vision model used as a proxy reader for read_file (describes images when the session model has vision=false). */\n visionModel?: ModelRefDto;\n /**\n * When the Project's model/credential config last changed (ISO; the config file's mtime,\n * so it survives restarts). The web's auth-dead gate compares it against the last auth\n * abort: an abort OLDER than the last credential update no longer disables the composer\n * (the key was fixed since). Absent when the Project has no config file yet.\n */\n updatedAt?: string;\n /** Every group's stored connection (`[providers.<id>]`), keyed by provider id; a group with none is absent. */\n providers: Record<string, ProviderConnectionDto>;\n models: ModelInfo[];\n}\n\n/**\n * A change to one group's connection (`PUT /models/providers/:provider`, or a member of\n * `ModelsUpdateRequest.providers`). Omitted fields are kept.\n */\nexport interface ProviderConnectionUpdate {\n /** `null` clears it; omitted keeps it. */\n baseUrl?: string | null;\n /**\n * `null` or `\"\"` clears it; omitted keeps it. Any group takes one: a row that stores its own\n * protocol keeps it.\n */\n clientType?: string | null;\n /** A non-empty key; sets `createdAt`. Rows with their own key keep it. */\n apiKey?: string;\n /** When true, clears the group key (rows with their own key keep it). */\n clearApiKey?: boolean;\n}\n\n/** PUT full-table replace semantics: models not present are deleted; omitting apiKey = keep existing value. Key = (provider, modelId). */\nexport interface ModelUpdateEntry {\n /** Provider group (an independent entry field, always submitted with the request). */\n provider: string;\n /** Upstream model id (sent to MMSP as-is). */\n modelId: string;\n /**\n * Display name; the server does not persist it when it matches the built-in catalog (keeps\n * the config file clean). Absent and empty are different requests: absent inherits whatever\n * the catalog calls the model, the empty string records that the user cleared the name.\n */\n displayName?: string;\n /**\n * The pair reference this entry was renamed from (provided when either the group or the\n * upstream id changes): the server uses this to migrate the original entry's credential\n * and unknown fields to the new key — otherwise a full-table replace would delete the\n * original entry along with its credential.\n */\n renamedFrom?: ModelRefDto;\n contextWindow?: number;\n /**\n * Empty string/omitted = the row stores none: it follows its group's protocol, else MMSP\n * routes by the vendor family the modelId begins with.\n */\n clientType?: string;\n /** Whether image input (vision/multimodal) is supported; omitted = supported (not persisted). */\n vision?: boolean;\n /** Per-model max output tokens, a positive integer (wins over the Agent config); omitted = inherit the Agent value (the annotation is cleared). */\n maxTokens?: number;\n /** Per-model fast mode: only `true` is persisted; omitted or `false` clears the annotation (absent = off). */\n fastMode?: boolean;\n pricing?: ModelPricingDto;\n /**\n * Promotion to store for this row, a fraction in (0, 1) off `pricing`; `null` clears it. Omitted\n * keeps the stored promotion — unless this entry renames the row or changes its pricing, which\n * clears it.\n */\n discount?: number | null;\n /** Providing it overwrites and updates createdAt; omitting it keeps the existing value. */\n apiKey?: string;\n /** When true, clears the stored api_key. */\n clearApiKey?: boolean;\n /** null clears it; omitted keeps the existing value. */\n baseUrl?: string | null;\n}\n\nexport interface ModelsUpdateRequest {\n /** Must be included in models (matched by paired reference). */\n defaultModel?: ModelRefDto;\n /** Vision model used as a proxy reader for read_file: must be included in models and not annotated vision=false; omitted keeps the existing value. */\n visionModel?: ModelRefDto;\n /** Group connection changes, merged per provider; a provider absent here keeps its table. */\n providers?: Record<string, ProviderConnectionUpdate>;\n models: ModelUpdateEntry[];\n}\n\n/**\n * `POST /models/sync-presets` (owner): `add` adds the catalog's presets the Project lacks\n * (retired rows never), as a new Project stores them, writes the catalog's\n * `[providers.<id>]` table for a group that has neither rows nor a table yet, and seeds the\n * added rows' promotions, touching nothing else; `restore` makes the model configuration a new\n * Project's and keeps only the API keys: the presets in catalog order, the catalog's group\n * tables, default model and promotions, no vision model, and every other row and every\n * user-defined group deleted. Penguin Go included: its platform's next sync brings back the\n * platform's own models and promotions.\n */\nexport type PresetSyncMode = \"add\" | \"restore\";\n\nexport interface PresetSyncRequest {\n mode: PresetSyncMode;\n}\n\nexport interface PresetSyncResponse extends ModelsResponse {\n /** Preset rows added (both modes). */\n added: number;\n /** Preset rows the Project already had that `restore` changed (0 for `add`). */\n restored: number;\n}\n\n/**\n * Connectivity test (POST /api/projects/:p/models/test): the model reference is submitted as\n * a pair in the request body; the rest are optional overrides (for trying out an unsaved\n * config). When the model isn't in the config yet (adding a custom model — test-before-save),\n * all parameters come from this request body.\n */\nexport interface ModelTestRequest {\n /** Provider group of the model under test (paired with modelId). */\n provider: string;\n /** Upstream id of the model under test (sent to MMSP as-is). */\n modelId: string;\n /** Newly entered API key (plaintext); used for the test if provided. */\n apiKey?: string;\n /** \"Clear saved API key\" is checked: the test does **not** fall back to the stored key (tests against the current draft). */\n clearApiKey?: boolean;\n /** Speed-test mode: raises the probe's output cap (16 -> 64 tokens) so TTFT/TPS are measurable; costs a little more quota. */\n speed?: boolean;\n /**\n * base URL (not secret; the frontend always sends the form's current value): a string\n * means use it, `null` means explicitly clear it (no fallback to the stored value),\n * `undefined` means fall back to the stored value only when not provided.\n */\n baseUrl?: string | null;\n /** MMSP client type; required for an unsaved custom model whose id begins with no known vendor family (otherwise it can't be routed). */\n clientType?: string;\n /**\n * Test with fast mode (the frontend always sends the form's current value, so an unsaved\n * toggle — either direction — is what gets tested); omitted falls back to the stored\n * annotation. Lets \"Test connection\" surface a fast-mode rejection before saving.\n */\n fastMode?: boolean;\n}\n\n/**\n * Vision capability probe: sends one 1x1 image and a one-word prompt on this model's\n * credential, to decide whether the models dialog's \"supports vision\" switch should be\n * turned on. The body mirrors the connectivity test (same paired reference and same\n * not-yet-saved overrides), so an unsaved model can be probed before it exists on disk.\n *\n * Unlike protocol detection this is a real, billed completion — an image request cannot be\n * shaped to cost nothing — so it only ever runs when the user presses the control.\n */\nexport interface ModelVisionDetectRequest {\n provider: string;\n modelId: string;\n /** Newly entered API key (plaintext); the stored one backs the probe when omitted. */\n apiKey?: string;\n /** \"Clear saved API key\" is checked: do not fall back to the stored key. */\n clearApiKey?: boolean;\n /** Form's current base URL; null means \"explicitly none\" (as in the connectivity test). */\n baseUrl?: string | null;\n /** Protocol to speak, when the form has one; otherwise the stored/auto-routed client. */\n clientType?: string;\n}\n\n/**\n * Vision probe verdict. `supported` = the model took the image and answered; `unsupported`\n * = it answered specifically that it will not take an image (a definitive negative, not an\n * error); `failed` = the probe never got a usable answer (auth, network, an unrelated\n * error), so nothing about the capability was learned and the setting is left alone.\n */\nexport type ModelVisionOutcome = \"supported\" | \"unsupported\" | \"failed\";\n\n/** Vision probe result; `message` carries the truncated provider error for the failed case. */\nexport interface ModelVisionDetectResponse {\n outcome: ModelVisionOutcome;\n message?: string;\n}\n\n/**\n * Connectivity test result: carries round-trip latency when ok, and a reason on failure\n * (truncated raw provider error). When streamed content was observed, also carries the\n * time-to-first-token and, when usage was reported (completed streams), the output rate.\n */\nexport interface ModelTestResponse {\n ok: boolean;\n latencyMs?: number;\n /** Time from request start to the first streamed content (thinking or text), ms. */\n ttftMs?: number;\n /**\n * Output tokens per second over the streaming window (first content -> stream end), 1dp.\n * Omitted unless the sample is large enough to mean anything: a reply of a few tokens is\n * dominated by the final chunk's round trip, so the rate it yields tracks network jitter\n * rather than the model. Callers render TTFT alone in that case.\n */\n tps?: number;\n message?: string;\n}\n\n/**\n * Protocol auto-detection (POST /api/projects/:p/models/detect, owner): probes which of\n * MMSP's generic protocol clients a custom base URL serves — `openai-responses` first,\n * then `ant-messages`, then `openai-chat` — and reports the first hit. Used by the custom\n * model dialog to fill `clientType` from the endpoint itself; costs no tokens (each probe\n * is a minimal invalid request whose error reveals the protocol shape).\n */\nexport interface ModelProtocolDetectRequest {\n /** Base URL to probe (as typed in the form); each probe appends its protocol path. */\n baseUrl: string;\n /** Newly entered API key (plaintext); used for probe auth if provided. Detection also works keyless: a protocol-shaped 401/403 still proves the route. */\n apiKey?: string;\n /** \"Clear saved API key\" is checked: do not fall back to the stored key (probe the current draft). */\n clearApiKey?: boolean;\n /**\n * Optional paired reference: when it names a stored entry and no apiKey is given, that\n * entry's saved key backs the probes (mirrors the connectivity test), else its group's.\n * `provider` may be sent without `modelId` (the group settings dialog has no model): the\n * group key backs the probes.\n */\n provider?: string;\n modelId?: string;\n}\n\n/**\n * One probe's outcome: `served` = the route answered in an API shape (including auth\n * failures — 401/403 with a protocol-shaped body proves the route exists);\n * `route_missing` = 404/405; `server_error` = 5xx (proves nothing about the path);\n * `junk` = HTML / non-JSON / JSON matching no API shape; `timeout` / `network_error` =\n * the request itself failed.\n */\nexport type ProtocolProbeOutcome =\n \"served\" | \"route_missing\" | \"server_error\" | \"junk\" | \"timeout\" | \"network_error\";\n\n/** One probe, for debugging display: the probed URL derives from baseUrl only (never contains the key). */\nexport interface ModelProtocolProbeDto {\n /** MMSP client type this probe stands for (`openai-responses` / `ant-messages` / `openai-chat`). */\n clientType: string;\n /** Full URL probed (base URL + the protocol's path). */\n url: string;\n outcome: ProtocolProbeOutcome;\n /** HTTP status, when a response arrived at all. */\n status?: number;\n}\n\n/**\n * Detection result: probes run sequentially over the candidate base URLs derived from the\n * one that was typed and stop at the first served protocol, so `probes` lists only the\n * ones actually run, in order.\n */\nexport interface ModelProtocolDetectResponse {\n /** The first protocol an endpoint serves (an MMSP client type); absent when none of the three matched. */\n detected?: string;\n /**\n * The base URL the detected protocol answered at — the typed URL normalized (endpoint\n * path stripped, repeated `/v1` collapsed) and possibly with a trailing `/v1` added or\n * removed, so `https://host/v1/chat/completions` comes back as `https://host/v1`.\n * Present only alongside `detected`; callers write it back into their base URL field,\n * since that is where the protocol is served.\n */\n baseUrl?: string;\n probes: ModelProtocolProbeDto[];\n}\n\n/**\n * Endpoint model listing (POST /api/projects/:p/models/list, owner): given a base URL and\n * the protocol `/detect` reported, returns every model id the endpoint serves (MMSP's\n * `listModels()` on the routed client). Used by the add-group dialog to import a provider's\n * whole listing in one go; the ids come back in the endpoint's own order.\n */\nexport interface EndpointModelListRequest {\n /** Endpoint base URL (as typed in the add-group dialog). */\n baseUrl: string;\n /** MMSP client type to speak (normally a detected generic protocol; whole-endpoint listings need one). */\n clientType: string;\n /** Newly entered API key (plaintext); omitted = the SDK's environment fallback for the protocol. */\n apiKey?: string;\n}\n\n/** Listing outcome: model ids on success, a truncated provider/SDK reason otherwise. */\nexport interface EndpointModelListResponse {\n ok: boolean;\n /** The model ids the endpoint serves, in the order the endpoint returned them (ok only). */\n models?: string[];\n /** The routed client has no models endpoint (MMSP UnsupportedOperationError) — callers offer the manual path. */\n unsupported?: boolean;\n message?: string;\n}\n\n/**\n * PUT /api/projects/:p/models/default (owner): narrow default-model switch — flips the same\n * top-level `default_model` the models page's whole-table PUT writes, without resending the\n * table (and thus without touching credentials). The pair must name a configured model\n * entry, exactly like the whole-table route's defaultModel validation.\n */\nexport interface DefaultModelUpdateRequest {\n provider: string;\n modelId: string;\n}\n\n/** Response mirrors what GET models reports as `defaultModel`. */\nexport interface DefaultModelResponse {\n defaultModel: ModelRefDto;\n}\n\n/** One balance in one currency. */\nexport interface ModelBalanceAmount {\n /** Decimal string in `currency`'s major unit, as the vendor states it (TokenDance's micro-yuan are converted to yuan). */\n amount: string;\n /** ISO 4217 code: `CNY`, `USD`, … */\n currency: string;\n}\n\n/**\n * Why a group's balance could not be read:\n * - `unsupported`: the group publishes no balance endpoint (no `balance` descriptor in the catalog);\n * - `no_key`: the group has no API key to ask with — none stored, and no vendor variable that\n * MMSP's vendor client reads for the group's base URL;\n * - `upstream_failed`: the vendor could not be reached in time, refused the request, or answered\n * without a readable balance.\n */\nexport type ModelBalanceErrorCode = \"unsupported\" | \"no_key\" | \"upstream_failed\";\n\n/**\n * GET /api/projects/:p/models/balance?provider=<group>[&force=1] (Project member): the account\n * balance behind the group's API key — the stored one, or else the environment key MMSP's\n * vendor client reads for the group's base URL (DeepSeek's DEEPSEEK_API_KEY) — read server-side\n * from the endpoint the catalog's `balance` descriptor names. The key never reaches the browser, and\n * neither does the vendor's own text. Like the connectivity test, a balance that cannot be read is an answer\n * (`ok: false` with a code), not a failed request. Readings and vendor failures are cached for\n * 60 s per Project and group, for as long as the group's key is the same; `force=1` skips the\n * cache and refreshes it.\n */\nexport type ModelBalanceResponse = ModelBalanceReading | ModelBalanceFailure;\n\nexport interface ModelBalanceReading extends ModelBalanceAmount {\n ok: true;\n provider: string;\n /** Whether the account can make requests right now (DeepSeek's `is_available`); absent when the vendor does not say. */\n available?: boolean;\n /** Further currencies the account holds, after the one in `amount` / `currency`; absent when there is only one. */\n others?: ModelBalanceAmount[];\n /** When the server read it from the vendor (ISO 8601); a cached answer keeps its original time. */\n fetchedAt: string;\n}\n\nexport interface ModelBalanceFailure {\n ok: false;\n provider: string;\n error: ModelBalanceErrorCode;\n /** The vendor's HTTP status, when it answered with an error. */\n status?: number;\n /** English detail for logs and bug reports; the Web App words each code itself. */\n message: string;\n /** When the answer was settled (ISO 8601). */\n fetchedAt: string;\n}\n\n// ---------------------------------------------------------------------------\n// Provider key-minting flows (/api/projects/:p/model-oauth, owner)\n// ---------------------------------------------------------------------------\n\n/**\n * How the authorization code travels back. `callback` sends the browser to a harness URL\n * the provider redirects to; `manual` asks the provider for a one-time code the user copies\n * back by hand, for the case where that redirect cannot reach the harness — or where the\n * provider would refuse it: only an HTTPS or loopback HTTP origin can be redirected to.\n */\nexport type ModelOAuthMode = \"callback\" | \"manual\";\n\n/**\n * POST /api/projects/:p/model-oauth/start (owner): opens a flow for one provider group.\n * The group must declare an authorization flow in the built-in catalog — the endpoints it\n * uses come from there, never from this request.\n */\nexport interface ModelOAuthStartRequest {\n /** Provider group id (must be a catalog group that publishes a key-minting flow). */\n provider: string;\n /** Defaults to `callback`; the response says which mode the flow was opened in. */\n mode?: ModelOAuthMode;\n}\n\n/** The opaque flow handle plus the page to send the user to. No secret of the flow's is included. */\nexport interface ModelOAuthStartResponse {\n flowId: string;\n authorizeUrl: string;\n /**\n * The mode the flow was opened in: `manual` for a `callback` request when the origin the\n * request reached this server on is neither HTTPS nor loopback HTTP, which the provider's\n * authorization page refuses as a callback.\n */\n mode: ModelOAuthMode;\n}\n\n/** Why a flow failed, as a code the frontend phrases; never carries a code, a verifier or a key. */\nexport type ModelOAuthErrorCode =\n \"invalid_request\" | \"code_rejected\" | \"upstream_failed\" | \"unreachable\" | \"apply_failed\";\n\n/**\n * GET /api/projects/:p/model-oauth/:flowId (owner): where a flow stands. Unknown, expired,\n * and other users' flows are all 404 alike.\n */\nexport interface ModelOAuthStatusResponse {\n status: \"pending\" | \"done\" | \"error\";\n /** The provider group the flow mints a key for. */\n provider: string;\n /**\n * How many models the minted key was written to — set on the `done` answer, the way\n * {@link ModelOAuthCodeResponse} sets it on a redemption. The dialog reports this number, so\n * it has to be the server's own: the caller's model table can outlive a rejected save and\n * would otherwise name a count the server never wrote.\n */\n applied?: number;\n error?: ModelOAuthErrorCode;\n}\n\n/**\n * POST /api/projects/:p/model-oauth/:flowId/code (owner): redeems a code the user pasted,\n * the manual counterpart of the redirect callback. A flow is single-use either way.\n */\nexport interface ModelOAuthCodeRequest {\n code: string;\n}\n\n/** Redemption outcome; `applied` counts the models the minted key was written to. */\nexport interface ModelOAuthCodeResponse {\n ok: boolean;\n applied?: number;\n error?: ModelOAuthErrorCode;\n}\n\n// ---------------------------------------------------------------------------\n// Penguin Go key authorization (/api/projects/:p/platform-auth, owner)\n// ---------------------------------------------------------------------------\n\nexport interface PlatformAuthStartResponse {\n flowId: string;\n authorizeUrl: string;\n expiresAt: string;\n}\n\nexport type PlatformAuthFlowErrorCode =\n | \"unreachable\"\n | \"upstream_failed\"\n | \"invalid_key\"\n | \"expired\"\n | \"locked\"\n | \"already_delivered\"\n | \"apply_failed\";\n\nexport interface PlatformAuthFlowStatusResponse {\n status: \"pending\" | \"applying\" | \"completed\" | \"cancelled\" | \"apply_failed\" | \"error\";\n error?: PlatformAuthFlowErrorCode;\n applied?: number;\n}\n\n/** Result of refreshing Penguin Go's catalog with the Project's stored platform key. */\nexport interface PlatformModelSyncResponse extends ModelsResponse {\n added: number;\n updated: number;\n}\n\n// ---------------------------------------------------------------------------\n// ModelScope key authorization (/api/projects/:p/modelscope-auth, owner)\n// ---------------------------------------------------------------------------\n\n/**\n * The wire shapes are Penguin Go's, deliberately: both flows create a request, poll it, and\n * report the same six states and the same seven failure codes, so the App drives both with one\n * dialog. They are aliases rather than a second declaration because a copy would be the same\n * fields with the same meaning, and a shape that drifted apart in one of the two would be a\n * bug rather than a distinction. The endpoints stay separate regardless — a flow id issued by\n * one is not accepted by the other.\n */\nexport type ModelScopeAuthStartResponse = PlatformAuthStartResponse;\nexport type ModelScopeAuthFlowErrorCode = PlatformAuthFlowErrorCode;\nexport type ModelScopeAuthFlowStatusResponse = PlatformAuthFlowStatusResponse;\n\n// ---------------------------------------------------------------------------\n// New-chat defaults (the `[default_chat]` block of .project_config.toml)\n// ---------------------------------------------------------------------------\n\n/**\n * Per-Project new-chat defaults: prefill for the chat draft page. Every key is optional —\n * an absent key means \"not set\" (the pre-existing behavior). Serves as the GET response,\n * the PUT request body (whole-block replace: an omitted key clears it) and the PUT\n * response (the stored block). The default MODEL is deliberately not here: it stays the\n * top-level `default_model` served/written via the models routes (single-sourced with the\n * models page).\n */\nexport interface ChatDefaultsDto {\n /** Preselected Agent; must reference an existing Agent of the Project (400 unknown_agent). */\n agentId?: string;\n /** Prefilled Workspace directory; absent/empty = a temporary workspace. */\n workspace?: string;\n /** Prefilled approval mode; absent = the built-in \"allow-all\". */\n approvalMode?: ApprovalMode;\n /**\n * Fallback thinking level for Agents whose config has no explicit `model.thinking_level`\n * (resolution chain: Agent explicit > this project default > built-in \"medium\"). Never\n * \"none\" — only the selectable tiers.\n */\n thinkingLevel?: Exclude<ThinkingLevelName, \"none\">;\n /**\n * Read-only, GET only: the sandbox policy a new Session starts with — the server's Sandbox\n * settings. Not part of the Project's block; PUT ignores it.\n */\n sandbox?: SessionSandbox;\n}\n\n// ---------------------------------------------------------------------------\n// Sandbox command policy (Project-level: the [command_policy] block)\n// ---------------------------------------------------------------------------\n\n/**\n * One deny rule of the sandbox command policy — plain project-editable data. The factory\n * rules are seeded into new projects and carry no special status thereafter: every rule\n * can be edited, disabled, or deleted.\n */\nexport interface CommandPolicyRuleDto {\n name: string;\n /** JavaScript regex source, matched against the whitespace-normalized command. */\n pattern: string;\n /** What the rule catches (free text). */\n description?: string;\n /** Per-rule switch, effective value (defaults on). */\n enabled: boolean;\n}\n\n/**\n * Per-Project sandbox command policy: deny rules for shell commands, evaluated ahead of\n * the approval mode (a hit is denied even under allow-all). Serves as the GET response and\n * the PUT response. The PUT request body carries `enabled?` plus the full `rules` list\n * (required — a PUT always materializes the list into the config, model-presets style);\n * per-rule `enabled` may be omitted there and defaults to on. Owner-only to write; any\n * member may read.\n */\nexport interface CommandPolicyDto {\n /** Effective master switch; an absent stored value reads as true (the policy defaults on). */\n enabled: boolean;\n /** The effective rule list — the factory set when the project predates seeding and has none stored. */\n rules: CommandPolicyRuleDto[];\n /** The factory set, served for the settings UI's \"restore defaults\". */\n defaultRules: CommandPolicyRuleDto[];\n}\n\n// ---------------------------------------------------------------------------\n// Vault environment variables (Agent-level: agent_state/.vault.toml)\n// ---------------------------------------------------------------------------\n\n/** Read-only vault entry display: key name + masked value; plaintext is never sent. */\nexport interface VaultEntryInfo {\n key: string;\n valueMasked: string;\n}\n\nexport interface VaultResponse {\n entries: VaultEntryInfo[];\n}\n\n/** A single entry under PUT full-table replace semantics: omitting value = keep the existing value (required for new keys). */\nexport interface VaultEntryUpdate {\n /** Shell environment variable name rule: starts with a letter or underscore, followed by letters/digits/underscores only. */\n key: string;\n /** Non-empty string; omitted keeps the existing value. */\n value?: string;\n}\n\n/** PUT full-table replace semantics (same as models): keys not present in the body are deleted. */\nexport interface VaultUpdateRequest {\n entries: VaultEntryUpdate[];\n}\n\n// ---------------------------------------------------------------------------\n// Agent and its config (system_config.yaml + AGENTS.md)\n// ---------------------------------------------------------------------------\n\n/** One library plugin an Agent's installed copy is behind on — one of its skills or its hook package carries an older dated version than the library's same part (see {@link AgentSummary.pluginUpdates}). */\nexport interface PluginUpdateRef {\n /** Plugin name — what `POST …/plugins` reinstalls to bring the Agent up to date. */\n name: string;\n /**\n * The LIBRARY's content version: the newest dated version (`YYYY.MM.DD.N`) among the plugin's\n * parts, i.e. the date of what installing again would bring, not what is on disk. A label —\n * it moves whenever any part changes, so a dismissed update comes back for the next one.\n */\n version: string;\n}\n\nexport interface AgentSummary {\n agentId: string;\n name?: string;\n description?: string;\n createdAt?: string;\n /** Last config modification time: the larger mtime of system_config.yaml / AGENTS.md (unset if stat fails). */\n updatedAt?: string;\n /** Number of this Agent's Sessions currently running / compacting. */\n activeSessionCount: number;\n /** Total Session count (DB index ∪ Trace directory discovery, including archived). */\n sessionCount: number;\n /** Daily active Session count for the last 30 days (index 0 = earliest, last = today; active = created that day or has a Trace record that day). */\n sessionActivity: number[];\n /** Tool count: number of tools.builtin + tools.mcpServers config entries (MCP counted per server). */\n toolCount: number;\n /** Agent State version number (the `version` in system_config.yaml; treated as 1 if missing). */\n version: number;\n /** Whether the config's kernel stamp is behind the current defaults generation (a missing stamp counts as outdated) — drives the list card's update hint. */\n kernelOutdated: boolean;\n /** Vault key count (number of keys in agent_state/.vault.toml). */\n vaultKeyCount: number;\n /** Schedule count (number of .toml files directly under agent_state/schedule/, including invalid ones; archived files under schedule/expired/ are not counted). */\n scheduleCount: number;\n /** Workbench folders with a manifest, including ones with a load error. */\n workbenchCount: number;\n /** Installed Skill count (number of agent_state/skills/<name>/ directories with a SKILL.md). */\n skillCount: number;\n /** Installed hook-package count (number of agent_state/hooks/<name>/ directories with a hooks.json). */\n hookCount: number;\n /**\n * Installed plugins the built-in library has moved past — a skill or a hook package whose\n * on-disk dated version is behind the library's same part (compared part by part, never\n * against the plugin's npm version) — each with the library's content version on offer: the\n * plugin-library update gate, riding along on the Agent list so a badge over that page costs\n * no request of its own. Empty when nothing is behind. Skills the library does not carry\n * (installed from a zip or a picked directory) are never listed: there is no library version\n * for them to be behind.\n */\n pluginUpdates: PluginUpdateRef[];\n /** Memory count (topic files summed over the scope directories under agent_state/memory/, independent of the memory switch). */\n memoryCount: number;\n /** Whether the Agent's API tab switch is on (this server's web.db, see AgentApiSettings.enabled): the list card's API mark. */\n apiEnabled: boolean;\n /**\n * The organizations of this Project that employ the Agent, one entry per organization:\n * every organization directory the Project holds, paused ones included, plus the CEO id of\n * an organization whose chart does not parse. Absent or empty = not an employee. An\n * employed Agent cannot be deleted (`DELETE` answers 409 `agent_employed`) until it leaves\n * every one of them; development mode lists employees in a section of their own.\n */\n employments?: Employment[];\n}\n\n/** One organization employing an Agent, as the Agent list carries it (see {@link AgentSummary.employments}). */\nexport interface Employment {\n orgId: string;\n /** The organization's display name (`org_config.toml`), the id when the config does not parse. */\n orgName: string;\n /** The Agent's title in that organization's chart; \"CEO\" for the CEO of a chart that does not parse. */\n title: string;\n status: OrgStatus;\n}\n\nexport interface AgentsResponse {\n agents: AgentSummary[];\n}\n\nexport interface AgentCreateRequest {\n /** Semantic id, specified by the creator: `^[a-z][a-z0-9_-]{1,63}$`, unique within the Project, immutable after creation. */\n agentId: string;\n /** Display name; defaults to agentId. */\n name?: string;\n description?: string;\n /**\n * Library plugin names installed into the new Agent, seeding it at creation — each plugin's\n * skills and hook package. Every name must exist in the library (404 `unknown_plugin`\n * otherwise, before anything is created); omitted or empty leaves the Agent with nothing\n * installed, which is what a plain Agent gets by default.\n */\n plugins?: string[];\n /**\n * Skills imported from a directory on disk instead of the library. `skillsDirectory` is the\n * absolute path the user picked and `directorySkills` are the names to install from it, read\n * back from `.agents/skills` / `.claude/skills` at creation time. Both are required together,\n * and every name must still be there (404 `unknown_skill` otherwise, before anything is\n * created) — the client sends names, never Skill content.\n */\n skillsDirectory?: string;\n directorySkills?: string[];\n /**\n * Base64 of an exported Agent State snapshot package (`.tar.gz`): the new Agent is\n * initialized from the package instead of the default template. Mutually exclusive with\n * seeding (`plugins` / `skillsDirectory`) — the package carries its own skills and hooks.\n * Explicit `name` / `description` override the package's values; absent ones keep them.\n */\n dataBase64?: string;\n}\n\nexport interface AgentCreateResponse {\n agent: AgentSummary;\n}\n\n/**\n * Where a deleted Agent's files stand. The Agent itself is gone the moment DELETE answers: its\n * folder was moved out of the Project's agents directory in one rename, and every list and route\n * stopped seeing it. What follows is the removal of that folder's contents in the background,\n * which this reports.\n */\nexport interface AgentDeletionInfo {\n /** Names this deletion (GET /api/projects/:projectId/agent-deletions/:deletionId). */\n deletionId: string;\n agentId: string;\n /**\n * `counting`: the folder is walked to learn how many entries it holds (`total` grows);\n * `removing`: they are removed (`removed` grows towards `total`); `done`: nothing is left;\n * `failed`: some entries could not be removed (`error` says why). What is left is tried again\n * when the server next starts.\n */\n phase: \"counting\" | \"removing\" | \"done\" | \"failed\";\n /** Entries found: so far while counting, then in all. Files and folders alike. */\n total: number;\n /** Entries removed so far. */\n removed: number;\n /** The first error the removal met, as the operating system worded it (`failed` only). */\n error?: string;\n}\n\n/** DELETE /api/projects/:projectId/agents/:agentId (202). */\nexport interface AgentDeleteResponse {\n deletion: AgentDeletionInfo;\n}\n\n/** GET /api/projects/:projectId/agent-deletions/:deletionId. */\nexport interface AgentDeletionResponse {\n deletion: AgentDeletionInfo;\n}\n\nexport interface AgentModelConfigDto {\n maxTokens?: number;\n thinkingLevel?: ThinkingLevelName;\n timeoutMs?: number;\n}\n\nexport interface AgentCompactionConfigDto {\n maxContextLength?: number;\n maxSessionTurns?: number;\n mode?: \"summarize\" | \"discard\";\n prompt?: string;\n}\n\n/** Memory config. All fields report effective values (a config with no `memory` section reads as enabled with the built-in prompts, matching core); the prompts are edited on the Memory tab. */\nexport interface AgentMemoryConfigDto {\n enabled: boolean;\n /** The always-injected half of the `{{MEMORY}}` block (carries `{{USER_MEMORY_INDEX}}`; the User directory is literal text). */\n prompt: string;\n /** Appended only in a persistent Workspace (carries `{{WORKSPACE_MEMORY_INDEX}}` and the rendered `{{WORKSPACE_MEMORY_DIR}}` directory). */\n workspacePrompt: string;\n}\n\n/**\n * Vault prompt-injection config, edited on the Vault tab. `enabled` / `prompt` report\n * effective values (a config with no `vault` section reads as enabled with the built-in\n * prompt, matching core); the last two are read-only facts computed from the stored template.\n */\nexport interface AgentVaultConfigDto {\n /** Whether the Vault section enters the model context (values are injected into subprocesses regardless). */\n enabled: boolean;\n /** The `{{VAULT}}` block (carries `{{VAULT_KEYS}}`). */\n prompt: string;\n /** Whether the stored template carries `{{VAULT}}`; POST …/vault/template-placeholder inserts (or migrates to) it explicitly. */\n templateHasPlaceholder: boolean;\n /** Whether the stored template still carries the legacy hardcoded # Vault section verbatim (a pre-`{{VAULT}}` Agent) — the migration case of the insert endpoint. */\n legacySectionPresent: boolean;\n}\n\n/** Skills prompt-injection config, edited on the Skills tab; same field semantics as AgentVaultConfigDto, for `{{SKILLS}}` / `{{SKILL_METADATA}}` and the legacy # Skills section. */\nexport interface AgentSkillsConfigDto {\n /** Whether the Skills section enters the model context (installed skills remain explicitly invocable regardless). */\n enabled: boolean;\n /** The `{{SKILLS}}` block (carries `{{SKILL_METADATA}}`). */\n prompt: string;\n /** Whether the stored template carries `{{SKILLS}}`; POST …/skills/template-placeholder inserts (or migrates to) it explicitly. */\n templateHasPlaceholder: boolean;\n /** Whether the stored template still carries the legacy hardcoded # Skills section verbatim — the migration case of the insert endpoint. */\n legacySectionPresent: boolean;\n}\n\n/** Schedules prompt-injection config, edited on the Schedules tab. No legacy field: Schedules never had a hardcoded template section. */\nexport interface AgentSchedulesConfigDto {\n /** Whether the Scheduled Tasks section enters the model context (the server fires configured tasks regardless). */\n enabled: boolean;\n /** The `{{SCHEDULES}}` block (carries `{{SCHEDULE_LIST}}`). */\n prompt: string;\n /** Whether the stored template carries `{{SCHEDULES}}`; POST …/schedules/template-placeholder inserts it explicitly. */\n templateHasPlaceholder: boolean;\n}\n\n/**\n * Hook config, edited on the Hooks tab. One Agent-level switch and no prompt: hook packages\n * are scripts run at the loop's hook points, not text injected into the context. `enabled`\n * reports the effective value (a config with no `hooks` section reads as enabled, matching\n * core).\n */\nexport interface AgentHooksConfigDto {\n /** Whether a Session created from now on runs the installed hook packages (they stay installed either way). */\n enabled: boolean;\n}\n\n/** Structured view of system_config.yaml (for the edit form). */\nexport interface AgentConfigDto {\n name?: string;\n description?: string;\n /** Agent State version number (treated as 1 if missing; shown in the settings page overview). */\n version: number;\n /** The stored kernel stamp (`kernel_version`): which defaults generation the config is based on; null when the config predates the kernel-version mechanism. */\n kernelVersion: string | null;\n /** The current defaults generation (core's KERNEL_VERSION) — what a kernel update would stamp. */\n kernelLatest: string;\n /** Whether the stamp is behind kernelLatest (a missing stamp counts as outdated). */\n kernelOutdated: boolean;\n systemPrompt: string;\n maxTurns?: number;\n model?: AgentModelConfigDto;\n compaction?: AgentCompactionConfigDto;\n memory: AgentMemoryConfigDto;\n vault: AgentVaultConfigDto;\n skills: AgentSkillsConfigDto;\n schedules: AgentSchedulesConfigDto;\n hooks: AgentHooksConfigDto;\n toolsBuiltin: ToolDefinitionConfig[];\n mcpServers: MCPServerConfig[];\n}\n\nexport interface AgentConfigResponse {\n agentsMd: string;\n /** Raw system_config.yaml text (read-only display / diagnostics). */\n systemConfigYaml: string;\n config: AgentConfigDto;\n /** Agent State absolute path. */\n stateDir: string;\n activeSessionCount: number;\n}\n\n/**\n * POST …/config/kernel-update result: the smart merge's outcome (core's applyKernelUpdate).\n * The entries are Agent settings **tabs** (`prompt`, `runtime`, `tools`, `skills`, `memory`,\n * `vault`, `schedules`) in the settings page's tab order; the client maps them to tab labels.\n */\nexport interface AgentKernelUpdateResponse {\n /** Tabs advanced to the current defaults (previously absent, or an untouched old default). */\n advanced: string[];\n /** Tabs kept whole because they match no recorded default (customized, kept conservatively). */\n kept: string[];\n /** The kernel stamp written (the current defaults generation). */\n kernelVersion: string;\n}\n\n/**\n * POST …/config/mcp-test result: reachability of one MCP Server entry. Its `${KEY}` references\n * are filled in from the Agent's vault only when the entry is saved on the Agent exactly as\n * tested (same name, same config — the user's rule); a new or changed entry that references the\n * vault is not filled in and not contacted (`saveFirst`).\n */\nexport interface McpServerTestResponse {\n ok: boolean;\n /** Discovered tool names (`mcp__<server>__<tool>`), present on success. */\n tools?: string[];\n /** Failure detail (connect error, timeout, server stderr tail; \"needs setup: …\" naming a vault key a saved entry references and the vault lacks, with nothing contacted), present on failure. */\n error?: string;\n /** The entry references the vault and is not saved as it is: nothing was filled in or contacted — save it, then test (`error` says so in English). */\n saveFirst?: boolean;\n /** Connect + discovery wall time (both outcomes) — the models test reports latency, this matches. */\n latencyMs?: number;\n}\n\n/** PUT any subset: only provided keys are updated (remaining YAML content and comments preserved); agentsMd overwrites the whole file. */\nexport interface AgentConfigUpdateRequest {\n agentsMd?: string;\n config?: {\n name?: string;\n description?: string;\n systemPrompt?: string;\n maxTurns?: number;\n model?: AgentModelConfigDto;\n compaction?: AgentCompactionConfigDto;\n memory?: Partial<AgentMemoryConfigDto>;\n /** Only the writable half of the DTO — the template facts (templateHasPlaceholder / legacySectionPresent) are computed, never written. */\n vault?: { enabled?: boolean; prompt?: string };\n skills?: { enabled?: boolean; prompt?: string };\n schedules?: { enabled?: boolean; prompt?: string };\n /** The Agent-level hook switch; it has no prompt half. */\n hooks?: { enabled?: boolean };\n toolsBuiltin?: ToolDefinitionConfig[];\n mcpServers?: MCPServerConfig[];\n };\n}\n\n// ---------------------------------------------------------------------------\n// Agent API (/api/projects/:projectId/agents/:agentId/api; the stream itself is AMSP,\n// /api/amsp/v1, whose snake_case wire types live in @prismshadow/amsp)\n// ---------------------------------------------------------------------------\n\n/** One API key of an Agent, as listed: the secret itself is shown once, at creation, and never again. */\nexport interface AgentApiKeyInfo {\n keyId: string;\n /** 1-64 characters, given by whoever created it. */\n name: string;\n /** The key's first 16 characters (`penguin_` + 8), enough to tell keys apart. */\n prefix: string;\n /** user_id of the Project owner who created it. */\n createdBy: string;\n createdAt: string;\n /** Last run request that authenticated with it; null = never used. */\n lastUsedAt: string | null;\n}\n\n/**\n * One Agent's public API settings — this server's, kept in web.db, never in the Agent State or\n * the Project file, so the Agent cannot expose itself or loosen its own approvals. An Agent\n * never configured reads as disabled, keyed, allow-all, with no keys.\n */\nexport interface AgentApiSettings {\n /** The API tab's switch; off = every AMSP request for this Agent is 404 `agent_not_found`. */\n enabled: boolean;\n /** Keyless access: a request without `Authorization` is accepted; off = 401 `unauthorized`. */\n open: boolean;\n /**\n * The approval mode an API Session is created with. It is copied onto each API Session when\n * that Session is created, and the Session's own mode is what every decision reads, so a\n * change here applies to API Sessions created afterwards.\n */\n approvalMode: ApprovalMode;\n /** Null follows the Project's default model. Applied only to new API Sessions. */\n model: ModelRefDto | null;\n /** Null follows the Agent/Project thinking configuration. */\n thinkingLevel: ThinkingLevelName | null;\n /** Null creates a temporary Workspace for each new API Session. */\n workspace: string | null;\n /** Positive integer. Checked for each API run; defaults to four concurrent Agent sessions. */\n maxConcurrentRuns: number;\n keys: AgentApiKeyInfo[];\n}\n\nexport interface AgentApiResponse {\n api: AgentApiSettings;\n /**\n * The admin's server-wide switch (`ServerSettings.agentApiEnabled`), which only an admin can\n * read through /api/admin/settings: off, every Agent's API is refused (403) whatever `api` says.\n */\n serverEnabled: boolean;\n}\n\n/** PUT body (owner only): every field optional, omitted fields keep their current value. */\nexport interface AgentApiUpdateRequest {\n enabled?: boolean;\n open?: boolean;\n approvalMode?: ApprovalMode;\n model?: ModelRefDto | null;\n thinkingLevel?: ThinkingLevelName | null;\n workspace?: string | null;\n maxConcurrentRuns?: number;\n}\n\n/** POST …/api/keys body (owner only). */\nexport interface AgentApiKeyCreateRequest {\n /** 1-64 characters. */\n name: string;\n}\n\n/** The one response that carries the secret (`penguin_` + 43 base64url characters); only its hash is stored. */\nexport interface AgentApiKeyCreateResponse {\n key: AgentApiKeyInfo;\n secret: string;\n}\n\n// ---------------------------------------------------------------------------\n// Memory\n// ---------------------------------------------------------------------------\n\n/** One Memory scope directory: `agent_state/memory/user/` or `agent_state/memory/<workspaceKey>/`. */\nexport interface MemoryScopeInfo {\n /** Directory name under `memory/`: `user`, or a Workspace's `<safe-basename>-<hash>` key. */\n scopeKey: string;\n /** `user` — the scope every Session reads, temporary Workspaces included; `workspace` — one Workspace's scope. */\n kind: \"user\" | \"workspace\";\n /** Workspace path the key was derived from, read from the directory's `.workspace` marker; unset on the user scope (it stands for no path) and for a directory edited by hand. */\n workspacePath?: string;\n /** Number of Markdown topic files in the directory (the `MEMORY.md` index not counted). */\n fileCount: number;\n /** Whether the directory holds a `MEMORY.md` index, so an import confirmation can say whether one would be replaced. */\n hasIndex: boolean;\n /** Most recent topic-file mtime in the directory (ISO 8601); unset when the directory holds no topic file. */\n updatedAt?: string;\n}\n\n/** One Memory topic file, as listed (frontmatter only — the body is fetched per file). */\nexport interface MemoryFileInfo {\n /** File name inside the scope directory, e.g. `prefers-pnpm.md`. */\n name: string;\n /** Frontmatter `name`; falls back to the file name. */\n title: string;\n /** Frontmatter `description`; empty when the file declares none. */\n description: string;\n /** Frontmatter `updated_at`, verbatim. */\n updatedAt?: string;\n /** File size in bytes. */\n size: number;\n /** File mtime (ISO 8601). */\n modifiedAt: string;\n}\n\n/** GET …/memory — the tab's landing payload: the switch and every scope group, user scope first. */\nexport interface MemoryOverviewResponse {\n /** Whether Memory reaches the model context (the Agent-level switch). */\n enabled: boolean;\n /** Whether the prompt template carries the `{{MEMORY}}` placeholder. An Agent created before Memory has none and injects nothing; POST …/memory/template-placeholder inserts it explicitly. */\n templateHasMemory: boolean;\n /** Absolute path of `agent_state/memory/`. */\n memoryDir: string;\n scopes: MemoryScopeInfo[];\n}\n\n/** GET …/memory/scopes/:key/files */\nexport interface MemoryFilesResponse {\n scopeKey: string;\n files: MemoryFileInfo[];\n}\n\n/** GET …/memory/scopes/:key/files/:name */\nexport interface MemoryFileResponse {\n scopeKey: string;\n file: MemoryFileInfo;\n content: string;\n}\n\n/** One topic file inside a transfer document: the name it had in its scope, and its whole text. */\nexport interface MemoryTransferFile {\n /** File name inside the scope directory, e.g. `prefers-pnpm.md` — a name, never a path. */\n name: string;\n /** The file's full Markdown text, frontmatter included. */\n content: string;\n}\n\n/**\n * GET …/memory/scopes/:key/export, and the body a POST …/import carries back: everything one\n * scope holds, as one JSON document — the topic files and the scope's own `MEMORY.md`.\n */\nexport interface MemoryScopeExport {\n /** Format marker, so a foreign JSON file is refused with a clear reason rather than half-imported. */\n format: \"penguin-memory-scope\";\n /** Document version. A reader accepts exactly this; a later format bumps it and states its own compatibility. */\n version: 1;\n /** The scope this was exported from. Informational: an import writes into the scope its URL names. */\n scopeKey: string;\n kind: MemoryScopeInfo[\"kind\"];\n /** The Workspace the source scope stood for, when it had a `.workspace` marker. */\n workspacePath?: string;\n /** When the document was produced (ISO 8601). */\n exportedAt: string;\n /** The scope's `MEMORY.md`, or null when the scope has none. Only the index reaches the model's context. */\n index: string | null;\n files: MemoryTransferFile[];\n}\n\n/**\n * What an import does with a name the target scope already holds:\n * - `skip` — keep what is on disk, write only names the scope does not have (destroys nothing);\n * - `overwrite` — replace a same-named file's content;\n * - `replace` — additionally delete every topic file the document does not carry.\n * The two destructive modes require `confirm`.\n */\nexport type MemoryImportMode = \"skip\" | \"overwrite\" | \"replace\";\n\n/** POST …/memory/scopes/:key/import */\nexport interface MemoryImportRequest {\n /** Defaults to `skip`. */\n mode?: MemoryImportMode;\n /** Required by `overwrite` and `replace`; without it they are refused with 409 `memory_import_confirm_required`. */\n confirm?: boolean;\n payload: MemoryScopeExport;\n}\n\n/** What one import did, name by name, so the UI can report it rather than claim success. */\nexport interface MemoryImportResponse {\n scopeKey: string;\n mode: MemoryImportMode;\n /** Names written that the scope did not have. */\n added: string[];\n /** Names whose existing content was replaced. */\n overwritten: string[];\n /** Names left untouched because the scope already had them (`skip` only). */\n skipped: string[];\n /** Names deleted because `replace` dropped everything the document did not carry. */\n removed: string[];\n /** Whether the scope's `MEMORY.md` was written or extended. */\n indexWritten: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Session\n// ---------------------------------------------------------------------------\n\n/** How far a Session's commands may reach the filesystem (the sandbox's confinement mode). */\nexport type SessionSandboxMode = \"read-only\" | \"workspace-write\" | \"danger-full-access\";\n\n/**\n * The part of a Session's sandbox policy a person picks from the composer: the filesystem\n * mode and the network level. The Session keeps its own copy — taken from the server's\n * Sandbox settings when it was created — so editing those settings only changes what NEW\n * Sessions start with.\n */\nexport interface SessionSandbox {\n mode: SessionSandboxMode;\n /** `open` = unrestricted, `local` = only the host's localhost, `none` = no network. */\n network: SessionSandboxNetwork;\n /**\n * Response only, ignored in requests: whether a sandbox backend on this server can enforce\n * the `read-only` and `workspace-write` modes. False on a deployment with no backend\n * installed, where either mode would refuse every command: the composer shows them greyed\n * out, saying no backend is installed.\n */\n confinementSupported?: boolean;\n /** Response only, ignored in requests: the same for the `none` network level. */\n noNetworkSupported?: boolean;\n /**\n * Response only, ignored in requests: whether a sandbox backend on this server can enforce\n * the `local` level. When false the composer shows it greyed out, and picking it is refused\n * (400 `sandbox_unsupported`).\n */\n localNetworkSupported?: boolean;\n /** Response only, ignored in requests: whether a backend here can enforce `masksPaths`. */\n maskPathsSupported?: boolean;\n /**\n * Response only, ignored in requests: true when this policy hides paths (the Sandbox card's\n * masked paths, kept by every pick). Where `maskPathsSupported` is false, every level then\n * refuses every command, and the composer says so.\n */\n masksPaths?: boolean;\n /**\n * Response only, ignored in requests: the names of the sandbox backends in use here, which the\n * composer names when a level is beyond what they enforce. Absent with none in use.\n */\n backendsInUse?: string[];\n /**\n * Response only, ignored in requests: the sandbox backends that are enabled here but failed\n * to load or failed their check (a WSL distro not set up, a wrong program path), each with\n * why. A backend for another platform is not listed. With none mounted and one listed, the\n * composer marks the confining levels unavailable and gives the first one's reason.\n */\n unavailableBackends?: UnavailableSandboxBackend[];\n /**\n * Response only, ignored in requests: the server's sandbox presets (the Sandbox card's table),\n * in table order, disabled rows included. The composer lists the enabled ones and names the\n * Session's level by the first row matching its mode, network and approval mode. A server\n * that does not report it gets the composer's built-in table.\n */\n presets?: SessionSandboxPreset[];\n /**\n * Response only, ignored in requests: true when this Session's policy holds something the\n * presets do not show — masked paths, or the temp directory not writable.\n */\n advanced?: boolean;\n /**\n * Response only, chat defaults only: the approval mode a new Session starts with when its\n * request names none — the Sandbox card's default preset's, while the switch is on. Absent:\n * the server's own fallback (`allow-all`).\n */\n defaultApprovalMode?: ApprovalMode;\n /**\n * Response only, ignored in requests: the Sandbox card's switch on this server — whether new\n * Sessions start confined. Off, the composer offers the approval modes alone (a Session keeps\n * its own policy either way). A server that does not report it is read as on.\n */\n switchOn?: boolean;\n}\n\n/** One sandbox preset: a named mode, network level and approval mode. */\nexport interface SessionSandboxPreset {\n id: string;\n /** The name an administrator gave it, or its declared English name. */\n name: string;\n /** Its declared Chinese name, while it has not been renamed. */\n nameZh?: string;\n /** Whether the composer's menu lists it. */\n enabled: boolean;\n mode: SessionSandboxMode;\n network: SessionSandboxNetwork;\n approvalMode: ApprovalMode;\n /**\n * Response-only: its file mode or network is wider than the server's sandbox settings (the\n * ceiling for non-admins), so a non-admin's pick of it is refused with `403 sandbox_forbidden`.\n * The same comparison as that refusal; absent when it is within them.\n */\n aboveCeiling?: true;\n}\n\n/** An enabled sandbox backend that is not in use on this server, and why. */\nexport interface UnavailableSandboxBackend {\n name: string;\n reason: string;\n}\n\n/** The network levels, widest first. */\nexport type SessionSandboxNetwork = \"open\" | \"local\" | \"none\";\n\nexport interface SessionInfo {\n sessionId: string;\n projectId: string;\n agentId: string;\n /**\n * Provider group of the session's **current** model (paired with `modelId` to form a model\n * reference). It moves with each in-session switch (`POST /api/sessions/:id/switch-model`).\n */\n provider: string;\n /** Upstream model_id of the session's current model (the request id sent to MMSP). */\n modelId: string;\n workspace: string;\n approvalMode: ApprovalMode;\n /** The Session's own sandbox policy (see {@link SessionSandbox}). */\n sandbox: SessionSandbox;\n /**\n * Thinking level pinned for this Session (set via PATCH; the Web App's in-chat picker).\n * Unset = never pinned: each model context the Session opens reads the Agent config's\n * `model.thinking_level`, so config edits keep taking effect. Once pinned it survives\n * reloads and applies from the Session's very next LLM request — the thinking level is\n * the soft-limited runtime parameter, changeable mid-context at the cost of the\n * provider's cached context (the picker advises compacting first) — and every later\n * context opens at it; an Agent-config change no longer moves it.\n */\n thinkingLevel?: ThinkingLevelName;\n /** Short title auto-generated by the model after the first turn; unset until generated (frontend shows \"New Chat\"). */\n title?: string;\n /**\n * What kind of conversation this is, read from core session_meta — the single source of\n * truth (the DB stores no source). With the user's marks it decides where the Session is\n * listed (`archived`): a `user` Session is in the sidebar until archived; an `api`,\n * `schedule`, `cli` or `workbench` Session is archived until the user keeps it, and in the sidebar marked\n * with its source once kept; a `subagent` or `company` Session is in no list — its parent's\n * Agent panel and company mode's own views open them. A Trace head that records no source\n * (written before it was required) reads as `company` for a row the organization runtime\n * opened (`client: \"org\"`), else as `user`; so does such a row before its first run. Absent\n * only while any other row is not yet classified (its Trace head has not been read yet),\n * which renders as `user`.\n */\n source?: SessionSource;\n createdAt: string;\n /**\n * Last activity the server drove for this Session (ISO 8601, same convention as\n * createdAt), monotonic — it never moves backwards. Set to createdAt at creation, then\n * stamped when a run starts and again when it ends; a run here is one Task, one\n * compaction, or one whole goal loop (every round of a goal belongs to a single run, so\n * an N-round goal stamps twice, not 2N times).\n *\n * Two kinds of row therefore stay at createdAt no matter how busy they look: Sessions\n * adopted from a CLI Trace (this server drives none of their runs) and subagent rows\n * (their work is driven through the parent Session's entry). Reading real activity for\n * those would mean consulting the Trace tree, which this field deliberately does not do.\n *\n * Rows that predate the field are backfilled once at startup from the Session's most\n * recent request timestamp, falling back to createdAt.\n */\n lastActiveAt: string;\n status: SessionStatus;\n /** Number of approvals awaiting human decision (a persisted count outside server events, for list badges). */\n pendingApprovalCount: number;\n /** Number of queued follow-up tasks (`queueIfBusy`) awaiting auto-start once the session is idle. */\n pendingFollowUpCount: number;\n /** Whether a Trace record exists (a Task has been started). */\n hasTrace: boolean;\n /**\n * Whether the Session is archived — the listing rule's answer, never the bare mark: the user\n * archived it, or a program opened it (source `api`, `schedule`, `cli` or `workbench`) and the user has\n * not kept it. An archived Session is left out of the sidebar and listed on the archive page\n * (`/api/me/archived-sessions`). Always false for a `subagent` or `company` Session, which is\n * in no list.\n */\n archived: boolean;\n /**\n * Absolute path of the session's latest Trace file (the current context shard); absent\n * when no Trace exists yet. Populated on the **single-session GET only** — list rows omit\n * it (locating it costs a directory walk per Session). The web's `/model` switch puts it\n * into the new session's `[model_switch_from]` block so the model can read the source\n * history itself when it needs it.\n */\n tracePath?: string;\n /** Present when the Session has an ENABLED messaging binding: its channel (the sidebar row's per-channel indicator). */\n messagingChannel?: MessagingChannel;\n /**\n * Company mode: the organization that owns this Session — a desk session of one of its\n * employees, or a session contributing to one of its tickets — read from the organization\n * caches. Absent for every ordinary Session. Development mode's list asks the server to\n * leave every such row out (`excludeOrg=1` on the list route — page and totals alike) and\n * still drops one that reaches it by another door; the company sidebar's 工位 group and the\n * tickets are where these Sessions are reached instead.\n */\n orgId?: string;\n /**\n * Which client opened the Session, as stored on the index row: \"cli\" from the CLI (a\n * Session adopted from a legacy CLI-direct Trace included), \"org\" from the organization\n * runtime (a desk or a ticket session, and every sub-session one of those spawns), \"api\"\n * from an Agent API run (the only Sessions the API may continue), \"web\" otherwise. Absent\n * only on a row that predates the column, which reads as \"web\". Unlike\n * {@link SessionInfo.orgId} — projected from the organization caches, so it disappears with\n * the organization and is not read while company mode is off — this is a durable stamp on\n * the row: development mode's list hides an \"org\" Session either way.\n */\n client?: \"web\" | \"cli\" | \"org\" | \"api\" | \"workbench\";\n /**\n * Background work the Session's loaded runtime still owns: command sessions running past\n * their yield window (`exec_command` promotions and `run_in_background` launches) and\n * background subagent sessions mid-round. Read from the runtime's in-memory registries —\n * no Trace is consulted — and present only while at least one count is non-zero, so a\n * Session that is not loaded (a resumed entry starts with empty registries) and one with\n * nothing running both omit it. Changes are pushed as `session_background` on the user\n * channel; list rows and the single-session GET carry the same field.\n */\n backgroundTasks?: SessionBackgroundTasks;\n}\n\n/** The background-task counts of one Session (see SessionInfo.backgroundTasks). */\nexport interface SessionBackgroundTasks {\n /** Background command sessions whose process is still running. */\n processes: number;\n /** Background subagent sessions (promoted to a `subagent_id`) currently mid-round. */\n subagents: number;\n}\n\n/**\n * Session list category, the listing rule applied server-side: `archived` when the user\n * archived the Session, or when a program opened it (source `api`, `schedule`, `cli` or `workbench`) and the\n * user has not kept it — unarchiving keeps it; otherwise `active`. A `subagent` or `company`\n * Session belongs to neither, whatever its marks: the list leaves it out of every category and\n * every total (a parent's Agent panel and company mode's own views open them).\n */\nexport type SessionCategory = \"active\" | \"archived\";\n\n/** Per-category totals across an Agent's whole Session list (returned when the list is requested with counts). */\nexport type SessionCategoryCounts = Record<SessionCategory, number>;\n\n/**\n * GET /api/projects/:projectId/agents/:agentId/sessions. Every query parameter is optional:\n *\n * - `order`: `created` (the default) lists newest creation first; `activity` lists most recent\n * `lastActiveAt` first, ties broken by `sessionId` descending, both compared as plain strings\n * (code points, never locale collation) so a client can compute the same order.\n * - `limit` (1–1000) with `offset` (≥ 0): an offset page, in either order. Offsets suit only the\n * `created` order, which activity never reshuffles.\n * - `before=<lastActiveAt>,<sessionId>` with `limit`: under `order=activity` only, the rows\n * strictly below that cursor — the key of the last row the client holds. A row that becomes\n * active between pages moves above the cursor and is not served again, and no other row is\n * skipped (`session_state` tells the client about the moved one). 400 without\n * `order=activity`, beside `offset`, without `limit`, or when not a parseable stamp and a\n * valid id split at the first comma.\n * - `category`, `workspaceGroup`, `excludeOrg=1` filter before paging; `counts=1` adds the\n * whole-list totals below, which no cursor or offset narrows. A request with `category`,\n * `workspaceGroup` or `counts=1` leaves every `subagent` and `company` Session out of the page\n * and the totals (they are in no category); one with none of them serves every row.\n */\nexport interface SessionsResponse {\n /**\n * The page. With `excludeOrg=1` on the request, the rows an organization owns — its desk\n * and ticket sessions and the sub-sessions they spawned — are left out of it and of every\n * total below, so a caller drawing the user's own conversations is never handed a total\n * for rows it will not be handed. Without the flag every row is served, whichever client\n * created it.\n */\n sessions: SessionInfo[];\n /** Present when the request asked for counts (`counts=1`): totals per category over the full list, not just the returned page. */\n counts?: SessionCategoryCounts;\n /**\n * Present with `counts`: the same totals broken down by Workspace path (only paths\n * with at least one Session appear). The sidebar's workspace grouping decides each\n * group's existence and \"More\" from its own active share, so a group never advertises\n * content that lives in other Workspaces.\n */\n workspaceCounts?: Record<string, SessionCategoryCounts>;\n /**\n * Present with `counts`: each Workspace path's newest Session (any category), as its\n * `createdAt` — the Workspace picker's \"recently used\", which counts every Session that\n * worked there.\n */\n workspaceLatest?: Record<string, string>;\n /**\n * Present with `counts`: each Workspace path's newest `active` Session, as its `createdAt`\n * (a path holding none is absent). With `workspaceCounts` this is what lets the sidebar list\n * every Workspace that holds conversations and place the groups by recency before — or\n * without — loading any of their rows; an archived Session does not move a group it is not\n * shown in.\n */\n workspaceLatestActive?: Record<string, string>;\n}\n\n/**\n * The archive page's source filter: `user` is a conversation the user archived by hand (source\n * `user`, or a row whose Trace has not been read yet); the other three are what a program\n * opened. `subagent` and `company` Sessions are never archived, so never listed.\n */\nexport type ArchivedSessionSource = \"user\" | \"api\" | \"schedule\" | \"cli\" | \"workbench\";\n\n/** Query of GET /api/me/archived-sessions/groups, and the part every archive query shares. */\nexport interface ArchivedSessionsQuery {\n /** Case-insensitive substring of the title (the DB title, else the Trace's first-prompt title); at most 200 characters. */\n q?: string;\n source?: ArchivedSessionSource;\n /** One Project the user can open (404 `project_not_found` otherwise); absent = every Project they own or are a member of. */\n projectId?: string;\n}\n\n/**\n * Which servers' groups GET /api/me/archived-sessions/groups answers with: `here` this server's\n * alone, never waiting on a machine; `machines` the machines' alone. Absent: both, in one answer.\n * The Web App asks the two apart, so a machine that answers nothing never holds this server's.\n */\nexport type ArchivedSessionGroupServers = \"here\" | \"machines\";\n\n/** One group of the archive page: one Workspace of one Project, on this server or on one machine. */\nexport interface ArchivedSessionGroup {\n projectId: string;\n projectName: string;\n /** The Workspace path, as the sidebar groups it; `temp` for the merged temporary Workspaces. */\n workspace: string;\n /** Archived Sessions in the group matching the query. */\n count: number;\n /** The newest `lastActiveAt` in the group — the order the groups come in, newest first. */\n latest: string;\n /** The machine the group's Sessions live on (its own id); absent on this server's groups. */\n machineId?: string;\n /** That machine's ssh alias, for the group's header. */\n machineAlias?: string;\n /** That machine's own time when it counted the group: a batch on the whole group passes it as `until`. */\n asOf?: string;\n}\n\n/** GET /api/me/archived-sessions/groups. */\nexport interface ArchivedSessionGroupsResponse {\n groups: ArchivedSessionGroup[];\n /** Sum of the groups' counts. */\n total: number;\n /**\n * The server's time when these counts were taken (ISO). A group's batch passes it as its\n * filter's `until`, so it acts on the Sessions the count saw and on none that came after.\n */\n asOf: string;\n /** Machines the user's Projects use whose archive could not be read (not connected, or no answer). */\n unreachable?: Array<{ machineId: string; alias: string }>;\n}\n\n/** Query of GET /api/me/archived-sessions: one group's page, newest activity first. */\nexport interface ArchivedSessionsRowsQuery extends ArchivedSessionsQuery {\n projectId: string;\n /** The group's `workspace` (a path, or `temp`). */\n workspace: string;\n /** 1–100. */\n limit: number;\n /** `<lastActiveAt>,<sessionId>` of the last row held, as the Session list's cursor. */\n before?: string;\n /** The group's machine (`ArchivedSessionGroup.machineId`): the page is read from it. */\n machineId?: string;\n}\n\n/** GET /api/me/archived-sessions. */\nexport interface ArchivedSessionsResponse {\n /** `title` falls back to the Trace's first-prompt title when the row has none. */\n sessions: SessionInfo[];\n hasMore: boolean;\n}\n\n/**\n * What an archive-page batch acts on: explicit ids (1–500), or every archived row a filter\n * matches — on this server, or with `machineId` on that machine (the filter then names its Project).\n */\nexport type ArchivedSessionsBatch = (\n { sessionIds: string[] } | { filter: ArchivedSessionsBatchFilter }\n) & { machineId?: string };\n\n/** A batch's filter: the archive query, one group's Workspace, and the snapshot it was counted at. */\nexport interface ArchivedSessionsBatchFilter extends ArchivedSessionsQuery {\n workspace?: string;\n /**\n * The groups response's `asOf` the user acted on (ISO): only Sessions archived by then and\n * not active since are acted on, so one archived or run after the count is left alone.\n */\n until?: string;\n /** Sessions left out although the filter matches them — rows unticked after Select all (1–500). */\n exclude?: string[];\n}\n\n/** POST /api/me/archived-sessions/unarchive and /delete. */\nexport interface ArchivedSessionsBatchResponse {\n /**\n * Sessions acted on. Unarchive counts only the ones that were archived. Delete counts every\n * named one; a filter's delete leaves a running Session alone (it is not aborted) and does not\n * count it.\n */\n done: number;\n /** Ids that name nothing the user can open (the ids form only; a filter never reports any). */\n missing: string[];\n}\n\n/**\n * One page request inside POST /api/projects/:p/sessions/batch — the same knobs the\n * Agent-level list takes, carried per entry so one call can stand in for a whole sidebar\n * reload (one first page per Agent, plus every other page pair already open).\n */\nexport interface SessionBatchPageRequest {\n agentId: string;\n /** Rows per page, 1–1000. */\n limit: number;\n /** `activity`: last activity first — the sidebar's order. Omitted: creation order. */\n order?: \"created\" | \"activity\";\n /** Rows to skip from the top (default 0). Not beside `before`. */\n offset?: number;\n /**\n * Activity order only: rows strictly below this cursor — the last row of the previous page\n * (`lastActiveAt` + `sessionId`, the pair the list endpoint's `before` param carries).\n */\n before?: { lastActiveAt: string; sessionId: string };\n category?: SessionCategory;\n workspaceGroup?: string;\n /**\n * Totals per category over the Agent's whole list, with the per-Workspace counts and newest\n * stamps beside them (`counts=1` on the Agent-level list).\n */\n withCounts?: boolean;\n /** The user's own rows only: leave an organization's own sessions out of page and totals. */\n excludeOrg?: boolean;\n}\n\n/**\n * One entry's answer. `ok: false` is a real answer about THAT Agent, never about the batch:\n * `absent` is an Agent this server does not host (its rows come from the machine that does),\n * `error` is a failure to answer at all — which the caller must not read as \"no rows\".\n */\nexport type SessionBatchResult =\n | ({ agentId: string; ok: true } & SessionsResponse)\n | { agentId: string; ok: false; reason: \"absent\" | \"error\" };\n\nexport interface SessionsBatchRequest {\n requests: SessionBatchPageRequest[];\n}\n\nexport interface SessionsBatchResponse {\n /** One entry per requested page, in request order (repeated Agent ids stay repeated). */\n results: SessionBatchResult[];\n}\n\n/** Server directory browsing (the Workspace picker): starts from the home directory by default, can navigate up to the root. */\nexport interface DirEntryInfo {\n name: string;\n /** Absolute path of this entry (a folder's can be submitted directly as a Workspace). */\n path: string;\n /**\n * `file` for anything that is not a folder (a symlink counts as what it points to). Absent\n * means a folder: a machine listed over ssh reports folders only.\n */\n kind?: \"dir\" | \"file\";\n /** Last modification, epoch milliseconds; absent when unknown (listings over ssh, an entry stat could not reach). */\n mtime?: number;\n /** Hidden by the machine's own rules beyond the dot-name convention (Windows hidden/system attribute). */\n hidden?: true;\n}\n/** A storage location the machine offers beside its folders: a Windows drive, a macOS volume, a Linux root or mount. */\nexport interface DirLocation {\n /** Absolute path it opens (`C:\\`, `/`, `/Volumes/USB`, `/media/me/USB`, `/mnt/c`). */\n path: string;\n kind: \"drive\" | \"removable\" | \"network\" | \"optical\" | \"volume\" | \"root\";\n /** The machine's own name for it (volume label, volume name, share `\\\\nas\\media`, WSL `C:`); absent when none. */\n label?: string;\n}\n/** The platform's standard folders as that machine resolves them; only those that exist. */\nexport interface DirStandardFolders {\n desktop?: string;\n documents?: string;\n downloads?: string;\n pictures?: string;\n}\nexport interface DirListResponse {\n /** Absolute path of the current directory (realpath). */\n path: string;\n /** Absolute path of the parent directory; null when already at the root. */\n parent: string | null;\n /** Entries sorted by name, hidden ones included (the picker drops them). */\n entries: DirEntryInfo[];\n /** The listed machine's `process.platform`; absent for a machine listed over ssh. */\n platform?: string;\n /** Home request with `places=1` only. */\n standardFolders?: DirStandardFolders;\n /** Home request with `places=1` only, in the order the sidebar lists them. */\n locations?: DirLocation[];\n}\n\n/** One Skill found in a picked directory: metadata plus which of the two layouts it came from. */\nexport interface DirectorySkillItem extends SkillMetadataItem {\n /** `.agents/skills` or `.claude/skills` — shown so the origin of an offered Skill is visible. */\n source: string;\n}\n\nexport interface DirectorySkillsResponse {\n /** Absolute path that was scanned (realpath). */\n path: string;\n /** Installable Skills found under the directory's Skill layouts; empty when it carries none. */\n skills: DirectorySkillItem[];\n}\n\nexport interface SessionCreateRequest {\n /** Upstream id of the session's model; always sent together with provider. Omit both for the Project's default Model. */\n modelId?: string;\n /**\n * Provider group for `modelId`. A model reference is always a complete\n * (provider, modelId) pair — the provider is never inferred, so sending one field\n * without the other returns 400 instead of being resolved.\n */\n provider?: string;\n /** Any existing directory on the server; defaults to auto-creating a temporary Workspace. */\n workspace?: string;\n /** Defaults to allow-all. */\n approvalMode?: ApprovalMode;\n /**\n * The Session's sandbox policy; either half omitted takes the server's Sandbox settings.\n * A non-admin may not pick anything looser than those settings (403 `sandbox_forbidden`).\n */\n sandbox?: Partial<SessionSandbox>;\n /**\n * Creating-client hint stored on the Session row: \"cli\" when the CLI creates the\n * Session through the API, \"org\" when the organization runtime opened it (a desk or a\n * ticket session — company mode's own, kept out of development mode's lists whether or\n * not the organization still exists); defaults to \"web\". A REQUEST may send only \"web\" or\n * \"cli\": the runtime writes \"org\" by calling the service directly, so no caller can claim\n * an organization's provenance for its own Session. Lists serve every row regardless of\n * client unless asked for the user's own rows only (`excludeOrg=1`), which is what\n * development mode's session list asks for.\n */\n client?: \"web\" | \"cli\" | \"org\";\n /**\n * The Session's source, when it is not a person's conversation: only `cli` is accepted from\n * a client (`penguin run` sends it); every other source is the server's own to write, and\n * absent means `user`. The retired `benchmark` is still accepted, as `cli`, until 0.3.0, for\n * an older CLI or Web App that still sends it.\n */\n source?: \"cli\";\n}\n\nexport interface SessionCreateResponse {\n session: SessionInfo;\n}\n\n/** Immutable location of one record in a Session's append-only Trace. */\nexport interface TracePosition {\n /** Trace shard index (`001` on disk becomes `1`). */\n fileIndex: number;\n /** Zero-based record ordinal within the parsed shard. */\n ordinal: number;\n}\n\n/** History-only transport metadata; `tracePosition` is never persisted into Trace JSONL. */\nexport type HistoryMessage = AmspRecord & { tracePosition?: TracePosition };\n\nexport interface SessionForkRequest {\n /** The final root assistant text record of the completed Task to keep. */\n position: TracePosition;\n}\n\nexport interface SessionForkResponse {\n session: SessionInfo;\n}\n\nexport interface SessionResponse {\n session: SessionInfo;\n}\n\nexport interface SessionPatchRequest {\n approvalMode?: ApprovalMode;\n /**\n * Change this Session's sandbox policy; applies from its next command. A non-admin may not\n * pick anything looser than the server's Sandbox settings (403 `sandbox_forbidden`).\n */\n sandbox?: Partial<SessionSandbox>;\n /**\n * Pin this Session's thinking level (`none | low | medium | high | xhigh | max`, anything else\n * is a 400). It replaces the Agent-config fallback for this Session and applies from the\n * very next LLM request — soft-limited: a mid-context change is allowed, at the cost of\n * the provider's cached context, which is why the picker advises compacting first. There\n * is no unpin: the picker only offers concrete levels.\n */\n thinkingLevel?: ThinkingLevelName;\n /**\n * `true` archives (clears the keep mark); `false` unarchives and writes the keep mark, so a\n * background Session stays in the sidebar through later runs. Announced as `session_archived`.\n * 409 `session_not_listed` on a `subagent` or `company` Session, which is in no list.\n */\n archived?: boolean;\n /** Manual rename; non-empty string, overrides the auto-generated title. */\n title?: string;\n}\n\n/**\n * Live in-progress tail of a running Session, carried by `MessagesResponse.live`.\n *\n * Contract (see runtime/live-tail.ts and the GET /messages route): the server captures\n * `cursor` and `fragments` atomically — in one synchronous tick, before starting the\n * trace read — while the Session is running/compacting.\n * - `cursor`: the Session channel's most recently assigned SSE event id\n * (`<epoch>-<seq>`); every event published up to and including this id is already\n * reflected in `fragments`.\n * - `fragments`: one synthetic `partial_* start` AmspRecord per open streaming\n * fragment, whose payload carries the full accumulated content so far (text/thinking\n * prefix, tool-call name + accumulated arguments, tool-output prefix + images), with\n * the original `origin` chain preserved.\n *\n * Client usage (the bundled Web App's connect-first flow): after applying `messages`,\n * when the cursor's epoch matches the epoch of the SSE events seen on the current\n * connection, drop every buffered **partial** event with seq <= cursor (its content is\n * already inside `fragments`), feed `fragments` through the normal reducer path, then\n * replay the rest of the buffer. Buffered **complete** messages are never dropped by the\n * cursor — the regular overlap dedup decides for them — so nothing is lost even when a\n * complete message's trace append is still in flight at read time.\n */\nexport interface MessagesLiveTail {\n cursor: string;\n fragments: AmspRecord[];\n}\n\n/**\n * Pagination envelope of a windowed `GET /messages` (`tailLimit` / `before` requests\n * only; the parameterless full read never carries it). A window is a run of whole\n * message-bearing units — one unit = one Task in the Web reducer's sense, opened by a\n * main-session user prompt — cut so that no pairing (tool_call/output), compaction span\n * or steering group ever splits across windows. Besides the unit count, a window stops\n * before the unit that would take its serialized messages past 4 MiB, but always holds at\n * least one unit; such a window carries `before` like any other, with fewer units than\n * asked for.\n */\nexport interface MessagesPageInfo {\n /**\n * Cursor of this window's first unit (`<shardIndex>:<ordinal>`): pass it back as\n * `before=` to fetch the previous window. Stable across requests and compaction —\n * rotation opens a NEW shard and closed shards are immutable. Absent = this window\n * reaches the very beginning of the transcript (no older history).\n */\n before?: string;\n /**\n * Outline turns (the Web conversation outline's entry rule) opened BEFORE this\n * window: the client offsets its global \"round N\" numbering by this, so a partial\n * window never mis-numbers. 0 when the window starts at the beginning.\n */\n earlierTurns: number;\n /**\n * Cumulative stats accrued before this window, seeded into the client's stats\n * tracker so header chips and per-turn cumulative rows equal a full load:\n * finished-Task elapsed, subagent token totals, and the last main-session\n * session/context token readings.\n */\n prior: {\n subagentTokens: number;\n elapsedMs: number;\n /**\n * Model-API time and tool wall time accrued before this window, the breakdown of\n * `elapsedMs` the chat header shows under it. Seeded together with their total: seeding\n * one alone would put a full elapsed time beside components covering only the window.\n * The two may overlap and do not partition `elapsedMs` (see `TraceTaskStats.toolMs`).\n */\n apiMs: number;\n toolMs: number;\n sessionTokens: number;\n contextTokens: number;\n };\n /**\n * The model of the context this window starts in — what the `session_meta` heading that\n * context's Trace file names. A Session can switch models between contexts, and a window\n * that starts partway into one does not hold that record: the client seeds it, so the Tasks\n * ahead of the window's first `session_meta` have their model, and a switch further down the\n * window is told from a plain rotation. Absent when the Session has no Trace yet.\n */\n contextModel?: ModelRefDto;\n /**\n * Agent State directory recorded by the starting context's `session_meta`. Seeds memory\n * change detection when that record falls before the window. Absent without a Trace.\n */\n agentState?: string;\n}\n\n/** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */\nexport interface MessagesResponse {\n /**\n * On windowed requests, images are served by reference: in every record that carries a\n * `tracePosition`, an inline PNG / JPEG / GIF / WebP `data:` URL — a user `image_url`, or\n * an entry of a tool output's `images` — is replaced by\n * `/api/sessions/<sessionId>/trace-image?file=<fileIndex>&ordinal=<ordinal>[&i=<k>]`\n * (`i` = the index into `images`), an immutable, access-checked image response. Expanded\n * subagent messages, held inputs not yet in the Trace, other image types and the\n * parameterless full read keep their data URLs.\n */\n messages: HistoryMessage[];\n /**\n * Present only while the Session is running/compacting: the in-progress stream tail\n * (open streaming fragments + the channel cursor they cover), so a client joining\n * mid-stream can render the currently streaming message. Omitted when idle. On\n * windowed requests it rides TAIL pages only — a `before` page is immutable history\n * and never carries it.\n */\n live?: MessagesLiveTail;\n /**\n * Present exactly on windowed requests (`tailLimit` / `before`): `messages` is then\n * the requested window (subagent pointers inside it expanded as usual) rather than\n * the full transcript. See MessagesPageInfo.\n */\n page?: MessagesPageInfo;\n}\n\n// ---------------------------------------------------------------------------\n// Task run, approval, interruption, compaction\n// ---------------------------------------------------------------------------\n\n/**\n * A single Prompt's input parts: text, image (data: / http(s) URL), or an uploaded file.\n * Docs: /docs/server-api § \"Session-Level Endpoints\".\n */\nexport type TaskInputPart =\n | { type: \"text\"; text: string }\n /**\n * An image that rides the conversation inline, as a base64 `data:` URL or an http(s) URL.\n * A data URL is capped at 20MB (413 `image_too_large`) — a fixed limit that does NOT follow\n * the admin-settable attachment cap, because an inline image is written into the Trace and\n * read back on every history page and every resume.\n */\n | { type: \"image_url\"; imageUrl: string }\n /**\n * File attachment (the composer's \"+\" menu): `dataUrl` is a base64 `data:` URL of the\n * file's bytes, capped per file and per message by the admin-settable upload limits\n * (defaults 100MB / 120MB; 413 `file_too_large` / `payload_too_large` / `too_many_files`,\n * and the request as a whole still has to fit the body cap those limits derive). The\n * server writes it into the Session scratchpad under a sanitized name and appends an\n * `[attached file: <path>]` line to the message text — the bytes never enter the\n * conversation, the model opens the file by path. `fileName` is the original name (no path\n * separators, no `..`).\n */\n | { type: \"file\"; fileName: string; dataUrl: string };\n\nexport interface TaskCreateRequest {\n input: TaskInputPart[];\n /**\n * Queue instead of 409 when a Task/compaction is already in progress: the input is held\n * server-side and auto-starts as an ordinary next task once the session returns to idle\n * (in queue order, one at a time). The response then carries `queued: true`.\n */\n queueIfBusy?: boolean;\n /**\n * Present = goal mode: the input's text becomes the objective (leading `[use_skills]`\n * blocks and the like are stripped from the recorded objective; the round-1 message keeps\n * them) and the Session's installed `goal` hook package drives round after round until the\n * goal reaches a terminal state. `budget` is the token budget (uncached input + output);\n * omitted or -1 = unlimited. Requires the goal plugin installed on the Agent — 409\n * `goal_plugin_not_installed` otherwise.\n */\n goal?: { budget?: number };\n}\n\n/** Goal-mode run state, read from the Session's GOAL.json — the goal plugin's file (the chat page's banner restores from it; a live status on an idle Session reads as `aborted`). */\nexport interface GoalStateView {\n objective: string;\n status: \"active\" | \"complete\" | \"blocked\" | \"budget_limited\" | \"aborted\";\n /** Token budget; -1 = unlimited. */\n budget: number;\n used: number;\n rounds: number;\n}\n\nexport interface GoalResponse {\n /** The Session's most recent goal run; null if it never ran one. */\n goal: GoalStateView | null;\n}\n\n/**\n * `POST /api/sessions/:sessionId/switch-model`: switch this Session to another model in place.\n * The running context is closed on the model it ran on, then the next one opens on the target.\n *\n * - **202** {@link TaskCreateResponse} — the switch streams, the Session status `compacting`.\n * A context with a completed turn is closed by an ordinary `manual` compaction pair (always\n * summarize); one without — just compacted, or its first request never finished — has\n * nothing to summarize and streams no pair. Then come the new context's opener records\n * and, last, its `session_meta`: its `provider` / `model_id` name the model the Session now\n * runs on, the Session reads report that pair from this record on, and `task_state` turns\n * idle after it.\n * A compaction that ends other than `completed` means no switch — no `session_meta` follows\n * and the Session keeps its model.\n * - **200** {@link SessionResponse} — a Session that never ran has no context to close: it\n * switched inside the request, and nothing is streamed.\n * - **409**, one code per refusal, before any event: `task_in_progress` / `compacting` (busy),\n * `same_model`, `model_not_configured` (the target is not in the Project config),\n * `model_unavailable` (its client cannot be constructed, e.g. no credential), and\n * `compaction_not_configured`.\n */\nexport interface SessionSwitchModelRequest {\n /** Provider group of the target model. */\n provider: string;\n /** Upstream model_id of the target model. */\n modelId: string;\n}\n\nexport interface TaskCreateResponse {\n /** Current actual session_id: a Trace-less invalid Session self-heals and returns a new id; the frontend updates its route accordingly. */\n sessionId: string;\n /** True when `queueIfBusy` enqueued the input as a follow-up instead of starting it (absent/false: the task started). */\n queued?: boolean;\n}\n\n/**\n * Mid-run steering (POST /api/sessions/:id/steer): a user message for the **running** Task,\n * delivered by core between turns as a standalone `[user_steering]` user message. 202 on\n * queue; 409 `not_running` when no Task is in progress (the frontend falls back to a normal\n * task POST).\n */\nexport interface SteerRequest {\n /** Message text (trimmed server-side); may be empty when `images` or `files` carries the message. */\n text: string;\n /**\n * Images sent with the steering message (`data:` or http(s) URLs, same rule as\n * `TaskInputPart.image_url`): delivered as user image messages right behind the\n * `[user_steering]` text. A model without vision receives them as scratchpad path lines\n * instead, exactly as it would a Prompt's images. At least one of `text` / `images` /\n * `files` must be non-empty.\n */\n images?: string[];\n /**\n * File attachments riding the steering message — the same shape, caps and handling as a\n * task input's `{type:\"file\"}` parts: written into the Session scratchpad and delivered\n * as `[attached file: <path>]` lines on the `[user_steering]` text, so a file-only draft\n * steers exactly like an image-only one instead of falling back to the follow-up queue.\n */\n files?: { fileName: string; dataUrl: string }[];\n}\n\n/**\n * One steering message queued on the server but not yet delivered to the model (delivery\n * happens at the next input assembly between turns). Carried on `task_state` events and the\n * SSE subscribe snapshot so the composer's \"steering queued\" hint — including what was sent —\n * survives reloads; entries leave the list as their `[user_steering]` message appears on the\n * stream, and the whole list drops when the run exits (core discards undelivered steering).\n */\nexport interface PendingSteeringInfo {\n /** Server-assigned id, stable for the entry's queued lifetime: the handle DELETE /steer/:steerId recalls it by. */\n id: string;\n /** The message text as accepted (trimmed); may be empty when images/files carry the message. */\n text: string;\n /** Number of images that rode along. */\n images: number;\n /** Number of file attachments that rode along. */\n files: number;\n}\n\n/**\n * One follow-up task queued with `queueIfBusy` but not yet auto-started. Carried on\n * `task_state` events and the SSE subscribe snapshot (like `pendingSteering`) so the\n * composer can show each queued message's content with a recall affordance; entries leave\n * the list when they auto-start on idle — or when DELETE /follow-ups/:followUpId recalls one.\n */\nexport interface PendingFollowUpInfo {\n /** Server-assigned id, stable for the entry's queued lifetime: the handle DELETE /follow-ups/:followUpId recalls it by. */\n id: string;\n /** The queued input's text parts, joined; may be empty when images/files carry the message. */\n text: string;\n /** Number of images in the queued input. */\n images: number;\n /** Number of file attachments in the queued input. */\n files: number;\n}\n\n/**\n * One live subagent child of a session's runtime, carried on `task_state` events and the SSE\n * subscribe snapshot: the child Session id (the origin hop the stream already correlates by),\n * its background registry handle (null while it only lives inside a foreground collect\n * window), and whether a round is currently running. Only an ACTIVE parent runtime reports\n * children — after a server restart the in-process children are gone, and the empty list is\n * the truth.\n */\nexport interface SubagentRuntimeInfo {\n sessionId: string;\n subagentId: string | null;\n running: boolean;\n}\n\n/**\n * Response of POST /api/sessions/:sessionId/subagents/:childSessionId/message — a user input\n * on the child, whatever its state: `steered` = queued as a mid-run interjection, `started` =\n * began a follow-up run on the idle child, `resumed` = the released child session was revived\n * (resume-session semantics) and the message began its next round. The failure shapes are\n * HTTP statuses instead: 404 when the child's session record does not exist or cannot be\n * revived, 409 when the child cannot take the message right now.\n */\nexport interface SubagentMessageResponse {\n outcome: \"steered\" | \"started\" | \"resumed\";\n}\n\n/**\n * Response of the two recall endpoints — DELETE /api/sessions/:id/steer/:steerId and\n * DELETE /api/sessions/:id/follow-ups/:followUpId: the withdrawn message's original content,\n * for the composer to restore into the input box for editing and resending (#287). File\n * attachments are read back from the Session scratchpad (then deleted from it); one that\n * disappeared meanwhile is omitted rather than failing the recall. 409 when the entry is no\n * longer queued, with a code per endpoint because the reasons read differently to the user:\n * `not_pending` — the steering message already reached the model; `follow_up_started` — the\n * follow-up already auto-started as a task of its own (unknown ids land on the same code).\n */\nexport interface RecalledMessageResponse {\n text: string;\n /** The images as submitted (`data:` / http(s) URLs). */\n images: string[];\n /** The file attachments, re-encoded as base64 data URLs (the shape the composer submits them in). */\n files: { fileName: string; dataUrl: string }[];\n}\n\nexport interface ApprovalDecisionRequest {\n decision: \"allow\" | \"deny\";\n}\n\n/**\n * POST /api/sessions/:sessionId/retry-now — skip the in-progress reconnect backoff and\n * fire the next retry immediately (the \"retry now\" button on the reconnect countdown).\n * `skipped: false` is the benign \"no reconnect wait in progress\" case (idle session, or\n * the wait elapsed in a timing race), not an error.\n */\nexport interface RetryNowResponse {\n skipped: boolean;\n}\n\n/**\n * One background command process started by the Session (an exec_command promoted past\n * its yield window). Served from the ACTIVE runtime only: a session whose runtime entry\n * is gone truthfully reports an empty list.\n */\nexport interface SessionProcessInfo {\n processId: string;\n /** OS pid of the process-group leader; null when the spawn itself failed. */\n pid: number | null;\n cmd: string;\n cwd: string;\n startedAt: string;\n running: boolean;\n /** The service the process serves, when detected: the last local URL its output printed, else an origin probed from its listening ports. */\n serviceUrl?: string;\n}\n\nexport interface SessionProcessesResponse {\n processes: SessionProcessInfo[];\n}\n\n// ---------------------------------------------------------------------------\n// Messaging bindings (/api/sessions/:sessionId/messaging/*) and enabled bot accounts\n// (/api/messaging/accounts, /api/projects/:projectId/messaging/account-holders)\n// ---------------------------------------------------------------------------\n\n/** Messaging channels a Session can bind to. */\nexport type MessagingChannel = \"feishu\" | \"telegram\" | \"qq\" | \"wechat\";\n\n/** Event-connection runtime state of one binding (kept in memory, not persisted). */\nexport type MessagingRuntimeState = \"disconnected\" | \"connecting\" | \"connected\" | \"error\";\n\n/**\n * A failure that happened AFTER an inbound message was accepted — as opposed to a\n * connection failure, which `MessagingRuntimeStatus.lastError` reports.\n *\n * The two stages fail in completely different places and lead to different actions, and\n * from the chat they are indistinguishable (both produce silence), which is why the stage\n * is stated rather than folded into one message.\n */\nexport interface MessagingDeliveryError {\n /** ISO 8601 timestamp of the failure. */\n at: string;\n /** `inbound` — the message arrived and its Task never started; `send` — a reply never reached the chat. */\n stage: \"inbound\" | \"send\";\n /** The failure's own message, as the channel or the Session reported it. */\n detail: string;\n}\n\nexport interface MessagingRuntimeStatus {\n state: MessagingRuntimeState;\n /** Failure detail; present only in the `error` state. */\n lastError?: string;\n /** When the state last changed (ISO 8601); absent for a binding that never connected. */\n changedAt?: string;\n /**\n * When this binding last accepted an inbound message (ISO 8601).\n *\n * \"Connected, and nothing has arrived\" is the answer to the one question a chat cannot\n * answer for itself — whether the platform is delivering anything at all. A channel that\n * withholds messages (Telegram's group privacy, a bot that is not a member) produces\n * exactly that, with no error anywhere.\n *\n * In-process AND scoped to one connection: it starts empty on every (re)connect, and a\n * re-enable or a credential save opens a new one. So an absent field means \"nothing since\n * this connection opened\", never \"nothing ever\" — anything reporting it has to say which,\n * or it sends a reader off to fix a channel that is working.\n */\n lastInboundAt?: string;\n /**\n * The most recent post-acceptance failure; absent when none has happened since this\n * connection opened, and never cleared by a later success — an intermittent failure would\n * otherwise be erased by the next ordinary message. `at` is what says how stale it is.\n */\n lastDeliveryError?: MessagingDeliveryError;\n /**\n * The most recent CONNECTION failure, kept after the connection recovers — unlike\n * `lastError`, which belongs to the `error` state and is gone the moment the state leaves it.\n *\n * A connection that fails and recovers repeatedly (a second program polling the same bot\n * token takes turns with this one) reads as `connected` in any snapshot taken between\n * flaps, with nothing at all to show for the failures in between. This is what is left\n * behind — for the life of the connection, like the two above it.\n */\n lastConnectionError?: { at: string; detail: string };\n /**\n * Since when (ISO 8601) the bot has been HOLDING replies it could not deliver yet, to send\n * them with the chat's next inbound message; absent while nothing is held. WeChat only: the\n * platform accepts a bot's messages only for a while after the user's last one, and only\n * about ten per message, so a long run or a quiet user leaves replies waiting rather than\n * lost.\n *\n * Not a failure, and deliberately not `lastDeliveryError`: nothing is broken and nobody has\n * to act beyond what the panel says — message the bot in WeChat and the held replies arrive\n * first. Cleared the moment they have been delivered. Unlike the fields above it is not\n * scoped to one connection: held replies are stored, so a backlog from before a restart is\n * reported again as soon as the connection opens.\n */\n heldReplySince?: string;\n}\n\n/**\n * What a messaging binding carries whatever channel it is: the connection intent, the three\n * delivery preferences, whether a chat is known, and the row's identity and timestamps.\n *\n * Split out because this is the half that grows. Three delivery preferences arrived in one\n * release, and each would otherwise have been written into three interfaces — the credential\n * slice is the only thing a channel genuinely differs in, and it is what stays per-channel.\n *\n * `channel` is NOT here: a discriminated union discriminates on a literal the member itself\n * declares, so each interface keeps its own.\n *\n * A channel may RE-DECLARE a field below when it has more to say about it — QQ does, for all\n * three preferences, because the platform's passive-reply budget and its expiring window\n * change what setting one costs there. The re-declared type must be identical, which the\n * compiler enforces, so the redeclaration can only add documentation and never a contract.\n */\nexport interface MessagingBindingCommon {\n sessionId: string;\n /** Connection INTENT (the state toggle's value); new bindings start disabled, and at most one of a Session's channels is enabled. */\n enabled: boolean;\n /**\n * Send each non-blank line of a relayed assistant reply as its own message instead of one\n * message per reply. Off by default; off is the original one-message-per-reply behaviour.\n */\n linePerMessage: boolean;\n /**\n * Relay only the LAST completed assistant message of a run, delivered when the run ends,\n * instead of mirroring each completed message as it completes. Off by default; off is the\n * original every-message behaviour. Independent of `linePerMessage`, which then applies to\n * that one final message. The approval notice is not a reply and is unaffected.\n */\n finalReplyOnly: boolean;\n /**\n * Render a relayed reply's Markdown in this channel's own markup instead of sending its\n * characters as written. ON by default. Each channel shows what it can — Telegram has no\n * headings, lists or tables, QQ has no code or tables, Feishu has all of them, and WeChat\n * reads Markdown itself so the render is a subtraction rather than a translation — and a\n * rendering the channel refuses falls back to the plain source, so this can cost\n * formatting and never a message.\n */\n renderMarkdown: boolean;\n /**\n * Whether an inbound chat is known (the bot has been messaged at least once). Replies and\n * test messages target that chat; until it exists nothing can be sent.\n */\n lastChatKnown: boolean;\n createdAt: string;\n updatedAt: string;\n}\n\n/** The stored Feishu config, secret masked (plaintext never leaves the server). */\nexport interface FeishuBindingInfo extends MessagingBindingCommon {\n channel: \"feishu\";\n appId: string;\n /**\n * Masked app secret (site-wide mask rule: `***`, or `first4…last4` for long values);\n * absent when no secret is stored (never entered, or cleared) — the binding cannot be\n * enabled until one is saved.\n */\n appSecretMasked?: string;\n baseDomain: string;\n}\n\n/** The stored Telegram config, token masked (plaintext never leaves the server). */\nexport interface TelegramBindingInfo extends MessagingBindingCommon {\n channel: \"telegram\";\n /** The numeric bot id (the token's half before the colon) — the channel-scoped account identity, never secret. */\n botId: string;\n /**\n * Masked bot token (site-wide mask rule: `***`, or `first4…last4` for long values);\n * absent when no token is stored (cleared) — the binding cannot be enabled until one\n * is saved.\n */\n botTokenMasked?: string;\n}\n\n/**\n * The stored QQ config, secret masked (plaintext never leaves the server).\n *\n * The three preference fields are re-declared unchanged: every one of them costs something\n * different here, and the platform's reply budget is what a reader setting one needs to know.\n */\nexport interface QQBindingInfo extends MessagingBindingCommon {\n channel: \"qq\";\n /** The bot's App ID — the channel-scoped account identity, never secret. */\n appId: string;\n /**\n * Masked App Secret (site-wide mask rule: `***`, or `first4…last4` for long values);\n * absent when no secret is stored (never entered, or cleared) — the binding cannot be\n * enabled until one is saved.\n */\n appSecretMasked?: string;\n /**\n * Send each non-blank line of a relayed assistant reply as its own message instead of one\n * message per reply. Off by default. On QQ the split is additionally capped at the\n * platform's passive-reply budget rather than the channel-neutral ceiling, so it yields\n * far fewer messages here than on the other channels.\n */\n linePerMessage: boolean;\n /**\n * Relay only the LAST completed assistant message of a run, delivered when the run ends.\n * Off by default.\n *\n * On QQ it cuts both ways, which is worth knowing before setting it here. It spends the\n * least of the platform's passive-reply budget a run can spend — one message, where an\n * every-message relay spends one per completed message. But a passive reply is accepted\n * only for a few minutes after the inbound message that funds it, and holding the reply\n * to the run's end spends that window on the run: a run that outlives it delivers nothing\n * at all, where the every-message relay would have sent whatever completed inside it.\n */\n finalReplyOnly: boolean;\n /**\n * Render a relayed reply's Markdown in this channel's own markup. ON by default. QQ's own\n * subset is the widest for prose and the narrowest for code: headings, lists, blockquotes\n * and rules render, while inline code, fenced code and tables have no syntax at all here —\n * a code block arrives as plain escaped lines. A rendering the platform refuses falls back\n * to the plain source, at the cost of one more slot from the passive-reply budget.\n */\n renderMarkdown: boolean;\n /**\n * Whether an inbound QQ chat is known (the bot has been messaged at least once). Weaker\n * than it looks on this channel: QQ accepts only replies to a recent message, so a known\n * chat is necessary but not sufficient for anything to be deliverable right now.\n */\n lastChatKnown: boolean;\n}\n\n/**\n * The stored WeChat config, token masked (plaintext never leaves the server).\n *\n * No preference field is re-declared. The three delivery preferences cost exactly what they\n * cost on Feishu and Telegram here — this channel has no reply budget and no expiring reply\n * window — and re-declaring a field to say nothing new about it would only invite the next\n * reader to look for the difference.\n */\nexport interface WeChatBindingInfo extends MessagingBindingCommon {\n channel: \"wechat\";\n /** The bot id a scan issued — the channel-scoped account identity, never secret. */\n botId: string;\n /**\n * Masked bot token (site-wide mask rule: `***`, or `first4…last4` for long values);\n * absent when none is stored (cleared, or a scan that never completed) — the binding\n * cannot be enabled until a scan saves one.\n */\n botTokenMasked?: string;\n}\n\n/** A Session's saved config for one messaging channel (`channel` is the discriminant). */\nexport type MessagingBindingInfo =\n FeishuBindingInfo | TelegramBindingInfo | QQBindingInfo | WeChatBindingInfo;\n\n/** One saved channel config with its event-connection runtime status. */\nexport interface MessagingChannelState {\n binding: MessagingBindingInfo;\n /** Only the enabled channel's connection is ever anything but disconnected. */\n status: MessagingRuntimeStatus;\n}\n\n/**\n * GET …/messaging response — the channel-agnostic read the channel-aware binding editor\n * loads: EVERY saved channel config (masked) with its runtime status. A Session may keep\n * both channels saved; at most one of them is enabled.\n */\nexport interface MessagingBindingsResponse {\n bindings: MessagingChannelState[];\n}\n\n/** GET / PUT …/messaging/feishu response: the Feishu config (null = not saved) plus its runtime status. */\nexport interface FeishuBindingResponse {\n binding: FeishuBindingInfo | null;\n status: MessagingRuntimeStatus;\n}\n\n/** GET / PUT …/messaging/telegram response (the Telegram narrowing of the same envelope). */\nexport interface TelegramBindingResponse {\n binding: TelegramBindingInfo | null;\n status: MessagingRuntimeStatus;\n}\n\n/** GET / PUT …/messaging/qq response (the QQ narrowing of the same envelope). */\nexport interface QQBindingResponse {\n binding: QQBindingInfo | null;\n status: MessagingRuntimeStatus;\n}\n\n/** GET / PUT …/messaging/wechat response (the WeChat narrowing of the same envelope). */\nexport interface WeChatBindingResponse {\n binding: WeChatBindingInfo | null;\n status: MessagingRuntimeStatus;\n}\n\n/**\n * PUT …/messaging/feishu — saves credentials/config ONLY, never flipping the connection\n * (exception: an enabled binding's connector restarts with the new credentials so stored\n * config and live connection never diverge). The connection toggle is POST …/state.\n */\n/**\n * The delivery preferences any channel's PUT may set. Each is optional and each behaves the\n * same way on every channel: an omitted field keeps the stored value, so a client that knows\n * about one preference can save it without having to send the others back. Their meanings are\n * on {@link MessagingBindingCommon}, and where a channel charges differently for one, on that\n * channel's own `*BindingInfo`.\n *\n * The defaults a binding created without them starts from: `linePerMessage` off,\n * `finalReplyOnly` off, `renderMarkdown` ON.\n */\nexport interface MessagingDeliveryPatch {\n linePerMessage?: boolean;\n finalReplyOnly?: boolean;\n renderMarkdown?: boolean;\n}\n\nexport interface FeishuBindingPutRequest extends MessagingDeliveryPatch {\n appId: string;\n /** Omitted or blank keeps the stored secret (the masked value never round-trips). */\n appSecret?: string;\n /** Defaults to https://open.feishu.cn when omitted or blank. */\n baseDomain?: string;\n /**\n * Drops the STORED secret (the models-page clear idiom; a typed `appSecret` wins over\n * it). Refused with 409 `messaging_disable_before_clear` while the binding is enabled.\n */\n clearAppSecret?: boolean;\n}\n\n/**\n * PUT …/messaging/telegram — saves the credential ONLY, same contract as the Feishu PUT\n * (an enabled binding's connector restarts with the new token; the connection toggle is\n * POST …/state). Saving never conflicts across Sessions: the same bot may sit saved on\n * several, and only enabling it is exclusive.\n */\nexport interface TelegramBindingPutRequest extends MessagingDeliveryPatch {\n /** Omitted or blank keeps the stored token (the masked value never round-trips). */\n botToken?: string;\n /**\n * Drops the STORED token (the models-page clear idiom; a typed `botToken` wins over\n * it — and the row keeps its bot identity). Refused with 409\n * `messaging_disable_before_clear` while the binding is enabled.\n */\n clearBotToken?: boolean;\n}\n\n/**\n * PUT …/messaging/qq — saves the credential pair ONLY, same contract as the Feishu PUT\n * (an enabled binding's connector restarts with the new credentials; the connection toggle\n * is POST …/state). The App ID is the account identity, so changing it rebinds the row to\n * a different bot and drops the remembered chat.\n */\nexport interface QQBindingPutRequest extends MessagingDeliveryPatch {\n appId: string;\n /** Omitted or blank keeps the stored secret (the masked value never round-trips). */\n appSecret?: string;\n /**\n * Drops the STORED secret (the models-page clear idiom; a typed `appSecret` wins over\n * it). Refused with 409 `messaging_disable_before_clear` while the binding is enabled.\n */\n clearAppSecret?: boolean;\n}\n\n/**\n * PUT …/messaging/wechat — the delivery preferences ONLY.\n *\n * The one PUT on this router that carries no credential, because there is none to carry: a\n * WeChat bot token exists only where a scan put it, and there is no console to copy one out\n * of. Saving therefore presupposes a binding — a PUT before any scan answers 400\n * `wechat_token_required` rather than creating an empty row — and the connection toggle\n * stays POST …/state, as on every channel.\n */\nexport interface WeChatBindingPutRequest extends MessagingDeliveryPatch {\n /**\n * Drops the STORED token (the models-page clear idiom). Refused with 409\n * `messaging_disable_before_clear` while the binding is enabled. The row and its bot\n * identity stay; only a fresh scan can make it connectable again.\n */\n clearBotToken?: boolean;\n}\n\n/**\n * POST …/messaging/<channel>/state — enable connects with the STORED credentials,\n * disable terminates. Enabling is what binds the bot account to this Session, and it is\n * mutually exclusive twice over: while another channel of the same Session is enabled it\n * answers 409 `another_channel_enabled`, and while another SESSION has the same account\n * enabled it answers 409 `account_enabled_elsewhere` (both say: turn that one off first).\n * A config whose secret is missing answers its channel's 400 `*_required`.\n */\nexport interface MessagingBindingStateRequest {\n enabled: boolean;\n}\n\n/** POST …/messaging/feishu/test — draft values; each omitted field falls back to the stored binding. */\nexport interface FeishuTestRequest {\n appId?: string;\n appSecret?: string;\n baseDomain?: string;\n}\n\n/** Credential-test outcome (an unreachable/rejected credential is `ok:false`, not an HTTP error). */\nexport interface FeishuTestResponse {\n ok: boolean;\n latencyMs?: number;\n error?: string;\n}\n\n/** POST …/messaging/telegram/test — a draft token, falling back to the stored binding when omitted. */\nexport interface TelegramTestRequest {\n botToken?: string;\n}\n\n/** Telegram credential-test outcome: success additionally names the bot the token signs in as. */\nexport interface TelegramTestResponse {\n ok: boolean;\n latencyMs?: number;\n /** The bot's `@username`, when the API reports one. */\n botUsername?: string;\n /**\n * True when @BotFather's **Group Privacy** is ON for this bot, which is the default. In\n * every group where the bot is not an administrator it then receives only a command\n * addressed to it or a reply to one of its own messages — an ordinary sentence is never\n * delivered, so a binding that answers fine in a direct chat stays silent there. The\n * setting is account-wide and Telegram overrides it for a group the bot administers, so\n * this reports the setting and never the outcome in any particular group. Absent when the\n * API did not report the setting; never inferred.\n */\n groupPrivacy?: boolean;\n error?: string;\n}\n\n/** POST …/messaging/qq/test — draft values; each omitted field falls back to the stored binding. */\nexport interface QQTestRequest {\n appId?: string;\n appSecret?: string;\n}\n\n/**\n * QQ credential-test outcome. There is no account label: the platform's only credential\n * call is the access-token exchange, which identifies nothing beyond the App ID that was\n * sent to it.\n */\nexport interface QQTestResponse {\n ok: boolean;\n latencyMs?: number;\n error?: string;\n}\n\n/**\n * POST …/messaging/qq/scan — starts a scan-to-connect flow: the server registers a bind\n * task under a fresh AES key it keeps to itself, and answers with what the browser may\n * know. The key is absent from this type on purpose — it decrypts the App Secret, so it\n * never leaves the server (the same rule that keeps a stored secret from round-tripping).\n */\nexport interface QQScanStartResponse {\n /** Opaque bind-task handle, passed back to the poll endpoint. */\n taskId: string;\n /** The URL to ENCODE into a QR code. It is opened by the QQ app, never fetched by the browser. */\n qrUrl: string;\n /** How often to poll, in milliseconds (the interval the protocol is designed around). */\n pollMs: number;\n}\n\n/**\n * POST …/messaging/qq/scan/poll — one step of the scan. `pending` means keep polling,\n * `expired` means start a new task and show a new QR, and `completed` means the server has\n * already decrypted the App Secret and SAVED the binding: `appId` names the bot that landed.\n * Saving is all it does — enabling the connection stays the separate, exclusive act it is on\n * every channel. A task id that is unknown, belongs to another Session, was already\n * resolved, or is being resolved by a poll still in flight answers 404\n * `qq_scan_task_unknown`: the task is claimed by one poll, so a client whose interval fires\n * before the previous request came back binds once rather than once per overlapping poll.\n */\nexport interface QQScanPollResponse {\n status: \"none\" | \"pending\" | \"completed\" | \"expired\";\n /** The bound bot's App ID; present only on `completed`. Never the secret. */\n appId?: string;\n /** The saved binding, present on `completed` so the editor refreshes without a second GET. */\n binding?: QQBindingInfo;\n}\n\n/**\n * WeChat credential-test outcome. There is no request body and no draft to probe: this\n * channel's credential exists only where a scan put it, so the test always probes the STORED\n * binding. It answers 400 `wechat_token_required` when there is none.\n *\n * No account label, for the same reason QQ has none: the probe (`getconfig`) reports the\n * bot's settings and names neither the bot nor the person.\n */\nexport interface WeChatTestResponse {\n ok: boolean;\n latencyMs?: number;\n error?: string;\n}\n\n/**\n * POST …/messaging/wechat/scan — starts a scan-to-connect flow, which on this channel is the\n * ONLY way to bind: the bot token has no console to be copied out of.\n *\n * The server registers a code and answers with what the browser may know. The platform's own\n * poll handle is absent from this type on purpose — it is what collects the bot token, so it\n * never leaves the server, and `taskId` is a handle this server mints in its place.\n */\nexport interface WeChatScanStartResponse {\n /** Opaque scan handle, passed back to the poll, verify and cancel endpoints. */\n taskId: string;\n /** The URL to ENCODE into a QR code. It is opened by WeChat, never fetched by the browser. */\n qrUrl: string;\n /** How often to poll, in milliseconds. */\n pollMs: number;\n}\n\n/**\n * Where a WeChat scan stands. Richer than QQ's four states because the flow is: WeChat\n * separates scanning from confirming, and may interpose a pairing code shown on the phone.\n *\n * - `pending` — the code is up and nothing has happened; keep polling.\n * - `scanned` — scanned; the phone is showing the confirmation prompt.\n * - `need_verify_code` — the phone is showing digits that must be sent to the verify\n * endpoint before the bind proceeds.\n * - `blocked` — too many wrong pairing codes; the code is spent and a new scan is needed.\n * - `expired` — the code lapsed before it was used; show a new one.\n * - `already_bound` — this bot is already bound to this server, so no new credentials were\n * issued and nothing was saved. Not a failure: the existing binding still works.\n * - `completed` — the server has already SAVED the binding; `botId` names the bot.\n */\nexport type WeChatScanStatus =\n | \"pending\"\n | \"scanned\"\n | \"need_verify_code\"\n | \"blocked\"\n | \"expired\"\n | \"already_bound\"\n | \"completed\";\n\n/**\n * POST …/messaging/wechat/scan/poll — one step of the scan.\n *\n * `completed` means the server has decrypted nothing and stored everything: the credentials\n * went from the platform into storage without passing through the browser. Saving is all it\n * does — enabling the connection stays the separate, exclusive act it is on every channel. A\n * task id that is unknown, belongs to another Session, or was already resolved answers 404\n * `wechat_scan_task_unknown`.\n *\n * Unlike QQ's poll, an overlapping request answers `pending` rather than 404: the upstream\n * call is a LONG poll, so one of them spans several of the client's intervals, and that\n * overlap is the normal rhythm rather than a replay.\n */\nexport interface WeChatScanPollResponse {\n status: WeChatScanStatus;\n /** The bound bot's id; present only on `completed`. Never the token. */\n botId?: string;\n /** The saved binding, present on `completed` so the editor refreshes without a second GET. */\n binding?: WeChatBindingInfo;\n}\n\n/**\n * POST …/messaging/wechat/scan/verify — the digits WeChat showed on the phone.\n *\n * They ride the NEXT poll rather than a request of their own, because the platform takes the\n * pairing code as a parameter of the status call: this endpoint records them and answers\n * 204. A wrong code is not reported here — the next poll asks for one again, which is how\n * the platform reports it.\n */\nexport interface WeChatScanVerifyRequest {\n taskId: string;\n verifyCode: string;\n}\n\n/**\n * POST …/messaging/<channel>/test-message — sent to the last known chat (409\n * `feishu_no_chat` / `telegram_no_chat` / `qq_no_chat` before one exists). On QQ this can\n * still fail with 502 afterwards: the platform accepts only replies to a message sent from\n * QQ minutes earlier, so a known chat does not mean a deliverable one.\n */\nexport interface MessagingTestMessageResponse {\n ok: true;\n}\n\n/**\n * One bot account a server has enabled: the channel and its account id (Feishu `app_id`,\n * Telegram bot id, QQ App ID, WeChat bot id), which is public by design. Never a Session,\n * never a secret.\n */\nexport interface MessagingAccount {\n channel: MessagingChannel;\n accountId: string;\n}\n\n/**\n * GET /api/messaging/accounts (admin only) — every bot account this server has enabled,\n * across all its Projects. A server that connects machines asks each of them this when a\n * bot is about to be turned on, so that one account is enabled on one server at a time.\n */\nexport interface MessagingAccountsResponse {\n accounts: MessagingAccount[];\n}\n\n/** A server that has the asked-about bot enabled: a machine by id and alias, or this server (both null). */\nexport interface MessagingAccountHolder {\n machineId: string | null;\n alias: string | null;\n}\n\n/**\n * Why a machine could not be asked: no connection to it, a build without the accounts\n * route (it answered 404), or any other failed answer.\n */\nexport type MessagingUnreachableReason = \"not_connected\" | \"older_build\" | \"failed\";\n\n/** A machine of the Project that could not be asked whether it holds the bot. */\nexport interface MessagingUnreachableMachine {\n machineId: string;\n alias: string;\n reason: MessagingUnreachableReason;\n}\n\n/**\n * GET /api/projects/:projectId/messaging/account-holders?channel=&accountId= — which servers\n * have this bot enabled: this server, and each machine the Project uses that could be asked.\n * Machines that could not be asked are listed apart; the caller decides what to do about\n * them. Never names a Session. A caller who is not an admin is answered for this server\n * only, because machines are admins' (both lists then hold no machine).\n */\nexport interface MessagingAccountHoldersResponse {\n holders: MessagingAccountHolder[];\n unreachable: MessagingUnreachableMachine[];\n}\n\n// ---------------------------------------------------------------------------\n// SSE server events (records use the default event, only server_event here)\n// ---------------------------------------------------------------------------\n\n/** Docs: /docs/server-api § \"Streaming (SSE)\". */\nexport type ServerEvent =\n /**\n * Approval request escalated to a human: every call under always-ask, plus rw/unknown-permission\n * calls under read-only (see runtime/approvals.ts); pending approvals are resent on reconnect.\n */\n | { type: \"approval_request\"; toolCall: AmspRecord<ToolCallPayload>; origin?: string[] }\n /** Session run status flip (for toggling the input area and list); `queued` = queued follow-up count (see TaskCreateRequest.queueIfBusy). */\n | {\n type: \"task_state\";\n state: SessionStatus;\n queued?: number;\n /** Steering messages queued but not yet delivered (absent = none): lets the composer's hint and its content survive reloads. */\n pendingSteering?: PendingSteeringInfo[];\n /**\n * Steering the run ended without delivering (absent = none) — an interrupt while a tool\n * was running is the ordinary way to produce one. Handed back rather than discarded: the\n * composer recalls each by the same handle and restores it into the draft, so the typed\n * message returns to the input box instead of being lost.\n */\n returnedSteering?: PendingSteeringInfo[];\n /** Queued follow-up tasks awaiting auto-start (absent = none): per-entry content + recall handle, alongside the `queued` count. */\n pendingFollowUps?: PendingFollowUpInfo[];\n /**\n * Live subagent children of this session's runtime (absent = none): the panel renders\n * child running marks from this structural liveness instead of parsing tool-output\n * text. Refreshed on every child run start/settle.\n */\n subagents?: SubagentRuntimeInfo[];\n }\n /** The model-generated title after the first turn has been persisted (for in-place list updates). */\n | { type: \"session_title\"; sessionId: string; title: string }\n /**\n * The user-channel counterpart of `task_state`: the same run-state flip, named by\n * `sessionId`, delivered on GET /api/events.\n *\n * `task_state` is session-scoped and deliberately carries no id, so it only ever reaches the\n * one conversation a tab has subscribed to — every OTHER row in that tab's Session list would\n * otherwise keep whatever status its last list fetch returned. This event exists so the list\n * can stay live without polling; the per-Session contract is unchanged.\n *\n * `lastActiveAt` and `hasTrace` are the row's own fields as they stand after the flip, both\n * written just before publishing (`markDriven` at run start sets has_trace and stamps\n * last_active_at; `touchLastActive` stamps again at run end). They are what let a list act on\n * the flip without refetching: the stamp separates \"this finished while I was looking\n * elsewhere\" from \"this finished before I last looked\", and `hasTrace` separates a Session\n * that has now run from one that never has — a first run would otherwise settle back into the\n * blank \"never ran\" row the client still believes in.\n *\n * `projectId` names the Session's Project: a list showing one Project ignores another's flips\n * without a request, and a flip for a Session of its own Project that it holds no row for is a\n * row it is missing — one that became active below its activity cursor (see SessionsResponse) —\n * which it fetches by id.\n *\n * Published only to the user channels of the Project's owner and members.\n */\n | {\n type: \"session_state\";\n sessionId: string;\n projectId: string;\n state: SessionStatus;\n lastActiveAt: string;\n hasTrace: boolean;\n }\n /**\n * A Session's background-task counts changed: a command promoted to the background, a\n * process that exited or was stopped, a background subagent starting or settling a round,\n * a released session. The counts are the row's `backgroundTasks` as they stand after the\n * change — zeros included, so a list can clear its mark without refetching. Published to\n * the user channels of the Project's owner and members, like `session_state`.\n */\n | { type: \"session_background\"; sessionId: string; processes: number; subagents: number }\n /**\n * A Session's count of tool calls waiting for a person's approval changed: a call was\n * escalated (every call under always-ask, rw/unknown calls under read-only), answered, or\n * denied by an interrupt. The count is the row's `pendingApprovalCount` as it stands after\n * the change, zeros included, so a list moves its mark without refetching; the calls\n * themselves are on the Session's own stream (`approval_request`, replayed on subscribe).\n * Published to the user channels of the Project's owner and members, like `session_state`.\n */\n | { type: \"session_approvals\"; sessionId: string; count: number }\n /** Last-Event-ID has been evicted from the buffer: the frontend should re-fetch the history endpoint before continuing to consume this connection. */\n | { type: \"resync_required\" }\n /**\n * The Project's model credentials changed (PUT /models): cached runtimes have been\n * invalidated server-side, so an auth-dead Session can continue — the frontend clears\n * its auth-dead composer state immediately. Published to every existing Session channel\n * of the Project; tabs without a live channel learn the same fact from the models\n * response's `updatedAt` on their next load.\n */\n | { type: \"credentials_updated\" }\n /** Placeholder handshake on the user channel (reserved for automated task notifications). */\n | { type: \"hello\" }\n /**\n * The stream's own beat, written by the endpoint's SSE writer every 20 seconds (http/sse.ts)\n * on `/api/events`, and by the hub on a machine's aggregate stream. The socket's ping and the\n * `: ping` comment line are invisible to a stream's consumer, so without this a stream that\n * stopped while its socket stayed healthy would look alive forever: the hub\n * (machines/event-hub.ts) and the browser (web api/socket.ts) each end a stream that misses\n * two beats and re-issue it with its last event id.\n */\n | { type: \"heartbeat\" }\n /** The served web assets were hot-swapped by a platform upgrade: clients reload to pick them up. */\n | { type: \"web_updated\"; rev: string }\n /**\n * A Session now exists. On the user channel for every creation — the CLI, another tab,\n * a schedule, an agent spawning a child — so the list learns about rows it did not make;\n * and on the parent Session's channel for a subagent, which no list shows. `source` is what\n * the new Session's meta records, and it tells a client which list, if any, the row joins.\n */\n | {\n type: \"session_created\";\n projectId: string;\n agentId: string;\n sessionId: string;\n source: SessionSource;\n }\n /**\n * The user's listing decision on one Session changed (PATCH `archived`, the archive page's\n * batch unarchive): an archived row leaves the sidebar, an unarchived one joins it. One per\n * Session, to the Project's users.\n */\n | {\n type: \"session_archived\";\n sessionId: string;\n projectId: string;\n agentId: string;\n archived: boolean;\n }\n /**\n * A Session was deleted (DELETE, the subagent children deleted with it, the archive page's\n * batch delete): every client drops the row. One per Session, to the Project's users.\n */\n | { type: \"session_deleted\"; sessionId: string; projectId: string }\n | ScheduleServerEvent\n | GoalServerEvent\n | CompanyServerEvent\n | BuiltinBrowserServerEvent\n | MachineServerEvent;\n\n/**\n * The machines' events on the user channel, published to admins only — the Machines page,\n * and the `/server/<id>` proxy everything else about a machine goes through, are admin-only.\n */\nexport type MachineServerEvent =\n /**\n * Something the machines list reports changed — a job queued, started, moved to its next\n * step, logged a line or ended; a connection was held or dropped; a reconnect began, failed\n * or succeeded; a machine joined or left a Project; a checkout's install image started or\n * finished building. No payload: a page re-reads `GET …/machines`, which is its one account\n * of every machine. Coalesced, so a burst of log lines costs a few reads, not one per line.\n */\n | { type: \"machines_changed\" }\n /**\n * One of a connected machine's own user-channel events, tagged with the machine it came\n * from. They ride the machines aggregate (event-hub.ts, `GET …/machines/events`) — ONE\n * stream for every connected machine, whose per-machine frames have this same shape; the\n * page that re-publishes them on its own channel wraps them this way too. One stream, not\n * a stream per machine: five of those once took the browser's whole HTTP/1.1 allowance of\n * six connections to one origin, and every other request of the page then waited behind\n * them for good.\n */\n | { type: \"machine_event\"; machineId: string; event: ServerEvent };\n\n/** Goal-mode progress on the session channel (the chat page drives its goal banner from these). */\nexport type GoalServerEvent =\n /** A goal run began (published before the first round). */\n | { type: \"goal_started\"; sessionId: string; objective: string; budget: number }\n /** A round is starting; `used` is the runner's accounting up to this point. */\n | { type: \"goal_round\"; sessionId: string; round: number; used: number; budget: number }\n /** The goal reached a terminal state. */\n | {\n type: \"goal_finished\";\n sessionId: string;\n outcome: \"complete\" | \"blocked\" | \"budget_limited\" | \"aborted\";\n rounds: number;\n used: number;\n };\n\n/** Schedule notification (user-level event stream; firing and delivery are notified via /api/events). */\nexport type ScheduleServerEvent =\n /** Fired and sent (sessionId is the session that received the Prompt; a new session under new-Session mode). */\n | { type: \"schedule_fired\"; projectId: string; agentId: string; name: string; sessionId: string }\n /** Target Session is running; this firing is queued and will be sent once it's idle. */\n | {\n type: \"schedule_queued\";\n projectId: string;\n agentId: string;\n name: string;\n sessionId: string;\n }\n /** A workbench of the Agent was (re)loaded — its folder changed, a reload was requested, or a version was restored. */\n | { type: \"workbench_updated\"; projectId: string; agentId: string; workbench: WorkbenchInfo }\n /** A workbench's folder is gone — removed from the Web App or deleted on disk. */\n | { type: \"workbench_removed\"; projectId: string; agentId: string; workbenchId: string };\n\n// ---------------------------------------------------------------------------\n// Trace browsing and performance analysis\n// ---------------------------------------------------------------------------\n\nexport interface TraceFileInfo {\n /** Trace file index (one file corresponds to one complete model context). */\n index: number;\n /** Date subdirectory it belongs to (yyyy-mm-dd). */\n date: string;\n sizeBytes: number;\n mtime: string;\n}\n\nexport interface SessionTracesResponse {\n files: TraceFileInfo[];\n}\n\n/** One tool's share of the context: its calls plus their results. Its *definition* is counted in `toolDefs`, not here. */\nexport interface ContextToolShare {\n name: string;\n tokens: number;\n}\n\n/**\n * One file's share of the context: its `read_file` / `edit_file` / `write_file` calls plus their\n * results, keyed by the file each call named.\n */\nexport interface ContextFileShare {\n /**\n * Workspace-relative when the file is inside the Session's Workspace; otherwise absolute, with\n * the home directory shortened to `~`. Spellings that resolve to the same file are one row.\n */\n path: string;\n tokens: number;\n /** How many calls of each file tool named the file in this context. */\n ops: { read: number; edit: number; write: number };\n}\n\n/**\n * What the Session's current model context is made of — the part derived from its messages.\n *\n * Every token figure is an **estimate** from a character heuristic, not a tokenizer: the\n * authoritative occupancy is the last `token_usage`'s `request.total`, which says how large the\n * context is but not what fills it. Consumers should present these as shares of that measured\n * occupancy rather than as counts of their own.\n *\n * The six parts partition the context and sum to `total`; `topTools` is a ranking inside\n * `toolRequests + toolResults` and can sum to less than those two (a result whose call was not\n * recorded in the same Trace shard has no tool to be attributed to). `topFiles` is the same\n * ranking narrowed to the three file tools and keyed by the file each call named.\n */\nexport interface SessionContextParts {\n systemPrompt: number;\n toolDefs: number;\n userMessages: number;\n assistantMessages: number;\n toolRequests: number;\n toolResults: number;\n /** Sum of the six parts. */\n total: number;\n /** Tools ranked by the context their traffic occupies, descending; at most five. */\n topTools: ContextToolShare[];\n /** Files ranked by the context their file-tool traffic occupies, descending; at most five. */\n topFiles: ContextFileShare[];\n /**\n * A completed compaction closed the context these figures describe, and the next one has not\n * been written yet: the composition is of what was compacted away, not of what the model now\n * carries. The same state in which the chat page's context ring shows `—`.\n */\n contextClosed: boolean;\n}\n\n/** `GET /api/sessions/:id/context`: the message-derived composition plus where compaction will fire. */\nexport interface SessionContextResponse extends SessionContextParts {\n /**\n * Occupancy (tokens) at which this Session's next Request triggers context compaction: the\n * Agent's configured `compaction.max_context_length`, capped by what the model's context window\n * leaves room for. Null when compaction is disabled, when the Agent's config could not be read,\n * or when the derived threshold is not below the window — nothing to mark inside the gauge.\n */\n compactionThreshold: number | null;\n}\n\nexport interface TraceEventsResponse {\n events: AmspRecord[];\n offset: number;\n limit: number;\n /**\n * Record count of the file (basis for pagination), in the ordinals `tracePosition` and the\n * analysis' `messageFrom` / `messageTo` use: malformed lines are not records.\n */\n total: number;\n}\n\n/** Duration span of a single LLM Request (request_begin/request_end paired by proximity). */\nexport interface RequestSpan {\n beginTs: string;\n endTs?: string;\n durationMs?: number;\n status?: string;\n /** The Task it belongs to (same convention as modelSegments/toolSpans). */\n taskIndex: number;\n /** Compaction request (falls between compaction_begin and compaction_end): excluded from TPS, see TraceTaskStats. */\n compaction?: boolean;\n /**\n * Total human approval wait time within this Request. core does `await approve(tc)` inside\n * the streaming loop — if approval doesn't return, the next chunk isn't consumed and\n * `request_end` can't be emitted either, so the entire human wait falls inside the span\n * (see context-engine's runTurn). Tool **execution** is not included (`void executeOne`,\n * doesn't block the loop).\n */\n approvalWaitMs?: number;\n /** LLM generation duration = durationMs − approvalWaitMs (≥ 0): only this can be used as the TPS denominator, not durationMs. */\n activeMs?: number;\n /**\n * The thinking level this request went out at, as its `request_begin` records it. Absent\n * when no level was sent, and on Traces written before the field — unknown, not a level.\n */\n thinkingLevel?: string;\n}\n\n/**\n * Per-Task Token / duration figures (aggregated server-side over the **entire** Trace file,\n * aligned with the Chat page's task-stats).\n *\n * Provided separately instead of letting the frontend aggregate `requests` + events itself:\n * the frontend's events are paginated (only the first N), so self-aggregation would mismatch\n * a numerator covering only the first N against a denominator covering the whole file.\n */\nexport interface TraceTaskStats {\n taskIndex: number;\n /**\n * This turn is a **compaction turn** (compaction forms its own turn); the UI marks it with\n * a badge accordingly. It's treated the same as a user turn: it has Token /\n * cost / duration / TPS, and **counts normally toward global stats** — the global totals are\n * just the sum of the per-turn cards below, the two scopes match, so adding up the per-turn\n * numbers must equal the total.\n */\n compaction?: boolean;\n /**\n * Which kind of compaction turn it is, from the turn's `compaction_begin`. Additive beside\n * the flag rather than folded into it: `compaction` stays the sole gate on \"is this a\n * compaction turn\", so a client that only knows the boolean keeps working unchanged, and one\n * that reads this can name the turn for what it did — the two modes are different operations,\n * and only `summarize` actually compacts anything (`discard` drops the old context outright).\n * Absent on a turn analyzed before this field existed, or whose `compaction_begin` carried no\n * mode; treat an absent value as `summarize`, which is what the badge said before the split.\n */\n compactionMode?: CompactionMode;\n /**\n * A short label for the round navigator: the first non-empty line of the user text that\n * started this turn, with harness marker blocks and attachment lines removed, whitespace\n * collapsed, at most 120 characters (the view truncates it further to fit). Absent when no\n * user text started the turn — a summarize compaction turn, an image-only send — or none of\n * its lines is left (only attachment lines).\n */\n prompt?: string;\n /**\n * This turn's message index range within the **entire file** (inclusive). A single\n * sequential scan on the server tells which turn each message belongs to; the frontend\n * attributes messages by this, **no longer guessing by timestamp** — the same millisecond\n * can pack \"previous turn's last reply + compaction start + compaction prompt + next turn's\n * request_begin\", which time boundaries can't separate, misattributing this turn's reply to\n * the next turn.\n */\n messageFrom: number;\n messageTo: number;\n /**\n * This turn's duration span: `startTs` = the moment of this turn's **first `request_begin`**\n * — duration only looks at LLM requests, not the timestamp of user text like the user\n * Prompt / compaction summary (`[context_summary]` is created during compaction but only\n * persisted on the next run; resuming the next day would inflate the first turn by a whole\n * day for no reason); `endTs` = the moment of the last non-session_meta message in the\n * range. For a degenerate turn with no Request at all (interrupted right after sending),\n * `startTs` is an empty string and duration counts as 0.\n */\n startTs: string;\n endTs: string;\n /**\n * Context usage at the end of this Task = the three-bucket Token snapshot of the last\n * **non-compaction** Request (same convention as the Chat page's `contextNow`). Note this\n * must not be the sum of this Task's Requests — each Request's input carries the full\n * history again, so summing double-counts the context, and a few rounds of tool calls\n * would blow past the context window. A pure-compaction Task (no non-compaction Request)\n * has no value here.\n */\n context?: { cacheRead: number; cacheWrite: number; output: number };\n /**\n * This turn's **cumulative** usage (the sum of the three buckets over every Request in this\n * Task), for Token stats and cost conversion. Two different figures from `context`: that one\n * is a snapshot (how much is occupied right now), this one is a ledger (how much this turn\n * spent in total). Includes compaction requests — compaction tokens are real money spent and\n * must be counted; consistent with the Chat page's tokensByBucket.\n */\n tokens: { cacheRead: number; cacheWrite: number; output: number };\n /**\n * This turn's cost in USD: each Request's three buckets at the Project's current rates for\n * the file's model, at the tier that Request's own timestamp fell in — the rule and the\n * price lookup the cost center applies to the usage row the same `token_usage` produced,\n * so this figure, the toolbar's and the cost center's agree on what a request cost. Present\n * on every turn of a priced file (a turn with no Request reads 0); absent when the model has\n * no pricing or the file's head names no provider, and then absent from the response's\n * total as well.\n */\n cost?: number;\n /**\n * Total LLM generation duration for this turn (the denominator for output TPS; human\n * approval wait already deducted). The numerator is simply `tokens.output`: since\n * compaction forms its own turn, each turn's output tokens are just its own Requests'\n * output — there's no second figure to reconcile.\n */\n llmMs: number;\n /**\n * Wall-clock time this turn spent executing tools: the **union** of its tool spans'\n * execution intervals (`approvalTs ?? callTs` to `outputTs`), so tools running in parallel\n * are counted once instead of summed. Two exclusions, both deliberate: the human approval\n * wait (`callTs` to `approvalTs`) is not tool work, and the argument-generation segment is\n * the model streaming arguments, already counted in `llmMs`. A span with no `outputTs` —\n * still running when the file ended, or interrupted — contributes nothing rather than being\n * extrapolated to now.\n *\n * This and `llmMs` may **overlap**: a tool started in the background keeps running while the\n * model decodes. They are two measured components of the turn's duration, not a partition of\n * it, and they need not add up to the turn's span (approval waits and harness overhead belong\n * to neither). Never derive one by subtracting the other from the duration.\n */\n toolMs: number;\n}\n\n/** Duration span of a single tool call (complete tool_call message → paired tool_call_output). */\nexport interface ToolCallSpan {\n toolCallId: string;\n name: string;\n startTs: string;\n endTs?: string;\n durationMs?: number;\n /** The paired tool_call_output's `status` (completed / aborted / fatal); absent while the call has no output. */\n status?: string;\n}\n\n/** Workspace file entry (Files tab). */\nexport interface WorkspaceFileEntry {\n name: string;\n kind: \"dir\" | \"file\";\n sizeBytes: number;\n mtime: string;\n}\n\nexport interface WorkspaceFilesResponse {\n /** Requested relative path (\"\" = Workspace root). */\n path: string;\n entries: WorkspaceFileEntry[];\n}\n\n/**\n * Write one Workspace file whole (the Upload button, a drop, and the Files panel's editor). A\n * write answers 204 with the written file's version in `ETag` — the marker the next conditional\n * write of the same file carries, so an editor can keep editing after a save.\n */\nexport interface FilesWriteRequest {\n /** The entire file, base64-encoded (≤14MB decoded). */\n dataBase64: string;\n /**\n * Write precondition: the version marker the caller read off this file's `ETag` on\n * `GET files/content`. The server compares it against the file's state immediately\n * before writing and answers 409 `file_changed` — having written nothing — when they\n * differ, which is what stops the editor's save from quietly dropping what the Agent\n * wrote during the turn. Omitted by a caller that read no version (an upload creates or\n * replaces unconditionally); that absence, not a sentinel value, is what says there was\n * no prior version to match.\n */\n ifVersion?: string;\n}\n\n/** Move or rename one Workspace file or folder (the Files panel's context menu). */\nexport interface FilesMoveRequest {\n /** Source path, relative to the Workspace root. */\n from: string;\n /** Destination path, relative to the Workspace root. Its parent directory is created when missing. */\n to: string;\n /**\n * Move precondition, read exactly like {@link FilesWriteRequest.ifVersion}: the marker the\n * caller got from this file's `ETag` on `GET files/content`, which the file must still\n * carry. It differs on only one point — the marker guards the **source**. The destination\n * has none, because the caller never read it, which is why an occupied destination is\n * refused with 409 `target_exists` rather than overwritten.\n *\n * A directory `from` takes none, and one sent with it is a 400: a directory carries no\n * single version marker, so no precondition can be stated for it. A folder moves whole and\n * unconditionally — nothing is lost that way, since whatever the Agent wrote into it meanwhile\n * moves with it — and is refused only when the destination is occupied or lies inside it.\n */\n ifVersion?: string;\n}\n\n/**\n * Create one empty text file or one folder in a Workspace (the Files panel's New menu). Missing\n * parent directories are created as a write creates them. Anything already at `path` — a file,\n * a folder or a link — is refused with 409 `target_exists` and nothing is written.\n */\nexport interface FilesCreateRequest {\n /** The new entry's path, relative to the Workspace root. */\n path: string;\n kind: \"file\" | \"dir\";\n}\n\n/**\n * One Workspace search match. `kind`, `sizeBytes` and `mtime` are named and typed exactly as\n * {@link WorkspaceFileEntry} names them: a hit is drawn by the same row renderer as a tree\n * entry, and it can only render identically if it carries the same fields.\n */\nexport interface WorkspaceSearchHit {\n /** Workspace-relative, \"/\"-separated, with no leading \"./\" (a tree entry's `name` is only its last segment). */\n path: string;\n kind: \"dir\" | \"file\";\n sizeBytes: number;\n mtime: string;\n}\n\nexport interface WorkspaceSearchResponse {\n /** Shallowest first, then directories before files, then by name — the order the breadth-first walk produced. */\n hits: WorkspaceSearchHit[];\n /**\n * A cap stopped the walk (200 hits, or 20000 directory entries visited), so this is a\n * partial list. Breadth-first is what makes that degrade well: the hits that survive\n * truncation are the shallowest ones, not the ones that happened to be enumerated first.\n */\n truncated: boolean;\n}\n\n/** Batch file existence check (message file cards only list files that actually exist). */\nexport interface FilesStatRequest {\n /** Paths relative to the Workspace root (≤100 items, each ≤512 characters). */\n paths: string[];\n}\n\nexport interface FilesStatResponse {\n /** Confirmed existing paths (regular files within bounds), preserving request order and deduplicated; out-of-bounds and resolution failures count as non-existent. */\n existing: string[];\n}\n\n/**\n * Model serial segments (autoregressive decoding): Trace records completion times, so each\n * segment's duration = its own time − the previous event's time (the request's first segment\n * is based on request_begin; user input is treated as sent instantaneously and takes no\n * segment).\n */\nexport interface TraceModelSegment {\n kind: \"thinking\" | \"text\" | \"tool_call\";\n startTs: string;\n endTs: string;\n /** Given when kind=tool_call. */\n toolCallId?: string;\n name?: string;\n /** The Task it belongs to (a single user turn can contain multiple Requests): the frontend groups by this, each Task on its own independent timeline. */\n taskIndex: number;\n}\n\n/**\n * Tool full lifecycle (parallel to model decoding): initiated (callTs) → approved\n * (approvalTs) → output (outputTs). Unclosed fields are unset (approval pending / executing /\n * file truncated).\n */\nexport interface TraceToolSpan {\n toolCallId: string;\n name: string;\n callTs: string;\n approvalTs?: string;\n decision?: string;\n outputTs?: string;\n /** The tool_call_output's `status` (completed / aborted / fatal); absent while unclosed. */\n status?: string;\n /** The Task that initiated this tool (grouped with its tool_call segment): async output belongs to this Task even if it arrives after request_end. */\n taskIndex: number;\n}\n\n/**\n * Non-tool auxiliary phase on the timeline: rendered in its own lane under the \"other\"\n * legend category — deliberately not a tool span, since nothing was called. Currently the\n * first run's MCP connect + discovery (from the mcp_connect_begin/end event pair).\n */\nexport interface TraceOtherSpan {\n /** Unique bar key (e.g. `mcp-connect-<beginTs>`). */\n key: string;\n /** Lane label, e.g. \"mcp connect\". */\n name: string;\n startTs: string;\n endTs: string;\n /** Attached to the Task that follows the phase (same grouping convention as toolSpans). */\n taskIndex: number;\n /** True when the phase ended with failures (some servers unreachable) or was aborted. */\n failed?: boolean;\n}\n\nexport interface UsageTrendPointInTrace {\n ts: string;\n requestTotal: number;\n sessionTotal: number;\n}\n\nexport interface TraceAnalysisResponse {\n /**\n * Sum of all turns' durations (**including compaction turns**, same scope as `tasks` — the\n * global figure is just the sum of the per-turn figures below; gaps between turns where the\n * user is thinking or away are not counted). Computed server-side over the entire file: the\n * frontend's events are paginated, so self-aggregation would undercount.\n */\n elapsedMs: number;\n /**\n * The file's model-API time: the sum of the turns' `llmMs` (human approval wait deducted,\n * compaction requests included, so the scope matches `elapsedMs`).\n */\n apiMs: number;\n /**\n * The file's tool wall time: the sum of the turns' `toolMs` (parallel tools counted once\n * within a turn). Summed per turn rather than unioned across the file, so the global figure\n * stays the sum of the per-turn figures, exactly as `elapsedMs` is. May overlap `apiMs` —\n * see `TraceTaskStats.toolMs`.\n */\n toolMs: number;\n /**\n * The file's cost in USD: the sum of the turns' `cost` (compaction turns included, the\n * scope every total here shares). Absent exactly when the turns carry no `cost`.\n */\n cost?: number;\n /**\n * `cost` split by pricing bucket: every Request's Tokens at the rates and tier its share of\n * `cost` was priced at, summed per bucket over the file. Present exactly when `cost` is; the\n * turns carry no split.\n */\n costParts?: CostParts;\n /**\n * The model's context window as the file's head `session_meta` records it\n * (`model_context_window`), for the context ring. The panel no longer reads every event, so the\n * analysis carries it; absent when the head records none (an older server, or a file without\n * one), and the panel then falls back as it always has.\n */\n modelContextWindow?: number | string;\n requests: RequestSpan[];\n /** Token / duration aggregated per Task (used directly by the Trace page's context ring and per-turn TPS). */\n tasks: TraceTaskStats[];\n toolCalls: ToolCallSpan[];\n /** Execution timeline: model serial segments (LLM lane). */\n modelSegments: TraceModelSegment[];\n /** Execution timeline: each tool's approval/execution phases (independent lane, can overlap with model decoding). */\n toolSpans: TraceToolSpan[];\n /** Execution timeline: non-tool auxiliary phases (their own \"other\" lanes — currently the first run's MCP connect + discovery). */\n otherSpans: TraceOtherSpan[];\n /** Number of request_end events with status ∈ {timeout, malformed}. */\n reconnectCount: number;\n /** Number of compaction_begin events. */\n compactionCount: number;\n usageTrend: UsageTrendPointInTrace[];\n}\n\nexport interface AgentTraceFileRef {\n index: number;\n sizeBytes: number;\n}\n\nexport interface AgentTraceSessionGroup {\n sessionId: string;\n files: AgentTraceFileRef[];\n}\n\nexport interface AgentTraceDateGroup {\n date: string;\n sessions: AgentTraceSessionGroup[];\n}\n\n/** One Trace file in the session-centric listing (`date` carried per file: one Session's shards can span date directories). */\nexport interface AgentTraceSessionFile {\n index: number;\n date: string;\n sizeBytes: number;\n}\n\n/** One Session's Trace files merged across date directories (the paginated listing's unit). */\nexport interface AgentTraceSessionEntry {\n sessionId: string;\n /**\n * Display title, resolved only for the returned page: the sessions DB title when one\n * exists, else derived from the Session's first user prompt (bounded head-read of the\n * earliest shard); absent when neither yields one (the client falls back to its\n * default title — raw session ids are never rendered).\n */\n title?: string;\n /**\n * Sidebar category of this Session, from the same bounded classification the listing\n * filters and counts with: archived exactly from the DB row; origin from the shared\n * in-process registry / previously observed session_meta; a DB-untracked Session this\n * process has not yet head-read falls into `active` until a page surfaces it (its\n * head-read then registers the true origin for subsequent requests).\n */\n category: SessionCategory;\n /** Workspace path locked at creation (DB row or observed session_meta); \"\" when unknown — the client's merged temp-group fallback. */\n workspace: string;\n /** Sorted by index ascending (a higher index is newer). */\n files: AgentTraceSessionFile[];\n}\n\n/**\n * Agent-level Trace browsing structure. Without `limit` the response is the legacy full\n * drill-down (`dates`: Agent → date → Session → Trace file, reverse chronological) and the\n * paging fields are absent. With `offset`/`limit` the response is session-group-centric:\n * `sessions` carries the requested slice (newest first by sessionId desc — ids embed a\n * timestamp, so that is reverse chronological) with titles and classification,\n * `totalSessions` the session-group count (within `category` when one is given, so paging\n * and the count agree), `counts` / `workspaceCounts` the per-category totals over ALL of\n * the Agent's session groups (folder labels / workspace-mode group headers), and `dates`\n * stays empty (per-file stats are only taken for the returned page).\n */\nexport interface AgentTracesResponse {\n dates: AgentTraceDateGroup[];\n /** Present only when the request paginates: the requested slice of Session groups, newest first. */\n sessions?: AgentTraceSessionEntry[];\n /** Present only when the request paginates: session-group count of the paged (category-filtered) set. */\n totalSessions?: number;\n /** Present only when the request paginates: per-category totals over all of the Agent's session groups. */\n counts?: SessionCategoryCounts;\n /** Present only when the request paginates: `counts` broken down by Workspace path (\"\" = unknown). */\n workspaceCounts?: Record<string, SessionCategoryCounts>;\n}\n\nexport interface TraceImportRequest {\n /** Base64 of the Trace file content (JSON Lines; the first record must be `session_meta`). */\n dataBase64: string;\n}\n\nexport interface TraceImportResponse {\n /** Session id taken from the imported file's `session_meta`. */\n sessionId: string;\n /** Allocated file index: always 1 — an import creates a new Session (a duplicate session id is rejected with 409 `trace_session_exists`). */\n index: number;\n /** Date directory the file landed in (local yyyy-mm-dd from the first record's timestamp, matching the Trace Writer's convention). */\n date: string;\n}\n\n// ---------------------------------------------------------------------------\n// Usage and cost statistics\n// ---------------------------------------------------------------------------\n\nexport type UsageGroupBy = \"date\" | \"agent\" | \"model\" | \"session\";\n\n/**\n * A cost split by the three pricing buckets, in USD: cached input (`cacheRead`), uncached input\n * (`cacheWrite`, input Tokens on a cache miss) and output. Each part is its bucket's Tokens at the\n * rate the whole cost was priced at, so the three sum to the cost they accompany (up to\n * floating-point rounding).\n */\nexport interface CostParts {\n cacheRead: number;\n cacheWrite: number;\n output: number;\n}\n\nexport interface UsageBucket {\n total: number;\n requests: number;\n /** Cost converted using current pricing at query time (USD); a partial sum when uncosted Models are included, null if none has pricing. */\n cost: number | null;\n /** Whether any Model has no pricing (its usage isn't included in cost; counted once pricing is added later). */\n hasUncosted: boolean;\n}\n\nexport interface UsageGroupRow {\n /** Group key: date / agentId / modelId / sessionId. */\n key: string;\n /** Provider group when groupBy=model (rows are broken down by (provider, modelId); unset for other dimensions). */\n provider?: string;\n cacheRead: number;\n cacheWrite: number;\n output: number;\n total: number;\n requests: number;\n cost: number | null;\n /**\n * `cost` split by pricing bucket, each record at the same rates, tier and promotion that\n * priced it in `cost`. Present exactly when `cost` is not null; a row's unpriced usage is in\n * neither.\n */\n costParts?: CostParts;\n hasUncosted: boolean;\n}\n\n/** Time-series precision for the usage series (`granularity` query parameter). `minute` requires the `fromTs`/`toTs` window bounds. */\nexport type UsageGranularity = \"minute\" | \"hour\" | \"day\" | \"week\" | \"month\";\n\n/**\n * One bucket of the usage time series (for the cost center's time-series charts).\n * Buckets are zero-filled across the whole requested range, so consecutive points\n * are always adjacent in time. Bucket keys by granularity: minute\n * `yyyy-mm-ddThh:mm` and hour `yyyy-mm-ddThh:00` (server-local clock), day\n * `yyyy-mm-dd`, week the ISO week's Monday `yyyy-mm-dd`, month `yyyy-mm`.\n */\nexport interface UsageSeriesPoint {\n bucket: string;\n cacheRead: number;\n cacheWrite: number;\n output: number;\n total: number;\n /** Cost converted at query time (USD); null when no priced Model contributed. */\n cost: number | null;\n requests: number;\n /** Successful requests in the bucket. */\n completed: number;\n /** Success-rate denominator: all requests minus aborted (a user interruption is not a model failure). */\n denominator: number;\n}\n\n/**\n * One entity's per-bucket request and success counts, aligned index-for-index\n * with `series` (the requests-and-success-rate chart draws one such entity —\n * or stacks them all). `denominator` excludes aborted, same as elsewhere.\n */\nexport interface UsageEntitySeriesCounts {\n requests: number[];\n completed: number[];\n denominator: number[];\n}\n\n/** Per-Agent counts per bucket. */\nexport interface UsageAgentSeries extends UsageEntitySeriesCounts {\n agentId: string;\n}\n\n/** Per-Model counts per bucket (entity identity is the (provider, modelId) pair). */\nexport interface UsageModelSeries extends UsageEntitySeriesCounts {\n provider: string;\n modelId: string;\n}\n\n/**\n * Lifetime Token total for one Model, keyed by the (provider, modelId) pair. Deliberately\n * unfiltered — no date range, no Agent: it answers \"how much has this model been used\", a\n * question with no window attached, and the models page that shows it carries no filters of its\n * own to honour. Models never used are absent rather than zero.\n */\nexport interface UsageModelTotal {\n provider: string;\n modelId: string;\n /** The three buckets summed (cache read + cache write + output), matching the cost center's Token figure. */\n tokens: number;\n requests: number;\n}\n\n/** Response of `GET /usage/model-totals`. */\nexport interface UsageModelTotals {\n totals: UsageModelTotal[];\n}\n\n/**\n * The two categories an error record is filed under: `unexpected` is a 500 or an unforeseen\n * runtime exception, `expected` an HttpError / business 4xx. The panel separates them by\n * colour, and `GET /usage/errors` takes one of them as its `kind` filter.\n */\nexport type UsageErrorKind = \"unexpected\" | \"expected\";\n\n/** Occurrence count of an error for a given source · code (the \"most common\" metric in the stats center's error panel). */\nexport interface UsageErrorCount {\n source: string;\n code: string;\n kind: string;\n count: number;\n}\n\n/**\n * One row of the stats center's error panel table: the records of one calendar day that share\n * a source, code, kind and message. The day is the viewer's when the request carried\n * `utcOffsetMinutes`, and the server's own otherwise.\n */\nexport interface UsageErrorItem {\n /** The latest of the records this row stands for. */\n ts: string;\n source: string;\n code: string;\n kind: string;\n message: string;\n /** How many records this row stands for (the panel's \"×N\"). */\n count: number;\n /** The earliest of them; equal to `ts` for a single record. */\n firstTs: string;\n}\n\n/**\n * Server-side error capture stats: not affected by the model\n * filter (HTTP / process errors have no Model dimension), but affected by date and agent\n * filters. Errors with no Project attribution (login, process-level) are counted in every\n * Project's view. The stats center presents this as \"summary stats + detail table\" with no\n * chart, so it only has a total count, the most common error code, and the most recent N\n * items.\n */\nexport interface UsageErrors {\n /** Filtered record count — also what a clear of the same filter takes (see {@link UsageErrorsClearResponse}). */\n total: number;\n /** Count of unexpected ones (500 / runtime exceptions) among them — the part the frontend highlights. */\n unexpected: number;\n /** The most frequent source · code (null when there are no errors). */\n topCode: UsageErrorCount | null;\n /** Most recent N rows (reverse chronological) — the first page; older ones come from `GET /usage/errors`. */\n recent: UsageErrorItem[];\n /** How many rows the table has for this filter (records folded per {@link UsageErrorItem}) — what the pager counts. */\n rows: number;\n}\n\n/**\n * GET /api/projects/:projectId/usage/errors — one page of the error detail table, newest\n * first. The dashboard response above already carries the first page; this exists so\n * \"show me earlier ones\" does not have to refetch the whole aggregate. It takes the same\n * date/agent filter as the dashboard (`fromTs`/`toTs` narrow it to a trailing window, both\n * or neither), so a page never widens what the summary counted, plus an optional `kind`\n * ({@link UsageErrorKind}) narrowing to one of the two categories — which\n * is how the cost-center badge asks \"are there unexpected errors, and how new is the newest\"\n * with `limit=1` instead of pulling the whole dashboard aggregate. `utcOffsetMinutes` (minutes\n * east of UTC, the dashboard takes it too) says whose calendar day a row's records share.\n */\nexport interface UsageErrorsPage {\n items: UsageErrorItem[];\n /** Filtered record count — the occurrences, which is what the cost-center badge counts. */\n total: number;\n /** Filtered row count (records folded per {@link UsageErrorItem}), so the caller knows when it has reached the end. */\n rows: number;\n}\n\n/**\n * DELETE /api/projects/:projectId/usage/errors — empties the error table for the filter the\n * panel is showing (its date range, the trailing window when one is on, and Agent), Project\n * owner only.\n *\n * Scoped to the filter rather than the Project's whole history, so a clear takes exactly the\n * rows on screen — for an admin, the unattributed rows an admin's panel shows included; a\n * member's panel never shows them and a member's clear never takes them.\n *\n * `from` and `to` are both required (400 otherwise), where the reads treat them as optional:\n * an absent bound is unbounded on that side, which is the whole history rather than a filter.\n */\nexport interface UsageErrorsClearResponse {\n /** How many rows were deleted, so the caller can say what went instead of guessing. */\n deleted: number;\n}\n\nexport interface UsageResponse {\n summary: {\n today: UsageBucket;\n last7d: UsageBucket;\n total: UsageBucket;\n };\n groupBy: UsageGroupBy;\n groups: UsageGroupRow[];\n /** Effective precision of `series` (the validated `granularity` query parameter; defaults to day). */\n granularity: UsageGranularity;\n /**\n * Usage time series over the requested from/to range (defaulting to the last 30\n * days), zero-filled at `granularity`; affected by agent/model filters. Carries\n * everything the time-series charts draw: Token buckets, cost, request count,\n * and per-bucket success counts.\n */\n series: UsageSeriesPoint[];\n /**\n * Per-Agent counts per bucket, aligned index-for-index with `series`, sorted\n * by total requests descending. Unaffected by the agent filter — the by-Agent\n * chart draws the whole breakdown — but affected by date/model filters.\n */\n byAgentSeries: UsageAgentSeries[];\n /**\n * Per-Model counts per bucket, aligned index-for-index with `series`, sorted\n * by total requests descending. Unaffected by the model filter — the by-Model\n * chart draws the whole breakdown — but affected by date/agent filters.\n */\n byModelSeries: UsageModelSeries[];\n /** Server-side error capture stats (affected by date/agent filters; unaffected by model filter). */\n errors: UsageErrors;\n /** List of Agent ids that have appeared in this Project (for the filter dropdown; unaffected by current filters). */\n agentIds: string[];\n /** List of Model paired references that have appeared in this Project (for the filter dropdown). */\n models: ModelRefDto[];\n}\n\n// ---------------------------------------------------------------------------\n// Schedule\n// ---------------------------------------------------------------------------\n\n/**\n * Display-facing schedule status: the file's `enabled` only expresses intent; the rest is derived from runtime state.\n * The scheduler archives an agent's settled tasks (done / missed / expired) into `schedule/expired/`, so the agent\n * listings show those three only while a queued fire is still to be sent or when the move failed.\n */\nexport type ScheduleStatus = \"active\" | \"disabled\" | \"expired\" | \"done\" | \"missed\" | \"invalid\";\n\nexport interface ScheduleItem {\n /** Filename (without .toml) is the identifier. */\n name: string;\n prompt: string;\n enabled: boolean;\n /** ISO 8601. */\n startAt: string;\n /** Raw fixed interval (e.g. `30m`); unset means a one-off task. */\n period?: string;\n endAt?: string;\n /** Bound target Session; defaults to creating a new Session each time. */\n sessionId?: string;\n workspace?: string;\n /** Model for new-Session mode (upstream id, always paired with provider); absent means the Project's default reference. */\n modelId?: string;\n /** Provider group for `modelId`; present exactly when `modelId` is — a model reference is always a pair. */\n provider?: string;\n status: ScheduleStatus;\n invalidReason?: string;\n /** Next scheduled fire time (ISO 8601); unset when done/missed/invalid/disabled. */\n nextFireAt?: string;\n /** Most recent actual fire time (ISO 8601). */\n lastFiredAt?: string;\n /** Queued, waiting for the target Session to become idle. */\n queued: boolean;\n creatorUserId?: string;\n}\n\nexport interface SchedulesResponse {\n schedules: ScheduleItem[];\n /** Files that failed to parse (skipped from scheduling and logged as errors). */\n invalidFiles: Array<{ name: string; error: string }>;\n}\n\n/**\n * One task in the Project-wide listing (GET /api/projects/:projectId/schedules): the agent's\n * item plus the agent whose schedule directory holds it, since the list spans every agent.\n */\nexport interface ProjectScheduleItem extends ScheduleItem {\n agentId: string;\n}\n\nexport interface ProjectSchedulesResponse {\n /** Every agent's tasks: agents in id order, each agent's tasks in the order its own listing returns them. */\n schedules: ProjectScheduleItem[];\n /** Files that failed to parse (skipped from scheduling and logged as errors), with the agent that holds each. */\n invalidFiles: Array<{ agentId: string; name: string; error: string }>;\n}\n\nexport interface ScheduleUpsertRequest {\n prompt: string;\n enabled: boolean;\n startAt: string;\n period?: string;\n endAt?: string;\n sessionId?: string;\n workspace?: string;\n /** Model for new-Session mode (upstream id); always sent together with provider, omit both for the Project's default reference. */\n modelId?: string;\n /**\n * Provider group for `modelId`. Both fields are sent as a pair (400 otherwise); the\n * pair is checked against the Project config at save/reconciliation time.\n */\n provider?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Agent State version and snapshots\n// ---------------------------------------------------------------------------\n\nexport interface AgentImportRequest {\n /** Base64 of the snapshot package (tar.gz). */\n dataBase64: string;\n /** Explicit confirmation is required when the package version is equal to or lower than the current version, otherwise 409. */\n confirm?: boolean;\n}\n\nexport interface AgentImportResponse {\n /** Agent State version number after import (taken from the package's value). */\n version: number;\n}\n\n// ---------------------------------------------------------------------------\n// Benchmark scoring (display), manual creation and deletion\n// ---------------------------------------------------------------------------\n\n/** Raw result of a single run (a scoreboard per-case runs[] entry). */\nexport interface BenchmarkRunScore {\n score: number;\n /** Run cost, or null when unavailable. */\n cost: number | null;\n durationMs: number;\n /** Id of the Session under test in this run (links to Trace). */\n sessionId: string;\n}\n\nexport interface BenchmarkCaseScore {\n case: string;\n /** Model-written average of this Case's Run scores, on the fixed 0..100 scale. */\n score: number;\n /** Model-written average of known Run costs; null when every Run cost is unknown. */\n cost: number | null;\n /** Model-written average of Run durations, rounded to an integer. */\n durationMs: number;\n /** Raw results per Run. */\n runs: BenchmarkRunScore[];\n}\n\nexport interface BenchmarkEvaluation {\n /** Evaluation timestamp (ISO 8601). */\n time: string;\n /**\n * Agent under test in this round (the `agent_id` field), part of the record's label; `null`\n * when the record carries none, as a Benchmark evaluates whichever Agents it is pointed at.\n */\n agentId: string | null;\n /** Evaluation summary title (a one-line conclusion; shown separately from the body summary; required when generating, tolerated as unset when displaying). */\n summaryTitle?: string;\n /** Evaluation summary body: how the score was derived, what optimizations were made to the Agent this round (required when generating, tolerated as unset when displaying). */\n summary?: string;\n /** Model actually used for this evaluation round (upstream id, paired with provider; the chart series is split by model). */\n modelId: string;\n /** Provider group for `modelId`. */\n provider: string;\n /** Thinking level read from the unchanged Target Agent configuration. */\n thinkingLevel: string;\n /** Agent State version number under test. */\n version: number;\n /** Model-written average of Case scores, on the fixed 0..100 scale. */\n score: number;\n /** Model-written average of known Case costs; null when every Case cost is unknown. */\n cost: number | null;\n /** Model-written average of Case durations, rounded to an integer. */\n durationMs: number;\n cases: BenchmarkCaseScore[];\n}\n\n/**\n * Whether the Skill that builds a Benchmark is done with it, and how it ended; see\n * `BenchmarkSummary.status`.\n */\nexport type BenchmarkStatus = \"draft\" | \"published\" | \"failed\";\n\n/**\n * Where a Benchmark's copy came from: `builtin` (seeded when the Project was created), `manual`\n * (the create form), `agent` (the benchmark-design Skill wrote it), `git` (an Agent imported a\n * repository folder), `zip` (an uploaded package).\n */\nexport type BenchmarkOriginKind = \"builtin\" | \"manual\" | \"agent\" | \"git\" | \"zip\";\n\n/**\n * Why a Benchmark's manifest cannot be read: the code of core's `BenchmarkManifestError`\n * (`benchmark_id_mismatch` for an `id` that is not the directory's, `benchmark_manifest_invalid`\n * for a file that is not TOML or a `version` or `[origin]` out of shape) and its message, which\n * names the key or the file.\n */\nexport interface BenchmarkManifestProblem {\n code: string;\n message: string;\n}\n\n/** The `[origin]` table of a Benchmark's benchmark_config.toml. */\nexport interface BenchmarkOrigin {\n kind: BenchmarkOriginKind;\n /** git: the folder link as the user gave it (a GitHub tree URL). Always an http(s) URL. */\n url?: string;\n /** git: the 40-hex commit the import resolved the link to. */\n ref?: string;\n /** git: the folder inside the repository. */\n path?: string;\n /** git / zip: when this copy was written (ISO 8601; `imported_at` in the file). */\n importedAt?: string;\n}\n\nexport interface BenchmarkSummary {\n /** Directory name is the identifier (semantic naming, e.g. swe-bench-v1). */\n id: string;\n /** Title from benchmark_config.toml; the directory name when it gives none or the manifest cannot be read. */\n title: string;\n description?: string;\n /** The description in Chinese (`description_zh` in benchmark_config.toml), when the manifest gives one. */\n descriptionZh?: string;\n /** Number of runs per case (the `runs` key in benchmark_config.toml, an integer ≥ 1); absent when it gives none or the manifest cannot be read, which reads as 1. */\n runs?: number;\n /**\n * `draft` while the Skill that builds the Benchmark is still writing its cases and\n * calibrating their difficulty — the Web App masks such a Benchmark; `published` once the\n * Benchmark is frozen and its Formal Baseline recorded; `failed` when calibration ended\n * without a Pilot result that could be frozen, which leaves the Benchmark unusable — the Web\n * App masks it too and says it has to be deleted and created again. benchmark_config.toml is\n * read literally: only `draft` is a draft and only `failed` is a failure, so a missing key, or\n * any other value, reads as published. A Benchmark whose manifest cannot be read lists as\n * failed, with `manifestError`.\n */\n status: BenchmarkStatus;\n /**\n * The manifest's date version, `YYYY.MM.DD.N`: the revision of the Benchmark's content. Absent\n * when the manifest has none (one written before versions is unversioned), when it cannot be\n * read, and from a machine whose server predates versions.\n */\n version?: string;\n /**\n * Where this copy came from, the manifest's `[origin]`. Absent when the manifest has none (where\n * a copy written before origins came from is unknown), when it cannot be read, and from a\n * machine whose server predates origins.\n */\n origin?: BenchmarkOrigin;\n /**\n * Set when the manifest cannot be read. Such a Benchmark lists under its directory name, as\n * `failed`, with no version or origin, and nothing may use or export it until the file is\n * fixed; the Web App masks it with this reason.\n */\n manifestError?: BenchmarkManifestProblem;\n /** Case count (number of case subfolders). */\n caseCount: number;\n /** Time-ordered evaluation records (the evaluations[] in scoreboard.yaml). */\n evaluations: BenchmarkEvaluation[];\n /**\n * Agents this Benchmark has evaluated: the distinct non-null `agentId`s of `evaluations`, in\n * first-seen order. Empty while nothing has been evaluated — a Benchmark names no Agent of its\n * own.\n */\n agentIds: string[];\n}\n\nexport interface BenchmarksResponse {\n benchmarks: BenchmarkSummary[];\n}\n\nexport type CaseMaterial = \"statement\" | \"rubric\";\n\n/** Public Benchmark Case metadata. Rubric and Gold content are never included. */\nexport interface BenchmarkCaseSummary {\n id: string;\n /** First Markdown heading with an optional leading \"Case N:\" removed; falls back to id. */\n title: string;\n}\n\nexport interface BenchmarkCasesResponse {\n cases: BenchmarkCaseSummary[];\n}\n\n/**\n * POST /api/projects/:p/benchmarks (owner only): create a Benchmark by hand. The\n * server writes the on-disk layout the evaluation Skills read — `benchmark_config.toml`\n * (published, the day's first version, origin `manual`), a `scoreboard.yaml` holding\n * `evaluations: []`, and one `<case id>/` per case with `statement/README.md` and\n * `rubric/README.md`. 409 `benchmark_exists` when the directory is already there; nothing is\n * merged into an existing Benchmark.\n */\nexport interface BenchmarkCreateRequest {\n /** Directory name, which is the identifier: letters, digits, `_` and `-` only. */\n id: string;\n title: string;\n description?: string;\n /** Runs per case for the optimization loop (integer ≥ 1; default 1). */\n runs?: number;\n /** At least one case; ids must be unique within the request. */\n cases: BenchmarkCreateCase[];\n}\n\nexport interface BenchmarkCreateCase {\n /** Case directory name: `CASE-` followed by letters, digits, `_` and `-` (for example `CASE-001-excel-task`). */\n id: string;\n /** Written as the statement README's first heading, which the case list reads back as the case title. */\n title: string;\n /** Statement body (Markdown), written after that heading; the Target Agent sees only this side. */\n statement: string;\n /** Scoring rubric (Markdown, items totalling 100 points), written verbatim as `rubric/README.md`. */\n rubric: string;\n}\n\nexport interface BenchmarkCreateResponse {\n benchmark: BenchmarkSummary;\n}\n\n/**\n * POST /api/projects/:p/benchmarks/archive (any Project member): import a Benchmark package from\n * a zip. The zip holds `benchmark_config.toml` and the `CASE-*` directories, at its root (where the\n * manifest must name its `id`) or inside exactly one top-level directory named by the Benchmark's\n * id — the shape `GET …/benchmarks/:benchmarkId/archive` exports. A zip carrying\n * `scoreboard.yaml`, `.jobs/` or anything else at its top level, a link, a manifest past the\n * create form's limits, a package that is not `published` or a case without both READMEs is\n * refused (400); so is a zip past the caps (413 `benchmark_too_large`: 14MB\n * zipped, 1000 files, 5MB a file, 20MB inflated). The copy is written with origin `zip` and an\n * empty scoreboard — or origin `git`, when the request says the package is a repository folder\n * fetched at a commit. An id already taken is 409 `benchmark_exists` (`details.benchmarkId` names\n * it) unless `overwrite`.\n */\nexport interface BenchmarkArchiveImportRequest {\n /** The zip, base64-encoded. */\n dataBase64: string;\n /**\n * Replace the Benchmark of the same id whole — its evaluation records and `.jobs/` included.\n * Refused with 409 `benchmark_busy` while an evaluation of it is still running a trial there.\n */\n overwrite?: boolean;\n /**\n * The repository folder the package was fetched from, pinned to one commit. The copy's origin\n * is then `git` with these fields and the import time, instead of `zip`.\n */\n origin?: BenchmarkArchiveGitOrigin;\n}\n\n/** Where a package fetched from a git repository came from. */\nexport interface BenchmarkArchiveGitOrigin {\n kind: \"git\";\n /** The folder link as the user gave it: an http(s) URL, at most 2048 characters. */\n url: string;\n /** The 40-character commit the link was resolved to. */\n ref: string;\n /** The folder inside the repository, at most 1024 characters. */\n path: string;\n}\n\nexport interface BenchmarkArchiveImportResponse {\n /** The imported Benchmark, as the list now reads it. */\n benchmark: BenchmarkSummary;\n}\n\n// ---------------------------------------------------------------------------\n// Plugin library, and an Agent's installed skills and hooks\n// ---------------------------------------------------------------------------\n\nexport interface SkillMetadataItem {\n /** Skill directory name (the identity key for install / uninstall / Prompt addressing). */\n name: string;\n description: string;\n /** Short description for frontend display (frontmatter short_description, optional; falls back to description if missing). */\n shortDescription?: string;\n shortDescriptionZh?: string;\n /**\n * Raw icon.svg text from the installed skill directory: the plugin's icon for a library\n * install (written beside SKILL.md at install time), a custom one for a user-authored skill.\n * Only installed lists carry it — the library listing's skills share their plugin's icon,\n * which the plugin item carries once. Absent, the frontend draws the book glyph.\n */\n icon?: string;\n /** The skill's own dated version (`YYYY.MM.DD.N`, frontmatter version; a copy installed before that spelling still carries `YYYY-MM-DD.N`); an empty string when the frontmatter carries none or a malformed one. */\n version: string;\n}\n\n/** One installed hook package (`agent_state/hooks/<name>/`): its manifest, without the scripts. */\nexport interface HookItem {\n /** Hook package name — the plugin that shipped it (the identity uninstall addresses it by). */\n name: string;\n description: string;\n descriptionZh?: string;\n /** Version (`YYYY.MM.DD.N`; a copy installed before that spelling still carries `YYYY-MM-DD.N`); an empty string when the manifest carries none. */\n version: string;\n /** The hook points the package answers at, e.g. `[\"stop\"]`. */\n events: string[];\n /** The plugin's raw icon.svg, written beside the manifest at install time; absent when the plugin ships none (the frontend draws the hook glyph). */\n icon?: string;\n}\n\n/**\n * One library plugin as the listing describes it: the manifest fields (its package.json — npm's\n * fields and the `penguin` block) plus what it ships (skill bodies and scripts are never sent).\n * Every field but the identity, the version and the contents is optional: a field the package\n * does not carry is missing here, and the page says so rather than inventing one.\n */\nexport interface PluginItem {\n name: string;\n /** Display name (`penguin.title`, `title_zh`); absent → the page shows `name`. */\n title?: string;\n titleZh?: string;\n /** English one-liner (package.json `description`); \"\" when the package carries none. */\n description: string;\n descriptionZh?: string;\n shortDescription?: string;\n shortDescriptionZh?: string;\n /** The plugin's npm version (its package.json, following the release) — what the card shows. Installed copies are compared part by part instead: each skill's `version`, and {@link hookVersion}. */\n version: string;\n /** The npm package the plugin is, e.g. `@penguinharness/a2ui` — what Delete and a server module's row name it by. */\n package: string;\n /** Where the plugin comes from: `builtin` — a package this build ships; `installed` — one an admin installed on the server (by name, link or zip). */\n source: \"builtin\" | \"installed\";\n /** The package's npm `author` (a name), `license`, `homepage` and `repository` URL, for the detail dialog. */\n author?: string;\n license?: string;\n homepage?: string;\n repository?: string;\n /** The plugin's skills (metadata only), each with its own dated `version`. */\n skills: SkillMetadataItem[];\n workbenches?: Array<{ name: string; version: string }>;\n /** The hook points the plugin's hook package answers at (`[]` without one). */\n hooks: string[];\n /** The hook package's own dated version (`penguin.hooks.version`, `YYYY.MM.DD.N`); absent without a hook package. */\n hookVersion?: string;\n /** The plugin's raw icon SVG (`icon.svg` at the package root, or `penguin.icon` — every built-in plugin ships one; an unsafe one is never sent), the icon of everything it ships; the frontend draws the puzzle-piece plugin glyph without it. */\n icon?: string;\n /** The demo the Plugins page's quick start pre-fills (`penguin.quick_start`); absent = pre-select its first skill. */\n quickStart?: QuickStartItem;\n /** The MCP servers the plugin installs into an Agent's `tools.mcpServers` (`penguin.mcp_servers`); `[]` without any. */\n mcpServers: PluginMcpServerItem[];\n}\n\n/** An MCP transport, as an entry resolves it (`transport`, else inferred from `command` / `url`). */\nexport type McpTransport = \"stdio\" | \"http\" | \"sse\";\n\n/** One vault key a plugin's MCP server needs before it can connect: declared in the manifest, or only referenced (then the key is all there is to show). */\nexport interface PluginSetupKeyItem {\n key: string;\n label?: string;\n labelZh?: string;\n /** Where to get the value (a URL), or one sentence. */\n help?: string;\n}\n\n/** A plugin's MCP server as the library lists it: identity and target only — no header, env or oauth value. */\nexport interface PluginMcpServerItem {\n name: string;\n transport: McpTransport;\n /** The URL (http/sse), or `command args…` (stdio), as written: `${PLUGIN_ROOT}` and `${KEY}` references unresolved. */\n target: string;\n /** Every vault key the server's config references, declared ones first. */\n setup: PluginSetupKeyItem[];\n /** The server offers OAuth sign-in (`config.oauth`). */\n oauth: boolean;\n /** It needs that sign-in — OAuth and no `Authorization` header of its own — which this version cannot do: an Agent skips it. */\n signIn: boolean;\n}\n\n/** A quick start: a prompt pre-filled into a new-chat draft — never sent by the page. */\nexport interface QuickStartItem {\n prompt: string;\n promptZh?: string;\n /** Skills to pre-select (a library plugin's own). */\n skills?: string[];\n /** Open the draft in goal mode. */\n goal?: boolean;\n}\n\nexport interface PluginGroupItem {\n id: string;\n title: string;\n /** Chinese category title (optional; the UI displays it per language). */\n titleZh?: string;\n plugins: PluginItem[];\n}\n\n/** GET /api/plugins: the library by category (any logged-in user). */\nexport interface PluginLibraryResponse {\n groups: PluginGroupItem[];\n}\n\n/**\n * GET /api/plugins/:plugin/files: everything a library plugin ships, for the detail view's file\n * browser — each skill's installable SKILL.md and auxiliary files under `skills/<name>/`, the\n * hook package's scripts under `hooks/` — keyed by path relative to the plugin directory. The\n * manifest and the icon are not among them: the listing already carries both.\n */\nexport interface PluginFilesResponse {\n files: Record<string, string>;\n}\n\n/**\n * POST /api/projects/:p/agents/:a/plugins: install library plugins by name — each one's skills\n * and hook package; already-installed ones are overwritten with library content (i.e. updated).\n * Every name must be in the library (404 `unknown_plugin`, nothing written). 201 returns the\n * refreshed installed lists.\n */\nexport interface PluginInstallRequest {\n names: string[];\n}\n\nexport interface AgentPluginsInstallResponse {\n workbenches?: Array<{ name: string; plugin: string; version: string }>;\n skills: SkillMetadataItem[];\n hooks: HookItem[];\n /** The Agent's MCP servers after the install (a plugin's servers join its `tools.mcpServers`). A server name the Agent already has from someone else is 409 `mcp_server_name_taken`, nothing installed. */\n mcpServers: AgentMcpServerItem[];\n}\n\n/**\n * One `tools.mcpServers` entry of an Agent, as the Plugins page and the Agent settings read it:\n * identity, target and what keeps it from connecting — never a header, env or oauth value.\n */\nexport interface AgentMcpServerItem {\n name: string;\n transport: McpTransport;\n /** The URL (http/sse), or `command args…` (stdio): `${PLUGIN_ROOT}` already resolved at install, `${KEY}` references left as written. */\n target: string;\n /** The plugin that installed it; absent for an entry the user added. */\n plugin?: string;\n /** `${KEY}` references of its config that the Agent's vault does not hold (names only); empty = nothing to set up. */\n missingKeys: string[];\n /** It needs an OAuth sign-in, which this version cannot do: skipped at connect time. */\n signIn: boolean;\n}\n\n/** GET /api/projects/:p/agents/:a/mcp-servers: the Agent's MCP servers in config order; DELETE …/mcp-servers/:name removes one (204, 404 `unknown_mcp_server`). */\nexport interface AgentMcpServersResponse {\n servers: AgentMcpServerItem[];\n}\n\n/** GET /api/projects/:p/agents/:a/skills: Skills installed on this Agent. */\nexport interface AgentSkillsResponse {\n skills: SkillMetadataItem[];\n}\n\n/** GET /api/projects/:p/agents/:a/hooks: hook packages installed on this Agent; DELETE …/hooks/:name uninstalls one (204). */\nexport interface AgentHooksResponse {\n hooks: HookItem[];\n}\n\n/**\n * POST /api/projects/:p/agents/:a/hooks/archive: install one hook package from an uploaded zip.\n * Layout: hooks.json and its scripts at the zip root, or exactly one top-level directory\n * containing them (the directory name is then the package name). 201 returns the refreshed\n * installed list (AgentHooksResponse); an already-installed name without `overwrite` is 409\n * `hook_exists`. GET …/hooks/:name/archive is the matching export: the installed directory as\n * a zip attachment, which round-trips through this POST.\n */\nexport interface HookArchiveInstallRequest {\n /** Base64-encoded zip archive (decoded size capped at 14MB, like the skill archive). */\n dataBase64: string;\n /** Replace an installed package of the same name instead of answering 409. */\n overwrite?: boolean;\n}\n\n/**\n * POST /api/projects/:p/agents/:a/skills/archive: install one Skill from an uploaded zip.\n * Layout: SKILL.md at the zip root, or exactly one top-level directory containing SKILL.md\n * (the directory name is then the Skill name). 201 returns the refreshed installed list\n * (AgentSkillsResponse); an already-installed name without `overwrite` is 409 `skill_exists`.\n */\nexport interface SkillArchiveInstallRequest {\n /** Base64-encoded zip archive (decoded size capped at 14MB, same as the Agent snapshot import). */\n dataBase64: string;\n /** Replace an already-installed Skill of the same name (deletes its directory first). */\n overwrite?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Plugin registry index\n// ---------------------------------------------------------------------------\n\n/**\n * One published version of a plugin — the index entry format every plugin registry\n * speaks (modeled on the typst/packages `index.json` schema: a flat array of\n * per-version entries; a plugin published at several versions appears once per\n * version). Installation is out of scope here: an entry's `name` is the package\n * specifier a Project's plugin list names.\n */\nexport interface PluginIndexEntry {\n /** Package specifier — the string a Project's plugin list names. */\n name: string;\n /** Localized display names; the UI falls back to the package name. */\n title?: string;\n titleZh?: string;\n /** Semantic version of this entry. */\n version: string;\n description: string;\n /** Chinese description (optional; the UI picks per language, English without it). */\n descriptionZh?: string;\n /** One-line descriptions for the card (optional; the card falls back to `description`). */\n shortDescription?: string;\n shortDescriptionZh?: string;\n /**\n * The plugin's raw icon SVG (optional; the card draws the puzzle piece without it). The\n * listing fills these display fields from the package's own package.json (its `penguin`\n * block) and icon when the package is on this server, over whatever the index row says.\n */\n icon?: string;\n authors: string[];\n /** SPDX license identifier. */\n license: string;\n /** Source repository URL. */\n repository?: string;\n homepage?: string;\n /** Free-form searchable terms; the Web App renders them as chips (e.g. the target OS). */\n keywords?: string[];\n /** Capability floor(s) the plugin provides on (e.g. \"sandbox\"). */\n categories?: string[];\n /** Unix timestamp (seconds) of the entry's last update. */\n updatedAt?: number;\n}\n\n/** GET /api/plugins/registry: the merged index of every configured registry (currently the builtin one). */\nexport interface PluginIndexResponse {\n plugins: PluginIndexEntry[];\n /**\n * Sources that could not be read, by `source` and reason. Present and empty when every\n * source answered. A remote index that is down shortens the listing rather than emptying\n * it, so the page needs to be able to say so instead of silently showing less.\n */\n failures: { source: string; error: string }[];\n /**\n * Sources the server was still reading when it answered, by `source`. Present and empty when\n * every source answered. The listing does not wait long for a published index, so one on a slow\n * or unreachable network is named here and its entries are missing; a later read lists them\n * once they have arrived, or names the source under `failures` once it has failed.\n */\n pending: string[];\n}\n\n/**\n * A plugin's long-form docs: the README.md at its package root, as npm ships it.\n * GET /api/plugins/registry/readme?name= answers for an index entry, with `readme` null when\n * the package is not on this server or has none; GET /api/plugins/:plugin/readme answers for a\n * library plugin, 404 when it ships none.\n */\nexport interface PluginReadmeResponse {\n name: string;\n /** Markdown, rendered by the Web App. Null when this entry has no readme. */\n readme: string | null;\n}\n\n// ---------------------------------------------------------------------------\n// Plugin-contributed languages\n// ---------------------------------------------------------------------------\n\n/**\n * One language a plugin contributes, as the listing reports it. The grammar itself is not\n * here: it is tens to hundreds of kilobytes, and only the languages a conversation actually\n * shows are worth fetching (`GET /api/languages/:id/grammar`).\n */\nexport interface LanguageSummary {\n /** Canonical id: the fence info string, and the id both endpoints address. */\n id: string;\n displayName: string;\n /** Alternative fence info strings, needed BEFORE the grammar loads (it is what decides to load it). */\n aliases?: string[];\n /** File extensions without the dot, for the Workspace file viewer. */\n extensions?: string[];\n}\n\n/** GET /api/languages: every language this App's plugins contributed, by id. */\nexport interface LanguageIndexResponse {\n languages: LanguageSummary[];\n}\n\n// ---------------------------------------------------------------------------\n// Version and self-update\n// ---------------------------------------------------------------------------\n\n/**\n * GET /api/version: the running build's identity plus this root's pushed harness, verbatim\n * from `versionReport()` — the same record `penguin version --json` prints, so the two\n * cannot drift apart. Field meanings live on {@link VersionReport} and {@link HarnessInfo};\n * the ones the web reads are `version` and `buildDate` (the stamped release date behind the\n * sidebar's \"last updated\", needing no network, and null for a source build or a release\n * predating the stamping — v0.1.2 and earlier — where the UI shows the version alone).\n */\nexport type VersionResponse = VersionReport;\n\nexport type { HarnessHistoryEntry, IfacesSummary } from \"@prismshadow/penguin-core\";\nexport type {\n WorkbenchInfo,\n WorkbenchVersion,\n WorkbenchRequest,\n WorkbenchResponse,\n WorkbenchRecord,\n} from \"../mechanisms/workbenches.js\";\n\n/** GET /api/version/history: the harness versions this data root has committed, newest first. */\nexport type VersionHistoryResponse = HarnessHistory;\n\n/** POST /api/version/history/rollback `{ id }`: the push back has started; the swap follows. */\nexport interface VersionRollbackResponse {\n started: true;\n id: string;\n}\n\n/** GET /api/version/history/diff?from=&to=: what changed between two stored interface tables. */\nexport type VersionHistoryDiffResponse = IfacesDiff;\nexport type { IfaceChange, IfacesDiff, MemberChange, ModuleChange } from \"@prismshadow/penguin-hmr\";\n\n/**\n * GET /api/version/update-check: newest published release vs the running version.\n * Always HTTP 200 (fail-soft): a lookup failure sets `error` and leaves `latestVersion`\n * null rather than failing the request; results are cached server-side.\n */\nexport interface UpdateCheckResponse {\n currentVersion: string;\n /** Same as VersionResponse.buildDate: the running version's release date, stamped at build time. */\n buildDate: string | null;\n /** Newest published release (normalized, no leading `v`); null when the lookup failed or checks are disabled. */\n latestVersion: string | null;\n updateAvailable: boolean;\n /** Release page of the newest release (for the \"release notes\" link). */\n releaseUrl: string | null;\n /** Publish timestamp of the newest release (ISO 8601). */\n publishedAt: string | null;\n /** When this result was produced (ISO 8601) — a cached result keeps its original timestamp. */\n checkedAt: string;\n /** Present (true) when update checks are turned off via PENGUIN_UPDATE_CHECK=off; no network call was made. */\n disabled?: true;\n /** Why the lookup failed: unreachable network / GitHub rate limit / unusable response. */\n error?: \"network\" | \"rate_limited\" | \"bad_response\";\n}\n\n// ---------------------------------------------------------------------------\n// Desktop client update (desktop mode only)\n// ---------------------------------------------------------------------------\n\n/**\n * The desktop shell's updater snapshot, pushed to the embedded server over the\n * utilityProcess message channel and served at GET /api/desktop/update. `state` is the\n * discriminator; the optional fields belong to the states named on them.\n *\n * The shell never downloads on its own: a check ends in `available`, and the download\n * starts only on the page's (or the native dialog's) say-so — the `download` command. A\n * `downloaded` build stays the reported state until it is installed: a later periodic\n * check (or its failure) must not hide the actionable \"restart to install\" step.\n */\nexport interface DesktopUpdateStatus {\n /** Installed shell version (Electron app.getVersion()). */\n appVersion: string;\n /**\n * Bumped by the shell on every updater event it folds, whether or not the visible\n * state changed. A row-initiated check settles when the seq has moved past its\n * at-click value and the state is no longer `checking` — snapshot equality can't\n * carry that signal (a check that ends where it started is byte-identical).\n */\n seq?: number;\n state:\n | \"idle\"\n | \"checking\"\n | \"up-to-date\"\n | \"available\"\n | \"downloading\"\n | \"downloaded\"\n | \"error\"\n | \"unsupported\";\n /** The newer release: offered (`available`), being fetched (`downloading`) or ready to install (`downloaded`). */\n version?: string;\n /** Download progress 0–100 (`downloading`). */\n percent?: number;\n /** Updater failure text (`error`). */\n message?: string;\n /** Why this install form cannot update itself (`unsupported`): dev run, or a Linux install that is not an AppImage (e.g. .deb — the system package manager owns it). */\n reason?: \"dev\" | \"linux-not-appimage\";\n}\n\n/**\n * GET /api/desktop/update (desktop-shell sessions only): the latest shell snapshot.\n * `status` is null until the shell's first push lands (a beat after server start).\n */\nexport interface DesktopUpdateStatusResponse {\n status: DesktopUpdateStatus | null;\n}\n\n/** Shell → server push over the utilityProcess message channel. */\nexport interface DesktopUpdaterStatusMessage {\n type: \"desktop-updater-status\";\n status: DesktopUpdateStatus;\n}\n\n/**\n * Server → shell command over the utilityProcess message channel (relayed from\n * POST /api/desktop/update/check|download|install): look for a release, fetch the one\n * offered, or restart into the one downloaded.\n */\nexport interface DesktopUpdaterCommandMessage {\n type: \"desktop-updater-command\";\n action: \"check\" | \"download\" | \"install\";\n}\n\n// Desktop tray icon (desktop mode only)\n//\n// The shell keeps an icon in the system tray for as long as the app runs, and Settings ›\n// Appearance is where it is turned off and on. The switch rides the same utilityProcess\n// message channel as the client updater above: the shell pushes what it currently shows,\n// the page reads it at GET /api/desktop/tray and writes through PUT, which is relayed\n// back. The window stays a plain browser — no renderer IPC bridge.\n//\n// The page reports its UI language over the same route, so the tray menu reads in the\n// language the window does. The shell cannot see that preference itself: it lives in the\n// browser's localStorage, on the other side of a boundary this design keeps one-way.\n\n/** The two languages the Web App has; the shell's tray menu follows whichever is in use. */\nexport type DesktopTrayLocale = \"zh\" | \"en\";\n\n/** What the shell is currently doing about its tray icon. */\nexport interface DesktopTrayStatus {\n /** Whether an icon is shown in the system tray while the app runs. */\n showTrayIcon: boolean;\n /**\n * The language the tray menu is drawn in. Until a page reports one the shell uses the\n * device language, so this can differ from the Web App's until the first report lands.\n */\n locale: DesktopTrayLocale;\n}\n\n/**\n * What one PUT asks the shell to change. Every field is optional and a request must carry at\n * least one: the switch and the language reach this route from different parts of the page.\n */\nexport interface DesktopTrayPatch {\n showTrayIcon?: boolean;\n locale?: DesktopTrayLocale;\n}\n\n/**\n * GET / PUT /api/desktop/tray (desktop-shell sessions only): the tray preference.\n * `status` is null until the shell's first push lands (a beat after server start); a\n * client that finds null reads it as on, which is the shell's own default.\n */\nexport interface DesktopTrayStatusResponse {\n status: DesktopTrayStatus | null;\n}\n\n/** Shell → server push over the utilityProcess message channel. */\nexport interface DesktopTrayStatusMessage {\n type: \"desktop-tray-status\";\n status: DesktopTrayStatus;\n}\n\n/**\n * Server → shell command over the utilityProcess message channel (relayed from PUT\n * /api/desktop/tray). A patch, not a snapshot: the switch and the language are written by\n * different parts of the page at different moments, and neither should have to restate the\n * other's value to change its own.\n */\nexport interface DesktopTrayCommandMessage {\n type: \"desktop-tray-command\";\n showTrayIcon?: boolean;\n locale?: DesktopTrayLocale;\n}\n\n/**\n * The API socket (PRFC-0011): one WebSocket per tab, every frame a call to an existing\n * endpoint. It has its own upgrade route, {@link API_SOCKET_PATH}: the runtime's websocket\n * seam authenticates the cookie and the Origin there and hands the socket to the platform,\n * which serves the protocol as the signed-in user (server: socket/ref.ts). Shared so the Web\n * App and anything else that speaks to another server spell the address the same way.\n */\nexport const API_SOCKET_PATH = \"/api/socket\";\n\n/**\n * Paths whose answer depends on the KIND of session behind the request — the desktop shell's\n * own window (`sessionVia === \"desktop\"`: its update, tray and privacy relays, folder access,\n * revealing a file) or a first-login link (a password change without the old password). The\n * socket's handshake does not say how the cookie behind it was minted, so these are made over\n * HTTP: the socket answers them 421 `not_on_socket` and the Web App never sends them there.\n */\nexport function sessionKindPath(pathname: string): boolean {\n return (\n pathname === \"/api/me/password\" ||\n pathname === \"/api/desktop\" ||\n pathname.startsWith(\"/api/desktop/\") ||\n /^\\/api\\/projects\\/[^/]+\\/dirs\\/access$/.test(pathname) ||\n (pathname.startsWith(\"/api/\") && pathname.endsWith(\"/reveal\"))\n );\n}\n/** The API socket's address. No id is involved: the handshake's cookie names the user. */\nexport function apiSocketPath(): string {\n return API_SOCKET_PATH;\n}\n\n// Desktop folder access (desktop mode, macOS)\n//\n// macOS privacy protection (TCC) guards Desktop, Documents, Downloads and removable and network\n// volumes. It asks the user only on behalf of an app it holds responsible for a read, and lists\n// under Files and Folders only the apps it has asked about; any other read gets a silent EPERM.\n// The Workspace picker's \"Allow access\" therefore goes to the shell, over the same\n// utilityProcess message channel as the updater and tray above: its main process reads the\n// folder once in the app's own name and says whether it could, and once the app is allowed its\n// children (this server, the agents' shells) read too. The page can also have the shell open\n// the Privacy & Security pane to change it. What either outcome means for the user is the\n// page's to say; the shell only reports.\n\n/** What the shell's read of one folder came to. */\nexport interface DesktopFolderAccessResult {\n /** The folder could be read. Always true off macOS, where the shell reads nothing. */\n granted: boolean;\n /** The failed read's errno code (`EPERM`, `EACCES`, `ENOENT`, …); `EINVAL` for a path that is not absolute. */\n code?: string;\n /**\n * Whether the app is a packaged build (Electron's `app.isPackaged`). An unpackaged one is a\n * development instance started from a terminal, and macOS holds that terminal responsible\n * for its reads rather than the app.\n */\n packaged: boolean;\n}\n\n/** Server → shell request over the utilityProcess message channel (relayed from POST /api/projects/:projectId/dirs/access). */\nexport interface DesktopFolderAccessMessage {\n type: \"desktop-folder-access\";\n /** Pairs the reply with its request. */\n id: string;\n /** The folder to read, as an absolute path. */\n path: string;\n}\n\n/** Shell → server reply to one {@link DesktopFolderAccessMessage}, carrying its `id`. */\nexport interface DesktopFolderAccessResultMessage extends DesktopFolderAccessResult {\n type: \"desktop-folder-access-result\";\n id: string;\n}\n\n/** POST /api/projects/:projectId/dirs/access: the shell's answer, for the picker to act on. */\nexport interface DirAccessResponse {\n granted: boolean;\n packaged: boolean;\n}\n\n/** The Privacy & Security panes the page may open: Files and Folders, and Full Disk Access. */\nexport type DesktopPrivacyPane = \"files\" | \"fullDisk\";\n\n/** Server → shell command over the utilityProcess message channel (relayed from POST /api/desktop/privacy-settings). No reply. */\nexport interface DesktopOpenPrivacySettingsMessage {\n type: \"desktop-open-privacy-settings\";\n pane: DesktopPrivacyPane;\n}\n\n/**\n * The outcome of one self-update run (`penguin update --yes` on the server host), carried\n * by {@link UpdateJobStatus.result}. `unsupported` covers both a server not launched via\n * the CLI and the CLI's own refusals (source checkout, unrecognized install layout, Windows).\n */\nexport interface UpdateRunResponse {\n status: \"updated\" | \"failed\" | \"unsupported\";\n /** Set when the server cannot run the CLI at all (started without `penguin server|web`). */\n reason?: \"not_launched_via_cli\";\n /** Tail of the update command's combined stdout+stderr (capped; empty when nothing ran). */\n output: string;\n /** True when the install changed (or was already current): restart the service to run the new version. */\n needsRestart: boolean;\n}\n\n/**\n * Where a running self-update is: resolving the release and fetching the installer,\n * downloading the bundle (the one phase with a percentage), or verifying and installing it.\n */\nexport type UpdateJobPhase = \"resolving\" | \"downloading\" | \"installing\";\n\n/**\n * GET / POST /api/version/update (admin only): the self-update job. POST starts a run when\n * none is in flight — a finished run may be started again, which is how a failed one is\n * retried — and answers with the status exactly as GET does; the page polls GET while\n * `state` is `running`. One job per process: two admins clicking at once share the one run,\n * and the finished status stays readable until the next start.\n */\nexport interface UpdateJobStatus {\n state: \"idle\" | \"running\" | \"done\";\n /** The release the run targets — the update check's newest version when the run started; null when none was known. */\n targetVersion: string | null;\n /** Running only. */\n phase?: UpdateJobPhase;\n /** Running, `downloading` only: 0–100 read off the installer's progress bar; null until its first tick. */\n percent?: number | null;\n /** Tail of the update command's combined stdout+stderr so far (capped; empty when nothing ran). */\n output: string;\n /** Done only. */\n result?: UpdateRunResponse;\n startedAt?: string;\n finishedAt?: string;\n}\n\n/**\n * POST /api/version/restart (admin only): asks the process to exit with the supervisor's\n * restart code after a graceful shutdown, so `penguin server|web` relaunches it on the\n * installed release. `restarting: false` when nothing supervises this process — it was\n * started some other way than through the CLI, or it is a dev run — and the page shows the\n * manual restart hint instead.\n */\nexport interface RestartResponse {\n restarting: boolean;\n reason?: \"no_supervisor\";\n}\n\n/**\n * What the API socket this server holds to one machine is doing (machines/machine-sockets.ts) —\n * the fact behind `MachineInfo.socket`. `since` is the instant the state was entered, so the\n * Machines page can say how long a machine has been dialling, failing or connected instead of\n * sending someone to the browser console for it.\n */\nexport interface MachineSocketFact {\n /**\n * `connected` once the handshake completed, `dialling` while a dial is outstanding, `refused`\n * when the machine answered the handshake with a status (a program predating the socket),\n * `unanswered` when its server answers HTTP and never answered the handshake, and `failed` when\n * the dial did not complete or a socket that was up closed.\n *\n * `unanswered` is a server PROCESS older than the socket: an update handed to a running server\n * swaps its platform and never the process that takes the upgrade, so the machine reports the\n * current build and still has no socket: it is not running this server's version, which a remote\n * machine must. A restart of its server, and failing that an update or a reinstall, brings it\n * there; the Machines page reads the state as needing a restart. It lasts until a handshake\n * completes: a\n * machine dialled again keeps it while the dial is outstanding, instead of reading `dialling`.\n */\n state: \"connected\" | \"dialling\" | \"refused\" | \"unanswered\" | \"failed\";\n /** When that state was entered (ISO). */\n since: string;\n /** The transport's own words where there are any: a refusal's status, a dial's error. */\n detail?: string;\n}\n\n/**\n * One machine's event, as the machines aggregate stream carries it\n * (`GET /api/projects/:projectId/machines/events`): the machine's own `ServerEvent`, with the\n * machine it came from — one stream carries many machines, and the tag is what keeps their\n * events apart (docs: /docs/server-api § \"One event stream per machine\").\n */\nexport interface MachineEvent {\n machineId: string;\n event: ServerEvent;\n}\n\n/** One `Host` entry of the server's `~/.ssh/config`, as the Machines page lists it. */\nexport interface MachineInfo {\n /** `ssh:<alias>` — the id the install route is asked for. */\n id: string;\n /**\n * The alias exactly as written in the config. The list is the config text and nothing\n * else — no `ssh -G`, no processes, no network — so a config declaring hundreds of hosts\n * costs one file read; an alias is resolved only when it is actually installed to.\n */\n alias: string;\n /**\n * The last install THIS server carried out there FOR THIS PROJECT, remembered in web.db so\n * it survives a restart, a hot push, and installing on some other machine. Null when this\n * Project has never installed there — a host another Project did is `elsewhere` below.\n *\n * A record of what was done, not a survey of the far side: a machine wiped by hand still\n * reads as installed until the next install probes it and corrects the record. Asking the\n * remote instead would cost an ssh round trip per host at page load, which is the price\n * the config-text list exists to avoid.\n */\n installed: { version: string; at: string } | null;\n /**\n * Installed by this server, but not this Project's machine — another Project's, or nobody's\n * since it was released — and so not a host nobody has touched either. Absent in every\n * other case, including when it IS this Project's (where `installed` carries the same record).\n *\n * Reported rather than folded into `installed` because the two lead to different actions:\n * one is a machine to use, the other is a machine to adopt, which costs a row and no ssh.\n * Reported rather than hidden because a row that silently looked uninstalled would send\n * someone to spend a 30 MB transfer re-doing what is already done.\n */\n elsewhere?: { version: string; at: string };\n /** This server signs in to the machine with a password it stores for the alias, not a key. */\n passwordSaved?: true;\n /**\n * In this Project's list: the Machines page shows a card for it. A machine joins the list when\n * it is added (`POST …/machines/add`, which installs nothing) or when an install or a use for\n * this Project runs on it, and leaves it with stop-using or release. Absent for a host that is\n * only in the ssh config, or only in another Project's list; never set on `local`, which is\n * always shown.\n */\n member?: true;\n /**\n * The machine's OWN id — 16 base64url characters minted by the server that runs there,\n * stable across renames, re-aliasing and reinstalls. Null until a server has started on\n * that machine, since nothing has minted one yet.\n *\n * This is what anything stored should point at; `id` above is an ADDRESS (`ssh:<alias>`),\n * and `alias` is what people read. Two aliases for one host share a `machineId`, and an\n * alias repointed at a different host answers a different one — an id never changes for a\n * machine, so a change of id is a change of machine.\n */\n machineId: string | null;\n /** The host this server itself runs on. Always present, always installed, never a target. */\n local: boolean;\n /**\n * The connection this server holds to it — the one ssh session everything to the machine\n * rides (machines/transport/ssh-session.ts): a fact about a process on THIS side, which\n * outlives the far server. Present means `/server/<id>/api/…` has somewhere to go; whether\n * a server ANSWERS over there is `status`'s word, from the last probe — and the two must not\n * be read for each other: taking the connection for the machine's liveness is the mistake\n * behind the connect loop (#561). Always null for `local`, which needs no connection.\n */\n connection: { pid: number } | null;\n /**\n * Present while this server is bringing a machine it keeps connected back — the connection\n * dropped, its server stopped or restarted, or this server itself just started — and absent\n * otherwise. A machine is kept connected from the moment it is enabled until it is stopped\n * (`POST …/stop`) or deleted (`POST …/stop-using`); see machines/service.ts's supervision.\n */\n reconnect?: MachineReconnect;\n /**\n * This server keeps a connection to it: it was enabled, or started, and has not been stopped\n * or deleted since. Absent otherwise — a machine only added, one whose server was stopped\n * here, one that never got as far as a connection.\n */\n keepConnected?: true;\n /**\n * The API socket this server holds to that machine — connected, dialling, refused or failed,\n * and since when (machines/machine-sockets.ts). Null for `local`, and until a stream has\n * asked for a socket at all. A fact about one process on THIS side, like `connection` and\n * for the same reason: it says the socket exists and what it is doing, never that the far\n * side is answering. The Machines page reads it so a stuck stream does not have to be found\n * in the browser console.\n */\n socket: MachineSocketFact | null;\n /**\n * The machine's API as last seen by this server's proxy — stamped by traffic passing\n * through, never by a probe of its own. `answeredAt` when the last forwarded request got\n * an HTTP answer (any status: a server that refuses is still answering); `failedAt` and\n * the transport's own words when the connection had nowhere to deliver. Null until a first\n * request flows, and only as fresh as the last one — no traffic, no measurement, which is\n * honest: nothing burns ssh for a page nobody is looking at. Always null for `local`;\n * this server answering the request that fetched this list is that measurement.\n */\n api: { answeredAt: string } | { failedAt: string; detail: string } | null;\n /**\n * What the last time this server handed it the Project config came to: at a connect, a Sync\n * config job, or an automatic sync after a config edit here. Absent until the first one since\n * this server started; kept in memory only.\n */\n configSync?: MachineConfigSync;\n /**\n * What the last probe found over there, or null when none has been taken. Never filled in\n * at list time: a probe costs an ssh round trip per machine, so the list reports the last\n * answer and the page asks for a fresh one when it wants one — on a widening schedule,\n * since each probe is an ssh round trip while the list is only the config's text.\n */\n status: MachineServerStatus | null;\n /**\n * The data root the server there runs on (`PENGUIN_HOME`). This instance's PROFILE decides\n * it — a dev instance names the machine's dev root and never the release one beside it\n * (machines/layout.ts) — which is exactly what a reader looking at two instances of this\n * page needs to tell them apart. Written in that machine's own spelling once its platform\n * is known, and in the POSIX one before that; for `local` it is this process's own root,\n * already resolved to an absolute path.\n */\n root: string;\n}\n\n/**\n * One config sync to a machine: when it ended, whether every part of it reached the machine for\n * every Project using it, and what did not, in the far side's words.\n */\nexport interface MachineConfigSync {\n /** ISO timestamp the sync ended. */\n at: string;\n ok: boolean;\n /** The parts that did not go through, one per line; absent when `ok`. */\n detail?: string;\n /**\n * The values the Project's models take from this server's environment variables rather than\n * its config (an API key, or a base URL), which no sync can carry: one row per variable. On\n * the list answered for a Project, that Project's rows only.\n */\n envOnly: MachineEnvOnly[];\n /**\n * The model entries the machine refused by name — a hand-added model in a group that takes\n * only its built-in ones there — left out while the rest of the table went. On the list\n * answered for a Project, that Project's rows only.\n */\n skipped: MachineSkippedModel[];\n}\n\n/** A model entry a machine would not take: its error code and its own words for why. */\nexport interface MachineSkippedModel {\n projectId: string;\n provider: string;\n modelId: string;\n /** The far side's error code (`model_not_addable`, `model_not_routable`). */\n code: string;\n reason: string;\n /**\n * The entry is one of this server's built-in models for its group, which the machine refused\n * as not addable: the machine's build does not list it yet, so updating the machine, then\n * syncing again, carries it. Absent for any other refusal.\n */\n builtInHere?: true;\n}\n\n/** A model entry that takes `variable` from this server's environment. */\nexport interface MachineEnvOnly {\n projectId: string;\n provider: string;\n modelId: string;\n variable: string;\n}\n\n/**\n * A machine this server keeps connected, while it is being brought back. Attempts back off\n * from 15 s, doubling, and stop after `maxAttempts` failures in a row — or after the first, when\n * ssh refused in a way no retry fixes, or when a person asks to stop\n * (`POST …/machines/:machineId/stop-reconnecting`) — until a person asks for a Retry\n * (`POST …/machines/:machineId/reconnect`). After three failures in a row the connection check\n * runs on its own, and its answer is what the page shows as the explanation.\n */\nexport interface MachineReconnect {\n /** When this streak began: the moment the machine was found not connected, or the Retry that began it again. */\n since: string;\n /** Attempts that failed in this streak; 0 while the first is still to run or running. */\n attempts: number;\n /** How many failures in a row end the streak: the page says \"attempt n of this\". */\n maxAttempts: number;\n /** When the next attempt runs; null while one is running, and once the streak stopped. */\n nextAt: string | null;\n /**\n * The streak stopped, and nothing runs until a person asks for a Retry, which begins a new\n * one: `attempts` — `maxAttempts` failed in a row; `ssh` — ssh refused in a way retrying\n * cannot fix (a host key unknown or changed, an unusable key file, a key refused, a broken\n * ssh config), said by `sshReason`; `cancelled` — a person stopped it. An attempt that was\n * under way when it was stopped runs out: it may still connect the machine, and its failure\n * is the `lastError`.\n */\n gaveUp?: \"attempts\" | \"ssh\" | \"cancelled\";\n /** The last attempt's failure in the far side's words; null before the first one fails. */\n lastError: string | null;\n /** Why ssh refused, when the last failure was ssh's own — for the page to say in its language. */\n sshReason?: MachineSshFailure;\n /** The connection check run after three failures in a row; null until then. */\n diagnosis: MachineDiagnosis | null;\n /**\n * The password stored for the machine was refused, or its host takes none (`sshReason`\n * says which): no attempt runs until a password is saved for it again or removed, since\n * retrying could lock the account. `nextAt` is null meanwhile.\n */\n waitsForPassword?: true;\n}\n\n/**\n * One machine's server state. There is deliberately no separate \"ssh\" status: ssh is the\n * transport, so a machine it cannot reach reads as `unreachable` with OpenSSH's own\n * diagnostic in `detail` rather than as two statuses a reader has to combine.\n */\nexport interface MachineServerStatus {\n state: \"running\" | \"stopped\" | \"unreachable\";\n /** ISO timestamp of the probe this answer came from. */\n checkedAt: string;\n /** The port it is serving on (`running`). */\n port?: number;\n /** Why the machine could not be reached (`unreachable`) — ssh's own words. */\n detail?: string;\n}\n\n/**\n * The running or last job on a machine — an install, or a connect — polled by GET\n * /api/machines while one runs. One at a time. `log` carries the far side's own words where\n * there are any: ssh's diagnostics and the remote installer's output say more about a\n * refused key or an unusable Node than a paraphrase would.\n */\nexport interface MachineJob {\n /**\n * `use` is the whole pipeline — install if needed, hand over, connect, sync — as one job;\n * `connect` is what the page calls Start service; `stop` stops the machine's server and stops\n * keeping it connected; `uninstall` stops it and removes the program, and the data directory\n * when asked; `sync` hands a connected machine the Project config.\n */\n kind: \"install\" | \"connect\" | \"restart\" | \"use\" | \"stop\" | \"uninstall\" | \"sync\";\n machineId: string;\n alias: string;\n /** Waiting its turn: a few machines are worked on at once, and a batch queues the rest. */\n queued: boolean;\n running: boolean;\n /**\n * The steps this job goes through, in order — what the page draws as its stepper. Fixed when\n * the job is queued; a step the job turns out not to need is passed over, never revisited.\n */\n steps: MachinePhase[];\n /**\n * Which of `steps` the job is on. Only moves forward. Null until the first step is named; a\n * finished job keeps its last phase.\n */\n phase: MachinePhase | null;\n log: string[];\n /**\n * The connection check run after the job failed at a step it can explain — checking,\n * installing, connecting: running, then its answer. Absent for every other job.\n */\n diagnosis?: { state: \"running\" } | ({ state: \"done\" } & MachineDiagnosis);\n result:\n | null\n | { ok: true; installed: \"installed\" | \"already-installed\"; version: string | null }\n | { ok: true; connected: true }\n /** The machine's server is stopped and stays so until it is started again. */\n | { ok: true; stopped: true }\n /** The program is gone from the machine; `dataRemoved` says whether its data root went too. */\n | { ok: true; uninstalled: true; dataRemoved: boolean }\n /** Every part of the Project config reached the machine (its `configSync` says the same). */\n | { ok: true; synced: true }\n | {\n ok: false;\n step: string;\n message: string;\n /**\n * The failure has a next step this side can take, and it needs saying yes to:\n * installing the PROGRAM over there and restarting it. Every failed install or\n * connect offers it — a failure that leaves no next step leaves a person stuck —\n * except a run that was itself that install, and one this server could not act on\n * (no build of its own to send), which says `false`. Offered rather than done,\n * because it restarts a server this Project does not own alone.\n */\n canReplaceProgram?: boolean;\n /**\n * Why ssh refused, when the failure is ssh's own (a host key, a key, a host name, a\n * route), for the page to say in its language; `message` says it in English, with\n * ssh's own words after `(ssh: …)`.\n */\n sshReason?: MachineSshFailure;\n };\n}\n\n/**\n * A source checkout's install image (machines/checkout-image.ts), built from the checkout\n * again at each install or use: not built yet, building, built, or failed with the build's\n * own last words.\n */\nexport type MachinesCheckoutImage =\n | { state: \"unbuilt\" }\n | { state: \"building\" }\n | { state: \"built\" }\n | { state: \"failed\"; detail: string };\n\n/** GET /api/machines, and the 202 body of POST /api/machines/:machineId/install. */\nexport interface MachinesResponse {\n machines: MachineInfo[];\n /**\n * The version an install would leave on the remote — the base release, plus a `+hmr.<sha>`\n * suffix when this server carries a pushed version to replicate. A source checkout answers\n * with the image it last built, and null before its first build (see `checkoutImage`).\n * Null otherwise only for a server that carries no image and builds none.\n */\n imageVersion: string | null;\n /**\n * Present only when this server runs from a source checkout: it builds its install image\n * from the checkout at each install or use, so no image yet is not a reason to refuse one.\n */\n checkoutImage?: MachinesCheckoutImage;\n /** The most recently started job, running or finished. */\n job: MachineJob | null;\n /**\n * Every job worth showing this generation: the queued ones, the running one, and the last\n * finished one per machine — so a batch reads as a list of rows each saying where it is.\n */\n jobs: MachineJob[];\n}\n\n/**\n * Every step a job may name, in the order they can occur. A job lists its own subset as\n * `MachineJob.steps`: a source checkout's enable starts by building its install image\n * (`build`); a stop is `check`, `stop`; an uninstall `check`, `stop`, `remove`; a config sync\n * `check`, `sync`.\n */\nexport const MACHINE_PHASES = [\n \"build\",\n \"check\",\n \"install\",\n \"handover\",\n \"restart\",\n \"connect\",\n \"sync\",\n \"stop\",\n \"remove\",\n] as const;\nexport type MachinePhase = (typeof MACHINE_PHASES)[number];\n\n/** `POST /api/projects/:projectId/machines/:machineId/uninstall`. */\nexport interface MachineUninstallRequest {\n /** Remove the machine's data root too — its Sessions, Agents and config there. Default false. */\n removeData?: boolean;\n}\n\n/**\n * `POST /api/projects/:projectId/machines/ssh-hosts`: append a host block to this server's\n * `~/.ssh/config`. Answers the machines list (201), or 400 `ssh_host_invalid` naming the\n * field, or 409 `ssh_host_exists`.\n */\nexport interface SshHostRequest {\n /** The alias — what `ssh <alias>` will take, and the machine's name everywhere here. */\n alias: string;\n hostName: string;\n user?: string;\n port?: number;\n identityFile?: string;\n /**\n * Sign in with this password instead of a key: stored on this server in a 0600 file under its\n * data root, never in the ssh config, and never sent back. On a PUT, `null` deletes the stored\n * one (back to key sign-in) and leaving it out keeps it. A PUT on a block written by hand may\n * set it when the other fields are the block as it stands. 400 `ssh_password_invalid` for an\n * empty or multi-line one; 409 `ssh_password_unsupported` (`details.ssh`: what `ssh -V` said)\n * when this computer's ssh cannot be handed a password.\n */\n password?: string | null;\n}\n\n/**\n * `GET /api/projects/:projectId/machines/ssh-hosts/:alias`: a host's block read back, and\n * whether this app wrote it. Only a block this app wrote may be rewritten\n * (`PUT …/ssh-hosts/:alias`, the same fields less the alias): a hand-written one may carry\n * options this app does not know, and rewriting it would drop them.\n */\nexport interface SshHostResponse extends Omit<SshHostRequest, \"password\"> {\n editable: boolean;\n /** A password is stored for this host, which then signs in with it rather than with a key. */\n passwordSaved?: true;\n}\n\n/**\n * `GET /api/projects/:projectId/machines/ssh-hosts`: every host this server's ssh config declares,\n * its included files' too, with what each one's own block says about reaching it — the literal\n * lines, not what ssh resolves (no `ssh -G`): a `Host *` default or a `Match` block is not\n * applied. For the add dialog's rows; an option the block leaves out is absent.\n */\nexport interface SshHostSummary {\n alias: string;\n hostName?: string;\n user?: string;\n port?: number;\n}\n\nexport interface SshHostsResponse {\n hosts: SshHostSummary[];\n}\n\n/**\n * `POST /api/projects/:projectId/machines/add`: put these machines in this Project's list without\n * installing anything there — each then has a card whose one thing to do is enabling it. Answers\n * like `…/use`, refusals by id (`unknown-machine`, `self`).\n */\nexport interface MachinesAddRequest {\n machines: string[];\n}\n\n/** `POST /api/projects/:projectId/machines/use`: bring these machines into use, as one queued batch. */\nexport interface MachinesUseRequest {\n /** Machine ids (`ssh:<alias>`). Every one is queued; refusals come back by id. */\n machines: string[];\n /** Install the program even where its version matches, and restart there — the answer to a job that asked for it. */\n replaceProgram?: boolean;\n}\n\n/** Why one machine of a batch was not queued; the rest were. */\nexport type MachineUseRefusal = \"unknown-machine\" | \"self\" | \"no-image\";\n\n/**\n * Why ssh could not get a session to a machine, read from its own diagnostic\n * (machines/ssh-failure.ts); `other` when it printed none of the ones with a fixed meaning.\n */\nexport type MachineSshFailure =\n | \"host-key-unknown\"\n | \"host-key-changed\"\n | \"key-file\"\n | \"auth\"\n | \"password-rejected\"\n | \"password-not-offered\"\n | \"host-not-found\"\n | \"refused\"\n | \"timeout\"\n | \"closed\"\n | \"ssh-config\"\n | \"other\";\n\n/** Whether a machine reaches one release source: the package is there, the source answered without it, or no answer. */\nexport type MachineSourceReach = \"ok\" | \"missing\" | \"unreachable\";\n\n/**\n * One check of a machine (machines/diagnose.ts), as facts for the page to word. `pass` is\n * fine, `warn` works with a caveat, `fail` stops an install or a connect, `skip` could not be\n * asked (an earlier check failed, or it does not apply).\n */\nexport type MachineCheck =\n /** A session came up (keys, or the stored password), as this account on that host. */\n | { id: \"ssh\"; state: \"pass\"; user: string; host: string }\n /** It did not; `said` is ssh's own words, led by what they mean. */\n | { id: \"ssh\"; state: \"fail\"; reason: MachineSshFailure; said: string }\n /**\n * `warn` for Windows (installable, not connectable yet), and for a Linux whose glibc runs the\n * release but is older than `terminalsNeed`, the one its terminal binding loads on.\n */\n | {\n id: \"platform\";\n state: \"pass\" | \"warn\";\n os: string;\n arch: string;\n glibc?: string;\n terminalsNeed?: string;\n }\n /**\n * What the release's Node cannot run on: an OS or architecture no release is published for\n * (`unsupported`, `said` what the machine answered), a glibc older than `need` (`glibc`), or\n * a musl libc such as Alpine's (`musl`).\n */\n | {\n id: \"platform\";\n state: \"fail\";\n reason: \"unsupported\" | \"glibc\" | \"musl\";\n os?: string;\n arch?: string;\n glibc?: string;\n need?: string;\n said?: string;\n }\n /**\n * What the release installer needs and the machine lacks. `warn` when curl alone is missing:\n * the release is then sent over ssh rather than downloaded there.\n */\n | { id: \"tools\"; state: \"pass\" | \"warn\" | \"fail\"; missing: string[] }\n /**\n * Whether the machine itself reaches release `version` for its platform. `warn`: out of\n * reach, so an install cannot download it there; `fail`: both sources answered and neither\n * has it (not published).\n */\n | {\n id: \"download\";\n state: \"pass\" | \"warn\" | \"fail\";\n version: string;\n github: MachineSourceReach;\n oss: MachineSourceReach;\n }\n /** Free space in the machine's home against what an install needs, in MB. */\n | { id: \"disk\"; state: \"pass\" | \"warn\" | \"fail\"; freeMb: number; needMb: number }\n /** The port its server would start on: free, already its PenguinHarness server's, or taken by something else. */\n | { id: \"port\"; state: \"pass\" | \"fail\"; port: number; holder: \"free\" | \"penguin\" | \"other\" }\n | { id: \"platform\" | \"tools\" | \"download\" | \"disk\" | \"port\"; state: \"skip\" };\n\n/**\n * `POST /api/projects/:projectId/machines/:machineId/diagnose`: the checks, in order. Nothing\n * is written on the machine; 409 `job_running` while a job works on it.\n */\nexport interface MachineDiagnosis {\n machineId: string;\n checkedAt: string;\n checks: MachineCheck[];\n}\n\nexport interface MachinesUseResponse extends MachinesResponse {\n refused: { machineId: string; why: MachineUseRefusal }[];\n}\n\n/** `POST /api/projects/:projectId/machines/stop-using`: let go of these machines — connection dropped, Project membership released; the install stays. */\nexport interface MachinesStopUsingRequest {\n machines: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Company mode: organizations (files are the truth; every DTO here is a projection)\n// ---------------------------------------------------------------------------\n\n/**\n * Organization status: `paused` stops every automatic trigger; humans can still talk to any\n * desk. These two values are the whole lifecycle — no route deletes an organization, so a\n * paused one keeps its conversations, employees, desks and tickets.\n */\nexport type OrgStatus = \"active\" | \"paused\";\n/** Approval mode for desk and ticket sessions; unattended runs never get always-ask. */\nexport type OrgApprovalMode = \"allow-all\" | \"read-only\" | \"deny-all\";\n/** The five kanban columns, each a directory under `tickets/<yyyy-mm>/`. */\nexport type OrgTicketStatus = \"proposed\" | \"in_progress\" | \"review\" | \"done\" | \"rejected\";\nexport type OrgTicketPriority = \"P0\" | \"P1\" | \"P2\";\n/** Live employee state: running when the desk or any ticket session has a Task in progress; paused when budget-paused. */\nexport type OrgEmployeeState = \"running\" | \"idle\" | \"paused\";\n/**\n * The trigger kinds an `[org_trigger]` block carries. `ticket_notice` is read-only: a ticket\n * change starts no run of its own, so nothing writes that kind any more, and it stays in the\n * union because Traces recorded while it did still carry it.\n */\nexport type OrgTriggerKind = \"init\" | \"event\" | \"mention\" | \"ticket_notice\" | \"ticket_work\";\n/** The ticket changes an employee is told about, listed in the body of its next calendar sweep. */\nexport type OrgTicketChange = \"assigned\" | \"blocked\" | \"blocker_closed\" | \"done\" | \"rejected\";\n/** What the last evaluation of a calendar event did. */\nexport type OrgCalendarOutcome = \"fired\" | \"queued\" | \"paused\" | \"missed\" | \"error\";\n/**\n * The organization's working language: the language its handbook, its CEO's initialization\n * run, every employee brief and every desk and ticket run are written in. Detected from the\n * mission at creation (CJK text → `zh`, anything else → `en`) unless the request names one.\n */\nexport type OrgLanguage = \"zh\" | \"en\";\n\n/** The organization's settings as `org_config.toml` records them. */\nexport interface OrganizationSettings {\n name: string;\n mission: string;\n status: OrgStatus;\n /** IANA timezone: budget periods (natural months) and chat day files follow it. */\n timezone: string;\n approvalMode: OrgApprovalMode;\n /** Chat @-chain limit: a message whose hop reaches it records its mentions without triggering anyone. */\n mentionChainLimit: number;\n budgetWarnRatio: number;\n budgetPauseRatio: number;\n createdBy: string;\n /** The shared workspace root when it is not the organization's own `workspace/` (absolute path). */\n workspace?: string;\n /** The model desks and ticket sessions run on when the employee names none; absent = the Project default. */\n model?: { provider: string; modelId: string };\n /** The working language (`language` in `org_config.toml`); absent on an organization written before the field existed, which reads as `en`. */\n language?: OrgLanguage;\n}\n\n/** Period spend against the CEO's budget (= the whole organization). */\nexport interface OrgSpendSummary {\n /** `yyyy-mm` in the organization's timezone. */\n period: string;\n cost: number;\n budget?: number;\n /** cost / budget; absent without a budget. */\n ratio?: number;\n}\n\nexport interface OrganizationSummary {\n projectId: string;\n orgId: string;\n name: string;\n mission: string;\n status: OrgStatus;\n employeeCount: number;\n runningCount: number;\n pausedCount: number;\n /** proposed + in_progress + review. */\n openTickets: number;\n blockedTickets: number;\n createdBy: string;\n spend: OrgSpendSummary;\n /** Present when `org_config.toml` / `org_chart.yaml` fail validation: the organization is listed but every automatic trigger is held until it is fixed. */\n invalid?: string;\n}\n\nexport interface OrganizationsResponse {\n organizations: OrganizationSummary[];\n}\n\nexport interface OrgEmployeeItem {\n agentId: string;\n /**\n * What this employee goes by in the organization, unique across it: the chart's `name`, else\n * the Agent's display name, else the id — with the id noted (`name (id)`) when the name is\n * not this employee's alone. It is what every surface shows and what works after `@`.\n */\n name: string;\n /** The `name` as written in the chart, for editing; absent when the entry has none. */\n givenName?: string;\n /**\n * Present when the employee has an avatar: the content revision of the image served at\n * `GET …/employees/:agentId/avatar` (append it as `?rev=` — the image is cached for good).\n * Absent, surfaces draw the letter tile.\n */\n avatarRev?: string;\n title: string;\n /** null for the CEO (the root). */\n reportsTo: string | null;\n duties?: string;\n /** As written in the chart: a sub-directory of the shared workspace (`.` = all of it) or an absolute path. */\n workspace: string;\n /** Where that resolves to; absent when the directory does not exist (the entry is then invalid). */\n resolvedWorkspace?: string;\n /** Monthly budget in USD for this employee plus all subordinates; absent = unbounded. */\n budget?: number;\n model?: { provider: string; modelId: string };\n state: OrgEmployeeState;\n /**\n * The employee's current desk. Absent while the ledger names none, and while the Session\n * it names no longer exists (deleted by hand, or with its Agent): a desk is listed only\n * once it can be opened, and the reconcile pass re-opens a missing one when it can.\n */\n desk?: { sessionId: string; workspace: string; openedAt: string };\n /** Period spend: own sessions, and cumulative (own + every subordinate). */\n spend: { own: number; cumulative: number; ratio?: number };\n /** Why the entry cannot be triggered (missing Agent, missing workspace directory). */\n invalid?: string;\n /**\n * The entry's Agent no longer exists (`invalid` carries the sentence). Its desk cannot be\n * opened; the entry stays until a person makes the employee leave, or until an Agent with\n * the same id is created again (which is how a deleted CEO is restored).\n */\n agentMissing?: true;\n}\n\nexport interface OrgChartResponse {\n ceoAgentId: string;\n employees: OrgEmployeeItem[];\n}\n\nexport interface OrgCalendarItem {\n agentId: string;\n name: string;\n title?: string;\n prompt: string;\n enabled: boolean;\n startAt: string;\n period?: string;\n endAt?: string;\n status: ScheduleStatus;\n invalidReason?: string;\n nextFireAt?: string;\n lastFiredAt?: string;\n /** What the most recent evaluation did (absent until the event has been evaluated once). */\n lastOutcome?: OrgCalendarOutcome;\n /** The organization or this employee is paused, so due slots are skipped, not fired. */\n paused: boolean;\n}\n\nexport interface OrgCalendarResponse {\n events: OrgCalendarItem[];\n invalidFiles: Array<{ agentId: string; name: string; error: string }>;\n}\n\n/**\n * One line of a ticket's operation history, kept in the file's frontmatter apart from the\n * progress prose: what was done, by whom (`agent:<id>` / `user:<id>` — an employee is recognised\n * from the Agent id its command subprocesses carry, a person otherwise), and when.\n */\nexport type OrgTicketHistoryAction =\n | \"created\"\n | \"assigned\"\n | \"moved\"\n | \"blocked\"\n | \"unblocked\"\n | \"progress\"\n | \"session_started\"\n | \"session_attached\"\n | \"edited\";\n\nexport interface OrgTicketHistoryEntry {\n at: string;\n by: string;\n action: OrgTicketHistoryAction;\n /** The action's detail: the column moved to, the new owner, the block reason, the session id. */\n note?: string;\n}\n\n/**\n * The ticket id's slug: lowercase letters in hyphen-joined words, no digits (the date prefix\n * already carries the numbers), chosen for meaning — derived from an ASCII title, proposed by\n * the Project's model for a title in another language, or passed explicitly.\n */\nexport const TICKET_SLUG_PATTERN = /^[a-z]+(?:-[a-z]+)*$/;\n\nexport interface OrgTicketItem {\n ticketId: string;\n title: string;\n status: OrgTicketStatus;\n /** The ONE responsible principal (`agent:<id>` / `user:<id>`); who filed it is the history's `created` entry. */\n owner: string;\n parent?: string;\n notify: string[];\n priority: OrgTicketPriority;\n due?: string;\n /** Non-empty = blocked; the ticket stays in its column. */\n blocked?: string;\n blockedBy?: string;\n /** Contributing session ids, in the order they were attached. */\n sessions: string[];\n /** Any contributing session has a Task in progress. */\n running: boolean;\n /** Period cost of the contributing sessions (a session attached to n tickets counts 1/n here). */\n cost: number;\n /** Header/column disagreement or a duplicate id: shown with a danger mark, left where it is. */\n invalid?: string;\n}\n\nexport interface OrgTicketSessionItem {\n sessionId: string;\n agentId: string;\n title?: string;\n status: SessionStatus;\n lastActiveAt?: string;\n}\n\nexport interface OrgTicketDetail extends OrgTicketItem {\n goal: string;\n acceptanceCriteria: string;\n /** `## Progress` as plain sentences; who wrote one and when is the matching history entry. */\n progress: string[];\n result: string;\n /** The operation history from the frontmatter, oldest first. */\n history: OrgTicketHistoryEntry[];\n /** The whole file, for the Markdown view and for clients that prefer to edit it as text. */\n body: string;\n children: string[];\n /** Own cost plus every descendant's along `Parent`. */\n rolledUpCost: number;\n sessionItems: OrgTicketSessionItem[];\n}\n\nexport interface OrgTicketsResponse {\n columns: Record<OrgTicketStatus, OrgTicketItem[]>;\n /** Files that failed to parse (skipped; also recorded as errors). */\n invalidFiles: Array<{ path: string; error: string }>;\n}\n\n/**\n * What a `system` line records, structured: the kind of event and its parameters, so a client\n * renders the sentence in the reader's language and with the principals' display names instead\n * of showing the English `text` verbatim. `text` stays the English sentence (the CLI, older\n * clients and the file itself read it); `notice` is the same fact for a client that can do better.\n */\nexport type OrgChannelNoticeKind =\n | \"employee_joined\"\n | \"employee_left\"\n | \"channel_created\"\n | \"channel_archived\"\n | \"channel_unarchived\"\n | \"channel_joined\"\n | \"channel_invited\"\n | \"channel_left\"\n | \"channel_removed\"\n | \"budget_warned\"\n | \"budget_paused\"\n /**\n * Legacy, read-only: ticket changes no longer write into a channel — the board is read from\n * the board and the overview's inbox. These three kinds stay so the lines already in an\n * organization's message files keep rendering; nothing writes them any more.\n */\n | \"ticket_blocked\"\n | \"ticket_done\"\n | \"ticket_rejected\";\n\n/**\n * The parameters each kind carries, all strings: principals as `agent:<id>` / `user:<id>`\n * (`agent`, `principal`, `by`, `reportsTo`), a ticket as `ticket` + `title`, a budget event as\n * `agent`, `period`, `percent`, `cost`, `budget`.\n */\nexport interface OrgChannelNotice {\n kind: OrgChannelNoticeKind;\n params: Record<string, string>;\n}\n\nexport interface OrgChannelMessage {\n id: string;\n /** ISO 8601 UTC. */\n time: string;\n /** `agent:<id>` / `user:<id>` / `system`. */\n sender: string;\n hop: number;\n text: string;\n /** Principals mentioned, `all` included. */\n mentions: string[];\n refs?: { ticket?: string; session?: string; replyTo?: string };\n /** Present on `system` lines the server wrote with a structured notice; absent on lines from before the field existed. */\n notice?: OrgChannelNotice;\n}\n\n/**\n * One channel as the API reports it. The all-hands channel (`default_channel`,\n * `everyone: true`) exists for as long as the organization does and every employee and\n * Project member is in it; every other channel carries the membership its members edit.\n */\nexport interface OrgChannelItem {\n channelId: string;\n /** As stored; the UI renders the all-hands channel's label itself. */\n name: string;\n /** \"\" when unset. */\n purpose: string;\n /** True only for `default_channel`: membership is implicit. */\n everyone: boolean;\n /** An archived channel is read-only and folded away. */\n archived: boolean;\n /** `user:<id>` / `agent:<id>` / `system`. */\n createdBy: string;\n /** ISO 8601 UTC. */\n createdAt: string;\n /** Implicit membership counted for the all-hands channel. */\n memberCount: number;\n /** Whether the caller (person or employee) is a member. */\n isMember: boolean;\n /** People only; 0 for an employee caller. */\n unread: number;\n /** People only; 0 for an employee caller. */\n mentionsMe: number;\n lastMessageAt: string | null;\n}\n\nexport interface OrgChannelMember {\n /** `agent:<id>` or `user:<id>`. */\n principal: string;\n name: string;\n kind: \"agent\" | \"user\";\n}\n\nexport interface OrgChannelDetail extends OrgChannelItem {\n /** The all-hands channel resolves to every employee plus every Project member. */\n members: OrgChannelMember[];\n}\n\nexport interface OrgChannelsResponse {\n /** `default_channel` first, then by name. */\n channels: OrgChannelItem[];\n}\n\nexport interface OrgChannelCreateRequest {\n channelId: string;\n name?: string;\n purpose?: string;\n}\n\nexport interface OrgChannelPatchRequest {\n name?: string;\n purpose?: string;\n archived?: boolean;\n}\n\nexport interface OrgChannelMemberRequest {\n /** `agent:<id>` (an employee) or `user:<id>` (a Project member). */\n principal: string;\n}\n\nexport interface OrgChannelMessagesResponse {\n /** The channel served. */\n channelId: string;\n /** The day file served (`yyyy-mm-dd` in the organization's timezone). */\n date: string;\n /** The days this channel has a file for, newest first, for paging back. */\n days: string[];\n messages: OrgChannelMessage[];\n /** Messages of this channel after the caller's read cursor in it, across the recent days. */\n unread: number;\n /** Of those, the ones that mention the caller (or all). */\n mentionsMe: number;\n lastReadId?: string;\n}\n\nexport interface OrgBudgetAlert {\n agentId: string;\n period: string;\n warnedAt?: string;\n pausedAt?: string;\n}\n\nexport interface OrgFinanceEmployee {\n agentId: string;\n name: string;\n title: string;\n reportsTo: string | null;\n own: number;\n cumulative: number;\n budget?: number;\n ratio?: number;\n warned: boolean;\n paused: boolean;\n}\n\nexport interface OrgFinanceTicket {\n ticketId: string;\n title: string;\n status: OrgTicketStatus;\n parent?: string;\n cost: number;\n rolledUp: number;\n}\n\nexport interface OrgFinanceResponse {\n period: string;\n currency: \"USD\";\n employees: OrgFinanceEmployee[];\n tickets: OrgFinanceTicket[];\n /** Daily cost of the organization's sessions over the period. */\n daily: Array<{ date: string; cost: number }>;\n alerts: OrgBudgetAlert[];\n total: number;\n /** Some usage ran on a model without pricing: tokens were counted, cost is a lower bound. */\n unpriced: boolean;\n}\n\nexport interface OrgDeskItem {\n agentId: string;\n name: string;\n sessionId: string;\n title?: string;\n status: SessionStatus;\n workspace: string;\n lastActiveAt?: string;\n /**\n * Present when the desk session has an ENABLED messaging binding: its channel, read as\n * `SessionInfo.messagingChannel` is (the company sidebar's desk row draws the same mark as a\n * development row, and the development list never holds a desk).\n */\n messagingChannel?: MessagingChannel;\n}\n\nexport interface OrgSessionsResponse {\n /** One per employee whose current desk Session exists; a desk the ledger names but the server no longer has is left out. */\n desks: OrgDeskItem[];\n tickets: Array<{\n ticketId: string;\n title: string;\n status: OrgTicketStatus;\n sessions: OrgTicketSessionItem[];\n }>;\n}\n\nexport interface OrgDeskResponse {\n agentId: string;\n sessionId: string;\n workspace: string;\n openedAt: string;\n /** True when this call created the desk session. */\n created: boolean;\n}\n\nexport interface OrganizationDetail extends OrganizationSummary {\n settings: OrganizationSettings;\n board: Record<OrgTicketStatus, number>;\n /** Today's calendar events (organization timezone) with their outcomes. */\n today: OrgCalendarItem[];\n pending: {\n /** Unread messages mentioning the caller (or all). */\n mentions: number;\n reviewTickets: OrgTicketItem[];\n blockedByMe: OrgTicketItem[];\n };\n /** The last messages of the all-hands channel. */\n recentMessages: OrgChannelMessage[];\n alerts: OrgBudgetAlert[];\n /** What the overview's inbox lists; absent only from a server older than the field. */\n inbox?: OrgInbox;\n /** The CEO's desk session once opened (creation opens it). */\n ceoDeskSessionId?: string;\n}\n\n/**\n * The overview's inbox: the three things a person is waited on or told about — messages\n * that name them (or `@all`) in the all-hands channel, the tickets that are blocked (all of\n * them, whoever they wait on), and the tickets closed as done this period. Newest first in\n * each list; each capped at a page.\n */\nexport interface OrgInbox {\n mentions: OrgChannelMessage[];\n blockedTickets: OrgTicketItem[];\n doneTickets: Array<OrgTicketItem & { closedAt?: string }>;\n}\n\nexport interface OrganizationCreateRequest {\n /** Semantic id, unique within the Project; also the directory name. */\n orgId: string;\n name?: string;\n mission: string;\n timezone?: string;\n /** An existing absolute directory to use as the shared workspace; default = the organization's own `workspace/`. */\n workspace?: string;\n /** The model for desks and ticket sessions (a configured pair); default = the Project default. */\n model?: { provider: string; modelId: string };\n /**\n * The CEO's monthly budget in USD, written as the `budget` of its `org_chart.yaml` entry.\n * Budgets are compared on the cumulative line, so the CEO's is the whole company's.\n * Omitted = 100; 0 is a real (zero) budget, not \"unbounded\" — only clearing the field\n * later (`PATCH …/employees/:agentId` with `budget: null`) leaves the CEO unbounded.\n */\n ceoBudget?: number;\n /** The working language; omitted = detected from the mission. */\n language?: OrgLanguage;\n}\n\nexport interface OrganizationPatchRequest {\n name?: string;\n mission?: string;\n status?: OrgStatus;\n approvalMode?: OrgApprovalMode;\n timezone?: string;\n mentionChainLimit?: number;\n budgetWarnRatio?: number;\n budgetPauseRatio?: number;\n /** null returns to the organization's own `workspace/`. */\n workspace?: string | null;\n /** null returns to the Project default. */\n model?: { provider: string; modelId: string } | null;\n language?: OrgLanguage;\n}\n\n/**\n * What a proposed semantic id names. Each kind has its own shape — organization and channel\n * ids carry a `co_` / `ch_` prefix, a non-admin's Project id its owner's `<username>-`, a\n * Benchmark id is kebab-case — and its own set of ids to avoid.\n */\nexport type SemanticIdKind = \"org\" | \"channel\" | \"project\" | \"agent\" | \"benchmark\";\n\n/**\n * A semantic id proposed for a display name — every create dialog that names an object with a\n * semantic id (a Project, an Agent, a Benchmark, an organization, a channel) lets the user name\n * the thing first and derive the id from that name. The server asks the Project's default\n * model for a short English id in the kind's spelling (a Chinese name has no mechanical\n * transliteration), falling back to an ASCII slug of the name when the model is unavailable,\n * and to a dated placeholder when neither can name it. The request never fails for a name it\n * cannot translate: a dialog that asked for an id always gets one back.\n */\nexport interface SemanticIdSuggestRequest {\n /** The display name typed so far (or the mission / description, when nothing else names the thing). */\n name: string;\n /** What the id is for: decides the id's shape, the prompt's examples and the ids avoided. */\n kind: SemanticIdKind;\n /**\n * Ids already in use in the target scope; the proposal avoids them. For a Project, an Agent or\n * a Benchmark, the server also avoids, on its own, every id that kind's create route refuses\n * as taken, a leftover folder no list shows included.\n */\n taken?: string[];\n}\n\n/**\n * Why a proposal fell all the way through to a placeholder — what the client tells the user to\n * explain the id it was just handed. `no_default_model`: the Project has no default model to\n * ask. `model_failed`: it was asked and the request failed (no credential, a rejection, a\n * timeout). `unusable_answer`: it answered twice and neither answer yielded an id.\n * `no_ascii`: no model was consulted at all and the name carries no ASCII to transliterate.\n */\nexport type SemanticIdSuggestReason =\n \"no_default_model\" | \"model_failed\" | \"unusable_answer\" | \"no_ascii\";\n\nexport interface SemanticIdSuggestResponse {\n /**\n * An id the kind's create route accepts, not in `taken`: `^[a-z][a-z0-9_]{1,63}$` for most\n * kinds, `<username>-<suffix>` for a non-admin's Project, kebab-case for a Benchmark.\n */\n id: string;\n /** Who produced it: the model, the ASCII fallback, or the dated placeholder that names nothing. */\n source: \"model\" | \"fallback\" | \"placeholder\";\n /** Present only with `source: \"placeholder\"`: why the two real paths produced nothing. */\n reason?: SemanticIdSuggestReason;\n}\n\nexport interface OrgHireRequest {\n /** Employ an existing Agent … */\n agentId?: string;\n /** … or create one (the two are exclusive). Plugins default to agent-company + agent-development. */\n newAgent?: { agentId: string; name?: string; description?: string; plugins?: string[] };\n /**\n * What the organization calls the employee (any script, one line, no `@`, ≤ 64 characters).\n * Omitted, a new Agent's `name` is used; with neither, the Agent's display name stands in.\n */\n name?: string;\n title: string;\n reportsTo: string;\n workspace?: string;\n budget?: number;\n duties?: string;\n model?: { provider: string; modelId: string };\n}\n\nexport interface OrgEmployeePatchRequest {\n /** null (or an empty string) clears the name: back to the Agent's display name. */\n name?: string | null;\n title?: string;\n reportsTo?: string;\n workspace?: string;\n /** null clears the budget. */\n budget?: number | null;\n duties?: string;\n /** null clears the model (back to the Project default). */\n model?: { provider: string; modelId: string } | null;\n}\n\nexport interface OrgCalendarUpsertRequest {\n /** POST only: the employee the event belongs to. */\n agentId?: string;\n /** POST only: the file name. */\n name?: string;\n title?: string;\n prompt: string;\n enabled: boolean;\n startAt: string;\n period?: string;\n endAt?: string;\n}\n\n/**\n * What a calendar write answers: the stored event, plus advisory `warnings` — one line each —\n * about the rota: another employee's recurring event on the same start minute, or the same\n * employee already holding a recurring event (desks that fire together compete for the same\n * budget minute and the same tickets). The write succeeds regardless; the CLI prints the\n * lines so the scheduling employee can stagger.\n */\nexport interface OrgCalendarWriteResponse extends OrgCalendarItem {\n warnings?: string[];\n}\n\nexport interface OrgTicketCreateRequest {\n title: string;\n /** Overrides the slug derived from the title; lowercase English words joined by hyphens. */\n slug?: string;\n goal?: string;\n acceptanceCriteria?: string;\n /** The whole Markdown body instead of goal + acceptanceCriteria (the frontmatter is still generated). */\n body?: string;\n /**\n * The responsible principal: an employee's `agent:<id>` (or bare Agent id) or a member's\n * `user:<id>`. Default = the caller — the session's employee inside a desk or ticket\n * session, else the token's or cookie's user.\n */\n owner?: string;\n parent?: string;\n notify?: string[];\n priority?: OrgTicketPriority;\n due?: string;\n}\n\nexport interface OrgTicketUpdateRequest {\n title?: string;\n /** The new responsible principal; never null — a ticket always has an owner. */\n owner?: string;\n parent?: string | null;\n notify?: string[];\n priority?: OrgTicketPriority;\n due?: string | null;\n goal?: string;\n acceptanceCriteria?: string;\n result?: string;\n}\n\nexport interface OrgTicketMoveRequest {\n status: OrgTicketStatus;\n /** Required when moving into rejected; recorded under Result. */\n reason?: string;\n}\n\nexport interface OrgTicketBlockRequest {\n reason: string;\n /** A ticket id or a principal. */\n by?: string;\n}\n\nexport interface OrgTicketProgressRequest {\n text: string;\n /**\n * The calling session (CLI: PENGUIN_SESSION_ID); the session is booked as a contributing\n * session and the history entry is attributed to its employee. Honoured only for a request\n * carrying the local API token — the control environment's credential; a signed-in user's\n * write is attributed to the user.\n */\n sessionId?: string;\n /**\n * The calling employee's Agent id (CLI: PENGUIN_AGENT_ID from the control environment), the\n * identity a write is recorded under; it wins over the session's employee, is honoured only\n * with the local API token, like sessionId, and is ignored — the write falls back to the\n * session's employee, then to the person — when it names no employee.\n */\n agentId?: string;\n}\n\nexport interface OrgTicketStartRequest {\n /** The employee the ticket session runs as (CLI: PENGUIN_AGENT_ID); defaults to the ticket owner. */\n agentId?: string;\n message?: string;\n /** Another directory inside the shared workspace; defaults to the employee's desk workspace. */\n workspace?: string;\n}\n\nexport interface OrgTicketStartResponse {\n sessionId: string;\n}\n\nexport interface OrgTicketAttachRequest {\n sessionId: string;\n}\n\nexport interface OrgChannelMessageSendRequest {\n text: string;\n refs?: { ticket?: string; session?: string; replyTo?: string };\n /**\n * The calling session (CLI: PENGUIN_SESSION_ID): the message is sent as its Agent and\n * inherits its hop. Honoured only for a request carrying the local API token — the control\n * environment's credential; a signed-in user's message is sent as the user, at hop 0.\n */\n sessionId?: string;\n}\n\nexport interface OrgChannelReadRequest {\n /** Mark everything up to this message id as read. */\n upTo: string;\n}\n\n/** The handbook index (`handbook/README.md`), the file every work run reads first. */\nexport interface OrgHandbookResponse {\n content: string;\n}\n\n/** One file of the organization handbook (`handbook/`, the company's knowledge base); `path` is relative to that directory. */\nexport interface OrgHandbookFile {\n path: string;\n size: number;\n updatedAt: string;\n}\n\nexport interface OrgHandbookFilesResponse {\n /**\n * The handbook directory's absolute path on this server: the Web App browses it with the Files\n * panel's own directory-addressed routes, which take the same Project access these do.\n */\n dir: string;\n /** The index first, then the other documents by path. */\n files: OrgHandbookFile[];\n}\n\nexport interface OrgHandbookFileResponse {\n path: string;\n content: string;\n}\n\nexport interface OrgHandbookFileWriteRequest {\n content: string;\n}\n\n/** Company-mode notifications on the user-level event stream (best effort; the query routes carry the durable state). */\nexport type CompanyServerEvent =\n /** A work run (desk session) or ticket session was started by the organization scheduler or a ticket start. */\n | {\n type: \"org_run\";\n projectId: string;\n orgId: string;\n agentId: string;\n sessionId: string;\n kind: OrgTriggerKind;\n }\n /** A new channel message (mentions included, so the client can tell whether it is addressed). */\n | {\n type: \"org_channel\";\n projectId: string;\n orgId: string;\n channelId: string;\n message: OrgChannelMessage;\n }\n /** A ticket's status, owner, blocked state or contributing sessions changed. */\n | { type: \"org_ticket\"; projectId: string; orgId: string; ticketId: string; change: string }\n /** Budget warning, pause or resume for an employee. */\n | {\n type: \"org_budget\";\n projectId: string;\n orgId: string;\n agentId: string;\n state: \"warned\" | \"paused\" | \"resumed\";\n ratio: number;\n };\n\n// ---------------------------------------------------------------------------\n// Web contributions (GET /api/contributions)\n// ---------------------------------------------------------------------------\n\n/** How the web app renders a contributed surface: a name from its own registry, or an iframe. */\nexport type RendererRef = { builtin: string } | { iframe: { src: string; namespace: string } };\n\n/** One contribution to a web slot: its id, the contributing module, and the slot's data. */\nexport interface WebContribution {\n id: string;\n from: string;\n [key: string]: unknown;\n}\n\nexport interface ContributionsResponse {\n pages: WebContribution[];\n agentTabs: WebContribution[];\n sessionTabs: WebContribution[];\n}\n\n/**\n * One field of a settings group a module declares (its `PluginConfigProvider.groups`\n * contribution's `properties.<name>`): what the Settings dialog draws for it. `secret` is\n * drawn as a password field and masked on the way out; `enum` is a choice among `options`;\n * `list` is a list of strings, drawn one per line; `table` is a fixed set of `rows`, each a\n * value per one of its `columns`, drawn as a table.\n */\nexport interface PluginConfigField {\n type: \"string\" | \"secret\" | \"boolean\" | \"number\" | \"enum\" | \"list\" | \"table\";\n title: string;\n titleZh?: string;\n /** What the field means: disclosed behind a \"?\" beside its title. */\n description?: string;\n descriptionZh?: string;\n /**\n * The shape a value must take (\"one absolute path per line\"): shown under the field at all\n * times, since it is read while typing.\n */\n hint?: string;\n hintZh?: string;\n placeholder?: string;\n /** The value a package with nothing stored reads; also what an empty field falls back to. */\n default?: string | number | boolean | string[];\n /**\n * Drawn in the card's Advanced fold, collapsed by default, rather than among the basic\n * fields. Presentation only: it is stored, validated and read like any other field.\n */\n advanced?: boolean;\n /** A save that would leave this field empty is refused. */\n required?: boolean;\n /** `enum` only: the values it may take, in display order. */\n options?: PluginConfigOption[];\n /** `list` only: the most entries a save may leave (after trimming and de-duplicating). */\n maxItems?: number;\n /** `number` only: the smallest and largest value a save may store. */\n minimum?: number;\n maximum?: number;\n /** `string` / `list` only: a regular expression every value (every line) must match. */\n pattern?: string;\n /** What a save refused by `pattern` says, after the field's name (e.g. \"must be an absolute path\"). */\n patternErrorMessage?: string;\n /**\n * `table` only: its columns, in display order, and its rows, in display order. The table\n * has no `default`: the rows' declared cells are its defaults, and what is stored is only\n * the cells that differ from them — `{ [row id]: { [column]: value } }`.\n */\n columns?: PluginConfigTableColumn[];\n rows?: PluginConfigTableRow[];\n /**\n * `table` only: a single choice of one row — the sandbox's default preset. The choice is\n * stored in `field`, an `enum` field of the same group whose options are the row ids (plus,\n * in an `extensible` table, the ids of the rows added to it); the page draws that field only\n * as its title in brackets after the chosen row's name (\"(Default)\"), and picks it from a\n * row's \"…\" menu.\n */\n rowChoice?: PluginConfigRowChoice;\n /**\n * `table` only: rows may be added (and only those deleted, from the row's \"…\" menu) and every\n * row reordered. A new row\n * starts from these values. What is stored, beside the declared rows' changed cells, is the\n * added rows under `\"$added\"` (`{ [row id]: { [column]: value } }`, every column present) and\n * the row order under `\"$order\"` (row ids); neither key can be a row id. A save sends either\n * key whole; a document without them reads as the declared rows in declared order.\n */\n extensible?: PluginConfigNewRow;\n /**\n * `table` only: a header drawn over adjacent columns that belong together (the sandbox's\n * Pin, under \"Action\"). `columns` names them. When the group ends the columns, the row's drag\n * handle and \"…\" menu join it.\n */\n columnGroup?: PluginConfigColumnGroup;\n /**\n * `table` only: a `boolean` column drawn as a pin toggle rather than a switch — a row pinned\n * to a list (the sandbox presets in the composer's menu). The texts are its tooltip in each\n * state. Declared on the table, not the column, so every column keeps one shape.\n */\n pin?: PluginConfigPinColumn;\n}\n\n/** A table's pin column: which boolean column, and its tooltip pinned and not. */\nexport interface PluginConfigPinColumn {\n column: string;\n on: string;\n onZh?: string;\n off: string;\n offZh?: string;\n}\n\n/**\n * A table's single choice of a row: the `enum` field it stores into, and its title, drawn in\n * brackets after the chosen row's name. The choice may name no row (nothing stored, and the\n * group's `derive` gives none): no row is marked and a save is not refused for it. A save that\n * leaves it naming a row the table no longer has is refused.\n */\nexport interface PluginConfigRowChoice {\n field: string;\n title: string;\n titleZh?: string;\n}\n\n/** One column of a `table` field: a scalar field type (`string`, `boolean`, or an `enum` with `options`). */\nexport interface PluginConfigTableColumn {\n name: string;\n type: \"string\" | \"boolean\" | \"enum\";\n title: string;\n titleZh?: string;\n /** What the column means: disclosed behind a \"?\" beside its header. */\n description?: string;\n descriptionZh?: string;\n options?: PluginConfigOption[];\n}\n\n/** One row of a `table` field: its id and its declared cells. */\nexport interface PluginConfigTableRow {\n id: string;\n /** Every column's declared value. */\n values: Record<string, string | boolean>;\n /** A string cell's declared value in Chinese, shown until a save changes the cell. */\n valuesZh?: Record<string, string>;\n /** Columns whose cell in this row a save may not change. */\n locked?: string[];\n /** A row added to an `extensible` table (not declared): the only kind that may be deleted. */\n added?: boolean;\n /**\n * What the row is for, disclosed behind a \"?\" beside its name while its `enum` cells hold\n * their declared values. A row without one, or whose choices changed, gets a \"?\" listing its\n * `enum` cells' values and what each does (the options' `description`s).\n */\n description?: string;\n descriptionZh?: string;\n}\n\n/** A header over adjacent table columns, with its own \"?\". */\nexport interface PluginConfigColumnGroup {\n title: string;\n titleZh?: string;\n description?: string;\n descriptionZh?: string;\n columns: string[];\n}\n\n/** The values a row added to an `extensible` table starts from. */\nexport interface PluginConfigNewRow {\n /** The add button's text (\"Add preset\"). */\n add?: string;\n addZh?: string;\n values: Record<string, string | boolean>;\n /** A string column's starting text in Chinese. */\n valuesZh?: Record<string, string>;\n}\n\n/** One choice of an `enum` field. */\nexport interface PluginConfigOption {\n value: string;\n title: string;\n titleZh?: string;\n /** What picking it does: listed, for a table column's option, in a row's \"?\" (see the row's `description`). */\n description?: string;\n descriptionZh?: string;\n}\n\n/** A declared configuration: a titled group of fields, in declaration order. */\nexport interface PluginConfiguration {\n title?: string;\n titleZh?: string;\n description?: string;\n descriptionZh?: string;\n /**\n * A `boolean` field that turns the group on (the sandbox's `enabled`). While it is off, as\n * drafted, the card draws that field alone: no other field, notice or action, and none of the\n * groups drawn inside it. Turning it on in a group that reports a `backend` it lacks offers\n * to install one.\n */\n switch?: string;\n properties: Record<string, PluginConfigField>;\n}\n\n/** A line of live status a contributed group reports beside its fields (e.g. that nothing can enforce it). */\nexport interface PluginConfigNotice {\n /**\n * `progress` = work the group started is still running (an install, a download): the page\n * reads the groups again every few seconds while any notice says so, and the text is the\n * step it is on.\n */\n tone: \"attention\" | \"muted\" | \"progress\";\n text: string;\n textZh?: string;\n /** More about the notice, disclosed under it on request; lines separated by `\\n`. */\n details?: string;\n detailsZh?: string;\n}\n\n/** One settings group (GET /api/admin/plugin-config): its schema and its values, secrets masked. */\nexport interface PluginConfigEntry {\n /** The group's name — the id of the contribution that declared it; also the store key. */\n name: string;\n configuration: PluginConfiguration;\n /** Stored values merged onto the defaults; a secret arrives masked (`first4…last4` or `***`), never in the clear. */\n values: Record<string, unknown>;\n /** Drawn inside that entry's card and saved with it (a sandbox backend's options inside the sandbox's). */\n parent?: string;\n /** Live status beside the fields; absent when there is none. */\n notices?: PluginConfigNotice[];\n /** What this group can DO once, on the machine, drawn as buttons beneath its notices. */\n actions?: PluginConfigActionDecl[];\n /** Enum options this machine cannot honour now: drawn greyed out with the reason; a save choosing one is refused. */\n unavailable?: PluginConfigUnavailableDecl[];\n /**\n * For a group whose settings a backend plugin enforces (the sandbox): whether one that\n * applies to this machine's OS is installed, and which packages this OS defaults to. The card\n * offers to install `recommended` when the group's switch is turned on and none is installed.\n * Asked per read, like the notices; the client never guesses the OS.\n */\n backend?: PluginConfigBackend;\n}\n\n/** Whether a backend that applies to this machine is installed, and this OS's default ones. */\nexport interface PluginConfigBackend {\n /** A backend for this OS is installed: loaded, or installed and failing its check. */\n installed: boolean;\n /**\n * The npm packages this OS defaults to, installed together; absent on an OS with no default\n * backend. Linux names two: bubblewrap, and sandbox-dsh, which confines files through\n * Landlock where bubblewrap is refused.\n */\n recommended?: string[];\n}\n\n/** One enum option (or boolean position) a settings group cannot honour on this machine, and why. */\nexport interface PluginConfigUnavailableDecl {\n field: string;\n /** A `table` field's column: the option is unavailable in every cell of that column. */\n column?: string;\n /** An enum option, or a boolean field's position: \"true\" or \"false\". */\n value: string;\n reason: string;\n reasonZh?: string;\n}\n\n/** One button under a settings group: what it is called, and what pressing it will do. */\nexport interface PluginConfigActionDecl {\n id: string;\n title: string;\n titleZh?: string;\n description?: string;\n descriptionZh?: string;\n}\n\n/** POST /api/admin/plugin-config/action — what running one reported. */\nexport interface PluginConfigActionResponse {\n ok: boolean;\n message: string;\n messageZh?: string;\n /** The groups as they stand after it ran: a setup that worked changes what the page says. */\n plugins: PluginConfigEntry[];\n}\n\nexport interface PluginConfigResponse {\n plugins: PluginConfigEntry[];\n}\n\n/**\n * PUT /api/admin/plugin-config — one group's update. Every named field is validated\n * against its type; an omitted field keeps its stored value; a secret sent as the masked\n * value keeps the stored one, and `null` or `\"\"` clears any field. 400 `plugin_config_invalid`\n * (with `field`) on a value that does not fit or a required field left empty; 404\n * `plugin_config_unknown` for a name no group answers to.\n */\nexport interface PluginConfigUpdateRequest {\n name: string;\n values: Record<string, unknown>;\n}\n\n/**\n * An installed plugin the running build cannot fully run (plugin/unsatisfied.ts): it\n * contributes to a slot, or wires to a module or interface, that this build does not have.\n * Also what a refused push names (`plugins_unsatisfied`) and a confirmed one reports.\n */\nexport interface UnsatisfiedPlugin {\n /** The package specifier, as the installed-plugins list spells it. */\n specifier: string;\n /** True when the whole plugin is left out of this generation; false when only the contributions this build has no slot for were dropped and the rest of it runs. */\n disabled: boolean;\n /** What this build lacks, in the platform's own words. */\n reason: string;\n}\n\n/** One plugin a Project lists (GET /api/projects/:projectId/plugins/installed). */\nexport interface InstalledPlugin {\n /** The package specifier as written in the file. */\n specifier: string;\n /** Whether the running process holds this package — its modules are in the tree. */\n active: boolean;\n /**\n * Where the package came from: shipped with the build (a hot push's assets, or the\n * installation's own `plugins/`) rather than fetched from npm. A tag on an installed\n * plugin — being shipped is not being installed.\n */\n builtin: boolean;\n /** Module names the package declares it adds. */\n modules: string[];\n /** Node names the package declares it stands in for. */\n replaces: string[];\n /**\n * Why the package is not running: unresolvable, or a load that\n * failed (an import that threw, a module name another plugin already took). Only ever\n * reported for a plugin this server is asked to run (`here`).\n */\n error?: string;\n /**\n * Present when this build cannot fully run the package (see {@link UnsatisfiedPlugin}).\n * With `disabled`, `active` is false and a restart would not load it; without, the package\n * runs minus the contributions this build has no slot for. Only reported for a plugin this\n * server is asked to run (`here`).\n */\n unsatisfied?: Pick<UnsatisfiedPlugin, \"disabled\" | \"reason\">;\n /** Listed in the shared `[plugins]` table: every machine runs it. */\n everywhere: boolean;\n /** The machines whose own `[plugins.<machineId>]` table lists it, by machine id. */\n machines: string[];\n /**\n * Whether THIS server is asked to run it — shared, or listed for this server's own id. A\n * plugin listed only for other machines is neither installed nor loaded here, so `active`\n * is false and no `error` is reported for it.\n */\n here: boolean;\n}\n\n/** What an install put on this server (POST …/plugins/installed and …/installed/archive). */\nexport interface PluginInstallOutcome {\n /** The package name npm installed — for a link, the name its package.json carries. */\n name: string;\n version: string | null;\n /** It carries skills, a hook package or MCP servers: the library lists it, and nothing loads it. */\n library: boolean;\n /** It carries server modules: the Project's list loads it. */\n modules: boolean;\n /** The zip held the version already installed: nothing was installed. */\n unchanged?: boolean;\n}\n\n/** POST /api/projects/:projectId/plugins/installed/archive (admin): a plugin package zip, base64. */\nexport interface PluginArchiveImportRequest {\n dataBase64: string;\n /** Replace another installed version of the package (the 409 `plugin_exists` question). */\n overwrite?: boolean;\n}\n\nexport interface InstalledPluginsResponse {\n plugins: InstalledPlugin[];\n /** After an install: what landed on this server. */\n installed?: PluginInstallOutcome;\n /**\n * Specifiers this build SHIPS (the hot push's assets, or the installation's own\n * `plugins/`): installable without a download, and not installed until listed.\n */\n shipped: string[];\n /** The file the list lives in, named for the page that explains where to edit it by hand. */\n file: string;\n /** This server's own machine id — the key of its `[plugins.<machineId>]` table. */\n machineId: string;\n /** A listed plugin neither runs, nor failed to load, nor is one this build cannot run: the App could not be re-assembled around it (the previous one was restored), so a restart is what applies it. */\n restartPending: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// The agent browser, two backends behind one link (see builtin-browser/): the desktop's\n// built-in browser — Electron <webview> guests in the persist:penguin-browser partition,\n// driven over CDP by the shell on the server's behalf — and the user's own Chrome, driven\n// through the PenguinHarness Browser extension over a WebSocket the extension opens.\n// ---------------------------------------------------------------------------\n\n/** The browser an agent drives: the desktop's built-in one, or the user's own Chrome. */\nexport type BrowserBackend = \"builtin\" | \"chrome\";\n\n/**\n * What a backend's link can do. The shell cannot create tabs (the Web App's <webview> does) and\n * throttles and measures its guests; the extension creates, closes and focuses tabs itself and\n * has no cookie store the server may touch.\n */\nexport interface BrowserLinkCapabilities {\n /** `open-tab`, `close-tab` and `activate-tab` are answered (chrome). */\n createsTabs: boolean;\n /** `throttle` and the `metrics` events (builtin). */\n throttles: boolean;\n /** `set-cookies` and `clear-data` (builtin). */\n cookieStore: boolean;\n}\n\n/**\n * One tab of the agent browser. Built-in: a guest page, `id` its webContents id. Chrome: a tab\n * the extension drives (one it created in the Penguin tab group, or one the user added), `id`\n * Chrome's tab id; `canGoBack`/`canGoForward` are always false there (the tabs API cannot read\n * them).\n */\nexport interface BuiltinBrowserTab {\n id: number;\n /** The built-in tab belongs to this conversation; absent for pages opened outside a conversation. */\n sessionId?: string;\n /** Electron storage partition, shared only within this tab's Project. */\n partition?: string;\n url: string;\n title: string;\n loading: boolean;\n canGoBack: boolean;\n canGoForward: boolean;\n favicon?: string;\n /**\n * Present while the page's renderer is gone, with Electron's reason (`crashed`, `oom`,\n * `killed`, …): the panel shows the crash until the page is reloaded, and an agent action on\n * the tab answers 409 `tab_crashed`.\n */\n crashed?: string;\n}\n\n/**\n * Why the agent browser cannot be driven. Built-in: not under the desktop shell, a shell too old\n * to host it, or no app window to host a new tab. Chrome: this user has no paired extension, it\n * is paired but not connected now, or an admin switched Chrome connections off server-wide.\n */\nexport type BuiltinBrowserUnavailableReason =\n | \"not_desktop\"\n | \"shell_unsupported\"\n | \"no_window\"\n | \"extension_not_paired\"\n | \"extension_disconnected\"\n | \"extension_disabled\";\n\n/** One backend as GET /status lists it for the caller. */\nexport interface BrowserBackendInfo {\n backend: BrowserBackend;\n available: boolean;\n reason?: BuiltinBrowserUnavailableReason;\n /** chrome: the caller's connected extension, else the one seen most recently; absent when none is paired. */\n extension?: {\n id: string;\n name: string;\n version: string;\n connected: boolean;\n lastSeenAt: string | null;\n };\n}\n\n/**\n * GET /api/builtin-browser/status: the caller's effective backend (`backend`) — whether it can\n * be driven now, its tabs — and every backend this server offers the caller (`backends`).\n */\nexport interface BuiltinBrowserStatus {\n /** Whether the effective backend can be driven now. */\n available: boolean;\n reason?: BuiltinBrowserUnavailableReason;\n backend: BrowserBackend;\n backends: BrowserBackendInfo[];\n tabs: BuiltinBrowserTab[];\n activeTabId: number | null;\n /** The shell's latest measurement of the built-in browser's load; absent before its first one, and on chrome. */\n metrics?: BuiltinBrowserMetrics;\n}\n\n/** GET / PUT /api/builtin-browser/backend: the caller's backend and the ones they may choose. */\nexport interface BrowserBackendResponse {\n backend: BrowserBackend;\n choices: BrowserBackend[];\n}\n\n/** One Chrome paired to the caller's account (a row of `browser_extensions`). */\nexport interface BrowserExtensionRecord {\n id: string;\n /** As the extension names itself, e.g. \"Chrome 130 on macOS\". */\n name: string;\n version: string;\n createdAt: string;\n lastSeenAt: string | null;\n /** Whether this one holds the caller's connection now (one at a time per user). */\n connected: boolean;\n}\n\n/** GET /api/builtin-browser/extension. */\nexport interface BrowserExtensionsResponse {\n paired: BrowserExtensionRecord[];\n /** The id of the connected one, when one is. */\n connected?: string;\n /** The admin's server-wide switch (`ServerSettings.browserExtensionsEnabled`). */\n enabled: boolean;\n}\n\n/**\n * POST /api/builtin-browser/extension/pairings: a one-time code for the signed-in user, valid\n * for ten minutes, used once; the dialog shows it beside the server address.\n */\nexport interface BrowserExtensionPairingResponse {\n /** 43 base64url characters. */\n code: string;\n /** ISO time the code stops working. */\n expiresAt: string;\n /**\n * The address the browser reaches this server at, which the extension pairs with: the public\n * origin (scheme, host and port; from the forwarded headers when the server trusts a proxy).\n * Null only from an older server, which echoed the request's `Origin` and had none to echo for\n * a call that carried no `Origin`.\n */\n origin: string | null;\n}\n\n/** POST /api/builtin-browser/extension/pair (no cookie: the extension's own request). */\nexport interface BrowserExtensionPairRequest {\n code: string;\n /** How the extension names this Chrome, e.g. \"Chrome 130 on macOS\" (1–80 characters). */\n name: string;\n /** The extension's own version (manifest.version). */\n version: string;\n}\n\n/** What /extension/pair answers: the long-lived token, shown once and stored only hashed here. */\nexport interface BrowserExtensionPairResponse {\n extensionId: string;\n /** Rides the WebSocket's subprotocol list as `token.<token>`; never a URL. */\n token: string;\n /** This server's install id (a stable name for the data root); null when it is unknown. */\n installId: string | null;\n user: { userId: string; displayName: string | null };\n serverVersion: string;\n}\n\n/**\n * The extension WebSocket's close codes (`/api/builtin-browser/extension/ws`). Besides these the\n * server closes with 1012 when it restarts or hot-swaps (reconnect with the usual backoff).\n *\n * - 4001 replaced: another extension of the same user connected (last connected wins); do not\n * auto-retry.\n * - 4003 revoked: the pairing was revoked (or the token was); forget this server.\n * - 4005 protocol_mismatch: no `penguin-browser.1` subprotocol, or a hello the server does not\n * speak; the extension needs updating.\n * - 4008 ping_timeout: two pings (or the hello) went unanswered.\n * - 4009 disabled: an admin switched Chrome connections off; retry hourly.\n */\nexport type BrowserExtensionCloseCode = 4001 | 4003 | 4005 | 4008 | 4009;\n\n/** One tab's share of the built-in browser's load, as the shell last measured it. */\nexport interface BuiltinBrowserTabMetrics {\n tabId: number;\n /** The memory held by the processes behind the page (its own and its cross-site frames'), KB. */\n memoryKB: number;\n /** Their CPU use since the previous measurement, in percent of one core. */\n cpuPercent: number;\n}\n\n/** Why the built-in browser should be lighter. */\nexport type BuiltinBrowserLoadWarning =\n /** Its pages together hold more memory than they should. */\n | \"memory\"\n /** This computer is running out of memory. */\n | \"low_system_memory\"\n /** More tabs are open than the browser should hold. */\n | \"many_tabs\";\n\n/**\n * The built-in browser's load: GET /status's `metrics`, and the `builtin_browser_metrics` event\n * after each of the shell's measurements (every ~10 s while tabs are open, and soon after one\n * closes). The thresholds are the server's; the UI and the CLI only word what it concluded.\n */\nexport interface BuiltinBrowserMetrics {\n /** When the server received the measurement (epoch ms). */\n at: number;\n tabs: BuiltinBrowserTabMetrics[];\n /** All the pages' memory, a process two tabs share counted once, KB. */\n totalKB: number;\n /**\n * This computer's memory, KB. Absent on macOS, whose free count leaves out the memory it would\n * readily hand back, so that \"free\" there reads as nearly none on a healthy machine.\n */\n system?: { freeKB: number; totalKB: number };\n /** What to warn about now; empty when all is well. */\n warnings: BuiltinBrowserLoadWarning[];\n /** While a memory warning stands: the tabs holding the most memory, heaviest first. */\n heavyTabIds: number[];\n}\n\n/** GET /api/builtin-browser/tabs. */\nexport interface BuiltinBrowserTabsResponse {\n tabs: BuiltinBrowserTab[];\n activeTabId: number | null;\n}\n\n/** POST /api/builtin-browser/tabs/:tab/scan — GenericAgent's web_scan. */\nexport interface BuiltinBrowserScanResult {\n tab: BuiltinBrowserTab;\n tabs: BuiltinBrowserTab[];\n activeTabId: number | null;\n /** Simplified HTML, or plain text with `textOnly`. */\n content?: string;\n truncated?: boolean;\n}\n\n/** POST /api/builtin-browser/tabs/:tab/exec (and click / type) — GenericAgent's web_execute_js. */\nexport interface BuiltinBrowserExecResult {\n status: \"success\" | \"failed\";\n tabId: number;\n /** The script's JSON-safe return value. */\n value?: unknown;\n error?: string;\n /** The page navigated or reloaded while the script ran. */\n reloaded?: boolean;\n /** Tabs opened while the call ran (popups, target=_blank). */\n newTabs?: { id: number; url: string }[];\n /** Text that appeared during the call and may be gone again (toasts, flashes). */\n transients?: string[];\n diff?: { changed: number; topChange?: string };\n suggestion?: string;\n /** click only: where the trusted click landed. */\n clicked?: { x: number; y: number; tag?: string; text?: string };\n /** The page's dialogs the call answered, in order (see BuiltinBrowserDialog). */\n dialogs?: BuiltinBrowserDialog[];\n}\n\n/**\n * A dialog the page opened during an exec, click or type, which the call answered so the page\n * would not block: an alert is accepted, and a confirm, prompt or leave-page dialog dismissed\n * unless the request said `acceptDialogs`.\n */\nexport interface BuiltinBrowserDialog {\n type: \"alert\" | \"confirm\" | \"prompt\" | \"beforeunload\";\n message: string;\n accepted: boolean;\n}\n\n/** POST /api/builtin-browser/tabs/:tab/screenshot. */\nexport interface BuiltinBrowserScreenshot {\n mime: \"image/png\";\n /** Base64. */\n data: string;\n}\n\nexport type BuiltinBrowserImportBrowser =\n \"chrome\" | \"edge\" | \"brave\" | \"chromium\" | \"vivaldi\" | \"opera\" | \"arc\" | \"firefox\";\n\n/** One profile of a system browser that can be imported from. */\nexport interface BuiltinBrowserImportSource {\n /** `<browser>:<profile dir>`, e.g. `chrome:Default`. */\n id: string;\n browser: BuiltinBrowserImportBrowser;\n browserName: string;\n /** The profile's directory name. */\n profile: string;\n /** The profile's display name. */\n profileName: string;\n hasCookies: boolean;\n hasHistory: boolean;\n}\n\nexport interface BuiltinBrowserImportSourcesResponse {\n sources: BuiltinBrowserImportSource[];\n}\n\n/** POST /api/builtin-browser/import. */\nexport interface BuiltinBrowserImportRequest {\n sessionId?: string;\n projectId?: string;\n sourceId: string;\n cookies?: boolean;\n history?: boolean;\n /** Only cookies of these sites (a domain matches itself and its subdomains). */\n domains?: string[];\n}\n\nexport interface BuiltinBrowserImportResult {\n sourceId: string;\n cookies?: { found: number; imported: number; skipped: number; failed: number };\n history?: { found: number; imported: number };\n warnings: string[];\n}\n\nexport interface BuiltinBrowserHistoryEntry {\n url: string;\n title: string;\n visitCount: number;\n /** Epoch ms. */\n lastVisitAt: number;\n /** `builtin` for pages visited in the built-in browser, else the browser it was imported from. */\n source: string;\n}\n\nexport interface BuiltinBrowserHistoryResponse {\n entries: BuiltinBrowserHistoryEntry[];\n}\n\n/**\n * GET / PUT /api/builtin-browser/settings: the browser's own settings, a file of the server's\n * that is read and written without the desktop shell. PUT takes the whole object.\n */\nexport interface BuiltinBrowserSettings {\n /**\n * The page a new tab opens when it is given no address — the panel's \"+\", an agent's new tab\n * — and the toolbar's Home button goes to: an http(s) address, or null for none (a new tab is\n * then blank). PUT takes a bare host too and answers the address as stored.\n */\n homepage: string | null;\n}\n\nexport type BuiltinBrowserAction =\n \"navigate\" | \"scan\" | \"exec\" | \"click\" | \"type\" | \"screenshot\" | \"cdp\";\n\n/**\n * User-channel events of the agent browser. The built-in backend's go to every admin; a chrome\n * backend's go to the user whose Chrome it is, as do `builtin_browser_backend` and\n * `builtin_browser_extension`.\n */\nexport type BuiltinBrowserServerEvent =\n /**\n * A backend's whole tab list. `backend` names whose: a desktop admin hears both the built-in\n * browser's and their own Chrome's, and the two lists must not overwrite each other.\n */\n | {\n type: \"builtin_browser_tabs\";\n tabs: BuiltinBrowserTab[];\n activeTabId: number | null;\n backend: BrowserBackend;\n }\n /** Create a guest for `url`, then POST /tabs/claim with `requestId` once it has a webContents id. */\n | {\n type: \"builtin_browser_open\";\n partition?: string;\n requestId: string;\n url: string;\n activate: boolean;\n openerTabId?: number;\n sessionId?: string;\n }\n | { type: \"builtin_browser_close\"; tabId: number }\n /** A draft or healed conversation acquired its permanent id. */\n | { type: \"builtin_browser_session\"; fromSessionId: string; sessionId: string }\n | {\n type: \"builtin_browser_activity\";\n tabId: number;\n busy: boolean;\n action: BuiltinBrowserAction;\n sessionId?: string;\n }\n /** The shell measured the browser's load (see BuiltinBrowserMetrics). */\n | { type: \"builtin_browser_metrics\"; metrics: BuiltinBrowserMetrics }\n /** The user switched backend (PUT /backend); every window of theirs follows. */\n | { type: \"builtin_browser_backend\"; backend: BrowserBackend }\n /** The user's Chrome connected, went away, was replaced by another Chrome of theirs, or was revoked. */\n | {\n type: \"builtin_browser_extension\";\n state: \"connected\" | \"disconnected\" | \"replaced\" | \"revoked\";\n extension?: BrowserExtensionRecord;\n };\n\n/** A cookie as the shell writes it (Electron's CookiesSetDetails). */\nexport interface DesktopBrowserCookie {\n url: string;\n name: string;\n value: string;\n domain?: string;\n path?: string;\n secure?: boolean;\n httpOnly?: boolean;\n /** Unix seconds; absent = session cookie. */\n expirationDate?: number;\n sameSite?: \"unspecified\" | \"no_restriction\" | \"lax\" | \"strict\";\n}\n\n/**\n * Server → browser link: what the shell (built-in) or the extension (chrome) does with its tabs.\n * Mechanism only — the product logic stays on the server. The names are historical: the same\n * envelopes travel the extension's WebSocket as JSON text frames.\n */\nexport type DesktopBrowserCommand =\n /** Reply: a BrowserHello. An older shell never answers. */\n | { op: \"hello\" }\n /** Reply: `{ tabs: BuiltinBrowserTab[] }`. */\n | { op: \"tabs\" }\n /**\n * Reply: the CDP method's result object. `events` names the tab's CDP events the shell relays\n * from then on, as `cdp-event`s: it replaces the list before (empty relays none), and is set\n * before the command runs, so an event the command itself causes is not missed. Without it\n * the list stays as it is.\n */\n | {\n op: \"cdp\";\n tabId: number;\n method: string;\n params?: Record<string, unknown>;\n events?: string[];\n }\n /** Reply: `{ set: number; failed: number; errors: string[] }` (at most 10 errors). */\n | { op: \"set-cookies\"; cookies: DesktopBrowserCookie[]; partition?: string }\n /** Reply: `{}`. */\n | { op: \"clear-data\"; storages: (\"cookies\" | \"cache\" | \"storage\")[]; partition?: string }\n /**\n * Reply: `{}`. The tabs whose pages may be throttled while out of sight (Electron's background\n * throttling, applied at once); every other tab runs at full speed. It replaces the list\n * before. A tab is unthrottled until a command lists it, so a server that never sends one\n * changes nothing. A shell older than this command answers `unknown_op`.\n */\n | { op: \"throttle\"; tabIds: number[] }\n /**\n * Chrome only (the shell answers `unknown_op`). Reply: `{ tab: BuiltinBrowserTab }`. A tab at\n * `url` (http(s) or about:blank, checked by the server) in the extension's Penguin tab group;\n * `activate` asks for it to be the selected tab of its window.\n */\n | { op: \"open-tab\"; url: string; activate: boolean }\n /** Chrome only. Reply: `{}`. Closes a tab the extension drives. */\n | { op: \"close-tab\"; tabId: number }\n /** Chrome only. Reply: `{}`. Shows the tab in the user's Chrome (selects it, focuses its window). */\n | { op: \"activate-tab\"; tabId: number }\n /** Chrome only. Reply: `{}`. Sent every 20 s; the traffic keeps the MV3 service worker alive. */\n | { op: \"ping\" };\n\n/**\n * The `hello` reply. Built-in: `{ version: 1, partition }` (an older shell sends no `backend`).\n * Chrome: `{ version: 1, backend: \"chrome\", extension }`; any other version is closed 4005.\n */\nexport interface BrowserHello {\n version: 1;\n backend?: BrowserBackend;\n partition?: string;\n extension?: { version: string; chrome: string; name: string };\n}\n\nexport interface DesktopBrowserCommandMessage {\n type: \"desktop-browser-command\";\n id: string;\n command: DesktopBrowserCommand;\n}\n\nexport interface DesktopBrowserReplyMessage {\n type: \"desktop-browser-reply\";\n id: string;\n ok: boolean;\n result?: unknown;\n error?: string;\n}\n\nexport type DesktopBrowserEvent =\n | { kind: \"tab\"; tab: BuiltinBrowserTab }\n | { kind: \"tab-closed\"; tabId: number }\n /**\n * window.open / target=_blank inside a guest (the shell denied it and asks for a tab instead),\n * or the context menu's \"Open link in new tab\". `background`: the tab should open behind the\n * current one, as a middle-click or that menu entry does in Chrome.\n */\n | { kind: \"open-request\"; url: string; openerTabId: number; background?: boolean }\n /** One of the tab's CDP events a `cdp` command's `events` asked for. */\n | { kind: \"cdp-event\"; tabId: number; method: string; params: Record<string, unknown> }\n /**\n * The tab's renderer went away (Electron's render-process-gone reason and exit code). The tab\n * stays; its `tab` events carry `crashed` until the page is reloaded, and the shell refuses\n * CDP commands for it meanwhile (`tab_crashed`).\n */\n | { kind: \"tab-crashed\"; tabId: number; reason: string; exitCode: number }\n /**\n * The shell's measurement of its guests (every ~10 s while there are any, and once more\n * shortly after one closes, empty after the last): each tab's memory and CPU, and the pages'\n * memory together, a process shared by two tabs counted once.\n */\n | { kind: \"metrics\"; tabs: BuiltinBrowserTabMetrics[]; totalKB: number }\n /**\n * Chrome only: the tab still exists in Chrome but the agent may no longer drive it — the user\n * pressed Cancel on Chrome's debugging bar (`user`), the debugger went away otherwise\n * (`detached`), or the page is one Chrome lets no extension attach to (`restricted`). The\n * extension fails that tab's pending commands with the error `tab_released`.\n */\n | { kind: \"tab-released\"; tabId: number; reason: \"user\" | \"detached\" | \"restricted\" };\n\nexport interface DesktopBrowserEventMessage {\n type: \"desktop-browser-event\";\n event: DesktopBrowserEvent;\n}\n"],"mappings":";AAmfO,IAAM,6BAA6B,OAAO;AA6B1C,IAAM,cAAc,CAAC,QAAQ,UAAU,OAAO,WAAW,WAAW,MAAM;AAI1E,IAAM,wBAAwB,MAAM;AAGpC,IAAM,2BAA2B;AA0kJjC,IAAM,kBAAkB;AASxB,SAAS,gBAAgB,UAA2B;AACzD,SACE,aAAa,sBACb,aAAa,kBACb,SAAS,WAAW,eAAe,KACnC,yCAAyC,KAAK,QAAQ,KACrD,SAAS,WAAW,OAAO,KAAK,SAAS,SAAS,SAAS;AAEhE;AAEO,SAAS,gBAAwB;AACtC,SAAO;AACT;AAoeO,IAAM,iBAAiB;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAoXO,IAAM,sBAAsB;","names":[]}