@dna113p/machines 0.1.0 → 0.3.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 (82) hide show
  1. package/README.md +120 -4
  2. package/claude/statusline.mjs +244 -0
  3. package/dist/examples/agy.d.ts +1 -0
  4. package/dist/examples/agy.js +30 -0
  5. package/dist/examples/chatgpt-web.d.ts +1 -0
  6. package/dist/examples/chatgpt-web.js +36 -0
  7. package/dist/examples/claude.d.ts +1 -0
  8. package/dist/examples/claude.js +30 -0
  9. package/dist/examples/codex.d.ts +1 -0
  10. package/dist/examples/codex.js +30 -0
  11. package/dist/examples/decision.d.ts +1 -0
  12. package/dist/examples/decision.js +66 -0
  13. package/dist/examples/deepseek.d.ts +1 -0
  14. package/dist/examples/deepseek.js +27 -0
  15. package/dist/mcp/server.d.ts +1 -0
  16. package/dist/mcp/server.js +72 -10
  17. package/dist/pi-extension/index.js +16 -9
  18. package/dist/src/acp.js +4 -41
  19. package/dist/src/agent-bindings.d.ts +6 -0
  20. package/dist/src/agent-bindings.js +35 -5
  21. package/dist/src/agent-limit.d.ts +25 -0
  22. package/dist/src/agent-limit.js +34 -0
  23. package/dist/src/agent-limits.d.ts +37 -0
  24. package/dist/src/agent-limits.js +212 -0
  25. package/dist/src/agent-protocol.d.ts +6 -0
  26. package/dist/src/agent-protocol.js +40 -0
  27. package/dist/src/agy.d.ts +11 -0
  28. package/dist/src/agy.js +168 -0
  29. package/dist/src/catalog.d.ts +3 -0
  30. package/dist/src/catalog.js +94 -12
  31. package/dist/src/chatgpt-web.d.ts +143 -0
  32. package/dist/src/chatgpt-web.js +946 -0
  33. package/dist/src/claude.d.ts +35 -0
  34. package/dist/src/claude.js +294 -0
  35. package/dist/src/cli.js +194 -16
  36. package/dist/src/codex.d.ts +16 -0
  37. package/dist/src/codex.js +176 -0
  38. package/dist/src/conversation.d.ts +74 -0
  39. package/dist/src/conversation.js +46 -0
  40. package/dist/src/coordinator-guidance.d.ts +3 -0
  41. package/dist/src/coordinator-guidance.js +17 -0
  42. package/dist/src/decision.d.ts +29 -0
  43. package/dist/src/decision.js +67 -0
  44. package/dist/src/deepseek.d.ts +12 -0
  45. package/dist/src/deepseek.js +16 -0
  46. package/dist/src/host.d.ts +3 -0
  47. package/dist/src/host.js +68 -7
  48. package/dist/src/index.d.ts +39 -2
  49. package/dist/src/index.js +62 -5
  50. package/dist/src/jev.d.ts +15 -0
  51. package/dist/src/jev.js +90 -0
  52. package/dist/src/json.d.ts +6 -0
  53. package/dist/src/json.js +28 -0
  54. package/dist/src/launcher.d.ts +11 -1
  55. package/dist/src/launcher.js +72 -3
  56. package/dist/src/laya.d.ts +15 -0
  57. package/dist/src/laya.js +13 -0
  58. package/dist/src/openrouter.d.ts +16 -0
  59. package/dist/src/openrouter.js +97 -0
  60. package/dist/src/run-inbox.d.ts +49 -0
  61. package/dist/src/run-inbox.js +229 -0
  62. package/dist/src/run-status.d.ts +97 -0
  63. package/dist/src/run-status.js +285 -0
  64. package/dist/src/session.d.ts +13 -0
  65. package/dist/src/session.js +41 -3
  66. package/dist/src/system-one-http.d.ts +25 -0
  67. package/dist/src/system-one-http.js +146 -0
  68. package/dist/src/terminal-human.d.ts +8 -1
  69. package/dist/src/terminal-human.js +36 -8
  70. package/dist/src/von.d.ts +15 -0
  71. package/dist/src/von.js +13 -0
  72. package/docs/architecture.md +69 -2
  73. package/docs/authoring.md +849 -2
  74. package/docs/integrations.md +243 -3
  75. package/docs/local-decisions.md +212 -0
  76. package/package.json +23 -4
  77. package/services/laya/server.py +230 -0
  78. package/skills/machine-builder/SKILL.md +56 -6
  79. package/skills/machine-delegation/SKILL.md +123 -0
  80. package/skills/machine-delegation/references/delegation-machine.md +90 -0
  81. package/skills/machine-project-setup/SKILL.md +181 -0
  82. package/skills/machine-project-setup/references/project-workflows.md +50 -0
