@tekmidian/pai 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +1 -0
  2. package/dist/aibroker-client-BP74gGwD.mjs +215 -0
  3. package/dist/aibroker-client-BP74gGwD.mjs.map +1 -0
  4. package/dist/aibroker-client-C6m71p6O.mjs +3 -0
  5. package/dist/{auto-route-BwzmlW3W.mjs → auto-route-_5zzJz5q.mjs} +2 -2
  6. package/dist/{auto-route-BwzmlW3W.mjs.map → auto-route-_5zzJz5q.mjs.map} +1 -1
  7. package/dist/browser-mcp/index.d.mts +1 -0
  8. package/dist/browser-mcp/index.mjs +352 -0
  9. package/dist/browser-mcp/index.mjs.map +1 -0
  10. package/dist/{chain-DhVHVnmT.mjs → chain-DGXNoTwc.mjs} +93 -320
  11. package/dist/chain-DGXNoTwc.mjs.map +1 -0
  12. package/dist/cli/index.mjs +12 -10
  13. package/dist/cli/index.mjs.map +1 -1
  14. package/dist/cli/program.mjs +12 -10
  15. package/dist/{clusters-DRHhfli9.mjs → clusters-j0ZuNYp9.mjs} +1 -1
  16. package/dist/{clusters-DRHhfli9.mjs.map → clusters-j0ZuNYp9.mjs.map} +1 -1
  17. package/dist/{config-D1G9IFpn.mjs → config-B64vFg14.mjs} +3 -50
  18. package/dist/config-B64vFg14.mjs.map +1 -0
  19. package/dist/{config-DD52BPB-.mjs → config-C_ErGddD.mjs} +1 -1
  20. package/dist/{config-Dl8lT4Lu.mjs → config-DgzR5sfb.mjs} +2 -2
  21. package/dist/{config-Dl8lT4Lu.mjs.map → config-DgzR5sfb.mjs.map} +1 -1
  22. package/dist/{context-handover-cache-C6_rs7JT.mjs → context-handover-cache-dUnSZwaA.mjs} +4 -4
  23. package/dist/{context-handover-cache-C6_rs7JT.mjs.map → context-handover-cache-dUnSZwaA.mjs.map} +1 -1
  24. package/dist/daemon/index.mjs +13 -12
  25. package/dist/daemon/index.mjs.map +1 -1
  26. package/dist/daemon-CKBkhOsZ.mjs +23 -0
  27. package/dist/{daemon-CpdVUnl_.mjs → daemon-DsvP8KMy.mjs} +357 -27
  28. package/dist/daemon-DsvP8KMy.mjs.map +1 -0
  29. package/dist/daemon-mcp/index.mjs +20 -6
  30. package/dist/daemon-mcp/index.mjs.map +1 -1
  31. package/dist/detector-C-7KRi42.mjs +5 -0
  32. package/dist/{detector-smpCm7ku.mjs → detector-C73Nxm94.mjs} +1 -1
  33. package/dist/{detector-smpCm7ku.mjs.map → detector-C73Nxm94.mjs.map} +1 -1
  34. package/dist/{factory-Bc5EyN54.mjs → factory-DEb9NmHJ.mjs} +7 -7
  35. package/dist/{factory-Bc5EyN54.mjs.map → factory-DEb9NmHJ.mjs.map} +1 -1
  36. package/dist/factory-Dbo8yNPi.mjs +3 -0
  37. package/dist/{fallback-CdXv-Np0.mjs → fallback-5UJO-RyX.mjs} +5 -4
  38. package/dist/{fallback-CdXv-Np0.mjs.map → fallback-5UJO-RyX.mjs.map} +1 -1
  39. package/dist/hooks/worker-status-line.mjs +9 -4
  40. package/dist/hooks/worker-status-line.mjs.map +3 -3
  41. package/dist/hooks/worker-supervision.mjs +593 -0
  42. package/dist/hooks/worker-supervision.mjs.map +7 -0
  43. package/dist/{indexer-backend-rohVVKrA.mjs → indexer-backend-BPTzKFZU.mjs} +1 -1
  44. package/dist/{indexer-backend-rohVVKrA.mjs.map → indexer-backend-BPTzKFZU.mjs.map} +1 -1
  45. package/dist/{ipc-client-C7AVpsyv.mjs → ipc-client-BmypMNYk.mjs} +2 -2
  46. package/dist/{ipc-client-C7AVpsyv.mjs.map → ipc-client-BmypMNYk.mjs.map} +1 -1
  47. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  48. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  49. package/dist/{latent-ideas-iNp5s55R.mjs → latent-ideas-CtSca4qG.mjs} +2 -2
  50. package/dist/{latent-ideas-iNp5s55R.mjs.map → latent-ideas-CtSca4qG.mjs.map} +1 -1
  51. package/dist/{link-boost-NNzADHX_.mjs → link-boost-Bucnz3n8.mjs} +1 -1
  52. package/dist/{link-boost-NNzADHX_.mjs.map → link-boost-Bucnz3n8.mjs.map} +1 -1
  53. package/dist/{main-resolver-CM1IHbuu.mjs → main-resolver-BgIrC-D9.mjs} +4 -214
  54. package/dist/main-resolver-BgIrC-D9.mjs.map +1 -0
  55. package/dist/main-resolver-DXGuygFG.mjs +7 -0
  56. package/dist/{neighborhood-AGL7oXQ2.mjs → neighborhood-CM7Jgb51.mjs} +1 -1
  57. package/dist/{neighborhood-AGL7oXQ2.mjs.map → neighborhood-CM7Jgb51.mjs.map} +1 -1
  58. package/dist/{note-context-BHwYpSFf.mjs → note-context-DCwE2A-M.mjs} +1 -1
  59. package/dist/{note-context-BHwYpSFf.mjs.map → note-context-DCwE2A-M.mjs.map} +1 -1
  60. package/dist/{planner-Cm3g6fWH.mjs → planner-BLDJt3ZS.mjs} +6 -5
  61. package/dist/{planner-Cm3g6fWH.mjs.map → planner-BLDJt3ZS.mjs.map} +1 -1
  62. package/dist/{postgres-Dwxe290H.mjs → postgres-CtOzfoQU.mjs} +1 -1
  63. package/dist/{postgres-Dwxe290H.mjs.map → postgres-CtOzfoQU.mjs.map} +1 -1
  64. package/dist/{program-AR0qlYRg.mjs → program-CruRnLtc.mjs} +99 -84
  65. package/dist/program-CruRnLtc.mjs.map +1 -0
  66. package/dist/query-feedback-DGoVRaWD.mjs +3 -0
  67. package/dist/{query-feedback-DZfjI34w.mjs → query-feedback-Dqz-HC__.mjs} +1 -1
  68. package/dist/{query-feedback-DZfjI34w.mjs.map → query-feedback-Dqz-HC__.mjs.map} +1 -1
  69. package/dist/router-BTIGZR2_.mjs +3 -0
  70. package/dist/{router-DHracTUC.mjs → router-DD6D4j31.mjs} +1 -1
  71. package/dist/{router-DHracTUC.mjs.map → router-DD6D4j31.mjs.map} +1 -1
  72. package/dist/runtime-paths-rni52zHX.mjs +50 -0
  73. package/dist/runtime-paths-rni52zHX.mjs.map +1 -0
  74. package/dist/scope-NIwCGxRI.mjs +351 -0
  75. package/dist/scope-NIwCGxRI.mjs.map +1 -0
  76. package/dist/{server-BOuAOj9b.mjs → server-Cp7tNzNC.mjs} +19 -2
  77. package/dist/server-Cp7tNzNC.mjs.map +1 -0
  78. package/dist/skills/Worker/SKILL.md +13 -0
  79. package/dist/{sources-ju6AA7lL.mjs → sources-CS43CpuS.mjs} +1 -1
  80. package/dist/{sources-ju6AA7lL.mjs.map → sources-CS43CpuS.mjs.map} +1 -1
  81. package/dist/{sqlite-kFZeWCJ2.mjs → sqlite-Cq3gJcq9.mjs} +1 -1
  82. package/dist/{sqlite-kFZeWCJ2.mjs.map → sqlite-Cq3gJcq9.mjs.map} +1 -1
  83. package/dist/{state-ahFAbD8b.mjs → state-BTa10SOM.mjs} +1 -1
  84. package/dist/{state-ahFAbD8b.mjs.map → state-BTa10SOM.mjs.map} +1 -1
  85. package/dist/{state-DH_mp5Xe.mjs → state-RXepvbnO.mjs} +1 -1
  86. package/dist/{themes-DqhNUUQk.mjs → themes-B7h9YDiy.mjs} +1 -1
  87. package/dist/{themes-DqhNUUQk.mjs.map → themes-B7h9YDiy.mjs.map} +1 -1
  88. package/dist/{tools-COna9vZR.mjs → tools-BW3NLMPk.mjs} +1 -1
  89. package/dist/{tools-CMlHa9lZ.mjs → tools-LlzcV_eK.mjs} +14 -14
  90. package/dist/{tools-CMlHa9lZ.mjs.map → tools-LlzcV_eK.mjs.map} +1 -1
  91. package/dist/{trace-DTP6k4B_.mjs → trace-u4hT-pLD.mjs} +1 -1
  92. package/dist/{trace-DTP6k4B_.mjs.map → trace-u4hT-pLD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-YMH22mLU.mjs → vault-indexer-mata9S7b.mjs} +1 -1
  94. package/dist/{vault-indexer-YMH22mLU.mjs.map → vault-indexer-mata9S7b.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B3nl8yyn.mjs → work-queue-worker-7Vkli4Yh.mjs} +4 -4
  96. package/dist/{work-queue-worker-B3nl8yyn.mjs.map → work-queue-worker-7Vkli4Yh.mjs.map} +1 -1
  97. package/dist/work-queue-worker-D1p2V-ae.mjs +14 -0
  98. package/dist/{zettelkasten-DDvKRm2V.mjs → zettelkasten-F0D_XfQ4.mjs} +3 -3
  99. package/dist/{zettelkasten-DDvKRm2V.mjs.map → zettelkasten-F0D_XfQ4.mjs.map} +1 -1
  100. package/docs/commands/README.md +2 -2
  101. package/docs/commands/mcp.md +4 -4
  102. package/docs/commands/worker.md +3 -0
  103. package/docs/worker.md +40 -0
  104. package/package.json +1 -1
  105. package/plugins/productivity/hooks/hooks.json +5 -0
  106. package/src/hooks/ts/user-prompt/worker-supervision.ts +123 -0
  107. package/statusline-command.sh +65 -73
  108. package/dist/chain-DhVHVnmT.mjs.map +0 -1
  109. package/dist/config-D1G9IFpn.mjs.map +0 -1
  110. package/dist/daemon-BSL9sXxm.mjs +0 -22
  111. package/dist/daemon-CpdVUnl_.mjs.map +0 -1
  112. package/dist/detector-BLVOCiJ_.mjs +0 -5
  113. package/dist/factory-C7ua_3ik.mjs +0 -3
  114. package/dist/main-resolver-CM1IHbuu.mjs.map +0 -1
  115. package/dist/main-resolver-E5WlilhM.mjs +0 -6
  116. package/dist/program-AR0qlYRg.mjs.map +0 -1
  117. package/dist/query-feedback-CGcEiGtY.mjs +0 -3
  118. package/dist/router-CAtyVQud.mjs +0 -3
  119. package/dist/server-BOuAOj9b.mjs.map +0 -1
  120. package/dist/work-queue-worker-BM1GcAAq.mjs +0 -14
