pilotdeck-bridge.js 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805
  1. /**
  2. * PilotDeck bridge — the only chat-execution entry point in `ui/server/`.
  3. *
  4. *
  5. * 1. Connects to the standalone PilotDeck gateway server
  6. * (`pilotdeck server`, default ws://127.0.0.1:18789/ws) as a
  7. * WebSocket client. We never instantiate an in-process gateway
  8. * here — that would create a second, divergent agent runtime that
  9. * doesn't share `~/.pilotdeck/projects/<id>/chats/*.jsonl` writes
  10. * and permission state with the CLI/TUI surfaces. One process, one
  11. * gateway.
  12. * 2. Maps each old "sessionId" → PilotDeck "sessionKey" (1:1, generated
  13. * on first turn and remembered for resume).
  14. * 3. Translates GatewayEvent → NormalizedMessage and writes back via
  15. * `writer.send(...)` so the existing UI rendering pipeline stays
  16. * unchanged.
  17. * 4. Tracks active runs so `abort-session` and the `complete` ack work.
  18. *
  19. * Anything that is NOT chat execution (project listing, files, git, mcp,
  20. * skills, taskmaster, memory, cron management) still runs through the
  21. * existing `ui/server/` route handlers — those are local/disk operations
  22. * that do not need an agent runtime.
  23. *
  24. * Two-process launch:
  25. *
  26. * - `pilotdeck server` (port 18789) owns the gateway, agent loop,
  27. * model router, MCP runtime, cron daemon, and on-disk session
  28. * transcripts. Edit `src/**` then restart this process to pick up
  29. * changes — no `npm run build` required when running via `tsx`.
  30. * - `ui/server/index.js` (port 3001) is the express bridge: REST
  31. * endpoints for non-agent UI concerns + a WebSocket adapter that
  32. * re-shapes gateway events into the legacy NormalizedMessage frames
  33. * the React frontend reducer still expects.
  34. *
  35. * The pair is started together via `cd ui && npm run dev` (or
  36. * `npm start`), which uses `concurrently` to launch both. Either order
  37. * is fine — the bridge retries the WebSocket handshake for
  38. * `GATEWAY_CONNECT_TIMEOUT_MS` so race conditions resolve themselves.
  39. */
  40. import { fileURLToPath } from 'node:url';
  41. import path from 'node:path';
  42. import fs from 'node:fs';
  43. import { promises as fsPromises } from 'node:fs';
  44. import { randomUUID } from 'node:crypto';
  45. import { installGlobalProxy } from '../../src/cli/proxy.js';
  46. installGlobalProxy();
  47. import { resolvePilotHome, createProjectId, sanitizeSessionIdForPath } from './utils/pilotPaths.js';
  48. // Read the gateway client straight from TypeScript source via tsx — the UI
  49. // server is launched with `node --import tsx`, so no prior `npm run build`
  50. // is required. (A prior tsx 4.x JSDoc dynamic-import parse bug was fixed by
  51. // rewriting the offending @type annotation below to `ReturnType<typeof
  52. // createRemoteGateway>`, which is why this import can live on `src/` again.)
  53. import { createRemoteGateway } from '../../src/gateway/index.js';
  54. import { createNormalizedMessage } from './pilotdeck-message.js';
  55. import { readPermissionSettings } from './services/permissionSettings.js';
  56. const __filename = fileURLToPath(import.meta.url);
  57. const __dirname = path.dirname(__filename);
  58. const REPO_ROOT = path.resolve(__dirname, '..', '..');
  59. const GENERAL_HOME = resolvePilotHome(process.env);
  60. const GATEWAY_URL =
  61. process.env.PILOTDECK_GATEWAY_URL || 'ws://127.0.0.1:18789/ws';
  62. const GATEWAY_TOKEN_PATH =
  63. process.env.PILOTDECK_GATEWAY_TOKEN_PATH ||
  64. path.join(GENERAL_HOME, 'server-token');
  65. // The two processes (gateway + bridge) are typically started in
  66. // parallel by `concurrently`. We allow up to 30 s for the gateway to
  67. // come up before failing the first call — covers cold MCP startup on
  68. // slower machines.
  69. const GATEWAY_CONNECT_TIMEOUT_MS = 30_000;
  70. const GATEWAY_CONNECT_RETRY_INTERVAL_MS = 250;
  71. const subagentActivityStarts = new Map();
  72. function normalizeToolDisplayName(name) {
  73. const aliases = {
  74. agent: 'Task',
  75. ask_user_question: 'AskUserQuestion',
  76. bash: 'Bash',
  77. edit_file: 'Edit',
  78. glob: 'Glob',
  79. grep: 'Grep',
  80. read_file: 'Read',
  81. write_file: 'Write',
  82. };
  83. if (aliases[name]) return aliases[name];
  84. if (name === 'todo_write') return 'TodoWrite';
  85. if (name === 'todo_read') return 'TodoRead';
  86. return name;
  87. }
  88. function isPlanModeToolDenyText(text) {
  89. return typeof text === 'string' && /plan mode denies side-effecting tool\b/i.test(text);
  90. }
  91. function normalizeToolErrorCode(errorCode, resultPreview) {
  92. if (isPlanModeToolDenyText(resultPreview)) return 'plan_mode_denied';
  93. return errorCode;
  94. }
  95. /**
  96. * Default permission mode for sessions started from the Web UI. We use
  97. * `default` so PilotDeck's `Permission.decide()` fully evaluates rules
  98. * + tool semantics — read-only tools allow, side-effecting tools either
  99. * surface an interactive `permission_request` (resolved via the banner)
  100. * or short-circuit on an allow rule the user accumulated this session.
  101. * Override with `PILOTDECK_WEB_PERMISSION_MODE`.
  102. */
  103. const WEB_DEFAULT_PERMISSION_MODE =
  104. process.env.PILOTDECK_WEB_PERMISSION_MODE || 'default';
  105. // Resolves to the Gateway returned by `createRemoteGateway`. We express
  106. // the type via `typeof createRemoteGateway` (the symbol is already imported
  107. // above) instead of a JSDoc dynamic-import annotation, because some tsx 4.x
  108. // builds mis-parse such tokens inside JSDoc when running through
  109. // `node --import tsx`, producing a spurious "Parse error" at EOF during
  110. // ESM rewriting on fresh installs.
  111. /** @type {ReturnType<typeof createRemoteGateway> | null} */
  112. let gatewayPromise = null;
  113. async function readGatewayToken() {
  114. try {
  115. const raw = await fsPromises.readFile(GATEWAY_TOKEN_PATH, 'utf8');
  116. const trimmed = raw.trim();
  117. return trimmed || null;
  118. } catch {
  119. return null;
  120. }
  121. }
  122. async function connectWithRetry() {
  123. const deadline = Date.now() + GATEWAY_CONNECT_TIMEOUT_MS;
  124. let lastError;
  125. while (Date.now() < deadline) {
  126. const token = await readGatewayToken();
  127. if (token) {
  128. try {
  129. const gateway = await createRemoteGateway({
  130. url: GATEWAY_URL,
  131. token,
  132. clientName: 'web',
  133. });
  134. console.log(
  135. `[pilotdeck-bridge] connected → ${GATEWAY_URL}`,
  136. );
  137. return gateway;
  138. } catch (error) {
  139. lastError = error;
  140. }
  141. }
  142. await new Promise((resolve) =>
  143. setTimeout(resolve, GATEWAY_CONNECT_RETRY_INTERVAL_MS),
  144. );
  145. }
  146. const detail = lastError instanceof Error ? `: ${lastError.message}` : '';
  147. throw new Error(
  148. `[pilotdeck-bridge] gateway connect failed after ${GATEWAY_CONNECT_TIMEOUT_MS}ms${detail}`,
  149. );
  150. }
  151. function ensureGateway() {
  152. if (!gatewayPromise) {
  153. gatewayPromise = connectWithRetry().catch((error) => {
  154. // Reset so the next caller retries instead of cementing the
  155. // failure forever. The deadline inside connectWithRetry()
  156. // already bounds individual attempts.
  157. gatewayPromise = null;
  158. throw error;
  159. });
  160. }
  161. return gatewayPromise;
  162. }
  163. /**
  164. * Public accessor for the shared gateway client. Other ui/server modules
  165. * (`projects.js`, etc.) await this so they share one WebSocket
  166. * connection instead of opening their own.
  167. */
  168. export async function getPilotDeckGateway() {
  169. return ensureGateway();
  170. }
  171. export function getPilotDeckRepoRoot() {
  172. return REPO_ROOT;
  173. }
  174. /**
  175. * Per-session bookkeeping kept locally so abort + permission flows can
  176. * find their target without round-tripping to the gateway just to
  177. * resolve a sessionId. The gateway is still the source of truth for
  178. * the transcript and the agent state machine.
  179. */
  180. const sessionState = new Map();
  181. function isPilotDeckSessionKey(value) {
  182. return typeof value === 'string' && /^web[:_-]s_/.test(value);
  183. }
  184. function newSessionKey() {
  185. // On Windows, colons are illegal in filenames. The on-disk session file
  186. // is named after the key via sanitizeSessionIdForPath which replaces ':'
  187. // with '-'. Using '-' from the start avoids a mismatch between the key
  188. // the frontend holds (from session_created) and the key the session
  189. // listing returns (from the filename).
  190. const sep = process.platform === 'win32' ? '-' : ':';
  191. return `web${sep}s_${randomUUID()}`;
  192. }
  193. function ensureSessionState(sessionKey, projectKey, channelKey) {
  194. let state = sessionState.get(sessionKey);
  195. if (!state) {
  196. state = {
  197. sessionKey,
  198. projectKey,
  199. channelKey,
  200. runId: undefined,
  201. active: false,
  202. tokenBudget: null,
  203. };
  204. sessionState.set(sessionKey, state);
  205. } else {
  206. state.projectKey = projectKey;
  207. state.channelKey = channelKey;
  208. }
  209. return state;
  210. }
  211. function clearActiveRunIfCurrent(state, runId) {
  212. if (!state || state.runId !== runId) return;
  213. state.active = false;
  214. state.runId = undefined;
  215. }
  216. export function getSessionTokenBudget(sessionKey) {
  217. const state = sessionState.get(sessionKey);
  218. return state?.tokenBudget || {
  219. used: 0,
  220. total: 0,
  221. unknown: true,
  222. };
  223. }
  224. /**
  225. * Convert UI-shape image attachments into Gateway-shape ChannelAttachment[].
  226. *
  227. * UI sends:
  228. * { name, data: 'data:image/png;base64,XXX', size, mimeType }
  229. *
  230. * Gateway expects ChannelAttachment:
  231. * { type: 'image', name, mimeType, content: <raw base64, no data: prefix>, bytes }
  232. *
  233. * The bare-base64 form matches how `CanonicalImageBlock` and the
  234. * AttachmentResolver store the payload elsewhere in the codebase.
  235. *
  236. * Returns undefined when there's nothing to forward — so callers can
  237. * spread it conditionally without injecting an empty array.
  238. *
  239. * @param {unknown} images
  240. * @returns {Array<{type:'image',name?:string,mimeType:string,content:string,bytes?:number}>|undefined}
  241. */
  242. function uiImagesToAttachments(images) {
  243. if (!Array.isArray(images) || images.length === 0) return undefined;
  244. const out = [];
  245. for (const img of images) {
  246. if (!img || typeof img !== 'object') continue;
  247. const raw = typeof img.data === 'string' ? img.data : '';
  248. if (!raw) continue;
  249. // Accept both bare base64 and full data URLs. We pluck the
  250. // declared mime out of the data URL when the caller did not
  251. // pass one explicitly, since we can't reliably guess otherwise.
  252. const dataUrlMatch = raw.match(/^data:([^;]+);base64,(.*)$/);
  253. const mimeType = String(img.mimeType || dataUrlMatch?.[1] || 'image/png');
  254. const base64 = dataUrlMatch ? dataUrlMatch[2] : raw;
  255. if (!base64) continue;
  256. out.push({
  257. type: 'image',
  258. name: typeof img.name === 'string' ? img.name : undefined,
  259. mimeType,
  260. content: base64,
  261. ...(typeof img.size === 'number' ? { bytes: img.size } : {}),
  262. });
  263. }
  264. return out.length > 0 ? out : undefined;
  265. }
  266. function uiFilesToAttachments(files) {
  267. if (!Array.isArray(files) || files.length === 0) return undefined;
  268. const out = [];
  269. for (const file of files) {
  270. if (!file || typeof file !== 'object') continue;
  271. const filePath = typeof file.path === 'string' ? file.path : '';
  272. if (!filePath) continue;
  273. out.push({
  274. type: 'file',
  275. name: typeof file.name === 'string' ? file.name : undefined,
  276. path: filePath,
  277. mimeType: typeof file.mimeType === 'string' ? file.mimeType : undefined,
  278. ...(typeof file.size === 'number' ? { bytes: file.size } : {}),
  279. });
  280. }
  281. return out.length > 0 ? out : undefined;
  282. }
  283. function resolvePermissionMode(options) {
  284. const explicit = options?.permissionMode || options?.mode;
  285. // A literal "default" from the chat composer is the implicit
  286. // no-special-mode position of the per-turn picker, not a real
  287. // per-turn override. Let the user-level skipPermissions toggle
  288. // win over it. Genuine non-default picks (plan / acceptEdits /
  289. // bypassPermissions / dontAsk) still take precedence — they're a
  290. // deliberate per-turn decision.
  291. if (explicit && explicit !== 'default') return explicit;
  292. const persisted = readPermissionSettings();
  293. if (persisted.skipPermissions === true) {
  294. return 'bypassPermissions';
  295. }
  296. return explicit || WEB_DEFAULT_PERMISSION_MODE;
  297. }
  298. /**
  299. * Map a `GatewayEvent` to one or more legacy `NormalizedMessage` frames.
  300. *
  301. * @param {object} event Gateway event payload.
  302. * @param {string} sessionId UI-facing session id.
  303. * @param {string} provider Provider hint (pilotdeck).
  304. * @returns {object[]} NormalizedMessage frames.
  305. */
  306. export function gatewayEventToFrames(event, sessionId, provider) {
  307. const base = { sessionId, provider };
  308. switch (event.type) {
  309. case 'turn_started':
  310. return [
  311. createNormalizedMessage({
  312. ...base,
  313. kind: 'status',
  314. text: 'started',
  315. }),
  316. ];
  317. case 'assistant_text_delta':
  318. return [
  319. createNormalizedMessage({
  320. ...base,
  321. kind: 'stream_delta',
  322. content: event.text,
  323. }),
  324. ];
  325. case 'assistant_thinking_delta':
  326. return [
  327. createNormalizedMessage({
  328. ...base,
  329. kind: 'thinking',
  330. content: event.text,
  331. }),
  332. ];
  333. case 'tool_call_started':
  334. return [
  335. createNormalizedMessage({
  336. ...base,
  337. kind: 'tool_use',
  338. toolId: event.toolCallId,
  339. toolName: normalizeToolDisplayName(event.name),
  340. toolInput: tryParseJson(event.argsPreview),
  341. }),
  342. ];
  343. case 'tool_call_finished': {
  344. const normalizedErrorCode = normalizeToolErrorCode(event.errorCode, event.resultPreview);
  345. return [
  346. createNormalizedMessage({
  347. ...base,
  348. kind: 'tool_result',
  349. toolId: event.toolCallId,
  350. content: event.resultPreview ?? '',
  351. isError: !event.ok,
  352. // errorCode lets the UI distinguish permission denials
  353. // (`permission_denied` / `permission_required`) from
  354. // ordinary execution failures (`tool_execution_failed`,
  355. // `file_not_found`, …) so the "Add to Allowed Tools"
  356. // affordance only fires for the former.
  357. ...(normalizedErrorCode && { errorCode: normalizedErrorCode }),
  358. // Inline tool-result images (e.g. read_file on a PNG).
  359. // The wire shape uses raw base64; we wrap as data URLs
  360. // here so the UI can drop them straight into <img src>.
  361. ...(Array.isArray(event.images) && event.images.length > 0
  362. ? {
  363. toolResultImages: event.images.map((image) => ({
  364. data: `data:${image.mimeType};base64,${image.data}`,
  365. mimeType: image.mimeType,
  366. })),
  367. }
  368. : {}),
  369. ...(event.toolName === 'exit_plan_mode' && event.data?.planFilePath
  370. ? {
  371. planFilePath: event.data.planFilePath,
  372. planTitle: event.data.planTitle,
  373. planSummary: event.data.planSummary,
  374. }
  375. : {}),
  376. ...(event.toolName === 'ask_user_question' && event.data
  377. ? { toolUseResult: event.data }
  378. : {}),
  379. }),
  380. ];
  381. }
  382. case 'permission_request':
  383. return [
  384. createNormalizedMessage({
  385. ...base,
  386. kind: 'permission_request',
  387. requestId: event.requestId,
  388. toolName: event.toolName,
  389. input: event.payload,
  390. context: { provider },
  391. }),
  392. ];
  393. case 'elicitation_request':
  394. // Route structured elicitation through the same `permission_request`
  395. // shape the UI already uses for the permission banner, so the
  396. // registered `AskUserQuestion` PermissionPanel (rich multi-step
  397. // multi-select dialog) renders inline in the chat instead of the
  398. // legacy "wait in CLI" yellow box. We force `toolName` to the
  399. // PascalCase alias that matches `registerPermissionPanel('AskUserQuestion', ...)`
  400. // and tag the frame with `isElicitation: true` so the composer can
  401. // route the user's answer back through `elicitation-response`
  402. // (GatewayPermissionBus).
  403. if (event.toolName === 'exit_plan_mode') {
  404. return [
  405. createNormalizedMessage({
  406. ...base,
  407. kind: 'permission_request',
  408. requestId: event.requestId,
  409. toolCallId: event.toolCallId,
  410. toolName: 'ExitPlanModeV2',
  411. input: {
  412. plan: event.metadata?.plan,
  413. planFilePath: event.metadata?.planFilePath,
  414. questions: event.questions,
  415. metadata: event.metadata,
  416. },
  417. context: { provider, originalToolName: event.toolName },
  418. isElicitation: true,
  419. }),
  420. ];
  421. }
  422. return [
  423. createNormalizedMessage({
  424. ...base,
  425. kind: 'permission_request',
  426. requestId: event.requestId,
  427. toolCallId: event.toolCallId,
  428. toolName: 'AskUserQuestion',
  429. input: {
  430. questions: event.questions,
  431. metadata: event.metadata,
  432. },
  433. context: { provider, originalToolName: event.toolName },
  434. isElicitation: true,
  435. }),
  436. ];
  437. case 'elicitation_cancelled':
  438. return [
  439. createNormalizedMessage({
  440. ...base,
  441. kind: 'permission_cancelled',
  442. requestId: event.requestId,
  443. }),
  444. ];
  445. case 'structured_output':
  446. return [
  447. createNormalizedMessage({
  448. ...base,
  449. kind: 'status',
  450. text: 'structured',
  451. payload: event.payload,
  452. }),
  453. ];
  454. case 'plan_mode_changed':
  455. return [
  456. createNormalizedMessage({
  457. ...base,
  458. kind: 'status',
  459. text: `mode:${event.mode}`,
  460. }),
  461. ];
  462. case 'turn_completed':
  463. return [
  464. createNormalizedMessage({
  465. ...base,
  466. kind: 'complete',
  467. exitCode: 0,
  468. success: true,
  469. finishReason: event.finishReason,
  470. usage: event.usage,
  471. }),
  472. ];
  473. case 'context_budget':
  474. return [
  475. createNormalizedMessage({
  476. ...base,
  477. kind: 'status',
  478. text: 'token_budget',
  479. tokenBudget: {
  480. used: event.used,
  481. total: event.total,
  482. ratio: event.ratio,
  483. state: event.state,
  484. },
  485. }),
  486. ];
  487. case 'error':
  488. return [
  489. createNormalizedMessage({
  490. ...base,
  491. kind: 'error',
  492. content: event.message,
  493. code: event.code,
  494. recoverable: event.recoverable,
  495. }),
  496. ];
  497. case 'agent_status': {
  498. const subagentFrame = createSubagentStatusFrame(event, base);
  499. if (subagentFrame) return [subagentFrame];
  500. const detail = event.detail || {};
  501. if (event.event === 'compact_started') {
  502. const compactProgress = {
  503. level: detail.level || 1,
  504. stage: detail.stage || 'compacting',
  505. label: detail.label || detail.stage || 'Compacting',
  506. state: 'running',
  507. pre_tokens: detail.preTokens,
  508. reason: detail.trigger,
  509. };
  510. return [
  511. createNormalizedMessage({
  512. ...base,
  513. kind: 'status',
  514. text: 'compacting',
  515. tokens: 0,
  516. canInterrupt: true,
  517. compactProgress,
  518. }),
  519. ];
  520. }
  521. if (event.event === 'compact_completed') {
  522. return [
  523. createNormalizedMessage({
  524. ...base,
  525. kind: 'compact_boundary',
  526. trigger: detail.trigger || 'auto',
  527. preTokens: detail.preTokens,
  528. compactLevel: detail.level,
  529. compactStage: detail.stage,
  530. compactStageLabel: detail.stageLabel || detail.stage,
  531. compactMetadata: detail,
  532. }),
  533. ];
  534. }
  535. return [];
  536. }
  537. default:
  538. return [];
  539. }
  540. }
  541. function createSubagentStatusFrame(event, base) {
  542. const detail = event?.detail || {};
  543. const visibleEvents = [
  544. 'subagent_started',
  545. 'subagent_completed',
  546. 'subagent_status',
  547. ];
  548. const hiddenEvents = [
  549. 'subagent_text_delta',
  550. 'subagent_thinking_delta',
  551. 'subagent_tool_call_started',
  552. 'subagent_tool_result',
  553. 'subagent_model_error',
  554. ];
  555. if (hiddenEvents.includes(event?.event)) {
  556. return null;
  557. }
  558. if (!visibleEvents.includes(event?.event)) return null;
  559. const subagentId = String(detail.subagentId || 'unknown');
  560. const status = normalizeSubagentStatus(event.event, detail);
  561. const subagentType = detail.subagentType || 'agent';
  562. const activityKey = `${base.sessionId || ''}:${subagentId}`;
  563. const nowMs = Date.now();
  564. const reportedDurationMs = Number(detail.durationMs);
  565. let startedAtMs = subagentActivityStarts.get(activityKey);
  566. if (event.event === 'subagent_started' || !startedAtMs) {
  567. startedAtMs = Number.isFinite(reportedDurationMs) && reportedDurationMs > 0
  568. ? nowMs - reportedDurationMs
  569. : nowMs;
  570. subagentActivityStarts.set(activityKey, startedAtMs);
  571. }
  572. const durationMs = Number.isFinite(reportedDurationMs) && reportedDurationMs >= 0
  573. ? reportedDurationMs
  574. : Math.max(0, nowMs - startedAtMs);
  575. const isDone = status === 'completed' || status === 'failed';
  576. const title = formatSubagentActivityTitle(subagentType, status);
  577. const activity = createNormalizedMessage({
  578. ...base,
  579. id: `subagent_activity_${sanitizeMessageId(base.sessionId)}_${sanitizeMessageId(subagentId)}`,
  580. kind: 'agent_activity',
  581. activityId: `subagent:${subagentId}`,
  582. runId: `subagent:${subagentId}`,
  583. phase: 'subagent',
  584. state: status,
  585. title,
  586. detail: '',
  587. startedAt: new Date(startedAtMs).toISOString(),
  588. endedAt: isDone ? new Date(nowMs).toISOString() : null,
  589. durationMs,
  590. severity: status === 'failed' ? 'error' : undefined,
  591. toolName: 'agent',
  592. });
  593. if (isDone) {
  594. subagentActivityStarts.delete(activityKey);
  595. }
  596. return activity;
  597. }
  598. function formatSubagentActivityTitle(subagentType, status) {
  599. if (status === 'completed') {
  600. return `Subagent ${subagentType} completed`;
  601. }
  602. if (status === 'failed') {
  603. return `Subagent ${subagentType} failed`;
  604. }
  605. return `Subagent ${subagentType} running`;
  606. }
  607. function normalizeSubagentStatus(eventName, detail) {
  608. if (eventName === 'subagent_completed') {
  609. return detail.success === false ? 'failed' : 'completed';
  610. }
  611. return 'running';
  612. }
  613. function sanitizeMessageId(value) {
  614. return String(value || 'unknown').replace(/[^a-zA-Z0-9_.:-]/g, '_');
  615. }
  616. function tryParseJson(value) {
  617. if (typeof value !== 'string' || !value) return undefined;
  618. try {
  619. return JSON.parse(value);
  620. } catch {
  621. return value;
  622. }
  623. }
  624. /**
  625. * Run a chat command through the PilotDeck gateway.
  626. *
  627. * The frontend addresses sessions by the PilotDeck `sessionKey` itself
  628. * (`web:s_<uuid>`). On the first turn we mint a key and announce it via
  629. * a `session_created` frame; the frontend stores that and uses it on
  630. * every subsequent turn (and after page refresh, since the URL embeds
  631. * it). The transcript on disk is named after the same key, so
  632. * `/api/sessions/<sessionKey>/messages` resolves cleanly.
  633. *
  634. * Permission grants accumulated via the in-banner "Allow + remember"
  635. * action are stored server-side for the duration of the agent session
  636. * (see `createGatewayPermissionHook`) — `toolsSettings.allowedTools`
  637. * pre-population from the legacy settings panel is currently NOT
  638. * re-played here because the override map lives in another process.
  639. * That feature can be restored by extending `submitTurn` to carry an
  640. * optional `permissionAllow[]` payload; not needed for the common
  641. * banner-driven flow.
  642. *
  643. * @param {string} command User prompt text.
  644. * @param {object} options Legacy options blob from the WS frame.
  645. * @param {{send: (msg: object) => void}} writer Existing writer.
  646. * @param {string} provider Provider hint (kept for legacy frame branding).
  647. */
  648. export async function runChatViaGateway(
  649. command,
  650. options = {},
  651. writer,
  652. provider = 'pilotdeck',
  653. ) {
  654. const gw = await ensureGateway();
  655. const projectKey = options.projectPath || options.cwd || GENERAL_HOME;
  656. const channelKey = 'web';
  657. const incoming = options.sessionId || options.sessionKey;
  658. const sessionKey = isPilotDeckSessionKey(incoming) ? incoming : newSessionKey();
  659. const isNewSession = sessionKey !== incoming;
  660. const state = ensureSessionState(sessionKey, projectKey, channelKey);
  661. // If a previous turn for this session is still in-flight (e.g. the
  662. // browser reloaded while a permission prompt was pending), abort it
  663. // before starting the new one. Without this the gateway rejects
  664. // with session_busy because the old turn's inFlightTurns slot is
  665. // still occupied.
  666. if (state.active && state.runId) {
  667. console.log(
  668. `[pilotdeck-bridge] aborting stale turn ${state.runId} for ${sessionKey} before resubmit`,
  669. );
  670. try {
  671. await gw.abortTurn({ sessionKey, runId: state.runId });
  672. } catch (err) {
  673. console.warn('[pilotdeck-bridge] stale abort failed (continuing):', err?.message || err);
  674. }
  675. state.active = false;
  676. state.runId = undefined;
  677. }
  678. if (isNewSession) {
  679. writer.send(
  680. createNormalizedMessage({
  681. provider,
  682. sessionId: sessionKey,
  683. kind: 'session_created',
  684. newSessionId: sessionKey,
  685. sessionKey,
  686. }),
  687. );
  688. }
  689. const runId = randomUUID();
  690. state.runId = runId;
  691. state.active = true;
  692. const attachments = [
  693. ...(uiImagesToAttachments(options?.images) || []),
  694. ...(uiFilesToAttachments(options?.attachments) || []),
  695. ];
  696. const resolvedMode = resolvePermissionMode(options);
  697. console.log(`[pilotdeck-bridge] submitTurn mode=${resolvedMode} (options.permissionMode=${options?.permissionMode}, options.mode=${options?.mode})`);
  698. try {
  699. const stream = gw.submitTurn({
  700. sessionKey,
  701. channelKey,
  702. projectKey,
  703. message: command ?? '',
  704. mode: resolvedMode,
  705. runId,
  706. ...(attachments.length > 0 ? { attachments } : {}),
  707. ...(options.workspaceCwd ? { workspaceCwd: options.workspaceCwd } : {}),
  708. });
  709. for await (const event of stream) {
  710. if (event && event.type === 'error') {
  711. console.error(
  712. '[pilotdeck-bridge] gateway error event:',
  713. JSON.stringify(
  714. {
  715. sessionKey,
  716. projectKey,
  717. runId,
  718. code: event.code,
  719. message: event.message,
  720. recoverable: event.recoverable,
  721. },
  722. null,
  723. 2,
  724. ),
  725. );
  726. }
  727. if (event && event.type === 'context_budget') {
  728. state.tokenBudget = {
  729. used: event.used,
  730. total: event.total,
  731. ratio: event.ratio,
  732. state: event.state,
  733. };
  734. }
  735. // Clear active flag as soon as we see turn_completed so that
  736. // a subsequent submitTurn from the user (who already sees the
  737. // input box) does NOT trigger the stale-abort path while we
  738. // wait for the async generator to fully close.
  739. if (event && event.type === 'turn_completed') {
  740. clearActiveRunIfCurrent(state, runId);
  741. }
  742. for (const frame of gatewayEventToFrames(event, sessionKey, provider)) {
  743. writer.send(frame);
  744. }
  745. }
  746. writer.send(
  747. createNormalizedMessage({
  748. provider,
  749. sessionId: sessionKey,
  750. kind: 'complete',
  751. exitCode: 0,
  752. success: true,
  753. }),
  754. );
  755. } catch (error) {
  756. console.error(
  757. '[pilotdeck-bridge] runChatViaGateway threw:',
  758. error instanceof Error ? (error.stack || error.message) : error,
  759. );
  760. writer.send(
  761. createNormalizedMessage({
  762. provider,
  763. sessionId: sessionKey,
  764. kind: 'error',
  765. content: error instanceof Error ? error.message : String(error),
  766. }),
  767. );
  768. } finally {
  769. clearActiveRunIfCurrent(state, runId);
  770. }
  771. }
  772. export async function abortViaGateway(sessionId, _provider = 'pilotdeck') {
  773. const gw = await ensureGateway();
  774. const sessionKey = isPilotDeckSessionKey(sessionId) ? sessionId : null;
  775. if (!sessionKey) return false;
  776. const state = sessionState.get(sessionKey);
  777. try {
  778. await gw.abortTurn({ sessionKey, runId: state?.runId });
  779. return true;
  780. } catch (error) {
  781. console.warn('[pilotdeck-bridge] abortTurn failed:', error);
  782. return false;
  783. }
  784. }
  785. export async function decidePermissionViaGateway(requestId, decision, options = {}) {
  786. const gw = await ensureGateway();
  787. // PermissionBus is keyed by sessionKey + requestId. We don't know
  788. // which session owns the request, so try each known session.
  789. for (const state of sessionState.values()) {
  790. try {
  791. const result = await gw.permissionDecide({
  792. sessionKey: state.sessionKey,
  793. requestId,
  794. decision: decision === 'allow' || decision === true ? 'allow' : 'deny',
  795. remember: options.remember,
  796. reason: options.reason,
  797. });
  798. if (result?.delivered) return true;
  799. } catch (error) {
  800. console.warn('[pilotdeck-bridge] permissionDecide failed:', error);
  801. }
  802. }
  803. return false;
  804. }
  805. export async function grantSessionPermissionViaGateway(sessionId, entry) {
  806. const gw = await ensureGateway();
  807. if (!isPilotDeckSessionKey(sessionId) || typeof entry !== 'string' || !entry.trim()) {
  808. return false;
  809. }
  810. try {
  811. const result = await gw.grantSessionPermission({
  812. sessionKey: sessionId,
  813. entry,
  814. });
  815. return Boolean(result?.granted);
  816. } catch (error) {
  817. console.warn('[pilotdeck-bridge] grantSessionPermission failed:', error);
  818. return false;
  819. }
  820. }
  821. export function isSessionActiveViaGateway(sessionId) {
  822. if (!isPilotDeckSessionKey(sessionId)) return false;
  823. return Boolean(sessionState.get(sessionId)?.active);
  824. }
  825. export async function getActiveTurnSnapshotFramesViaGateway(sessionId, provider = 'pilotdeck') {
  826. if (!isPilotDeckSessionKey(sessionId)) return [];
  827. const gw = await ensureGateway();
  828. if (typeof gw.getActiveTurnSnapshot !== 'function') return [];
  829. const snapshot = await gw.getActiveTurnSnapshot({ sessionKey: sessionId });
  830. if (!snapshot?.active || !Array.isArray(snapshot.events)) return [];
  831. return snapshot.events.flatMap((event) => gatewayEventToFrames(event, sessionId, provider) || []);
  832. }
  833. export function getActiveSessionIdsViaGateway() {
  834. return [...sessionState.values()]
  835. .filter((state) => state.active)
  836. .map((state) => state.sessionKey);
  837. }
  838. /**
  839. * Read persisted router stats from `~/.pilotdeck/router/stats.json`.
  840. * Falls back to the legacy `~/.pilotdeck/router-stats.json` path.
  841. *
  842. * Both the gateway server and this bridge run in different processes;
  843. * we no longer have an in-memory accessor (`getLocalGatewayRouterStats`
  844. * was tied to the bridge owning the gateway). The gateway server's
  845. * `TokenStatsCollector` periodically flushes to disk — this function
  846. * is the bridge's read-only window into that file.
  847. *
  848. * @returns {Map<string, {aggregate: object, records: object[]}>}
  849. */
  850. /**
  851. * Build a sessionId->projectPath lookup from the filesystem.
  852. * Scans project chat directories under ~/.pilotdeck/projects/ and maps
  853. * each session filename back to the actual project path (resolved via
  854. * the .cwd marker or well-known directory names).
  855. *
  856. * @returns {{ sessionIndex: Map<string,string>, dirToPath: Map<string,string> }}
  857. */
  858. function _buildSessionProjectIndex() {
  859. const sessionIndex = new Map();
  860. const dirToPath = new Map();
  861. try {
  862. const projectsDir = path.join(GENERAL_HOME, 'projects');
  863. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  864. for (const d of dirs) {
  865. if (!d.isDirectory()) continue;
  866. // Resolve actual project path from .cwd marker (handles lossy encoding)
  867. const cwdFile = path.join(projectsDir, d.name, '.cwd');
  868. try {
  869. const realPath = fs.readFileSync(cwdFile, 'utf-8').trim();
  870. if (realPath) dirToPath.set(d.name, realPath);
  871. } catch { /* no .cwd — will use fallback below */ }
  872. const chatsDir = path.join(projectsDir, d.name, 'chats');
  873. let files;
  874. try { files = fs.readdirSync(chatsDir); } catch { continue; }
  875. for (const f of files) {
  876. if (!f.endsWith('.jsonl')) continue;
  877. const sessionId = f.slice(0, -6);
  878. sessionIndex.set(sessionId, d.name);
  879. }
  880. }
  881. } catch { /* projects dir may not exist yet */ }
  882. return { sessionIndex, dirToPath };
  883. }
  884. function loadPersistedStatsFromDisk() {
  885. const result = new Map();
  886. try {
  887. // Prefer new JSONL format, fall back to legacy JSON.
  888. const jsonlPath = path.join(GENERAL_HOME, 'router', 'stats.jsonl');
  889. const jsonPath = path.join(GENERAL_HOME, 'router', 'stats.json');
  890. const legacyPath = path.join(GENERAL_HOME, 'router-stats.json');
  891. let records;
  892. if (fs.existsSync(jsonlPath)) {
  893. records = _loadRecordsFromJsonl(jsonlPath);
  894. } else {
  895. records = _loadRecordsFromJson(jsonPath, legacyPath);
  896. }
  897. if (!records || records.length === 0) return result;
  898. // Build a filesystem-based sessionId→projectDirName index for
  899. // backward compatibility (records written before projectPath existed).
  900. const { sessionIndex: fsIndex, dirToPath } = _buildSessionProjectIndex();
  901. const generalProjectDirName = createProjectId(GENERAL_HOME);
  902. const resolveProjectPath = (dirName) => {
  903. if (dirName === generalProjectDirName) return GENERAL_HOME;
  904. const fromCwd = dirToPath.get(dirName);
  905. if (fromCwd) return fromCwd;
  906. const repoProjectDirName = createProjectId(REPO_ROOT);
  907. if (dirName === repoProjectDirName) return REPO_ROOT;
  908. return GENERAL_HOME;
  909. };
  910. const byProject = new Map();
  911. for (const rec of records) {
  912. let projectKey = rec.projectPath;
  913. if (!projectKey) {
  914. const sessionId = rec.sessionId;
  915. if (sessionId) {
  916. const safeId = sanitizeSessionIdForPath(sessionId);
  917. const dirName = fsIndex.get(safeId) || fsIndex.get(sessionId);
  918. if (dirName) {
  919. projectKey = resolveProjectPath(dirName);
  920. }
  921. }
  922. }
  923. if (!projectKey) projectKey = GENERAL_HOME;
  924. if (!byProject.has(projectKey)) {
  925. byProject.set(projectKey, []);
  926. }
  927. byProject.get(projectKey).push(rec);
  928. }
  929. for (const [projectKey, projRecords] of byProject.entries()) {
  930. projRecords.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
  931. result.set(projectKey, {
  932. aggregate: {},
  933. records: projRecords.slice(-1000),
  934. });
  935. }
  936. } catch (err) {
  937. if (err?.code !== 'ENOENT') {
  938. console.warn('[router-dashboard] failed to load router stats:', err?.message || err);
  939. }
  940. }
  941. return result;
  942. }
  943. function _loadRecordsFromJsonl(filePath) {
  944. const raw = fs.readFileSync(filePath, 'utf-8');
  945. const records = [];
  946. for (const line of raw.split('\n')) {
  947. if (!line) continue;
  948. try {
  949. const rec = JSON.parse(line);
  950. if (rec?.sessionId && rec?.startedAt) records.push(rec);
  951. } catch { /* skip malformed */ }
  952. }
  953. return records;
  954. }
  955. function _loadRecordsFromJson(jsonPath, legacyPath) {
  956. const statsPath = fs.existsSync(jsonPath) ? jsonPath : legacyPath;
  957. const raw = fs.readFileSync(statsPath, 'utf-8');
  958. const parsed = JSON.parse(raw);
  959. if (!parsed?.sessions || typeof parsed.sessions !== 'object') return [];
  960. const records = [];
  961. for (const sess of Object.values(parsed.sessions)) {
  962. if (!sess || !Array.isArray(sess.requestLog)) continue;
  963. for (const rec of sess.requestLog) {
  964. if (rec?.sessionId && rec?.startedAt) records.push(rec);
  965. }
  966. }
  967. return records;
  968. }
  969. /**
  970. * Read the first user prompt from a session transcript file to use as
  971. * a human-readable title. Cached for the lifetime of the process.
  972. */
  973. const _sessionTitleCache = new Map();
  974. function lookupSessionTitle(sessionId, projectKey) {
  975. if (_sessionTitleCache.has(sessionId)) return _sessionTitleCache.get(sessionId);
  976. const title = _readFirstPrompt(sessionId, projectKey);
  977. _sessionTitleCache.set(sessionId, title);
  978. return title;
  979. }
  980. function _readFirstPrompt(sessionId, projectKey) {
  981. const pilotHome = GENERAL_HOME;
  982. // Sessions are stored on disk under a sanitized filename (raw sessionId
  983. // may contain /, :, = which would split into nested dirs). We try
  984. // both the sanitized and raw form so this also resolves any legacy files
  985. // that pre-date the sanitize fix.
  986. const safeId = sanitizeSessionIdForPath(sessionId);
  987. const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  988. const candidates = [];
  989. if (projectKey) {
  990. for (const id of fileVariants) {
  991. candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
  992. }
  993. }
  994. // Also check the general workspace (sessions may live there)
  995. for (const id of fileVariants) {
  996. const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
  997. if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
  998. }
  999. try {
  1000. const projectsDir = path.join(pilotHome, 'projects');
  1001. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  1002. for (const d of dirs) {
  1003. if (!d.isDirectory()) continue;
  1004. for (const id of fileVariants) {
  1005. const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
  1006. if (!candidates.includes(p)) candidates.push(p);
  1007. }
  1008. }
  1009. } catch { /* ignore */ }
  1010. for (const filePath of candidates) {
  1011. try {
  1012. const fd = fs.openSync(filePath, 'r');
  1013. try {
  1014. const buf = Buffer.alloc(16384);
  1015. const bytesRead = fs.readSync(fd, buf, 0, 16384, 0);
  1016. const head = buf.toString('utf-8', 0, bytesRead);
  1017. const firstLine = head.split('\n').find(l => l.includes('"type":"accepted_input"'));
  1018. if (firstLine) {
  1019. const parsed = JSON.parse(firstLine);
  1020. const text = parsed.messages
  1021. ?.flatMap(m => m.content ?? [])
  1022. .find(b => b.type === 'text')?.text;
  1023. if (text?.trim()) {
  1024. const trimmed = text.trim();
  1025. return trimmed.length > 80 ? trimmed.slice(0, 77) + '…' : trimmed;
  1026. }
  1027. }
  1028. } finally {
  1029. fs.closeSync(fd);
  1030. }
  1031. } catch { /* file not found or parse error — try next */ }
  1032. }
  1033. return null;
  1034. }
  1035. /**
  1036. * Extract all user queries from a session's transcript JSONL file.
  1037. * Returns up to `limit` trimmed strings (truncated at 120 chars).
  1038. * Cache is invalidated when the transcript file changes (mtime check).
  1039. */
  1040. const _userQueriesCache = new Map();
  1041. function extractUserQueries(sessionId, projectKey, limit = 20) {
  1042. const cacheKey = `${sessionId}::${projectKey || ''}`;
  1043. const cached = _userQueriesCache.get(cacheKey);
  1044. if (cached) {
  1045. const currentMtime = _getTranscriptMtime(sessionId, projectKey);
  1046. if (currentMtime && currentMtime === cached.mtime) return cached.queries;
  1047. }
  1048. const queries = _readUserQueriesFromTranscript(sessionId, projectKey, limit);
  1049. const mtime = _getTranscriptMtime(sessionId, projectKey);
  1050. _userQueriesCache.set(cacheKey, { queries, mtime });
  1051. return queries;
  1052. }
  1053. function _getTranscriptMtime(sessionId, projectKey) {
  1054. const pilotHome = GENERAL_HOME;
  1055. const safeId = sanitizeSessionIdForPath(sessionId);
  1056. const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  1057. const candidates = [];
  1058. if (projectKey) {
  1059. for (const id of fileVariants) {
  1060. candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
  1061. }
  1062. }
  1063. try {
  1064. const projectsDir = path.join(pilotHome, 'projects');
  1065. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  1066. for (const d of dirs) {
  1067. if (!d.isDirectory()) continue;
  1068. for (const id of fileVariants) {
  1069. const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
  1070. if (!candidates.includes(p)) candidates.push(p);
  1071. }
  1072. }
  1073. } catch { /* ignore */ }
  1074. for (const filePath of candidates) {
  1075. try {
  1076. return fs.statSync(filePath).mtimeMs;
  1077. } catch { /* next */ }
  1078. }
  1079. return null;
  1080. }
  1081. function _readUserQueriesFromTranscript(sessionId, projectKey, limit) {
  1082. const pilotHome = GENERAL_HOME;
  1083. const safeId = sanitizeSessionIdForPath(sessionId);
  1084. const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  1085. const candidates = [];
  1086. if (projectKey) {
  1087. for (const id of fileVariants) {
  1088. candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
  1089. }
  1090. }
  1091. for (const id of fileVariants) {
  1092. const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
  1093. if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
  1094. }
  1095. try {
  1096. const projectsDir = path.join(pilotHome, 'projects');
  1097. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  1098. for (const d of dirs) {
  1099. if (!d.isDirectory()) continue;
  1100. for (const id of fileVariants) {
  1101. const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
  1102. if (!candidates.includes(p)) candidates.push(p);
  1103. }
  1104. }
  1105. } catch { /* ignore */ }
  1106. for (const filePath of candidates) {
  1107. try {
  1108. const raw = fs.readFileSync(filePath, 'utf-8');
  1109. const queries = [];
  1110. for (const line of raw.split('\n')) {
  1111. if (!line.trim()) continue;
  1112. try {
  1113. const entry = JSON.parse(line);
  1114. if (entry.type !== 'accepted_input') continue;
  1115. const text = entry.messages
  1116. ?.flatMap(m => m.content ?? [])
  1117. .find(b => b.type === 'text')?.text;
  1118. if (!text?.trim()) continue;
  1119. const trimmed = text.trim();
  1120. if (trimmed.length < 2) continue;
  1121. queries.push(trimmed.length > 120 ? trimmed.slice(0, 117) + '…' : trimmed);
  1122. if (queries.length >= limit) break;
  1123. } catch { /* skip malformed lines */ }
  1124. }
  1125. if (queries.length > 0) return queries;
  1126. } catch { /* file not found — try next */ }
  1127. }
  1128. return [];
  1129. }
  1130. /**
  1131. * Extract per-turn structure from a session transcript.
  1132. * Returns an array of turn objects:
  1133. * { tools: string[][], modelCalls: number }
  1134. *
  1135. * - tools: one entry per assistant_message that has tool_call blocks
  1136. * e.g. [["glob"], ["read_file", "read_file"], ["edit_file"]]
  1137. * - modelCalls: total assistant_messages in the turn (including the
  1138. * final text-only response)
  1139. *
  1140. * Continuation #N shows the tools from model call #N-1 that triggered it.
  1141. */
  1142. const _toolSequenceCache = new Map();
  1143. function _extractToolSequence(sessionId, projectKey) {
  1144. const cacheKey = `${sessionId}::${projectKey || ''}::tools`;
  1145. const cached = _toolSequenceCache.get(cacheKey);
  1146. if (cached) {
  1147. const currentMtime = _getTranscriptMtime(sessionId, projectKey);
  1148. if (currentMtime && currentMtime === cached.mtime) return cached.result;
  1149. }
  1150. const result = _readToolSequenceFromTranscript(sessionId, projectKey);
  1151. const mtime = _getTranscriptMtime(sessionId, projectKey);
  1152. _toolSequenceCache.set(cacheKey, { result, mtime });
  1153. return result;
  1154. }
  1155. function _readToolSequenceFromTranscript(sessionId, projectKey) {
  1156. const pilotHome = GENERAL_HOME;
  1157. const safeId = sanitizeSessionIdForPath(sessionId);
  1158. const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  1159. const candidates = [];
  1160. if (projectKey) {
  1161. for (const id of fileVariants) {
  1162. candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
  1163. }
  1164. }
  1165. for (const id of fileVariants) {
  1166. const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
  1167. if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
  1168. }
  1169. try {
  1170. const projectsDir = path.join(pilotHome, 'projects');
  1171. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  1172. for (const d of dirs) {
  1173. if (!d.isDirectory()) continue;
  1174. for (const id of fileVariants) {
  1175. const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
  1176. if (!candidates.includes(p)) candidates.push(p);
  1177. }
  1178. }
  1179. } catch { /* ignore */ }
  1180. for (const filePath of candidates) {
  1181. try {
  1182. const raw = fs.readFileSync(filePath, 'utf-8');
  1183. const turns = [];
  1184. let currentTurn = null;
  1185. for (const line of raw.split('\n')) {
  1186. if (!line.trim()) continue;
  1187. try {
  1188. const entry = JSON.parse(line);
  1189. if (entry.type === 'accepted_input') {
  1190. currentTurn = { tools: [], modelCalls: 0 };
  1191. turns.push(currentTurn);
  1192. } else if (entry.type === 'assistant_message' && currentTurn) {
  1193. currentTurn.modelCalls++;
  1194. const content = entry.message?.content ?? [];
  1195. const toolNames = content
  1196. .filter(b => b.type === 'tool_call' || b.type === 'tool_use')
  1197. .map(b => b.name)
  1198. .filter(Boolean);
  1199. if (toolNames.length > 0) {
  1200. currentTurn.tools.push(toolNames);
  1201. }
  1202. }
  1203. } catch { /* skip */ }
  1204. }
  1205. if (turns.length > 0) return turns;
  1206. } catch { /* file not found */ }
  1207. }
  1208. return [];
  1209. }
  1210. /**
  1211. * Assign user queries and tool names to requestLog entries.
  1212. *
  1213. * Primary method: group by `turnId` from router stats (each user turn
  1214. * shares one turnId; all continuations within that turn have the same
  1215. * turnId). The first request per turnId gets the user query; subsequent
  1216. * requests become tool continuations with tool names from the transcript.
  1217. *
  1218. * Fallback: when turnId is absent (older stats without the field), uses
  1219. * transcript model-call counts to partition entries.
  1220. */
  1221. /**
  1222. * Extract subagent prompts from a session transcript.
  1223. * Returns a Map<turnId, promptPreview[]> for assigning prompts to subagent entries.
  1224. */
  1225. const _subagentPromptCache = new Map();
  1226. function _extractSubagentPrompts(sessionId, projectKey) {
  1227. const cacheKey = `${sessionId}::${projectKey || ''}::subprompts`;
  1228. const cached = _subagentPromptCache.get(cacheKey);
  1229. if (cached) {
  1230. const currentMtime = _getTranscriptMtime(sessionId, projectKey);
  1231. if (currentMtime && currentMtime === cached.mtime) return cached.result;
  1232. }
  1233. const result = _readSubagentPromptsFromTranscript(sessionId, projectKey);
  1234. const mtime = _getTranscriptMtime(sessionId, projectKey);
  1235. _subagentPromptCache.set(cacheKey, { result, mtime });
  1236. return result;
  1237. }
  1238. function _readSubagentPromptsFromTranscript(sessionId, projectKey) {
  1239. const pilotHome = GENERAL_HOME;
  1240. const safeId = sanitizeSessionIdForPath(sessionId);
  1241. const fileVariants = safeId === sessionId ? [sessionId] : [safeId, sessionId];
  1242. const candidates = [];
  1243. if (projectKey) {
  1244. for (const id of fileVariants) {
  1245. candidates.push(path.join(pilotHome, 'projects', createProjectId(projectKey), 'chats', `${id}.jsonl`));
  1246. }
  1247. }
  1248. for (const id of fileVariants) {
  1249. const generalChatPath = path.join(pilotHome, 'projects', createProjectId(pilotHome), 'chats', `${id}.jsonl`);
  1250. if (!candidates.includes(generalChatPath)) candidates.push(generalChatPath);
  1251. }
  1252. try {
  1253. const projectsDir = path.join(pilotHome, 'projects');
  1254. const dirs = fs.readdirSync(projectsDir, { withFileTypes: true });
  1255. for (const d of dirs) {
  1256. if (!d.isDirectory()) continue;
  1257. for (const id of fileVariants) {
  1258. const p = path.join(projectsDir, d.name, 'chats', `${id}.jsonl`);
  1259. if (!candidates.includes(p)) candidates.push(p);
  1260. }
  1261. }
  1262. } catch { /* ignore */ }
  1263. const promptsByTurn = new Map();
  1264. for (const filePath of candidates) {
  1265. try {
  1266. const raw = fs.readFileSync(filePath, 'utf-8');
  1267. for (const line of raw.split('\n')) {
  1268. if (!line.trim()) continue;
  1269. try {
  1270. const entry = JSON.parse(line);
  1271. if (entry.type === 'subagent_started' && entry.turnId && entry.promptPreview) {
  1272. const list = promptsByTurn.get(entry.turnId) || [];
  1273. const preview = entry.promptPreview.length > 80
  1274. ? entry.promptPreview.slice(0, 80) + '…'
  1275. : entry.promptPreview;
  1276. list.push(preview);
  1277. promptsByTurn.set(entry.turnId, list);
  1278. }
  1279. } catch { /* skip */ }
  1280. }
  1281. if (promptsByTurn.size > 0) return promptsByTurn;
  1282. } catch { /* file not found */ }
  1283. }
  1284. return promptsByTurn;
  1285. }
  1286. function _assignQueriesToRequestLog(sessionEntry) {
  1287. const log = sessionEntry.routing?.requestLog;
  1288. const queries = sessionEntry.userQueries;
  1289. if (!log || log.length === 0 || !queries || queries.length === 0) return;
  1290. const turnStructure = _extractToolSequence(sessionEntry.sessionId, sessionEntry._projectKey);
  1291. const subagentPrompts = _extractSubagentPrompts(sessionEntry.sessionId, sessionEntry._projectKey);
  1292. const hasTurnIds = log.some(e => e.turnId);
  1293. if (hasTurnIds) {
  1294. _assignByTurnId(log, queries, turnStructure, subagentPrompts);
  1295. } else {
  1296. const mainEntries = log.filter(e => e.role === 'main');
  1297. if (mainEntries.length === 0) return;
  1298. _assignByModelCallCount(mainEntries, queries, turnStructure);
  1299. }
  1300. }
  1301. function _assignByTurnId(allEntries, queries, turnStructure, subagentPrompts) {
  1302. let turnIndex = 0;
  1303. let currentTurnId = null;
  1304. for (let i = 0; i < allEntries.length; i++) {
  1305. const entry = allEntries[i];
  1306. if (entry.turnId !== currentTurnId) {
  1307. currentTurnId = entry.turnId;
  1308. if (entry.role === 'main') {
  1309. entry.query = queries[Math.min(turnIndex, queries.length - 1)];
  1310. }
  1311. turnIndex++;
  1312. } else {
  1313. if (entry.role === 'main') {
  1314. entry._savedTier = entry.tier;
  1315. entry.role = 'sub';
  1316. delete entry.tier;
  1317. }
  1318. }
  1319. }
  1320. const turnIds = [...new Set(allEntries.map(e => e.turnId).filter(Boolean))];
  1321. for (let tIdx = 0; tIdx < turnIds.length; tIdx++) {
  1322. const turnId = turnIds[tIdx];
  1323. const turnEntries = allEntries.filter(e => e.turnId === turnId);
  1324. const turnTools = turnStructure[tIdx]?.tools || [];
  1325. const prompts = subagentPrompts?.get(turnId);
  1326. let toolIdx = 0;
  1327. let promptIdx = 0;
  1328. for (let j = 1; j < turnEntries.length; j++) {
  1329. const entry = turnEntries[j];
  1330. if (entry.query === 'sub-agent') {
  1331. if (prompts && promptIdx < prompts.length) {
  1332. entry.query = prompts[promptIdx];
  1333. entry.isSubagentDispatch = true;
  1334. if (entry._savedTier) { entry.tier = entry._savedTier; }
  1335. delete entry._savedTier;
  1336. promptIdx++;
  1337. }
  1338. } else if (!entry.query) {
  1339. if (toolIdx < turnTools.length) {
  1340. const names = turnTools[toolIdx];
  1341. const isAgentCall = names.some(n => n === 'agent' || n === 'sessions_spawn' || n === 'dispatch_agent');
  1342. if (isAgentCall && prompts && promptIdx < prompts.length) {
  1343. entry.query = prompts[promptIdx];
  1344. entry.isSubagentDispatch = true;
  1345. if (entry._savedTier) { entry.tier = entry._savedTier; }
  1346. delete entry._savedTier;
  1347. promptIdx++;
  1348. } else {
  1349. entry.query = '→ ' + [...new Set(names)].join(', ');
  1350. }
  1351. }
  1352. toolIdx++;
  1353. }
  1354. delete entry._savedTier;
  1355. }
  1356. }
  1357. }
  1358. function _assignByModelCallCount(mainEntries, queries, turnStructure) {
  1359. let turnIndex = 0;
  1360. let posInTurn = 0;
  1361. for (let i = 0; i < mainEntries.length; i++) {
  1362. const turnInfo = turnStructure[turnIndex];
  1363. const turnModelCalls = turnInfo ? turnInfo.modelCalls : 0;
  1364. if (posInTurn === 0) {
  1365. mainEntries[i].query = queries[Math.min(turnIndex, queries.length - 1)];
  1366. posInTurn++;
  1367. } else {
  1368. mainEntries[i].role = 'sub';
  1369. delete mainEntries[i].tier;
  1370. const continuationIdx = posInTurn - 1;
  1371. const turnTools = turnInfo?.tools;
  1372. if (turnTools && continuationIdx < turnTools.length) {
  1373. const names = turnTools[continuationIdx];
  1374. mainEntries[i].query = '→ ' + [...new Set(names)].join(', ');
  1375. }
  1376. posInTurn++;
  1377. }
  1378. if (turnModelCalls > 0 && posInTurn >= turnModelCalls) {
  1379. turnIndex++;
  1380. posInTurn = 0;
  1381. }
  1382. }
  1383. }
  1384. /**
  1385. * Build a `DashboardData` payload from persisted router stats. Shape
  1386. * mirrors what `ui/src/hooks/useRoutingDashboard.ts` expects so the V2
  1387. * Dashboard tab renders without changing any frontend code.
  1388. */
  1389. export function getRouterDashboardData() {
  1390. const statsByProject = loadPersistedStatsFromDisk();
  1391. const projects = [];
  1392. const overall = makeBucket();
  1393. const overallByTier = {};
  1394. const overallByRole = {};
  1395. let overallSessionCount = 0;
  1396. for (const [projectKey, snapshot] of statsByProject.entries()) {
  1397. const records = Array.isArray(snapshot.records) ? snapshot.records : [];
  1398. const sessionMap = new Map();
  1399. for (const record of records) {
  1400. if (record.sessionId && record.sessionId.includes('::sub::')) continue;
  1401. let sessionEntry = sessionMap.get(record.sessionId);
  1402. if (!sessionEntry) {
  1403. sessionEntry = {
  1404. sessionId: record.sessionId,
  1405. _projectKey: projectKey,
  1406. title: lookupSessionTitle(record.sessionId, projectKey) || record.sessionId,
  1407. provider: record.provider || 'pilotdeck',
  1408. lastActivity: record.endedAt,
  1409. userQueries: extractUserQueries(record.sessionId, projectKey),
  1410. routing: {
  1411. total: makeBucket(),
  1412. byTier: {},
  1413. byScenario: {},
  1414. byRole: {},
  1415. byModel: {},
  1416. requestLog: [],
  1417. firstSeenAt: Date.parse(record.startedAt) || 0,
  1418. lastActiveAt: Date.parse(record.endedAt) || 0,
  1419. },
  1420. };
  1421. sessionMap.set(record.sessionId, sessionEntry);
  1422. }
  1423. const logRole = record.role === 'subagent' ? 'sub' : 'main';
  1424. sessionEntry.routing.requestLog.push({
  1425. ts: Date.parse(record.startedAt) || 0,
  1426. turnId: record.turnId || undefined,
  1427. role: logRole,
  1428. tier: record.tier || record.scenarioType || undefined,
  1429. model: `${record.provider || 'unknown'}/${record.model || 'unknown'}`,
  1430. ...(record.role === 'subagent' ? { query: 'sub-agent' } : {}),
  1431. tokens: (record.usage?.totalTokens ?? (record.usage?.inputTokens || 0) + (record.usage?.outputTokens || 0)),
  1432. cost: record.cost?.total || 0,
  1433. baselineCost: record.baselineCost ?? (record.cost?.total || 0),
  1434. savedCost: (record.baselineCost ?? (record.cost?.total || 0)) - (record.cost?.total || 0),
  1435. });
  1436. mergeRecordIntoSession(sessionEntry.routing, record);
  1437. const ended = Date.parse(record.endedAt) || 0;
  1438. if (ended > (sessionEntry.routing.lastActiveAt || 0)) {
  1439. sessionEntry.routing.lastActiveAt = ended;
  1440. sessionEntry.lastActivity = record.endedAt;
  1441. }
  1442. }
  1443. for (const sessionEntry of sessionMap.values()) {
  1444. _assignQueriesToRequestLog(sessionEntry);
  1445. delete sessionEntry._projectKey;
  1446. }
  1447. const sessions = [...sessionMap.values()];
  1448. const aggregated = {
  1449. total: makeBucket(),
  1450. byTier: {},
  1451. byRole: {},
  1452. sessionCount: sessions.length,
  1453. routedSessionCount: sessions.length,
  1454. };
  1455. for (const session of sessions) {
  1456. addBuckets(aggregated.total, session.routing.total);
  1457. for (const [tier, bucket] of Object.entries(session.routing.byTier)) {
  1458. aggregated.byTier[tier] = aggregated.byTier[tier] || makeBucket();
  1459. addBuckets(aggregated.byTier[tier], bucket);
  1460. }
  1461. for (const [role, bucket] of Object.entries(session.routing.byRole)) {
  1462. aggregated.byRole[role] = aggregated.byRole[role] || makeBucket();
  1463. addBuckets(aggregated.byRole[role], bucket);
  1464. }
  1465. }
  1466. addBuckets(overall, aggregated.total);
  1467. for (const [tier, bucket] of Object.entries(aggregated.byTier)) {
  1468. overallByTier[tier] = overallByTier[tier] || makeBucket();
  1469. addBuckets(overallByTier[tier], bucket);
  1470. }
  1471. for (const [role, bucket] of Object.entries(aggregated.byRole)) {
  1472. overallByRole[role] = overallByRole[role] || makeBucket();
  1473. addBuckets(overallByRole[role], bucket);
  1474. }
  1475. overallSessionCount += sessions.length;
  1476. projects.push({
  1477. name: deriveProjectName(projectKey),
  1478. displayName: deriveProjectDisplayName(projectKey),
  1479. fullPath: projectKey,
  1480. sessions,
  1481. aggregated,
  1482. });
  1483. }
  1484. return {
  1485. projects,
  1486. overall: {
  1487. total: overall,
  1488. byTier: overallByTier,
  1489. byRole: overallByRole,
  1490. projectCount: projects.length,
  1491. sessionCount: overallSessionCount,
  1492. },
  1493. unmatchedSessions: [],
  1494. };
  1495. }
  1496. function makeBucket() {
  1497. return {
  1498. inputTokens: 0,
  1499. outputTokens: 0,
  1500. cacheReadTokens: 0,
  1501. totalTokens: 0,
  1502. requestCount: 0,
  1503. estimatedCost: 0,
  1504. baselineCost: 0,
  1505. savedCost: 0,
  1506. };
  1507. }
  1508. function addBuckets(target, source) {
  1509. target.inputTokens += source.inputTokens || 0;
  1510. target.outputTokens += source.outputTokens || 0;
  1511. target.cacheReadTokens += source.cacheReadTokens || 0;
  1512. target.totalTokens += source.totalTokens || 0;
  1513. target.requestCount += source.requestCount || 0;
  1514. target.estimatedCost += source.estimatedCost || 0;
  1515. if (typeof target.baselineCost !== 'number') target.baselineCost = 0;
  1516. if (typeof target.savedCost !== 'number') target.savedCost = 0;
  1517. target.baselineCost += source.baselineCost || 0;
  1518. target.savedCost += source.savedCost || 0;
  1519. }
  1520. function mergeRecordIntoSession(routing, record) {
  1521. const usage = record.usage || {};
  1522. const cost = record.cost || {};
  1523. const actualCost = cost.total || 0;
  1524. const baseline = record.baselineCost ?? actualCost;
  1525. const bucket = {
  1526. inputTokens: usage.inputTokens || 0,
  1527. outputTokens: usage.outputTokens || 0,
  1528. cacheReadTokens: usage.cacheReadTokens || 0,
  1529. totalTokens:
  1530. usage.totalTokens ??
  1531. (usage.inputTokens || 0) + (usage.outputTokens || 0),
  1532. requestCount: 1,
  1533. estimatedCost: actualCost,
  1534. baselineCost: baseline,
  1535. savedCost: baseline - actualCost,
  1536. };
  1537. addBuckets(routing.total, bucket);
  1538. const tierKey = record.tier || record.scenarioType || 'default';
  1539. routing.byTier[tierKey] = routing.byTier[tierKey] || makeBucket();
  1540. addBuckets(routing.byTier[tierKey], bucket);
  1541. const scenarioKey = record.scenarioType || 'default';
  1542. routing.byScenario[scenarioKey] = routing.byScenario[scenarioKey] || makeBucket();
  1543. addBuckets(routing.byScenario[scenarioKey], bucket);
  1544. const roleKey = record.resolvedFrom === 'subagent' ? 'sub' : 'main';
  1545. routing.byRole[roleKey] = routing.byRole[roleKey] || makeBucket();
  1546. addBuckets(routing.byRole[roleKey], bucket);
  1547. const modelKey = `${record.provider || 'unknown'}/${record.model || 'unknown'}`;
  1548. routing.byModel[modelKey] = routing.byModel[modelKey] || makeBucket();
  1549. addBuckets(routing.byModel[modelKey], bucket);
  1550. }
  1551. function isGeneralProject(projectKey) {
  1552. return path.resolve(projectKey) === path.resolve(GENERAL_HOME);
  1553. }
  1554. function deriveProjectName(projectKey) {
  1555. if (isGeneralProject(projectKey)) return 'general';
  1556. return projectKey
  1557. .replace(/^\/+/, '')
  1558. .replace(/[^A-Za-z0-9._-]+/g, '-');
  1559. }
  1560. function deriveProjectDisplayName(projectKey) {
  1561. if (isGeneralProject(projectKey)) return 'general';
  1562. const parts = projectKey.split('/').filter(Boolean);
  1563. return parts.length > 0 ? parts[parts.length - 1] : projectKey;
  1564. }
  1565. /**
  1566. * Per-session stats payload for `/api/ccr/stats/sessions/:id`. Returns
  1567. * `null` when no router activity has been observed for the session yet.
  1568. */
  1569. export function getRouterSessionStats(sessionId) {
  1570. const dashboard = getRouterDashboardData();
  1571. for (const project of dashboard.projects) {
  1572. const session = project.sessions.find((s) => s.sessionId === sessionId);
  1573. if (session) {
  1574. return {
  1575. sessionId,
  1576. projectName: project.name,
  1577. routing: session.routing,
  1578. };
  1579. }
  1580. }
  1581. return null;
  1582. }
  1583. /**
  1584. * Lifetime aggregate suitable for `/api/ccr/stats/summary`.
  1585. */
  1586. export function getRouterStatsSummary() {
  1587. const data = getRouterDashboardData();
  1588. const byScenario = {};
  1589. const byProvider = {};
  1590. const byTier = data.overall.byTier;
  1591. for (const project of data.projects) {
  1592. for (const session of project.sessions) {
  1593. for (const [scenario, bucket] of Object.entries(session.routing.byScenario)) {
  1594. byScenario[scenario] = byScenario[scenario] || makeBucket();
  1595. addBuckets(byScenario[scenario], bucket);
  1596. }
  1597. for (const [model, bucket] of Object.entries(session.routing.byModel)) {
  1598. const provider = model.includes('/') ? model.split('/', 1)[0] : model;
  1599. byProvider[provider] = byProvider[provider] || makeBucket();
  1600. addBuckets(byProvider[provider], bucket);
  1601. }
  1602. }
  1603. }
  1604. return {
  1605. lifetime: {
  1606. total: data.overall.total,
  1607. byScenario,
  1608. byProvider,
  1609. byTier,
  1610. },
  1611. lastUpdatedAt: new Date().toISOString(),
  1612. };
  1613. }
  1614. /**
  1615. * Register a notification handler that forwards Always-On turn events
  1616. * to all connected browser WebSocket clients as NormalizedMessage frames.
  1617. *
  1618. * Called once from `index.js` after the WebSocket server is ready, passing
  1619. * the shared `connectedClients` set.
  1620. *
  1621. * @param {Set<import('ws').WebSocket>} clients
  1622. */
  1623. export function registerAlwaysOnNotificationForwarding(clients) {
  1624. const knownSessions = new Set();
  1625. ensureGateway().then((gw) => {
  1626. gw.onNotification((name, payload) => {
  1627. if (name !== 'always-on:turn-event') return;
  1628. const { sessionKey, channelKey, event } = payload ?? {};
  1629. if (!sessionKey || !event) return;
  1630. const provider = 'pilotdeck';
  1631. if (!knownSessions.has(sessionKey)) {
  1632. knownSessions.add(sessionKey);
  1633. const createdFrame = createNormalizedMessage({
  1634. provider,
  1635. sessionId: sessionKey,
  1636. kind: 'session_created',
  1637. newSessionId: sessionKey,
  1638. sessionKey,
  1639. channelKey,
  1640. });
  1641. const createdMsg = JSON.stringify(createdFrame);
  1642. for (const client of clients) {
  1643. if (client.readyState === 1) client.send(createdMsg);
  1644. }
  1645. }
  1646. if (event.type === 'context_budget') {
  1647. const aoState = ensureSessionState(sessionKey, '', channelKey || 'web');
  1648. aoState.tokenBudget = {
  1649. used: event.used,
  1650. total: event.total,
  1651. ratio: event.ratio,
  1652. state: event.state,
  1653. };
  1654. }
  1655. for (const frame of gatewayEventToFrames(event, sessionKey, provider)) {
  1656. const msg = JSON.stringify(frame);
  1657. for (const client of clients) {
  1658. if (client.readyState === 1) client.send(msg);
  1659. }
  1660. }
  1661. if (event.type === 'turn_completed') {
  1662. knownSessions.delete(sessionKey);
  1663. }
  1664. });
  1665. }).catch((err) => {
  1666. console.warn('[pilotdeck-bridge] failed to register always-on notification forwarding:', err?.message || err);
  1667. });
  1668. }
  1669. export async function elicitationRespondViaGateway(requestId, answer) {
  1670. const gw = await ensureGateway();
  1671. for (const state of sessionState.values()) {
  1672. try {
  1673. const result = await gw.respondElicitation({
  1674. sessionKey: state.sessionKey,
  1675. requestId,
  1676. answer,
  1677. });
  1678. if (result?.delivered) return true;
  1679. } catch (error) {
  1680. console.warn('[pilotdeck-bridge] respondElicitation failed:', error);
  1681. }
  1682. }
  1683. return false;
  1684. }