@@ -0,0 +1,946 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, join, resolve } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import * as v from "valibot";
8
+ import { agentPrompt, readAgentEvent } from "./agent-protocol.js";
9
+ /** Each list names the page as it is today and as it was before, for accounts a rollout has not reached. */
10
+ export const defaultChatGptWebSelectors = {
11
+ composer: '#prompt-textarea, div.ProseMirror[contenteditable="true"]',
12
+ send: 'button[data-testid="send-button"], button[aria-label="Send"]',
13
+ stop: 'button[data-testid="stop-button"], button[aria-label="Stop"]',
14
+ assistant: '[data-message-author-role="assistant"], [data-chatgpt-search-unit-key$=":assistant"]',
15
+ plus: 'button[data-testid="composer-plus-btn"], button[aria-label="Add files and more"]',
16
+ menuItem: '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], button[data-list-navigation-item]',
17
+ signIn: '[data-testid="login-button"]',
18
+ };
19
+ const browserCandidates = [
20
+ "google-chrome-stable", "google-chrome", "google-chrome-beta", "chromium", "chromium-browser",
21
+ "brave-browser", "brave", "microsoft-edge", "microsoft-edge-stable", "vivaldi",
22
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
23
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
24
+ ];
25
+ /** Finds a Chromium-family browser able to expose the DevTools protocol. */
26
+ export async function findChromeExecutable(env = process.env) {
27
+ if (env.CHATGPT_WEB_BROWSER !== undefined && env.CHATGPT_WEB_BROWSER !== "")
28
+ return env.CHATGPT_WEB_BROWSER;
29
+ const directories = (env.PATH ?? "").split(delimiter).filter(entry => entry !== "");
30
+ for (const candidate of browserCandidates) {
31
+ const paths = candidate.startsWith("/") ? [candidate] : directories.map(directory => join(directory, candidate));
32
+ for (const path of paths) {
33
+ try {
34
+ await access(path, constants.X_OK);
35
+ return path;
36
+ }
37
+ catch { /* try the next candidate */ }
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ export function defaultChatGptWebProfile(env = process.env) {
43
+ const state = env.XDG_STATE_HOME !== undefined && env.XDG_STATE_HOME !== ""
44
+ ? env.XDG_STATE_HOME
45
+ : join(homedir(), ".local", "state");
46
+ return join(state, "machines", "chatgpt-web-profile");
47
+ }
48
+ /** Builds the conversation opener that points ChatGPT at the local workspace through rig-bridge. */
49
+ export function chatGptWebPrompt(request, connector) {
50
+ return [
51
+ `You are working on my local computer through the "${connector}" connector (MCP tools such as workspace_open, read, ls, find, grep, write, edit, and bash).`,
52
+ `First call workspace_open with cwd ${JSON.stringify(request.cwd)} and follow the project instructions it returns.`,
53
+ "Do every file read, edit, and command through that workspace's tools. Nobody is watching this conversation, so do not ask me to run anything or wait for replies.",
54
+ `Immediately before returning control in your final reply, call work_handoff with workspace_id, a fresh request_key, reason matching one of: ${request.outcomes.join(", ")} (or completed, needs_input, blocked, failed, cancelled), and a short summary.`,
55
+ "Close the workspace with workspace_close when you are finished.",
56
+ "",
57
+ agentPrompt(request),
58
+ "Write the MACHINES_EVENT line as plain text on its own line, not inside a code block.",
59
+ ].join("\n");
60
+ }
61
+ const defaultApproveLabels = ["Confirm", "Allow", "Approve", "Allow once"];
62
+ const signInLabels = ["log in", "sign in"];
63
+ const conversationSegment = /\/c\/([^/]+)/u;
64
+ const noLastingAddress = "ChatGPT did not show the chat's lasting address before the run ended, so this event has no conversation to continue.";
65
+ /**
66
+ * Reads the conversation identifier from a page path. ChatGPT shows a new chat under a temporary
67
+ * identifier first, such as `local-chatgpt%3A<uuid>`, which stops opening the chat once it has a lasting one.
68
+ */
69
+ function conversationId(pathname) {
70
+ const id = conversationSegment.exec(pathname)?.[1];
71
+ return id === undefined ? undefined : { id, lasting: /^[\w-]+$/u.test(id) };
72
+ }
73
+ function lastingConversation(url) {
74
+ return conversationId(new URL(url).pathname)?.lasting === true ? url : undefined;
75
+ }
76
+ function outputEmitter(report, outputMode) {
77
+ return text => {
78
+ const line = text.endsWith("\n") ? text : `${text}\n`;
79
+ report?.({ type: "output", text: line });
80
+ if (outputMode === "stream")
81
+ process.stdout.write(line);
82
+ };
83
+ }
84
+ function parseConversation(value) {
85
+ let url;
86
+ try {
87
+ url = new URL(String(value));
88
+ }
89
+ catch { /* reported below */ }
90
+ const found = url === undefined ? undefined : conversationId(url.pathname);
91
+ if (url === undefined || found === undefined || url.protocol !== "https:" || url.host !== "chatgpt.com"
92
+ || url.username !== "" || url.password !== "") {
93
+ throw new Error("ChatGPT Web continuation requires an https://chatgpt.com/c/<id> conversation URL without credentials");
94
+ }
95
+ if (!found.lasting) {
96
+ throw new Error(`ChatGPT Web continuation requires the chat's lasting https://chatgpt.com/c/<id> address: "${found.id}" is a temporary `
97
+ + "identifier that ChatGPT shows while a chat is new, and its address no longer opens the chat");
98
+ }
99
+ return { href: url.href, id: found.id };
100
+ }
101
+ /** Runs one fresh ChatGPT web conversation that works in the request's directory via rig-bridge. */
102
+ export function chatGptWebAgent(options = {}) {
103
+ return async (request, report) => {
104
+ if (request.cwd === undefined)
105
+ throw new Error("ChatGPT Web Agent requires a working directory");
106
+ if (request.outcomes.length === 0)
107
+ throw new Error("ChatGPT Web Agent requires at least one allowed outcome");
108
+ const env = { ...process.env, ...options.env };
109
+ const cwd = resolve(request.cwd);
110
+ const connector = options.connector ?? env.CHATGPT_WEB_CONNECTOR ?? "rig-bridge";
111
+ const model = options.model ?? env.CHATGPT_WEB_MODEL;
112
+ const cdpUrl = (options.cdpUrl ?? env.CHATGPT_WEB_CDP_URL ?? "http://127.0.0.1:9222").replace(/\/+$/u, "");
113
+ const statusUrl = options.bridgeStatusUrl === false ? undefined
114
+ : options.bridgeStatusUrl ?? env.RIG_BRIDGE_STATUS_URL ?? "http://127.0.0.1:8767/api/status";
115
+ const selectors = { ...defaultChatGptWebSelectors, ...options.selectors };
116
+ const outputMode = options.output ?? "stream";
117
+ const timeoutMs = options.timeoutMs ?? 60 * 60_000;
118
+ const settleMs = options.settleMs ?? 5_000;
119
+ const pollMs = options.pollMs ?? 1_000;
120
+ const composerTimeoutMs = options.composerTimeoutMs ?? 45_000;
121
+ const start = new URL(options.url ?? env.CHATGPT_WEB_URL ?? "https://chatgpt.com/");
122
+ if (model !== undefined)
123
+ start.searchParams.set("model", model);
124
+ report?.({
125
+ type: "identity",
126
+ harness: options.harness ?? "chatgpt-web",
127
+ ...(model === undefined ? {} : { model }),
128
+ });
129
+ const emit = outputEmitter(report, outputMode);
130
+ let page;
131
+ let targetId;
132
+ let conversation;
133
+ let succeeded = false;
134
+ try {
135
+ await ensureBrowser(cdpUrl, options.launch ?? true, env);
136
+ targetId = await createTarget(cdpUrl, start.href);
137
+ page = await CdpPage.connect(await targetSocket(cdpUrl, targetId));
138
+ await page.send("Runtime.enable");
139
+ await page.send("Page.enable");
140
+ await page.send("Page.bringToFront");
141
+ await waitForComposer(page, selectors, composerTimeoutMs, pollMs);
142
+ if (options.selectConnector ?? true) {
143
+ if (!await selectConnector(page, selectors, connector)) {
144
+ emit(`Could not enable "${connector}" from the composer menu; relying on the prompt to invoke it.`);
145
+ }
146
+ }
147
+ const baseline = (await snapshot(page, selectors)).assistantCount;
148
+ await submitPrompt(page, selectors, chatGptWebPrompt({ ...request, cwd }, connector), pollMs);
149
+ const bridge = statusUrl === undefined ? undefined : new BridgeActivity(statusUrl, cwd, Date.now() - 2_000, report);
150
+ const deadline = Date.now() + timeoutMs;
151
+ const approveLabels = (options.approveLabels ?? defaultApproveLabels).map(label => label.toLowerCase());
152
+ let lastText = "";
153
+ let changedAt = Date.now();
154
+ let waitingReported = false;
155
+ let finalText;
156
+ while (Date.now() < deadline) {
157
+ const current = await snapshot(page, selectors, approveLabels);
158
+ // The address is the temporary one at first; only the lasting one opens the chat again.
159
+ if (conversation === undefined && lastingConversation(current.url) !== undefined) {
160
+ conversation = current.url;
161
+ report?.({ type: "tool", id: "chatgpt-conversation", title: `ChatGPT conversation ${conversation}`, status: "in_progress" });
162
+ }
163
+ const busyBridge = await bridge?.poll() ?? false;
164
+ const handoff = bridge?.latestHandoff;
165
+ if (current.approval !== undefined) {
166
+ if (options.approveToolCalls === true) {
167
+ await click(page, current.approval);
168
+ changedAt = Date.now();
169
+ }
170
+ else if (!waitingReported) {
171
+ waitingReported = true;
172
+ emit(`ChatGPT is waiting for tool approval in the browser tab${conversation === undefined ? "" : ` (${conversation})`}.`);
173
+ }
174
+ }
175
+ const text = current.assistantCount > baseline ? current.lastAssistant : "";
176
+ if (text !== lastText || current.generating || busyBridge || current.approval !== undefined) {
177
+ if (text !== lastText)
178
+ lastText = text;
179
+ changedAt = Date.now();
180
+ }
181
+ else if (text !== "") {
182
+ const quiet = Date.now() - changedAt;
183
+ const hasEvent = /^MACHINES_EVENT /mu.test(text);
184
+ // When rig-bridge notifies a handoff, the assistant has completed work; use a brief quiet window.
185
+ const minQuiet = handoff !== undefined ? Math.min(settleMs, 1_000) : (hasEvent ? settleMs : settleMs * 4);
186
+ if (quiet >= minQuiet) {
187
+ finalText = text;
188
+ break;
189
+ }
190
+ }
191
+ await sleep(pollMs);
192
+ }
193
+ if (finalText === undefined)
194
+ throw new Error(`ChatGPT did not finish within ${timeoutMs} ms`);
195
+ await bridge?.poll();
196
+ conversation ??= lastingConversation((await snapshot(page, selectors)).url);
197
+ if (conversation !== undefined) {
198
+ report?.({ type: "tool", id: "chatgpt-conversation", title: `ChatGPT conversation ${conversation}`, status: "completed" });
199
+ }
200
+ emit(finalText);
201
+ const handoff = bridge?.latestHandoff;
202
+ let event;
203
+ const hasMachinesEvent = /^MACHINES_EVENT /mu.test(finalText);
204
+ if (hasMachinesEvent) {
205
+ event = readAgentEvent(finalText, { adapter: "ChatGPT Web", includeMessage: outputMode === "capture" });
206
+ if (handoff !== undefined) {
207
+ event = {
208
+ ...event,
209
+ summary: event.summary ?? handoff.summary,
210
+ handoff,
211
+ };
212
+ }
213
+ }
214
+ else if (handoff !== undefined) {
215
+ // Fallback: ChatGPT signaled work_handoff via MCP without adding a MACHINES_EVENT line in chat.
216
+ const type = request.outcomes.includes(handoff.reason) ? handoff.reason
217
+ : request.outcomes.length === 1 && (handoff.reason === "completed" || handoff.reason === "done")
218
+ ? request.outcomes[0]
219
+ : handoff.reason;
220
+ event = {
221
+ type,
222
+ summary: handoff.summary,
223
+ handoff,
224
+ ...(outputMode === "capture" && finalText.trim() !== "" ? { message: finalText.trim() } : {}),
225
+ };
226
+ }
227
+ else {
228
+ event = readAgentEvent(finalText, { adapter: "ChatGPT Web", includeMessage: outputMode === "capture" });
229
+ }
230
+ // Only the address the page showed is returned: one the assistant wrote in its event may be the temporary one.
231
+ const { conversation: _written, ...read } = event;
232
+ if (conversation !== undefined) {
233
+ event = { ...read, conversation };
234
+ }
235
+ else {
236
+ // A temporary address would be returned in vain, so the event says why it carries none.
237
+ emit(noLastingAddress);
238
+ const summary = typeof read.summary === "string" && read.summary.trim() !== "" ? `${read.summary}\n` : "";
239
+ event = { ...read, summary: `${summary}${noLastingAddress}` };
240
+ }
241
+ if (handoff !== undefined && (options.acknowledgeHandoff ?? true)) {
242
+ await bridge?.acknowledge(handoff.id);
243
+ }
244
+ succeeded = true;
245
+ return event;
246
+ }
247
+ catch (cause) {
248
+ const message = cause instanceof Error ? cause.message : String(cause);
249
+ throw new Error(`ChatGPT Web Agent failed for "${cwd}": ${message}${conversation === undefined ? "" : `\nConversation: ${conversation}`}`, { cause });
250
+ }
251
+ finally {
252
+ page?.close();
253
+ if (targetId !== undefined && succeeded && options.keepTab !== true) {
254
+ await fetch(`${cdpUrl}/json/close/${targetId}`).catch(() => undefined);
255
+ }
256
+ }
257
+ };
258
+ }
259
+ /**
260
+ * Sends one message to an existing ChatGPT web conversation and returns the next rig-bridge handoff.
261
+ * The handoff is the whole result: assistant messages are never read from the page.
262
+ */
263
+ export function chatGptWebContinue(options = {}) {
264
+ return async (input, report) => {
265
+ const { href: conversation, id } = parseConversation(input.conversation);
266
+ if (typeof input.text !== "string" || input.text.trim() === "") {
267
+ throw new Error("ChatGPT Web continuation requires a non-empty message");
268
+ }
269
+ if (typeof input.cwd !== "string" || input.cwd === "") {
270
+ throw new Error("ChatGPT Web continuation requires a working directory");
271
+ }
272
+ if (input.bridgeThreadId !== undefined && (typeof input.bridgeThreadId !== "string" || input.bridgeThreadId === "")) {
273
+ throw new Error("ChatGPT Web continuation requires bridgeThreadId to be a non-empty string when given");
274
+ }
275
+ if (options.bridgeStatusUrl === false) {
276
+ throw new Error("ChatGPT Web continuation requires a rig-bridge status URL; bridgeStatusUrl cannot be false");
277
+ }
278
+ const env = { ...process.env, ...options.env };
279
+ const cwd = resolve(input.cwd);
280
+ const cdpUrl = (options.cdpUrl ?? env.CHATGPT_WEB_CDP_URL ?? "http://127.0.0.1:9222").replace(/\/+$/u, "");
281
+ const statusUrl = options.bridgeStatusUrl ?? env.RIG_BRIDGE_STATUS_URL ?? "http://127.0.0.1:8767/api/status";
282
+ // Without an assistant selector, no page read below can reach assistant messages.
283
+ const selectors = { ...defaultChatGptWebSelectors, ...options.selectors, assistant: undefined };
284
+ const timeoutMs = options.timeoutMs ?? 60 * 60_000;
285
+ const pollMs = options.pollMs ?? 1_000;
286
+ const composerTimeoutMs = options.composerTimeoutMs ?? 45_000;
287
+ const approveLabels = (options.approveLabels ?? defaultApproveLabels).map(label => label.toLowerCase());
288
+ const emit = outputEmitter(report, options.output ?? "stream");
289
+ const reportConversation = (status) => {
290
+ report?.({ type: "tool", id: "chatgpt-conversation", title: `ChatGPT conversation ${conversation}`, status });
291
+ };
292
+ report?.({ type: "identity", harness: options.harness ?? "chatgpt-web" });
293
+ let page;
294
+ let targetId;
295
+ let succeeded = false;
296
+ try {
297
+ await ensureBrowser(cdpUrl, options.launch ?? true, env);
298
+ targetId = await createTarget(cdpUrl, conversation);
299
+ page = await CdpPage.connect(await targetSocket(cdpUrl, targetId));
300
+ await page.send("Runtime.enable");
301
+ await page.send("Page.enable");
302
+ await page.send("Page.bringToFront");
303
+ const ready = await waitForComposer(page, selectors, composerTimeoutMs, pollMs);
304
+ // The stop button can render after the composer while the page loads, so look twice before sending.
305
+ await sleep(pollMs);
306
+ const loaded = await snapshot(page, selectors);
307
+ if (conversationId(new URL(loaded.url).pathname)?.id !== id) {
308
+ throw new Error(`ChatGPT opened ${loaded.url} instead of the conversation; the message was not sent`);
309
+ }
310
+ if (ready.generating || loaded.generating) {
311
+ throw new Error("the conversation is busy generating a reply; the message was not sent");
312
+ }
313
+ // Entering text adds to what the composer holds, so a saved draft would be sent along with the message.
314
+ if (normalize(loaded.composerText) !== "") {
315
+ throw new Error("the composer already holds unsent text; the message was not sent");
316
+ }
317
+ reportConversation("in_progress");
318
+ // Only a handoff recorded once the message is on its way can answer it, however long entering it took.
319
+ let sentAt;
320
+ try {
321
+ await submitPrompt(page, selectors, input.text, pollMs, {
322
+ exact: true,
323
+ idle: true,
324
+ onSend: () => { sentAt = Date.now(); },
325
+ });
326
+ }
327
+ catch (cause) {
328
+ const message = cause instanceof Error ? cause.message : String(cause);
329
+ // Nothing is sent before onSend runs, so an earlier failure leaves the message unsent.
330
+ if (sentAt === undefined)
331
+ throw new Error(`${message}; the message was not sent`, { cause });
332
+ throw new Error(`delivery of the message is uncertain (${message}). It was not sent again; check the conversation before retrying`, { cause });
333
+ }
334
+ const bridge = new BridgeActivity(statusUrl, cwd, sentAt ?? Date.now(), report, {
335
+ requireWorkspace: true,
336
+ threadId: input.bridgeThreadId,
337
+ });
338
+ const deadline = Date.now() + timeoutMs;
339
+ let waitingReported = false;
340
+ let handoff;
341
+ while (handoff === undefined) {
342
+ if (Date.now() >= deadline)
343
+ throw new Error(`no rig-bridge handoff arrived within ${timeoutMs} ms`);
344
+ const current = await snapshot(page, selectors, approveLabels);
345
+ if (current.approval !== undefined) {
346
+ if (options.approveToolCalls === true) {
347
+ await click(page, current.approval);
348
+ }
349
+ else if (!waitingReported) {
350
+ waitingReported = true;
351
+ emit(`ChatGPT is waiting for tool approval in the browser tab (${conversation}).`);
352
+ }
353
+ }
354
+ await bridge.poll();
355
+ handoff = bridge.latestHandoff;
356
+ if (handoff === undefined)
357
+ await sleep(pollMs);
358
+ }
359
+ // The handoff precedes the end of the reply; let it finish so the conversation is free for the next message.
360
+ while (Date.now() < deadline && (await snapshot(page, selectors)).generating) {
361
+ await sleep(pollMs);
362
+ await bridge.poll();
363
+ }
364
+ reportConversation("completed");
365
+ if (options.acknowledgeHandoff ?? true) {
366
+ await bridge.acknowledge(handoff.id);
367
+ }
368
+ succeeded = true;
369
+ return { type: handoff.reason, summary: handoff.summary, handoff, conversation };
370
+ }
371
+ catch (cause) {
372
+ const message = cause instanceof Error ? cause.message : String(cause);
373
+ throw new Error(`ChatGPT Web continuation failed for ${conversation}: ${message}`, { cause });
374
+ }
375
+ finally {
376
+ page?.close();
377
+ if (targetId !== undefined && succeeded && options.keepTab !== true) {
378
+ await fetch(`${cdpUrl}/json/close/${targetId}`).catch(() => undefined);
379
+ }
380
+ }
381
+ };
382
+ }
383
+ // ---------------------------------------------------------------------------
384
+ // Browser and DevTools protocol
385
+ const versionSchema = v.object({ webSocketDebuggerUrl: v.string() });
386
+ const targetListSchema = v.array(v.object({ id: v.string(), webSocketDebuggerUrl: v.optional(v.string()) }));
387
+ async function browserVersion(cdpUrl) {
388
+ try {
389
+ const response = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(2_000) });
390
+ if (!response.ok)
391
+ return undefined;
392
+ const parsed = v.safeParse(versionSchema, await response.json());
393
+ return parsed.success ? parsed.output : undefined;
394
+ }
395
+ catch {
396
+ return undefined;
397
+ }
398
+ }
399
+ async function ensureBrowser(cdpUrl, launch, env) {
400
+ if (await browserVersion(cdpUrl) !== undefined)
401
+ return;
402
+ if (launch === false)
403
+ throw new Error(`No browser DevTools endpoint at ${cdpUrl}`);
404
+ const endpoint = new URL(cdpUrl);
405
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(endpoint.hostname)) {
406
+ throw new Error(`No browser DevTools endpoint at ${cdpUrl}; only local endpoints can be launched`);
407
+ }
408
+ const settings = launch === true ? {} : launch;
409
+ const executable = settings.executable ?? await findChromeExecutable(env);
410
+ if (executable === undefined) {
411
+ throw new Error("No Chromium-family browser found; set CHATGPT_WEB_BROWSER or launch.executable");
412
+ }
413
+ const profile = settings.userDataDir ?? env.CHATGPT_WEB_PROFILE ?? defaultChatGptWebProfile(env);
414
+ await mkdir(profile, { recursive: true, mode: 0o700 });
415
+ // The browser is a shared, persistent resource holding the login. Detach it so
416
+ // terminating one Machine run does not close other conversations or sign out.
417
+ const child = spawn(executable, [
418
+ `--remote-debugging-port=${endpoint.port === "" ? "9222" : endpoint.port}`,
419
+ `--user-data-dir=${profile}`,
420
+ "--no-first-run", "--no-default-browser-check",
421
+ ...(settings.headless === true ? ["--headless=new"] : []),
422
+ ...(settings.args ?? []),
423
+ ], { detached: true, stdio: "ignore" });
424
+ const failed = new Promise((_, reject) => child.once("error", reject));
425
+ child.unref();
426
+ const deadline = Date.now() + 20_000;
427
+ while (Date.now() < deadline) {
428
+ const ready = await Promise.race([browserVersion(cdpUrl), failed]);
429
+ if (ready !== undefined)
430
+ return;
431
+ await sleep(250);
432
+ }
433
+ throw new Error(`Browser "${executable}" did not expose DevTools at ${cdpUrl}`);
434
+ }
435
+ async function createTarget(cdpUrl, url) {
436
+ const version = await browserVersion(cdpUrl);
437
+ if (version === undefined)
438
+ throw new Error(`No browser DevTools endpoint at ${cdpUrl}`);
439
+ const browser = await CdpPage.connect(version.webSocketDebuggerUrl);
440
+ try {
441
+ const created = await browser.send("Target.createTarget", { url });
442
+ if (typeof created.targetId !== "string")
443
+ throw new Error("Browser did not create a tab");
444
+ return created.targetId;
445
+ }
446
+ finally {
447
+ browser.close();
448
+ }
449
+ }
450
+ async function targetSocket(cdpUrl, targetId) {
451
+ for (let attempt = 0; attempt < 20; attempt += 1) {
452
+ const response = await fetch(`${cdpUrl}/json/list`);
453
+ const targets = v.parse(targetListSchema, await response.json());
454
+ const url = targets.find(target => target.id === targetId)?.webSocketDebuggerUrl;
455
+ if (url !== undefined)
456
+ return url;
457
+ await sleep(100);
458
+ }
459
+ throw new Error("Browser tab is not available for automation");
460
+ }
461
+ class CdpPage {
462
+ #socket;
463
+ #pending = new Map();
464
+ #next = 1;
465
+ constructor(socket) {
466
+ this.#socket = socket;
467
+ socket.addEventListener("message", message => {
468
+ let data;
469
+ try {
470
+ data = JSON.parse(String(message.data));
471
+ }
472
+ catch {
473
+ return;
474
+ }
475
+ if (data.id === undefined)
476
+ return;
477
+ const pending = this.#pending.get(data.id);
478
+ if (pending === undefined)
479
+ return;
480
+ this.#pending.delete(data.id);
481
+ if (data.error !== undefined)
482
+ pending.reject(new Error(data.error.message ?? "DevTools request failed"));
483
+ else
484
+ pending.resolve(data.result ?? {});
485
+ });
486
+ socket.addEventListener("close", () => {
487
+ for (const pending of this.#pending.values())
488
+ pending.reject(new Error("Browser connection closed"));
489
+ this.#pending.clear();
490
+ });
491
+ }
492
+ static connect(url) {
493
+ return new Promise((resolveConnect, reject) => {
494
+ const socket = new WebSocket(url);
495
+ socket.addEventListener("open", () => resolveConnect(new CdpPage(socket)), { once: true });
496
+ socket.addEventListener("error", () => reject(new Error(`Could not connect to ${url}`)), { once: true });
497
+ });
498
+ }
499
+ send(method, params = {}) {
500
+ const id = this.#next++;
501
+ return new Promise((resolveSend, reject) => {
502
+ this.#pending.set(id, { resolve: resolveSend, reject });
503
+ this.#socket.send(JSON.stringify({ id, method, params }));
504
+ });
505
+ }
506
+ async call(fn, arg) {
507
+ const result = await this.send("Runtime.evaluate", {
508
+ expression: `(${fn.toString()})(${JSON.stringify(arg)})`,
509
+ returnByValue: true,
510
+ awaitPromise: true,
511
+ });
512
+ if (result.exceptionDetails !== undefined) {
513
+ const details = result.exceptionDetails;
514
+ throw new Error(details.exception?.description ?? details.text ?? "Page script failed");
515
+ }
516
+ return result.result?.value;
517
+ }
518
+ close() {
519
+ this.#socket.close();
520
+ }
521
+ }
522
+ function pageSnapshot(arg) {
523
+ const { selectors, approveLabels } = arg;
524
+ const composer = document.querySelector(selectors.composer);
525
+ const send = document.querySelector(selectors.send);
526
+ const assistants = selectors.assistant === undefined ? [] : document.querySelectorAll(selectors.assistant);
527
+ const last = assistants[assistants.length - 1];
528
+ let approval;
529
+ if (approveLabels.length > 0) {
530
+ for (const button of Array.from(document.querySelectorAll("button"))) {
531
+ if (composer !== null && button.closest("form")?.contains(composer))
532
+ continue;
533
+ if (!approveLabels.includes(button.innerText.trim().toLowerCase()) || button.disabled)
534
+ continue;
535
+ const rect = button.getBoundingClientRect();
536
+ if (rect.width === 0 || rect.height === 0)
537
+ continue;
538
+ button.scrollIntoView({ block: "center" });
539
+ const moved = button.getBoundingClientRect();
540
+ approval = { x: moved.left + moved.width / 2, y: moved.top + moved.height / 2 };
541
+ break;
542
+ }
543
+ }
544
+ return {
545
+ url: location.href,
546
+ composer: composer !== null,
547
+ composerText: composer instanceof HTMLTextAreaElement ? composer.value : composer?.innerText ?? "",
548
+ generating: document.querySelector(selectors.stop) !== null,
549
+ sendEnabled: send instanceof HTMLButtonElement && !send.disabled,
550
+ assistantCount: assistants.length,
551
+ lastAssistant: last?.innerText ?? "",
552
+ ...(approval === undefined ? {} : { approval }),
553
+ };
554
+ }
555
+ function locate(arg) {
556
+ const candidates = Array.from(document.querySelectorAll(arg.selector));
557
+ const pattern = arg.text?.toLowerCase();
558
+ const element = candidates.find(candidate => {
559
+ const rect = candidate.getBoundingClientRect();
560
+ if (rect.width === 0 || rect.height === 0)
561
+ return false;
562
+ return pattern === undefined || candidate.innerText.toLowerCase().includes(pattern);
563
+ });
564
+ if (element === undefined)
565
+ return undefined;
566
+ element.scrollIntoView({ block: "center" });
567
+ const rect = element.getBoundingClientRect();
568
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
569
+ }
570
+ function pasteText(arg) {
571
+ const composer = document.querySelector(arg.selector);
572
+ if (composer === null)
573
+ return false;
574
+ composer.focus();
575
+ if (composer instanceof HTMLTextAreaElement) {
576
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
577
+ setter?.call(composer, arg.text);
578
+ composer.dispatchEvent(new Event("input", { bubbles: true }));
579
+ return true;
580
+ }
581
+ // ChatGPT's ProseMirror composer keeps paragraphs from a pasted plain-text payload.
582
+ const data = new DataTransfer();
583
+ data.setData("text/plain", arg.text);
584
+ composer.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
585
+ return true;
586
+ }
587
+ function signInVisible(arg) {
588
+ const shown = (element) => {
589
+ const rect = element.getBoundingClientRect();
590
+ return rect.width > 0 && rect.height > 0;
591
+ };
592
+ if (Array.from(document.querySelectorAll(arg.selector)).some(shown))
593
+ return true;
594
+ return Array.from(document.querySelectorAll('a, button, [role="button"]'))
595
+ .some(control => shown(control) && arg.labels.includes(control.innerText.trim().toLowerCase()));
596
+ }
597
+ function snapshot(page, selectors, approveLabels = []) {
598
+ return page.call(pageSnapshot, { selectors, approveLabels });
599
+ }
600
+ async function waitFor(page, selectors, predicate, timeoutMs, pollMs) {
601
+ const deadline = Date.now() + timeoutMs;
602
+ while (Date.now() < deadline) {
603
+ try {
604
+ const current = await snapshot(page, selectors);
605
+ if (predicate(current))
606
+ return current;
607
+ }
608
+ catch { /* the page may be navigating */ }
609
+ await sleep(Math.min(pollMs, 250));
610
+ }
611
+ return undefined;
612
+ }
613
+ /** Waits for the composer. When it does not appear, the error tells a signed-out page from out-of-date selectors. */
614
+ async function waitForComposer(page, selectors, timeoutMs, pollMs) {
615
+ const ready = await waitFor(page, selectors, current => current.composer, timeoutMs, pollMs);
616
+ if (ready !== undefined)
617
+ return ready;
618
+ const url = (await snapshot(page, selectors)).url;
619
+ if (await page.call(signInVisible, { selector: selectors.signIn, labels: [...signInLabels] })) {
620
+ throw new Error(`ChatGPT composer did not appear at ${url}: the page shows a sign-in control, so the browser is not signed in. `
621
+ + "Sign in to ChatGPT in the automation browser profile and retry");
622
+ }
623
+ throw new Error(`ChatGPT composer did not appear at ${url}, and the page shows no sign-in control. The page selectors are most likely `
624
+ + "out of date: compare them with the page and override `selectors`. If the page is signed out after all, "
625
+ + "sign in to ChatGPT in the automation browser profile and retry");
626
+ }
627
+ async function click(page, point) {
628
+ await page.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y });
629
+ await page.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 });
630
+ await page.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 });
631
+ }
632
+ async function locateWithin(page, selector, text, timeoutMs) {
633
+ const deadline = Date.now() + timeoutMs;
634
+ do {
635
+ const point = await page.call(locate, text === undefined ? { selector } : { selector, text });
636
+ if (point !== undefined)
637
+ return point;
638
+ await sleep(100);
639
+ } while (Date.now() < deadline);
640
+ return undefined;
641
+ }
642
+ async function selectConnector(page, selectors, connector) {
643
+ const plus = await locateWithin(page, selectors.plus, undefined, 2_000);
644
+ if (plus === undefined)
645
+ return false;
646
+ await click(page, plus);
647
+ let item = await locateWithin(page, selectors.menuItem, connector, 1_000);
648
+ for (const submenu of ["Developer mode", "More"]) {
649
+ if (item !== undefined)
650
+ break;
651
+ const parent = await locateWithin(page, selectors.menuItem, submenu, 300);
652
+ if (parent === undefined)
653
+ continue;
654
+ await click(page, parent);
655
+ item = await locateWithin(page, selectors.menuItem, connector, 1_000);
656
+ }
657
+ if (item !== undefined)
658
+ await click(page, item);
659
+ await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
660
+ await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
661
+ return item !== undefined;
662
+ }
663
+ const normalize = (text) => text.replace(/\s+/gu, " ").trim();
664
+ async function submitPrompt(page, selectors, prompt, pollMs, options = {}) {
665
+ const whole = normalize(prompt);
666
+ const probe = whole.slice(0, 60);
667
+ const typed = (current) => {
668
+ const text = normalize(current.composerText);
669
+ return options.exact === true ? text === whole : text.includes(probe);
670
+ };
671
+ await page.call(pasteText, { selector: selectors.composer, text: prompt });
672
+ if (await waitFor(page, selectors, typed, 1_500, pollMs) === undefined) {
673
+ // Typing into a composer that holds other text could never produce the prompt alone.
674
+ if (options.exact === true && normalize((await snapshot(page, selectors)).composerText) !== "") {
675
+ throw new Error("The ChatGPT composer holds text other than the prompt");
676
+ }
677
+ const composer = await locateWithin(page, selectors.composer, undefined, 1_000);
678
+ if (composer === undefined)
679
+ throw new Error("ChatGPT composer is unavailable");
680
+ await click(page, composer);
681
+ await page.send("Input.insertText", { text: prompt });
682
+ if (await waitFor(page, selectors, typed, 3_000, pollMs) === undefined) {
683
+ throw new Error("Could not enter the prompt into the ChatGPT composer");
684
+ }
685
+ }
686
+ const busy = (current) => options.idle === true && current.generating;
687
+ const ready = await waitFor(page, selectors, current => current.sendEnabled || busy(current), 10_000, pollMs);
688
+ const send = ready?.sendEnabled === true ? await locateWithin(page, selectors.send, undefined, 500) : undefined;
689
+ // The page can change while the prompt is entered and the send button becomes ready, so this is the last look.
690
+ const last = await snapshot(page, selectors);
691
+ // A reply can start after the caller saw the conversation idle, as when another tab sends to it.
692
+ if (busy(last))
693
+ throw new Error("The ChatGPT conversation is busy generating a reply");
694
+ // ChatGPT can restore a saved draft late.
695
+ if (options.exact === true && !typed(last)) {
696
+ throw new Error("The ChatGPT composer holds text other than the prompt");
697
+ }
698
+ options.onSend?.();
699
+ if (send !== undefined)
700
+ await click(page, send);
701
+ else {
702
+ await page.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" });
703
+ await page.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
704
+ }
705
+ const accepted = await waitFor(page, selectors, current => current.generating || !typed(current), 15_000, pollMs);
706
+ if (accepted === undefined)
707
+ throw new Error("ChatGPT did not accept the prompt");
708
+ }
709
+ // ---------------------------------------------------------------------------
710
+ // rig-bridge activity and handoffs
711
+ const commandSchema = v.object({
712
+ id: v.string(),
713
+ tool: v.string(),
714
+ description: v.optional(v.string()),
715
+ startedAt: v.number(),
716
+ success: v.optional(v.boolean()),
717
+ });
718
+ const handoffSchema = v.object({
719
+ id: v.string(),
720
+ projectId: v.string(),
721
+ threadId: v.string(),
722
+ runId: v.optional(v.string()),
723
+ reason: v.string(),
724
+ summary: v.string(),
725
+ createdAt: v.number(),
726
+ readAt: v.optional(v.nullable(v.number())),
727
+ });
728
+ const statusSchema = v.object({
729
+ version: v.optional(v.string()),
730
+ startedAt: v.optional(v.number()),
731
+ uptimeSeconds: v.optional(v.number()),
732
+ workspacesCount: v.optional(v.number()),
733
+ activeCommandsCount: v.optional(v.number()),
734
+ workspaces: v.array(v.object({
735
+ id: v.string(),
736
+ projectId: v.optional(v.string()),
737
+ threadId: v.optional(v.string()),
738
+ cwd: v.string(),
739
+ createdAt: v.optional(v.number()),
740
+ activeCommands: v.optional(v.array(commandSchema)),
741
+ recentCommands: v.optional(v.array(commandSchema)),
742
+ })),
743
+ notifications: v.optional(v.array(handoffSchema)),
744
+ unreadCount: v.optional(v.number()),
745
+ });
746
+ /** Mirrors commands and tracks handoffs that ChatGPT runs through rig-bridge in the target directory. */
747
+ class BridgeActivity {
748
+ #reported = new Map();
749
+ #url;
750
+ #readUrl;
751
+ #cwd;
752
+ #since;
753
+ #report;
754
+ #handoffs;
755
+ #knownWorkspaceIds = new Set();
756
+ #knownThreadIds = new Set();
757
+ #knownProjectIds = new Set();
758
+ #latestHandoff;
759
+ constructor(url, cwd, since, report, handoffs = {}) {
760
+ this.#url = url;
761
+ this.#readUrl = url.replace(/\/api\/status\/?$/u, "/api/handoffs/read");
762
+ this.#cwd = cwd;
763
+ this.#since = since;
764
+ this.#report = report;
765
+ this.#handoffs = handoffs;
766
+ }
767
+ get latestHandoff() {
768
+ return this.#latestHandoff;
769
+ }
770
+ /** Returns true while a matching workspace has an active command. */
771
+ async poll() {
772
+ let status;
773
+ try {
774
+ const response = await fetch(this.#url, { signal: AbortSignal.timeout(2_000) });
775
+ if (!response.ok)
776
+ return false;
777
+ const parsed = v.safeParse(statusSchema, await response.json());
778
+ if (!parsed.success)
779
+ return false;
780
+ status = parsed.output;
781
+ }
782
+ catch {
783
+ return false;
784
+ }
785
+ let busy = false;
786
+ for (const workspace of status.workspaces) {
787
+ if (resolve(workspace.cwd) !== this.#cwd)
788
+ continue;
789
+ this.#knownWorkspaceIds.add(workspace.id);
790
+ if (workspace.threadId)
791
+ this.#knownThreadIds.add(workspace.threadId);
792
+ if (workspace.projectId)
793
+ this.#knownProjectIds.add(workspace.projectId);
794
+ if ((workspace.createdAt ?? 0) >= this.#since) {
795
+ this.#update(`workspace:${workspace.id}`, `workspace_open ${workspace.cwd}`, "completed");
796
+ }
797
+ for (const command of workspace.recentCommands ?? []) {
798
+ if (command.startedAt < this.#since)
799
+ continue;
800
+ this.#update(command.id, command.description ?? command.tool, command.success === false ? "failed" : "completed");
801
+ }
802
+ for (const command of workspace.activeCommands ?? []) {
803
+ if (command.startedAt < this.#since)
804
+ continue;
805
+ busy = true;
806
+ this.#update(command.id, command.description ?? command.tool, "in_progress");
807
+ }
808
+ }
809
+ for (const notification of status.notifications ?? []) {
810
+ if (notification.createdAt < this.#since)
811
+ continue;
812
+ const matchesThread = this.#knownThreadIds.has(notification.threadId);
813
+ const matchesProject = this.#handoffs.requireWorkspace !== true && this.#knownProjectIds.has(notification.projectId);
814
+ const anyWorkspace = this.#handoffs.requireWorkspace !== true
815
+ && this.#knownThreadIds.size === 0 && this.#knownProjectIds.size === 0;
816
+ if (!anyWorkspace && !matchesThread && !matchesProject)
817
+ continue;
818
+ if (this.#handoffs.threadId !== undefined && notification.threadId !== this.#handoffs.threadId)
819
+ continue;
820
+ if (this.#latestHandoff === undefined || this.#latestHandoff.id !== notification.id) {
821
+ this.#latestHandoff = {
822
+ id: notification.id,
823
+ projectId: notification.projectId,
824
+ threadId: notification.threadId,
825
+ ...(notification.runId ? { runId: notification.runId } : {}),
826
+ reason: notification.reason,
827
+ summary: notification.summary,
828
+ createdAt: notification.createdAt,
829
+ };
830
+ this.#update(`handoff:${notification.id}`, `work_handoff (${notification.reason}): ${notification.summary}`, "completed");
831
+ }
832
+ }
833
+ return busy;
834
+ }
835
+ async acknowledge(handoffId) {
836
+ try {
837
+ const response = await fetch(this.#readUrl, {
838
+ method: "POST",
839
+ headers: { "content-type": "application/json" },
840
+ body: JSON.stringify({ handoff_id: handoffId }),
841
+ signal: AbortSignal.timeout(3_000),
842
+ });
843
+ return response.ok;
844
+ }
845
+ catch {
846
+ return false;
847
+ }
848
+ }
849
+ #update(id, title, status) {
850
+ const previous = this.#reported.get(id);
851
+ if (previous === status || previous === "completed" || previous === "failed")
852
+ return;
853
+ this.#reported.set(id, status);
854
+ this.#report?.({ type: "tool", id, title, status });
855
+ }
856
+ }
857
+ /** Fetches the latest bridge status from rig-bridge. */
858
+ export async function fetchRigBridgeStatus(statusUrl = process.env.RIG_BRIDGE_STATUS_URL ?? "http://127.0.0.1:8767/api/status") {
859
+ try {
860
+ const response = await fetch(statusUrl, { signal: AbortSignal.timeout(3_000) });
861
+ if (!response.ok)
862
+ return undefined;
863
+ const parsed = v.safeParse(statusSchema, await response.json());
864
+ return parsed.success ? parsed.output : undefined;
865
+ }
866
+ catch {
867
+ return undefined;
868
+ }
869
+ }
870
+ /** Finds the most recent handoff notification from rig-bridge matching the given directory or thread. */
871
+ export async function fetchLatestRigBridgeHandoff(options = {}) {
872
+ const status = await fetchRigBridgeStatus(options.statusUrl);
873
+ if (!status || !status.notifications || status.notifications.length === 0)
874
+ return undefined;
875
+ const targetCwd = options.cwd ? resolve(options.cwd) : undefined;
876
+ const threadIds = new Set();
877
+ const projectIds = new Set();
878
+ if (targetCwd !== undefined) {
879
+ let matched = false;
880
+ for (const ws of status.workspaces) {
881
+ if (resolve(ws.cwd) === targetCwd) {
882
+ matched = true;
883
+ if (ws.threadId)
884
+ threadIds.add(ws.threadId);
885
+ if (ws.projectId)
886
+ projectIds.add(ws.projectId);
887
+ }
888
+ }
889
+ if (!matched && !options.threadId)
890
+ return undefined;
891
+ }
892
+ for (const notification of status.notifications) {
893
+ if (options.since !== undefined && notification.createdAt < options.since)
894
+ continue;
895
+ if ((threadIds.size > 0 || projectIds.size > 0) && !threadIds.has(notification.threadId) && !projectIds.has(notification.projectId)) {
896
+ continue;
897
+ }
898
+ return {
899
+ id: notification.id,
900
+ projectId: notification.projectId,
901
+ threadId: notification.threadId,
902
+ ...(notification.runId ? { runId: notification.runId } : {}),
903
+ reason: notification.reason,
904
+ summary: notification.summary,
905
+ createdAt: notification.createdAt,
906
+ };
907
+ }
908
+ return undefined;
909
+ }
910
+ /** Acknowledges / marks read a handoff on rig-bridge. */
911
+ export async function acknowledgeRigBridgeHandoff(handoffId, options = {}) {
912
+ const base = (options.statusUrl ?? process.env.RIG_BRIDGE_STATUS_URL ?? "http://127.0.0.1:8767/api/status")
913
+ .replace(/\/api\/status\/?$/u, "/api/handoffs/read");
914
+ try {
915
+ const response = await fetch(base, {
916
+ method: "POST",
917
+ headers: { "content-type": "application/json" },
918
+ body: JSON.stringify({ handoff_id: handoffId }),
919
+ signal: AbortSignal.timeout(3_000),
920
+ });
921
+ return response.ok;
922
+ }
923
+ catch {
924
+ return false;
925
+ }
926
+ }
927
+ /**
928
+ * Creates an Operation for a Machine that inspects rig-bridge for the latest handoff notification,
929
+ * returning an Event with `type = handoff.reason` and attaching `summary` and `handoff`.
930
+ */
931
+ export function rigBridgeHandoffOperation(options = {}) {
932
+ return async () => {
933
+ const handoff = await fetchLatestRigBridgeHandoff(options);
934
+ if (handoff === undefined) {
935
+ throw new Error("No rig-bridge handoff notification found");
936
+ }
937
+ if (options.acknowledge ?? true) {
938
+ await acknowledgeRigBridgeHandoff(handoff.id, options);
939
+ }
940
+ return {
941
+ type: handoff.reason,
942
+ summary: handoff.summary,
943
+ handoff,
944
+ };
945
+ };
946
+ }