package/README.md CHANGED
@@ -112,6 +112,7 @@ pai worker replay <id> # transcript of a finished or run
112
112
  pai worker say <id> <text> # message a running worker mid-run
113
113
  pai worker handoff '<json>' # from inside a worker: report to the parent
114
114
  pai worker merge <id> # merge the worker's branch back, drop the worktree
115
+ pai worker wait <id>... # block until workers finish (never sleep-loop)
115
116
  pai worker watch # ps refreshed every 2 seconds
116
117
  ```
117
118
 
@@ -0,0 +1,215 @@
1
+ import { t as aibrokerSocketPath } from "./runtime-paths-rni52zHX.mjs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { connect } from "node:net";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ //#region src/cli/lib/aibroker-client.ts
7
+ /**
8
+ * aibroker-client.ts — Lightweight IPC client for AIBroker daemon.
9
+ *
10
+ * Connects to the AIBroker Unix Domain Socket, sends a JSON-RPC request,
11
+ * and reads a single newline-terminated JSON response. No class needed —
12
+ * just a thin async function matching the WatcherClient protocol.
13
+ *
14
+ * Socket path: /tmp/aibroker.sock (default; override via AIBROKER_SOCKET env).
15
+ */
16
+ const DEFAULT_SOCKET = process.env.AIBROKER_SOCKET ?? aibrokerSocketPath();
17
+ /**
18
+ * How long to wait for `send_to_session`, which must exceed the server's own
19
+ * ack window or the caller gives up while the callee is still working.
20
+ *
21
+ * AIBroker waits up to SEND_ACK_TIMEOUT_MS (15s) for the submit confirmation —
22
+ * the text leaving the input line — before answering. The generic client
23
+ * timeout is 8s. So every send needing 8-15s to confirm reported
24
+ * "AIBroker IPC call timed out" while succeeding, and the handler's eventual
25
+ * `delivered: true` was written into a socket nobody was reading any more.
26
+ *
27
+ * Measured on `pai pause all` across 15 sessions: 9 reported failed, 8 of them
28
+ * verifiably paused. A caller's deadline shorter than the callee's is not a
29
+ * tuning question, it is a guaranteed false negative on every slow success.
30
+ */
31
+ const SEND_TIMEOUT_MS = 3e4;
32
+ /**
33
+ * Call an AIBroker IPC method and return the result.
34
+ *
35
+ * Resolves with the `result` field of a successful response.
36
+ * Rejects if the socket is not available, the call times out, or the
37
+ * daemon returns an error.
38
+ *
39
+ * @param method IPC method name (e.g. "session_content", "send_to_session")
40
+ * @param params Method parameters object
41
+ * @param timeoutMs Connection + response timeout in milliseconds (default: 8 000)
42
+ */
43
+ function callAiBroker(method, params = {}, timeoutMs = 8e3) {
44
+ return new Promise((resolve, reject) => {
45
+ const socketPath = DEFAULT_SOCKET;
46
+ let done = false;
47
+ let buffer = "";
48
+ let timer = null;
49
+ function finish(err, value) {
50
+ if (done) return;
51
+ done = true;
52
+ if (timer !== null) {
53
+ clearTimeout(timer);
54
+ timer = null;
55
+ }
56
+ try {
57
+ socket.destroy();
58
+ } catch {}
59
+ if (err) reject(err);
60
+ else resolve(value);
61
+ }
62
+ const socket = connect(socketPath, () => {
63
+ const request = {
64
+ id: randomUUID(),
65
+ sessionId: process.env.TERM_SESSION_ID ?? "pai-cli",
66
+ method,
67
+ params
68
+ };
69
+ const itermId = process.env.ITERM_SESSION_ID;
70
+ if (itermId) Object.assign(request, { itermSessionId: itermId });
71
+ socket.write(JSON.stringify(request) + "\n");
72
+ });
73
+ socket.on("data", (chunk) => {
74
+ buffer += chunk.toString("utf8");
75
+ const nl = buffer.indexOf("\n");
76
+ if (nl === -1) return;
77
+ const line = buffer.slice(0, nl);
78
+ let response;
79
+ try {
80
+ response = JSON.parse(line);
81
+ } catch {
82
+ finish(/* @__PURE__ */ new Error(`AIBroker IPC parse error: ${line.slice(0, 120)}`));
83
+ return;
84
+ }
85
+ if (!response.ok) finish(new Error(response.error ?? "AIBroker IPC call failed"));
86
+ else finish(null, response.result ?? {});
87
+ });
88
+ socket.on("error", (e) => {
89
+ if (e.code === "ENOENT" || e.code === "ECONNREFUSED") finish(/* @__PURE__ */ new Error("AIBroker not running (socket not found)."));
90
+ else finish(e);
91
+ });
92
+ socket.on("end", () => {
93
+ if (!done) finish(/* @__PURE__ */ new Error("AIBroker IPC connection closed before response."));
94
+ });
95
+ timer = setTimeout(() => finish(/* @__PURE__ */ new Error("AIBroker IPC call timed out.")), timeoutMs);
96
+ });
97
+ }
98
+ /**
99
+ * Fetch all live iTerm2 session metadata from AIBroker via the `sessions` method.
100
+ * Returns an empty array if AIBroker is not running.
101
+ *
102
+ * This is metadata-only (no scrollback). It is faster than `session_content`
103
+ * and the correct source for listing/routing purposes.
104
+ */
105
+ async function fetchLiveSessions() {
106
+ try {
107
+ const sessions = (await callAiBroker("sessions", {})).sessions;
108
+ if (!Array.isArray(sessions)) return [];
109
+ return sessions;
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+ /**
115
+ * Send text to a specific AIBroker session by its iTerm2 sessionId.
116
+ *
117
+ * The wire keys are `target` and `message`, and neither is negotiable — the
118
+ * handler rejects anything else with "target is required" before it looks at
119
+ * the rest. This was written as `{ target: sessionId, message: text }`, which is the shape of
120
+ * THIS function's own parameters rather than the shape of the IPC, so every
121
+ * call failed identically. `pai pause all` was the only caller, so the live
122
+ * path had never once executed: its --dry-run branch returns before sending,
123
+ * and that was the only branch anyone had exercised.
124
+ *
125
+ * The caller must NOT terminate `text` with a newline. The transport sends with
126
+ * `enter: true` and appends the Enter itself, so a trailing \n submits twice —
127
+ * once for the text and once for an empty prompt.
128
+ */
129
+ async function sendToSession(sessionId, text, timeoutMs = SEND_TIMEOUT_MS) {
130
+ try {
131
+ await callAiBroker("send_to_session", {
132
+ target: sessionId,
133
+ message: text
134
+ }, timeoutMs);
135
+ return { ok: true };
136
+ } catch (e) {
137
+ const error = String(e);
138
+ return {
139
+ ok: false,
140
+ error,
141
+ timedOut: /timed out/i.test(error)
142
+ };
143
+ }
144
+ }
145
+ /**
146
+ * Switch iTerm2 focus to the session identified by `target`.
147
+ *
148
+ * `target` can be a sessionId, paiName, or tab index number (as string).
149
+ * After switching, activates the iTerm2 application itself so the window
150
+ * comes to the foreground.
151
+ *
152
+ * Returns { ok: true } if AIBroker confirmed the switch, or
153
+ * { ok: false, error } if AIBroker is not running or the session was not found.
154
+ */
155
+ async function switchToSession(target) {
156
+ try {
157
+ await callAiBroker("switch", { target });
158
+ const { spawnSync } = await import("node:child_process");
159
+ spawnSync("osascript", ["-e", "tell application \"iTerm\" to activate"], { stdio: "ignore" });
160
+ return { ok: true };
161
+ } catch (e) {
162
+ return {
163
+ ok: false,
164
+ error: String(e)
165
+ };
166
+ }
167
+ }
168
+ /**
169
+ * Bring the iTerm2 tab containing a specific session to the front.
170
+ *
171
+ * This is the mechanism AIBroker's screenshot path uses: match the session by
172
+ * its iTerm2 session id (the `sessionId` returned by `fetchLiveSessions`, which
173
+ * is iTerm's own `id of session`), then `select` its window, tab, and session.
174
+ * Unlike the `switch` IPC (which only flips an internal index and never touches
175
+ * iTerm), this actually reveals the tab.
176
+ */
177
+ function revealItermSession(itermSessionId) {
178
+ const script = `tell application "iTerm2"
179
+ activate
180
+ repeat with w in windows
181
+ repeat with t in tabs of w
182
+ repeat with s in sessions of t
183
+ if id of s is "${itermSessionId.replace(/^iterm:/i, "").trim()}" then
184
+ select w
185
+ select t
186
+ select s
187
+ return "ok"
188
+ end if
189
+ end repeat
190
+ end repeat
191
+ end repeat
192
+ return "not-found"
193
+ end tell`;
194
+ try {
195
+ const r = spawnSync("osascript", ["-e", script], { encoding: "utf8" });
196
+ if (r.status !== 0) return {
197
+ ok: false,
198
+ error: (r.stderr || "osascript failed").trim()
199
+ };
200
+ if ((r.stdout ?? "").trim() === "ok") return { ok: true };
201
+ return {
202
+ ok: false,
203
+ error: "session not found in any iTerm2 window"
204
+ };
205
+ } catch (e) {
206
+ return {
207
+ ok: false,
208
+ error: String(e)
209
+ };
210
+ }
211
+ }
212
+
213
+ //#endregion
214
+ export { switchToSession as a, sendToSession as i, fetchLiveSessions as n, revealItermSession as r, callAiBroker as t };
215
+ //# sourceMappingURL=aibroker-client-BP74gGwD.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aibroker-client-BP74gGwD.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"}
@@ -0,0 +1,3 @@
1
+ import { a as switchToSession, i as sendToSession, n as fetchLiveSessions, r as revealItermSession, t as callAiBroker } from "./aibroker-client-BP74gGwD.mjs";
2
+
3
+ export { sendToSession };
@@ -26,7 +26,7 @@ async function autoRoute(registryDb, federation, cwd, context) {
26
26
  const markerResult = findMarkerUpward(registryDb, target);
27
27
  if (markerResult) return markerResult;
28
28
  if (context && context.trim().length > 0) {
29
- const { detectTopicShift } = await import("./detector-BLVOCiJ_.mjs");
29
+ const { detectTopicShift } = await import("./detector-C-7KRi42.mjs");
30
30
  const topicResult = await detectTopicShift(registryDb, federation, {
31
31
  context,
32
32
  threshold: .5
@@ -83,4 +83,4 @@ function formatAutoRouteJson(result) {
83
83
 
84
84
  //#endregion
85
85
  export { autoRoute, formatAutoRouteJson };
86
- //# sourceMappingURL=auto-route-BwzmlW3W.mjs.map
86
+ //# sourceMappingURL=auto-route-_5zzJz5q.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"auto-route-BwzmlW3W.mjs","names":[],"sources":["../src/session/auto-route.ts"],"sourcesContent":["/**\n * Auto-route: automatic project routing suggestion on session start.\n *\n * Given a working directory (and optional conversation context), determine\n * which registered project the session belongs to.\n *\n * Strategy (in priority order):\n * 1. Path match — exact or parent-directory match in the project registry\n * 2. Marker walk — walk up from cwd looking for Notes/PAI.md, resolve slug\n * 3. Topic match — BM25 keyword search against memory (requires context text)\n *\n * The function is stateless and works with direct DB access (no daemon\n * required), making it fast and safe to call during session startup.\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { resolve, dirname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { readPaiMarker } from \"../registry/pai-marker.js\";\nimport { detectProject } from \"../cli/commands/detect.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type AutoRouteMethod = \"path\" | \"marker\" | \"topic\";\n\nexport interface AutoRouteResult {\n /** Project slug */\n slug: string;\n /** Human-readable project name */\n display_name: string;\n /** Absolute path to the project root */\n root_path: string;\n /** How the project was detected */\n method: AutoRouteMethod;\n /** Confidence [0,1]: 1.0 for path/marker matches, BM25 fraction for topic */\n confidence: number;\n}\n\n// ---------------------------------------------------------------------------\n// Core function\n// ---------------------------------------------------------------------------\n\n/**\n * Determine which project a session should be routed to.\n *\n * @param registryDb Open PAI registry database\n * @param federation Memory storage backend (needed only for topic fallback)\n * @param cwd Working directory to detect from (defaults to process.cwd())\n * @param context Optional conversation text for topic-based fallback\n * @returns Best project match, or null if nothing matched\n */\nexport async function autoRoute(\n registryDb: Database,\n federation: Database | StorageBackend,\n cwd?: string,\n context?: string\n): Promise<AutoRouteResult | null> {\n const target = resolve(cwd ?? process.cwd());\n\n // -------------------------------------------------------------------------\n // Strategy 1: Path match via registry\n // -------------------------------------------------------------------------\n\n const pathMatch = detectProject(registryDb, target);\n\n if (pathMatch) {\n return {\n slug: pathMatch.slug,\n display_name: pathMatch.display_name,\n root_path: pathMatch.root_path,\n method: \"path\",\n confidence: 1.0,\n };\n }\n\n // -------------------------------------------------------------------------\n // Strategy 2: PAI.md marker file walk\n //\n // Walk up from cwd, checking <dir>/Notes/PAI.md at each level.\n // Once found, resolve the slug against the registry to get full project info.\n // -------------------------------------------------------------------------\n\n const markerResult = findMarkerUpward(registryDb, target);\n if (markerResult) {\n return markerResult;\n }\n\n // -------------------------------------------------------------------------\n // Strategy 3: Topic detection (requires context text)\n // -------------------------------------------------------------------------\n\n if (context && context.trim().length > 0) {\n // Lazy import to avoid bundler pulling in daemon/index.mjs at module load time\n const { detectTopicShift } = await import(\"../topics/detector.js\");\n const topicResult = await detectTopicShift(registryDb, federation, {\n context,\n threshold: 0.5, // Lower threshold for initial routing (vs shift detection)\n });\n\n if (topicResult.suggestedProject && topicResult.confidence > 0) {\n // Look up the full project info from the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(topicResult.suggestedProject) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"topic\",\n confidence: topicResult.confidence,\n };\n }\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Marker walk helper\n// ---------------------------------------------------------------------------\n\n/**\n * Walk up the directory tree from `startDir`, checking each level for a\n * `Notes/PAI.md` file. If found, read the slug and look up the project.\n *\n * Stops at the filesystem root or after 20 levels (safety guard).\n */\nfunction findMarkerUpward(\n registryDb: Database,\n startDir: string\n): AutoRouteResult | null {\n let current = startDir;\n let depth = 0;\n\n while (depth < 20) {\n const markerPath = `${current}/Notes/PAI.md`;\n\n if (existsSync(markerPath)) {\n const marker = readPaiMarker(current);\n\n if (marker && marker.status !== \"archived\") {\n // Resolve slug to full project info in the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(marker.slug) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"marker\",\n confidence: 1.0,\n };\n }\n }\n }\n\n const parent = dirname(current);\n if (parent === current) break; // Reached filesystem root\n current = parent;\n depth++;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format an AutoRouteResult as a human-readable string for CLI output.\n */\nexport function formatAutoRoute(result: AutoRouteResult): string {\n const lines: string[] = [\n `slug: ${result.slug}`,\n `display_name: ${result.display_name}`,\n `root_path: ${result.root_path}`,\n `method: ${result.method}`,\n `confidence: ${(result.confidence * 100).toFixed(0)}%`,\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * Format an AutoRouteResult as JSON for machine consumption.\n */\nexport function formatAutoRouteJson(result: AutoRouteResult): string {\n return JSON.stringify(result, null, 2);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAsDA,eAAsB,UACpB,YACA,YACA,KACA,SACiC;CACjC,MAAM,SAAS,QAAQ,OAAO,QAAQ,KAAK,CAAC;CAM5C,MAAM,YAAY,cAAc,YAAY,OAAO;AAEnD,KAAI,UACF,QAAO;EACL,MAAM,UAAU;EAChB,cAAc,UAAU;EACxB,WAAW,UAAU;EACrB,QAAQ;EACR,YAAY;EACb;CAUH,MAAM,eAAe,iBAAiB,YAAY,OAAO;AACzD,KAAI,aACF,QAAO;AAOT,KAAI,WAAW,QAAQ,MAAM,CAAC,SAAS,GAAG;EAExC,MAAM,EAAE,qBAAqB,MAAM,OAAO;EAC1C,MAAM,cAAc,MAAM,iBAAiB,YAAY,YAAY;GACjE;GACA,WAAW;GACZ,CAAC;AAEF,MAAI,YAAY,oBAAoB,YAAY,aAAa,GAAG;GAE9D,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,YAAY,iBAAiB;AAIpC,OAAI,WACF,QAAO;IACL,MAAM,WAAW;IACjB,cAAc,WAAW;IACzB,WAAW,WAAW;IACtB,QAAQ;IACR,YAAY,YAAY;IACzB;;;AAKP,QAAO;;;;;;;;AAaT,SAAS,iBACP,YACA,UACwB;CACxB,IAAI,UAAU;CACd,IAAI,QAAQ;AAEZ,QAAO,QAAQ,IAAI;AAGjB,MAAI,WAFe,GAAG,QAAQ,eAEJ,EAAE;GAC1B,MAAM,SAAS,cAAc,QAAQ;AAErC,OAAI,UAAU,OAAO,WAAW,YAAY;IAE1C,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,OAAO,KAAK;AAInB,QAAI,WACF,QAAO;KACL,MAAM,WAAW;KACjB,cAAc,WAAW;KACzB,WAAW,WAAW;KACtB,QAAQ;KACR,YAAY;KACb;;;EAKP,MAAM,SAAS,QAAQ,QAAQ;AAC/B,MAAI,WAAW,QAAS;AACxB,YAAU;AACV;;AAGF,QAAO;;;;;AAwBT,SAAgB,oBAAoB,QAAiC;AACnE,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE"}
1
+ {"version":3,"file":"auto-route-_5zzJz5q.mjs","names":[],"sources":["../src/session/auto-route.ts"],"sourcesContent":["/**\n * Auto-route: automatic project routing suggestion on session start.\n *\n * Given a working directory (and optional conversation context), determine\n * which registered project the session belongs to.\n *\n * Strategy (in priority order):\n * 1. Path match — exact or parent-directory match in the project registry\n * 2. Marker walk — walk up from cwd looking for Notes/PAI.md, resolve slug\n * 3. Topic match — BM25 keyword search against memory (requires context text)\n *\n * The function is stateless and works with direct DB access (no daemon\n * required), making it fast and safe to call during session startup.\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { resolve, dirname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { readPaiMarker } from \"../registry/pai-marker.js\";\nimport { detectProject } from \"../cli/commands/detect.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type AutoRouteMethod = \"path\" | \"marker\" | \"topic\";\n\nexport interface AutoRouteResult {\n /** Project slug */\n slug: string;\n /** Human-readable project name */\n display_name: string;\n /** Absolute path to the project root */\n root_path: string;\n /** How the project was detected */\n method: AutoRouteMethod;\n /** Confidence [0,1]: 1.0 for path/marker matches, BM25 fraction for topic */\n confidence: number;\n}\n\n// ---------------------------------------------------------------------------\n// Core function\n// ---------------------------------------------------------------------------\n\n/**\n * Determine which project a session should be routed to.\n *\n * @param registryDb Open PAI registry database\n * @param federation Memory storage backend (needed only for topic fallback)\n * @param cwd Working directory to detect from (defaults to process.cwd())\n * @param context Optional conversation text for topic-based fallback\n * @returns Best project match, or null if nothing matched\n */\nexport async function autoRoute(\n registryDb: Database,\n federation: Database | StorageBackend,\n cwd?: string,\n context?: string\n): Promise<AutoRouteResult | null> {\n const target = resolve(cwd ?? process.cwd());\n\n // -------------------------------------------------------------------------\n // Strategy 1: Path match via registry\n // -------------------------------------------------------------------------\n\n const pathMatch = detectProject(registryDb, target);\n\n if (pathMatch) {\n return {\n slug: pathMatch.slug,\n display_name: pathMatch.display_name,\n root_path: pathMatch.root_path,\n method: \"path\",\n confidence: 1.0,\n };\n }\n\n // -------------------------------------------------------------------------\n // Strategy 2: PAI.md marker file walk\n //\n // Walk up from cwd, checking <dir>/Notes/PAI.md at each level.\n // Once found, resolve the slug against the registry to get full project info.\n // -------------------------------------------------------------------------\n\n const markerResult = findMarkerUpward(registryDb, target);\n if (markerResult) {\n return markerResult;\n }\n\n // -------------------------------------------------------------------------\n // Strategy 3: Topic detection (requires context text)\n // -------------------------------------------------------------------------\n\n if (context && context.trim().length > 0) {\n // Lazy import to avoid bundler pulling in daemon/index.mjs at module load time\n const { detectTopicShift } = await import(\"../topics/detector.js\");\n const topicResult = await detectTopicShift(registryDb, federation, {\n context,\n threshold: 0.5, // Lower threshold for initial routing (vs shift detection)\n });\n\n if (topicResult.suggestedProject && topicResult.confidence > 0) {\n // Look up the full project info from the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(topicResult.suggestedProject) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"topic\",\n confidence: topicResult.confidence,\n };\n }\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Marker walk helper\n// ---------------------------------------------------------------------------\n\n/**\n * Walk up the directory tree from `startDir`, checking each level for a\n * `Notes/PAI.md` file. If found, read the slug and look up the project.\n *\n * Stops at the filesystem root or after 20 levels (safety guard).\n */\nfunction findMarkerUpward(\n registryDb: Database,\n startDir: string\n): AutoRouteResult | null {\n let current = startDir;\n let depth = 0;\n\n while (depth < 20) {\n const markerPath = `${current}/Notes/PAI.md`;\n\n if (existsSync(markerPath)) {\n const marker = readPaiMarker(current);\n\n if (marker && marker.status !== \"archived\") {\n // Resolve slug to full project info in the registry\n const projectRow = registryDb\n .prepare(\n \"SELECT slug, display_name, root_path FROM projects WHERE slug = ? AND status != 'archived'\"\n )\n .get(marker.slug) as\n | { slug: string; display_name: string; root_path: string }\n | undefined;\n\n if (projectRow) {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"marker\",\n confidence: 1.0,\n };\n }\n }\n }\n\n const parent = dirname(current);\n if (parent === current) break; // Reached filesystem root\n current = parent;\n depth++;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format an AutoRouteResult as a human-readable string for CLI output.\n */\nexport function formatAutoRoute(result: AutoRouteResult): string {\n const lines: string[] = [\n `slug: ${result.slug}`,\n `display_name: ${result.display_name}`,\n `root_path: ${result.root_path}`,\n `method: ${result.method}`,\n `confidence: ${(result.confidence * 100).toFixed(0)}%`,\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * Format an AutoRouteResult as JSON for machine consumption.\n */\nexport function formatAutoRouteJson(result: AutoRouteResult): string {\n return JSON.stringify(result, null, 2);\n}\n"],"mappings":";;;;;;;;;;;;;;;AAsDA,eAAsB,UACpB,YACA,YACA,KACA,SACiC;CACjC,MAAM,SAAS,QAAQ,OAAO,QAAQ,KAAK,CAAC;CAM5C,MAAM,YAAY,cAAc,YAAY,OAAO;AAEnD,KAAI,UACF,QAAO;EACL,MAAM,UAAU;EAChB,cAAc,UAAU;EACxB,WAAW,UAAU;EACrB,QAAQ;EACR,YAAY;EACb;CAUH,MAAM,eAAe,iBAAiB,YAAY,OAAO;AACzD,KAAI,aACF,QAAO;AAOT,KAAI,WAAW,QAAQ,MAAM,CAAC,SAAS,GAAG;EAExC,MAAM,EAAE,qBAAqB,MAAM,OAAO;EAC1C,MAAM,cAAc,MAAM,iBAAiB,YAAY,YAAY;GACjE;GACA,WAAW;GACZ,CAAC;AAEF,MAAI,YAAY,oBAAoB,YAAY,aAAa,GAAG;GAE9D,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,YAAY,iBAAiB;AAIpC,OAAI,WACF,QAAO;IACL,MAAM,WAAW;IACjB,cAAc,WAAW;IACzB,WAAW,WAAW;IACtB,QAAQ;IACR,YAAY,YAAY;IACzB;;;AAKP,QAAO;;;;;;;;AAaT,SAAS,iBACP,YACA,UACwB;CACxB,IAAI,UAAU;CACd,IAAI,QAAQ;AAEZ,QAAO,QAAQ,IAAI;AAGjB,MAAI,WAFe,GAAG,QAAQ,eAEJ,EAAE;GAC1B,MAAM,SAAS,cAAc,QAAQ;AAErC,OAAI,UAAU,OAAO,WAAW,YAAY;IAE1C,MAAM,aAAa,WAChB,QACC,6FACD,CACA,IAAI,OAAO,KAAK;AAInB,QAAI,WACF,QAAO;KACL,MAAM,WAAW;KACjB,cAAc,WAAW;KACzB,WAAW,WAAW;KACtB,QAAQ;KACR,YAAY;KACb;;;EAKP,MAAM,SAAS,QAAQ,QAAQ;AAC/B,MAAI,WAAW,QAAS;AACxB,YAAU;AACV;;AAGF,QAAO;;;;;AAwBT,SAAgB,oBAAoB,QAAiC;AACnE,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE"}
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,352 @@
1
+ #!/usr/bin/env node
2
+ import { i as number, o as string, r as boolean } from "../schemas-De1F-ktH.mjs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ //#region src/browser-mcp/bridge-client.ts
7
+ /**
8
+ * WebSocket client for the browser bridge.
9
+ *
10
+ * Talks to the native host's hand-rolled WS server (ws://127.0.0.1:8756) with
11
+ * the global WebSocket — node >= 22, no external dependency. Requests carry an
12
+ * id; replies are correlated by it. The socket is connected lazily per call
13
+ * and reconnected on demand, so the server survives Chrome being closed.
14
+ */
15
+ const BRIDGE_PORT = Number(process.env.PAI_BROWSER_BRIDGE_PORT || 8756);
16
+ const BRIDGE_URL = `ws://127.0.0.1:${BRIDGE_PORT}`;
17
+ const NOT_CONNECTED_MESSAGE = "Chrome bridge not connected — start Chrome (the PAI browser-bridge extension must be running) and try again.";
18
+ const globalWebSocketFactory = (url) => {
19
+ const WS = globalThis.WebSocket;
20
+ if (!WS) throw new Error("this node runtime has no global WebSocket — pai-browser-mcp needs node >= 22");
21
+ return new WS(url);
22
+ };
23
+ var NotConnectedError = class extends Error {
24
+ constructor() {
25
+ super(NOT_CONNECTED_MESSAGE);
26
+ this.name = "NotConnectedError";
27
+ }
28
+ };
29
+ const CONNECT_TIMEOUT_MS = 5e3;
30
+ const REQUEST_TIMEOUT_MS = 5e3;
31
+ const OPEN = 1;
32
+ var BridgeClient = class {
33
+ socket = null;
34
+ connecting = null;
35
+ nextId = 1;
36
+ pending = /* @__PURE__ */ new Map();
37
+ constructor(url = BRIDGE_URL, socketFactory = globalWebSocketFactory) {
38
+ this.url = url;
39
+ this.socketFactory = socketFactory;
40
+ }
41
+ /** True when the underlying socket is open. */
42
+ get connected() {
43
+ return this.socket !== null && this.socket.readyState === OPEN;
44
+ }
45
+ ensureSocket() {
46
+ if (this.socket && this.socket.readyState === OPEN) return Promise.resolve(this.socket);
47
+ if (this.connecting) return this.connecting;
48
+ this.dropSocket();
49
+ const attempt = new Promise((resolve, reject) => {
50
+ let socket;
51
+ try {
52
+ socket = this.socketFactory(this.url);
53
+ } catch (e) {
54
+ reject(e);
55
+ return;
56
+ }
57
+ const timer = setTimeout(() => {
58
+ reject(new NotConnectedError());
59
+ socket.onopen = null;
60
+ socket.onclose = null;
61
+ socket.onerror = null;
62
+ socket.onmessage = null;
63
+ try {
64
+ socket.close();
65
+ } catch {}
66
+ }, CONNECT_TIMEOUT_MS);
67
+ socket.onopen = () => {
68
+ clearTimeout(timer);
69
+ this.socket = socket;
70
+ resolve(socket);
71
+ };
72
+ socket.onclose = () => {
73
+ clearTimeout(timer);
74
+ this.dropSocket();
75
+ reject(new NotConnectedError());
76
+ };
77
+ socket.onerror = () => {
78
+ clearTimeout(timer);
79
+ this.dropSocket();
80
+ reject(new NotConnectedError());
81
+ };
82
+ socket.onmessage = (ev) => this.onMessage(ev);
83
+ });
84
+ this.connecting = attempt;
85
+ const clear = () => {
86
+ if (this.connecting === attempt) this.connecting = null;
87
+ };
88
+ attempt.then(clear, clear);
89
+ return attempt;
90
+ }
91
+ dropSocket() {
92
+ if (this.socket) {
93
+ this.socket.onopen = null;
94
+ this.socket.onclose = null;
95
+ this.socket.onerror = null;
96
+ this.socket.onmessage = null;
97
+ try {
98
+ this.socket.close();
99
+ } catch {}
100
+ this.socket = null;
101
+ }
102
+ for (const [, p] of this.pending) {
103
+ clearTimeout(p.timer);
104
+ p.reject(new NotConnectedError());
105
+ }
106
+ this.pending.clear();
107
+ }
108
+ onMessage(ev) {
109
+ let reply;
110
+ try {
111
+ reply = JSON.parse(ev.data);
112
+ } catch {
113
+ return;
114
+ }
115
+ const p = reply?.id !== void 0 ? this.pending.get(reply.id) : void 0;
116
+ if (!p) return;
117
+ this.pending.delete(reply.id);
118
+ clearTimeout(p.timer);
119
+ p.resolve(reply);
120
+ }
121
+ /**
122
+ * Sends one command and awaits its correlated reply. Connection problems
123
+ * and timeouts both surface as NotConnectedError with the user-facing text.
124
+ */
125
+ async send(command, params = {}) {
126
+ await this.ensureSocket();
127
+ const id = this.nextId++;
128
+ const frame = JSON.stringify({
129
+ id,
130
+ command,
131
+ ...params
132
+ });
133
+ return new Promise((resolve, reject) => {
134
+ const timer = setTimeout(() => {
135
+ this.pending.delete(id);
136
+ reject(new NotConnectedError());
137
+ }, REQUEST_TIMEOUT_MS);
138
+ this.pending.set(id, {
139
+ resolve: (reply) => reply.ok ? resolve(reply.result) : reject(new Error(reply.error ?? "bridge error")),
140
+ reject,
141
+ timer
142
+ });
143
+ try {
144
+ this.socket.send(frame);
145
+ } catch (e) {
146
+ this.pending.delete(id);
147
+ clearTimeout(timer);
148
+ reject(new NotConnectedError());
149
+ }
150
+ });
151
+ }
152
+ close() {
153
+ this.dropSocket();
154
+ }
155
+ };
156
+
157
+ //#endregion
158
+ //#region src/browser-mcp/tools.ts
159
+ const tabSchema = number().int().describe("Tab id, as returned by tabs_list.");
160
+ function asRecord(v) {
161
+ return v ?? {};
162
+ }
163
+ function formatTabs(result) {
164
+ const tabs = Array.isArray(result) ? result : asRecord(result).tabs;
165
+ if (!Array.isArray(tabs) || tabs.length === 0) return "(no open tabs)";
166
+ return tabs.map((t) => `${t.id}${t.active ? " *" : " "} ${String(t.title ?? "")}\n ${String(t.url ?? "")}`).join("\n");
167
+ }
168
+ function formatSnapshot(result) {
169
+ return String(asRecord(result).yaml ?? "");
170
+ }
171
+ function formatLogs(result) {
172
+ const entries = asRecord(result).entries;
173
+ if (!Array.isArray(entries) || entries.length === 0) return "(no console output captured)";
174
+ return entries.map((e) => {
175
+ return `[${e.source === "console" ? String(e.type ?? "log") : String(e.level ?? "log")}] ${String(e.text ?? "")}`;
176
+ }).join("\n");
177
+ }
178
+ function formatEval(result) {
179
+ const value = asRecord(result).value;
180
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
181
+ }
182
+ function formatScreenshot(result) {
183
+ return String(asRecord(result).base64 ?? "");
184
+ }
185
+ const BROWSER_TOOLS = [
186
+ {
187
+ name: "tabs_list",
188
+ description: "List the tabs of the user's real running Chrome (id, title, url, active).",
189
+ shape: {},
190
+ command: "list_tabs",
191
+ toParams: () => ({}),
192
+ format: formatTabs
193
+ },
194
+ {
195
+ name: "tab_open",
196
+ description: "Open a new tab in the user's Chrome and return its tab id.",
197
+ shape: {
198
+ url: string().describe("URL to open."),
199
+ active: boolean().optional().describe("Focus the new tab (default true).")
200
+ },
201
+ command: "open_tab",
202
+ toParams: (a) => ({
203
+ url: a.url,
204
+ ...a.active !== void 0 ? { active: a.active } : {}
205
+ })
206
+ },
207
+ {
208
+ name: "tab_select",
209
+ description: "Bring a tab to the front (activate it and focus its window).",
210
+ shape: { tab: tabSchema },
211
+ command: "select_tab",
212
+ toParams: (a) => ({ tabId: a.tab })
213
+ },
214
+ {
215
+ name: "tab_close",
216
+ description: "Close a tab.",
217
+ shape: { tab: tabSchema },
218
+ command: "close_tab",
219
+ toParams: (a) => ({ tabId: a.tab })
220
+ },
221
+ {
222
+ name: "dom_snapshot",
223
+ description: "Snapshot a tab's DOM as an accessibility-tree YAML with [ref=sN] ids for interactive elements. Take a snapshot before dom_click/dom_type; refs stay valid until the next snapshot or navigation.",
224
+ shape: { tab: tabSchema },
225
+ command: "snapshot",
226
+ toParams: (a) => ({ tabId: a.tab }),
227
+ format: formatSnapshot
228
+ },
229
+ {
230
+ name: "dom_click",
231
+ description: "Click an element by its [ref=sN] from the latest dom_snapshot (trusted CDP event).",
232
+ shape: {
233
+ tab: tabSchema,
234
+ ref: string().describe("Ref id from dom_snapshot, e.g. s3.")
235
+ },
236
+ command: "click",
237
+ toParams: (a) => ({
238
+ tabId: a.tab,
239
+ ref: a.ref
240
+ })
241
+ },
242
+ {
243
+ name: "dom_type",
244
+ description: "Focus an element by its [ref=sN] and type text into it (inserts at the caret, works in SPAs).",
245
+ shape: {
246
+ tab: tabSchema,
247
+ ref: string().describe("Ref id from dom_snapshot, e.g. s5."),
248
+ text: string().describe("Text to type.")
249
+ },
250
+ command: "type",
251
+ toParams: (a) => ({
252
+ tabId: a.tab,
253
+ ref: a.ref,
254
+ text: a.text
255
+ })
256
+ },
257
+ {
258
+ name: "page_text",
259
+ description: "Read a tab's visible text (document.body.innerText).",
260
+ shape: { tab: tabSchema },
261
+ command: "eval",
262
+ toParams: () => ({ code: "document.body.innerText" }),
263
+ format: formatEval
264
+ },
265
+ {
266
+ name: "eval_js",
267
+ description: "Evaluate JavaScript in a tab and return the JSON-safe result. Awaits promises.",
268
+ shape: {
269
+ tab: tabSchema,
270
+ code: string().describe("JavaScript expression to evaluate.")
271
+ },
272
+ command: "eval",
273
+ toParams: (a) => ({ code: a.code }),
274
+ format: formatEval
275
+ },
276
+ {
277
+ name: "tab_screenshot",
278
+ description: "Screenshot a tab (PNG, base64).",
279
+ shape: { tab: tabSchema },
280
+ command: "screenshot",
281
+ toParams: (a) => ({ tabId: a.tab }),
282
+ format: formatScreenshot
283
+ },
284
+ {
285
+ name: "console_logs",
286
+ description: "Read the console output captured for a tab since its last snapshot.",
287
+ shape: { tab: tabSchema },
288
+ command: "console_logs",
289
+ toParams: (a) => ({ tabId: a.tab }),
290
+ format: formatLogs
291
+ }
292
+ ];
293
+ /** Runs one tool against the bridge; returns MCP content + isError. */
294
+ async function runBrowserTool(def, args, bridge) {
295
+ try {
296
+ const result = await bridge.send(def.command, def.toParams(args));
297
+ return { content: [{
298
+ type: "text",
299
+ text: def.format ? def.format(result) : JSON.stringify(result, null, 2)
300
+ }] };
301
+ } catch (e) {
302
+ return {
303
+ content: [{
304
+ type: "text",
305
+ text: e instanceof NotConnectedError ? e.message : e instanceof Error ? e.message : String(e)
306
+ }],
307
+ isError: true
308
+ };
309
+ }
310
+ }
311
+ /** Registers every browser tool on an MCP server. */
312
+ function registerBrowserTools(server, bridge) {
313
+ for (const def of BROWSER_TOOLS) server.tool(def.name, def.description, def.shape, (args) => runBrowserTool(def, args, bridge));
314
+ }
315
+
316
+ //#endregion
317
+ //#region src/browser-mcp/index.ts
318
+ /**
319
+ * pai-browser-mcp — stdio MCP server for the PAI browser bridge.
320
+ *
321
+ * The provider-independent claude-in-chrome replacement: drives the user's
322
+ * real running Chrome (tabs + DOM) through the browser-bridge extension's
323
+ * native messaging host, over ws://127.0.0.1:8756. Any MCP client works —
324
+ * nothing here is Anthropic-specific.
325
+ *
326
+ * provider → MCP (this server) → WebSocket → native host → extension → Chrome
327
+ *
328
+ * If Chrome is not running (or the extension is not loaded), every tool
329
+ * returns a clear "start Chrome" error; the server itself stays alive and
330
+ * reconnects lazily per call.
331
+ */
332
+ async function main() {
333
+ if (globalThis.WebSocket === void 0) {
334
+ process.stderr.write("pai-browser-mcp: this node runtime has no global WebSocket — run with node >= 22\n");
335
+ process.exit(1);
336
+ }
337
+ const server = new McpServer({
338
+ name: "pai-browser",
339
+ version: "0.1.0"
340
+ });
341
+ registerBrowserTools(server, new BridgeClient(void 0, globalWebSocketFactory));
342
+ const transport = new StdioServerTransport();
343
+ await server.connect(transport);
344
+ }
345
+ main().catch((e) => {
346
+ process.stderr.write(`pai-browser-mcp fatal error: ${String(e)}\n`);
347
+ process.exit(1);
348
+ });
349
+
350
+ //#endregion
351
+ export { };
352
+ //# sourceMappingURL=index.mjs.map