{"version":3,"file":"aibroker-client-CDIXmq-e.mjs","names":[],"sources":["../src/cli/lib/aibroker-client.ts"],"sourcesContent":["/**\n * aibroker-client.ts — Lightweight IPC client for AIBroker daemon.\n *\n * Connects to the AIBroker Unix Domain Socket, sends a JSON-RPC request,\n * and reads a single newline-terminated JSON response. No class needed —\n * just a thin async function matching the WatcherClient protocol.\n *\n * Socket path: /tmp/aibroker.sock (default; override via AIBROKER_SOCKET env).\n */\n\nimport { connect } from \"node:net\";\nimport { randomUUID } from \"node:crypto\";\nimport { spawnSync } from \"node:child_process\";\nimport { aibrokerSocketPath } from \"../../runtime-paths.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/**\n * Lightweight session metadata returned by the AIBroker `sessions` IPC method.\n * Does NOT include scrollback content — use `session_content` for that.\n */\nexport interface AiBrokerSessionMeta {\n  index: number;\n  sessionId: string;\n  /** iTerm2 tab title or profile name */\n  name: string;\n  /** PAI session name set via /Name; null for bare shells */\n  paiName: string | null;\n  atPrompt: boolean;\n  /** \"claude\" for Claude Code panes, \"shell\" for bare terminals */\n  kind: \"claude\" | \"shell\";\n  /** Whether this is the currently focused pane */\n  active: boolean;\n  /** Last user prompt seen in scrollback (only populated by fetchLiveSessionsWithPrompts) */\n  lastPrompt?: string;\n}\n\ninterface AiBrokerSessionsResult {\n  sessions: AiBrokerSessionMeta[];\n}\n\n// ---------------------------------------------------------------------------\n// Core call\n// ---------------------------------------------------------------------------\n\n// AIBROKER_SOCKET stays first: it is AIBroker's own variable and a user who has\n// set it means it. The fallback goes through runtime-paths so the test guard\n// can redirect it — writing to a live socket from a test is the same hazard as\n// writing to live user state, and this one is not even ours.\nconst DEFAULT_SOCKET = process.env.AIBROKER_SOCKET ?? aibrokerSocketPath();\n\n/**\n * How long to wait for `send_to_session`, which must exceed the server's own\n * ack window or the caller gives up while the callee is still working.\n *\n * AIBroker waits up to SEND_ACK_TIMEOUT_MS (15s) for the submit confirmation —\n * the text leaving the input line — before answering. The generic client\n * timeout is 8s. So every send needing 8-15s to confirm reported\n * \"AIBroker IPC call timed out\" while succeeding, and the handler's eventual\n * `delivered: true` was written into a socket nobody was reading any more.\n *\n * Measured on `pai pause all` across 15 sessions: 9 reported failed, 8 of them\n * verifiably paused. A caller's deadline shorter than the callee's is not a\n * tuning question, it is a guaranteed false negative on every slow success.\n */\nconst SEND_TIMEOUT_MS = 30_000;\n\n/**\n * Call an AIBroker IPC method and return the result.\n *\n * Resolves with the `result` field of a successful response.\n * Rejects if the socket is not available, the call times out, or the\n * daemon returns an error.\n *\n * @param method   IPC method name (e.g. \"session_content\", \"send_to_session\")\n * @param params   Method parameters object\n * @param timeoutMs  Connection + response timeout in milliseconds (default: 8 000)\n */\nexport function callAiBroker(\n  method: string,\n  params: Record<string, unknown> = {},\n  timeoutMs = 8_000\n): Promise<Record<string, unknown>> {\n  return new Promise((resolve, reject) => {\n    const socketPath = DEFAULT_SOCKET;\n    let done = false;\n    let buffer = \"\";\n    let timer: ReturnType<typeof setTimeout> | null = null;\n\n    function finish(err: Error | null, value?: Record<string, unknown>): void {\n      if (done) return;\n      done = true;\n      if (timer !== null) {\n        clearTimeout(timer);\n        timer = null;\n      }\n      try {\n        socket.destroy();\n      } catch {\n        /* ignore */\n      }\n      if (err) reject(err);\n      else resolve(value!);\n    }\n\n    const socket = connect(socketPath, () => {\n      const request = {\n        id: randomUUID(),\n        sessionId: process.env.TERM_SESSION_ID ?? \"pai-cli\",\n        method,\n        params,\n      };\n      const itermId = process.env.ITERM_SESSION_ID;\n      if (itermId) Object.assign(request, { itermSessionId: itermId });\n      socket.write(JSON.stringify(request) + \"\\n\");\n    });\n\n    socket.on(\"data\", (chunk: Buffer) => {\n      buffer += chunk.toString(\"utf8\");\n      const nl = buffer.indexOf(\"\\n\");\n      if (nl === -1) return;\n      const line = buffer.slice(0, nl);\n\n      let response: { ok: boolean; result?: Record<string, unknown>; error?: string };\n      try {\n        response = JSON.parse(line);\n      } catch {\n        finish(new Error(`AIBroker IPC parse error: ${line.slice(0, 120)}`));\n        return;\n      }\n\n      if (!response.ok) {\n        finish(new Error(response.error ?? \"AIBroker IPC call failed\"));\n      } else {\n        finish(null, response.result ?? {});\n      }\n    });\n\n    socket.on(\"error\", (e: NodeJS.ErrnoException) => {\n      if (e.code === \"ENOENT\" || e.code === \"ECONNREFUSED\") {\n        finish(new Error(\"AIBroker not running (socket not found).\"));\n      } else {\n        finish(e);\n      }\n    });\n\n    socket.on(\"end\", () => {\n      if (!done) finish(new Error(\"AIBroker IPC connection closed before response.\"));\n    });\n\n    timer = setTimeout(\n      () => finish(new Error(\"AIBroker IPC call timed out.\")),\n      timeoutMs\n    );\n  });\n}\n\n// ---------------------------------------------------------------------------\n// Typed helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Fetch all live iTerm2 session metadata from AIBroker via the `sessions` method.\n * Returns an empty array if AIBroker is not running.\n *\n * This is metadata-only (no scrollback). It is faster than `session_content`\n * and the correct source for listing/routing purposes.\n */\nexport async function fetchLiveSessions(): Promise<AiBrokerSessionMeta[]> {\n  try {\n    const result = await callAiBroker(\"sessions\", {});\n    const sessions = (result as unknown as AiBrokerSessionsResult).sessions;\n    if (!Array.isArray(sessions)) return [];\n    return sessions;\n  } catch {\n    return [];\n  }\n}\n\n/**\n * Fetch live sessions WITH the last user prompt extracted from terminal scrollback.\n *\n * Heavier than `fetchLiveSessions` (one IPC call returning full content for\n * every session), but yields a `lastPrompt` field useful for the unified\n * listing.\n */\nexport async function fetchLiveSessionsWithPrompts(): Promise<\n  (AiBrokerSessionMeta & { lastPrompt?: string })[]\n> {\n  // Get the basic metadata first (always works, fast).\n  const metas = await fetchLiveSessions();\n  if (metas.length === 0) return [];\n\n  // Then enrich with last prompts via session_content (single call, all sessions).\n  try {\n    const contentResult = (await callAiBroker(\"session_content\", { lines: 60 })) as {\n      sessions?: Array<{ sessionId: string; content?: string }>;\n    };\n    const contentMap = new Map<string, string>();\n    for (const s of contentResult.sessions ?? []) {\n      if (s.sessionId && typeof s.content === \"string\") {\n        contentMap.set(s.sessionId, s.content);\n      }\n    }\n    return metas.map((m) => {\n      const content = contentMap.get(m.sessionId);\n      const lastPrompt = content ? extractLastUserPrompt(content) : undefined;\n      return { ...m, lastPrompt };\n    });\n  } catch {\n    return metas.map((m) => ({ ...m }));\n  }\n}\n\n/**\n * Extract the most-recent user prompt from a terminal scrollback string.\n *\n * Claude Code's TUI shows user input lines prefixed with `❯ `. We scan from\n * the bottom up, skipping the active input box (between the two horizontal\n * rule lines) and the statusline footer.\n */\nfunction extractLastUserPrompt(content: string): string | undefined {\n  const lines = content.split(\"\\n\");\n  // Walk bottom-up looking for the most recent user-typed line.\n  // Claude Code marks user prompts with one of these prefixes:\n  //   ❯ <text>      (current/recent prompt in the input box or scrollback)\n  //   > <text>      (older variant)\n  // The line MUST have non-empty content after the prompt symbol.\n  // Skip the active input box (often empty `❯`) and statusline (👋 PAI CC...).\n  for (let i = lines.length - 1; i >= 0; i--) {\n    const line = lines[i].trim();\n    // Match ❯ or > (followed by space) then content\n    const m = line.match(/^[❯>]\\s+(.+?)\\s*$/);\n    if (!m) continue;\n    let text = m[1].trim();\n    if (!text) continue;\n    // Skip lines that are just box-drawing or known UI noise\n    if (/^[─━═]+/.test(text)) continue;\n    if (text.startsWith(\"👋\")) continue;\n    // Strip ANSI escape sequences that may leak in\n    text = text.replace(/\\x1B\\[[0-9;]*[A-Za-z]/g, \"\").trim();\n    if (text) return text;\n  }\n  return undefined;\n}\n\n/**\n * Send text to a specific AIBroker session by its iTerm2 sessionId.\n *\n * The wire keys are `target` and `message`, and neither is negotiable — the\n * handler rejects anything else with \"target is required\" before it looks at\n * the rest. This was written as `{ target: sessionId, message: text }`, which is the shape of\n * THIS function's own parameters rather than the shape of the IPC, so every\n * call failed identically. `pai pause all` was the only caller, so the live\n * path had never once executed: its --dry-run branch returns before sending,\n * and that was the only branch anyone had exercised.\n *\n * The caller must NOT terminate `text` with a newline. The transport sends with\n * `enter: true` and appends the Enter itself, so a trailing \\n submits twice —\n * once for the text and once for an empty prompt.\n */\nexport async function sendToSession(\n  sessionId: string,\n  text: string,\n  timeoutMs = SEND_TIMEOUT_MS\n): Promise<{ ok: boolean; error?: string; timedOut?: boolean }> {\n  try {\n    await callAiBroker(\"send_to_session\", { target: sessionId, message: text }, timeoutMs);\n    return { ok: true };\n  } catch (e) {\n    const error = String(e);\n    // A timeout is NOT a delivery failure, and conflating the two is expensive.\n    // The handler deposits into the target's mailbox BEFORE it waits for the\n    // submit ack, so a send that times out has still been delivered — only the\n    // confirmation is missing. Reported as a plain failure, the natural response\n    // is to send again, which is how `pai pause all` produced nested\n    // \"carried forward\" blocks in checkpoints that had already been written.\n    return { ok: false, error, timedOut: /timed out/i.test(error) };\n  }\n}\n\n/**\n * Switch iTerm2 focus to the session identified by `target`.\n *\n * `target` can be a sessionId, paiName, or tab index number (as string).\n * After switching, activates the iTerm2 application itself so the window\n * comes to the foreground.\n *\n * Returns { ok: true } if AIBroker confirmed the switch, or\n * { ok: false, error } if AIBroker is not running or the session was not found.\n */\nexport async function switchToSession(target: string): Promise<{ ok: boolean; error?: string }> {\n  try {\n    await callAiBroker(\"switch\", { target });\n    // Bring iTerm2 itself to the foreground (the IPC only selects the tab)\n    const { spawnSync } = await import(\"node:child_process\");\n    spawnSync(\"osascript\", [\"-e\", 'tell application \"iTerm\" to activate'], {\n      stdio: \"ignore\",\n    });\n    return { ok: true };\n  } catch (e) {\n    return { ok: false, error: String(e) };\n  }\n}\n\n/**\n * Bring the iTerm2 tab containing a specific session to the front.\n *\n * This is the mechanism AIBroker's screenshot path uses: match the session by\n * its iTerm2 session id (the `sessionId` returned by `fetchLiveSessions`, which\n * is iTerm's own `id of session`), then `select` its window, tab, and session.\n * Unlike the `switch` IPC (which only flips an internal index and never touches\n * iTerm), this actually reveals the tab.\n */\nexport function revealItermSession(itermSessionId: string): { ok: boolean; error?: string } {\n  // Strip any \"iterm:\" style prefix, matching AIBroker's stripItermPrefix.\n  const id = itermSessionId.replace(/^iterm:/i, \"\").trim();\n  const script = `tell application \"iTerm2\"\n  activate\n  repeat with w in windows\n    repeat with t in tabs of w\n      repeat with s in sessions of t\n        if id of s is \"${id}\" then\n          select w\n          select t\n          select s\n          return \"ok\"\n        end if\n      end repeat\n    end repeat\n  end repeat\n  return \"not-found\"\nend tell`;\n  try {\n    const r = spawnSync(\"osascript\", [\"-e\", script], { encoding: \"utf8\" });\n    if (r.status !== 0) {\n      return { ok: false, error: (r.stderr || \"osascript failed\").trim() };\n    }\n    if ((r.stdout ?? \"\").trim() === \"ok\") return { ok: true };\n    return { ok: false, error: \"session not found in any iTerm2 window\" };\n  } catch (e) {\n    return { ok: false, error: String(e) };\n  }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAmDA,MAAM,iBAAiB,QAAQ,IAAI,mBAAmB,oBAAoB;;;;;;;;;;;;;;;AAgB1E,MAAM,kBAAkB;;;;;;;;;;;;AAaxB,SAAgB,aACd,QACA,SAAkC,EAAE,EACpC,YAAY,KACsB;AAClC,QAAO,IAAI,SAAS,SAAS,WAAW;EACtC,MAAM,aAAa;EACnB,IAAI,OAAO;EACX,IAAI,SAAS;EACb,IAAI,QAA8C;EAElD,SAAS,OAAO,KAAmB,OAAuC;AACxE,OAAI,KAAM;AACV,UAAO;AACP,OAAI,UAAU,MAAM;AAClB,iBAAa,MAAM;AACnB,YAAQ;;AAEV,OAAI;AACF,WAAO,SAAS;WACV;AAGR,OAAI,IAAK,QAAO,IAAI;OACf,SAAQ,MAAO;;EAGtB,MAAM,SAAS,QAAQ,kBAAkB;GACvC,MAAM,UAAU;IACd,IAAI,YAAY;IAChB,WAAW,QAAQ,IAAI,mBAAmB;IAC1C;IACA;IACD;GACD,MAAM,UAAU,QAAQ,IAAI;AAC5B,OAAI,QAAS,QAAO,OAAO,SAAS,EAAE,gBAAgB,SAAS,CAAC;AAChE,UAAO,MAAM,KAAK,UAAU,QAAQ,GAAG,KAAK;IAC5C;AAEF,SAAO,GAAG,SAAS,UAAkB;AACnC,aAAU,MAAM,SAAS,OAAO;GAChC,MAAM,KAAK,OAAO,QAAQ,KAAK;AAC/B,OAAI,OAAO,GAAI;GACf,MAAM,OAAO,OAAO,MAAM,GAAG,GAAG;GAEhC,IAAI;AACJ,OAAI;AACF,eAAW,KAAK,MAAM,KAAK;WACrB;AACN,2BAAO,IAAI,MAAM,6BAA6B,KAAK,MAAM,GAAG,IAAI,GAAG,CAAC;AACpE;;AAGF,OAAI,CAAC,SAAS,GACZ,QAAO,IAAI,MAAM,SAAS,SAAS,2BAA2B,CAAC;OAE/D,QAAO,MAAM,SAAS,UAAU,EAAE,CAAC;IAErC;AAEF,SAAO,GAAG,UAAU,MAA6B;AAC/C,OAAI,EAAE,SAAS,YAAY,EAAE,SAAS,eACpC,wBAAO,IAAI,MAAM,2CAA2C,CAAC;OAE7D,QAAO,EAAE;IAEX;AAEF,SAAO,GAAG,aAAa;AACrB,OAAI,CAAC,KAAM,wBAAO,IAAI,MAAM,kDAAkD,CAAC;IAC/E;AAEF,UAAQ,iBACA,uBAAO,IAAI,MAAM,+BAA+B,CAAC,EACvD,UACD;GACD;;;;;;;;;AAcJ,eAAsB,oBAAoD;AACxE,KAAI;EAEF,MAAM,YADS,MAAM,aAAa,YAAY,EAAE,CAAC,EACc;AAC/D,MAAI,CAAC,MAAM,QAAQ,SAAS,CAAE,QAAO,EAAE;AACvC,SAAO;SACD;AACN,SAAO,EAAE;;;;;;;;;;;;;;;;;;AAsFb,eAAsB,cACpB,WACA,MACA,YAAY,iBACkD;AAC9D,KAAI;AACF,QAAM,aAAa,mBAAmB;GAAE,QAAQ;GAAW,SAAS;GAAM,EAAE,UAAU;AACtF,SAAO,EAAE,IAAI,MAAM;UACZ,GAAG;EACV,MAAM,QAAQ,OAAO,EAAE;AAOvB,SAAO;GAAE,IAAI;GAAO;GAAO,UAAU,aAAa,KAAK,MAAM;GAAE;;;;;;;;;;;;;AAcnE,eAAsB,gBAAgB,QAA0D;AAC9F,KAAI;AACF,QAAM,aAAa,UAAU,EAAE,QAAQ,CAAC;EAExC,MAAM,EAAE,cAAc,MAAM,OAAO;AACnC,YAAU,aAAa,CAAC,MAAM,yCAAuC,EAAE,EACrE,OAAO,UACR,CAAC;AACF,SAAO,EAAE,IAAI,MAAM;UACZ,GAAG;AACV,SAAO;GAAE,IAAI;GAAO,OAAO,OAAO,EAAE;GAAE;;;;;;;;;;;;AAa1C,SAAgB,mBAAmB,gBAAyD;CAG1F,MAAM,SAAS;;;;;yBADJ,eAAe,QAAQ,YAAY,GAAG,CAAC,MAAM,CAM9B;;;;;;;;;;;AAW1B,KAAI;EACF,MAAM,IAAI,UAAU,aAAa,CAAC,MAAM,OAAO,EAAE,EAAE,UAAU,QAAQ,CAAC;AACtE,MAAI,EAAE,WAAW,EACf,QAAO;GAAE,IAAI;GAAO,QAAQ,EAAE,UAAU,oBAAoB,MAAM;GAAE;AAEtE,OAAK,EAAE,UAAU,IAAI,MAAM,KAAK,KAAM,QAAO,EAAE,IAAI,MAAM;AACzD,SAAO;GAAE,IAAI;GAAO,OAAO;GAA0C;UAC9D,GAAG;AACV,SAAO;GAAE,IAAI;GAAO,OAAO,OAAO,EAAE;GAAE"}