@gorlitzer-labs/apiary 1.13.9 → 1.15.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.
- package/README.md +17 -3
- package/dist/agent/index.d.ts +73 -0
- package/dist/agent/index.js +1 -1
- package/dist/{chunk-BCQQJR5E.js → chunk-3UQBSELW.js} +7 -178
- package/dist/chunk-3UQBSELW.js.map +1 -0
- package/dist/{chunk-7JXPJO3R.js → chunk-74XU7HHN.js} +6 -6
- package/dist/{chunk-4A7MREYX.js → chunk-N2LPKMPO.js} +185 -2
- package/dist/chunk-N2LPKMPO.js.map +1 -0
- package/dist/chunk-OEAHTZCA.js +994 -0
- package/dist/chunk-OEAHTZCA.js.map +1 -0
- package/dist/{chunk-MEWZT5KA.js → chunk-W4KWQKPP.js} +2 -2
- package/dist/{chunk-WJD5EKO6.js → chunk-Z4J6TRJY.js} +154 -41
- package/dist/chunk-Z4J6TRJY.js.map +1 -0
- package/dist/cli/index.js +39 -386
- package/dist/cli/index.js.map +1 -1
- package/dist/{invites-GRROUSCE.js → invites-SJ4ZVKVX.js} +4 -4
- package/dist/{join-H62VB4HU.js → join-5UFFMSLS.js} +4 -4
- package/dist/{room-ID4DQDPT.js → room-S6ULVAEH.js} +10 -10
- package/dist/run-5PSUQJF4.js +270 -0
- package/dist/run-5PSUQJF4.js.map +1 -0
- package/package.json +3 -2
- package/dist/chunk-4A7MREYX.js.map +0 -1
- package/dist/chunk-BCQQJR5E.js.map +0 -1
- package/dist/chunk-WJD5EKO6.js.map +0 -1
- package/dist/chunk-XLUTCAY6.js +0 -288
- package/dist/chunk-XLUTCAY6.js.map +0 -1
- package/dist/run-C22WQZVK.js +0 -91
- package/dist/run-C22WQZVK.js.map +0 -1
- /package/dist/{chunk-7JXPJO3R.js.map → chunk-74XU7HHN.js.map} +0 -0
- /package/dist/{chunk-MEWZT5KA.js.map → chunk-W4KWQKPP.js.map} +0 -0
- /package/dist/{invites-GRROUSCE.js.map → invites-SJ4ZVKVX.js.map} +0 -0
- /package/dist/{join-H62VB4HU.js.map → join-5UFFMSLS.js.map} +0 -0
- /package/dist/{room-ID4DQDPT.js.map → room-S6ULVAEH.js.map} +0 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RemoteRoomDataSource,
|
|
3
|
+
RoomConnectionError,
|
|
4
|
+
buildCatchUpLines
|
|
5
|
+
} from "./chunk-Z4J6TRJY.js";
|
|
6
|
+
import {
|
|
7
|
+
extractToken,
|
|
8
|
+
tmuxCapturePane,
|
|
9
|
+
tmuxCapturePaneStyled,
|
|
10
|
+
tmuxInjectText,
|
|
11
|
+
tmuxPaneIsShell,
|
|
12
|
+
tmuxSendEnter,
|
|
13
|
+
tmuxSendKey
|
|
14
|
+
} from "./chunk-N2LPKMPO.js";
|
|
15
|
+
import {
|
|
16
|
+
can,
|
|
17
|
+
contentPartsToString
|
|
18
|
+
} from "./chunk-TMXQDGMN.js";
|
|
19
|
+
|
|
20
|
+
// src/cli/composer.ts
|
|
21
|
+
var PROMPT_MARKER = /^\s*[›❯>]\s?/;
|
|
22
|
+
function composerBlock(lines) {
|
|
23
|
+
const tail = lines.slice(-25);
|
|
24
|
+
let start = -1;
|
|
25
|
+
for (let i = tail.length - 1; i >= 0; i--) {
|
|
26
|
+
if (PROMPT_MARKER.test(tail[i])) {
|
|
27
|
+
start = i;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (start === -1) return "";
|
|
32
|
+
return tail.slice(start).join(" ");
|
|
33
|
+
}
|
|
34
|
+
function submissionProbe(text) {
|
|
35
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 40);
|
|
36
|
+
}
|
|
37
|
+
function composerStillHolds(lines, text) {
|
|
38
|
+
const probe = submissionProbe(text);
|
|
39
|
+
if (probe.length < 8) return false;
|
|
40
|
+
const composer = composerBlock(lines).replace(/\s+/g, " ");
|
|
41
|
+
return composer.includes(probe);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/cli/claude/tmux-bridge.ts
|
|
45
|
+
var SPINNER_CHARS = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F";
|
|
46
|
+
var BUSY_PATTERNS = [
|
|
47
|
+
/[A-Za-zÀ-ÿ]+…\s*\(\d+(?:m\s*\d+)?s\b/
|
|
48
|
+
];
|
|
49
|
+
function composerHoldsOnlyGhost(styledLines) {
|
|
50
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
51
|
+
for (let i = styledLines.length - 1; i >= 0; i--) {
|
|
52
|
+
const plain = styledLines[i].replace(SGR, "").trimStart();
|
|
53
|
+
if (!/^[❯›](\s|$)/.test(plain)) continue;
|
|
54
|
+
const afterPrompt = styledLines[i].slice(styledLines[i].search(/[❯›]/) + 1);
|
|
55
|
+
const typed = afterPrompt.replace(/\x1b\[2m[\s\S]*?(?:\x1b\[(?:0|22)?m|$)/g, "").replace(SGR, "").trim();
|
|
56
|
+
return typed.length === 0;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
var DIALOG_PATTERNS = [
|
|
61
|
+
"Enter to select",
|
|
62
|
+
"to navigate",
|
|
63
|
+
"Esc to cancel",
|
|
64
|
+
"Ready to code?",
|
|
65
|
+
"Review your answers",
|
|
66
|
+
"ctrl+g to edit in"
|
|
67
|
+
];
|
|
68
|
+
var PERMISSION_PATTERNS = [
|
|
69
|
+
"(Y)",
|
|
70
|
+
"Allow ",
|
|
71
|
+
"Deny ",
|
|
72
|
+
"approve",
|
|
73
|
+
"Yes / No",
|
|
74
|
+
// Current Claude Code wording, captured from a live prompt. Without these a
|
|
75
|
+
// permission dialog reads as idle and the bridge pastes a room event into
|
|
76
|
+
// it, answering on the agent's behalf.
|
|
77
|
+
"Do you want to proceed?",
|
|
78
|
+
"don't ask again",
|
|
79
|
+
// The folder-trust modal, which no flag skips.
|
|
80
|
+
"Yes, I trust this folder"
|
|
81
|
+
];
|
|
82
|
+
var BLOCKING_PATTERNS = [
|
|
83
|
+
"Do you want to proceed?",
|
|
84
|
+
"don't ask again",
|
|
85
|
+
"Yes, I trust this folder",
|
|
86
|
+
"Ready to code?",
|
|
87
|
+
"Enter to select",
|
|
88
|
+
/❯\s*1\.\s*Yes/,
|
|
89
|
+
/\(y\s*\/\s*n\)/i,
|
|
90
|
+
// The arrow glyphs claude renders beside it. Bare "to navigate" is prose.
|
|
91
|
+
/[↑↓⬆⬇]\s*(?:[↑↓⬆⬇]\s*)?to navigate/
|
|
92
|
+
];
|
|
93
|
+
function matchesAny(text, patterns) {
|
|
94
|
+
const haystack = text.toLowerCase();
|
|
95
|
+
for (const pattern of patterns) {
|
|
96
|
+
if (typeof pattern === "string") {
|
|
97
|
+
if (haystack.includes(pattern.toLowerCase())) return true;
|
|
98
|
+
} else if (pattern.test(text)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
function defaultUndeliveredWarning(text) {
|
|
105
|
+
const preview = text.replace(/\s+/g, " ").slice(0, 80);
|
|
106
|
+
process.stderr.write(`apiary: message NOT delivered to the agent \u2014 still in its composer: ${preview}
|
|
107
|
+
`);
|
|
108
|
+
}
|
|
109
|
+
var TmuxBridge = class _TmuxBridge {
|
|
110
|
+
session;
|
|
111
|
+
queue = [];
|
|
112
|
+
pollTimer = null;
|
|
113
|
+
pollIntervalMs;
|
|
114
|
+
keystrokeDelayMs;
|
|
115
|
+
onUndelivered;
|
|
116
|
+
attended;
|
|
117
|
+
stopped = false;
|
|
118
|
+
blockedCount = 0;
|
|
119
|
+
static BLOCKED_THRESHOLD = 150;
|
|
120
|
+
// ~30s at 200ms poll — force inject if stuck
|
|
121
|
+
constructor(session, opts) {
|
|
122
|
+
this.session = session;
|
|
123
|
+
this.pollIntervalMs = opts?.pollIntervalMs ?? 200;
|
|
124
|
+
this.keystrokeDelayMs = opts?.keystrokeDelayMs ?? 50;
|
|
125
|
+
this.onUndelivered = opts?.onUndelivered ?? defaultUndeliveredWarning;
|
|
126
|
+
this.attended = opts?.attended ?? false;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Delivery callback — drop-in replacement for the raw tmuxDeliver lambda.
|
|
130
|
+
* Pass `bridge.deliver.bind(bridge)` to EventProcessor.run().
|
|
131
|
+
*/
|
|
132
|
+
async deliver(parts, opts) {
|
|
133
|
+
const text = contentPartsToString(parts);
|
|
134
|
+
if (!text.trim()) return;
|
|
135
|
+
this.inject(text, opts?.dedupe === true);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Detect the current TUI state by reading the screen.
|
|
139
|
+
* Exported for testing — the heuristic logic is in detectStateFromLines().
|
|
140
|
+
*/
|
|
141
|
+
detectState() {
|
|
142
|
+
if (tmuxPaneIsShell(this.session)) return "absent";
|
|
143
|
+
const lines = this.captureScreen();
|
|
144
|
+
return detectStateFromLines(lines);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* State to REPORT to the room, as opposed to the state used to decide
|
|
148
|
+
* whether it is safe to type. Same screen, stricter about claiming a human
|
|
149
|
+
* is needed — see detectReportedStateFromLines.
|
|
150
|
+
*/
|
|
151
|
+
detectReportedState() {
|
|
152
|
+
if (tmuxPaneIsShell(this.session)) return "absent";
|
|
153
|
+
return detectReportedStateFromLines(this.captureScreen());
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Extract claude's live status label from the pane (the "Sautéed for 12s",
|
|
157
|
+
* "Compacting conversation… 23%" lines under the prompt). Returns null when
|
|
158
|
+
* the agent is idle / no status visible.
|
|
159
|
+
*
|
|
160
|
+
* The room TUI calls this through the agent runtime's poll loop to surface
|
|
161
|
+
* "is the agent actually working or stuck?" without making the user attach
|
|
162
|
+
* to each tmux pane.
|
|
163
|
+
*/
|
|
164
|
+
getActivityLabel() {
|
|
165
|
+
return extractActivityLabel(this.captureScreen());
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Try to inject text, choosing strategy based on TUI state.
|
|
169
|
+
* If the state is unsafe, queues the text and starts polling.
|
|
170
|
+
*
|
|
171
|
+
* Text is flattened to a single line before injection to avoid triggering
|
|
172
|
+
* Claude Code's paste detection. When multi-line text arrives via
|
|
173
|
+
* `send-keys -l`, Claude Code detects it as a paste and collapses it into
|
|
174
|
+
* "[Pasted text #1 +N lines]" which may not reliably submit with Enter.
|
|
175
|
+
*/
|
|
176
|
+
inject(text, dedupe) {
|
|
177
|
+
const flat = text.replace(/\n/g, " ");
|
|
178
|
+
const state = this.deliveryState();
|
|
179
|
+
switch (state) {
|
|
180
|
+
case "idle":
|
|
181
|
+
this.injectIdle(flat);
|
|
182
|
+
break;
|
|
183
|
+
case "typing":
|
|
184
|
+
this.injectWhileTyping(flat);
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
this.enqueue(flat, dedupe);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The state delivery acts on. Unattended it is the screen state. Attended,
|
|
193
|
+
* "typing" splits in two: only placeholder text is idle; a real draft is the
|
|
194
|
+
* human's, so the event waits ("unknown" queues it).
|
|
195
|
+
*/
|
|
196
|
+
deliveryState() {
|
|
197
|
+
const state = this.detectState();
|
|
198
|
+
if (!this.attended || state !== "typing") return state;
|
|
199
|
+
return composerHoldsOnlyGhost(tmuxCapturePaneStyled(this.session)) ? "idle" : "unknown";
|
|
200
|
+
}
|
|
201
|
+
/** Capture the screen via tmux capture-pane. */
|
|
202
|
+
captureScreen() {
|
|
203
|
+
return tmuxCapturePane(this.session);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Inject into an idle prompt: type text + Enter, then check it went.
|
|
207
|
+
*
|
|
208
|
+
* This used to fire a second Enter unconditionally as a "safety net" against
|
|
209
|
+
* Claude Code's paste detection swallowing the first. That is a guess in both
|
|
210
|
+
* directions: it cannot tell whether the first Enter worked, and it cannot
|
|
211
|
+
* tell whether the second one did either. Reading the composer back answers
|
|
212
|
+
* the question instead, and only sends the second Enter when it is needed.
|
|
213
|
+
*/
|
|
214
|
+
injectIdle(text) {
|
|
215
|
+
tmuxInjectText(this.session, text);
|
|
216
|
+
tmuxSendEnter(this.session);
|
|
217
|
+
this.confirmSubmitted(text);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check the message left the composer, and press Enter once more if it did not.
|
|
221
|
+
*
|
|
222
|
+
* A submitted message moves into the transcript; one still in the composer was
|
|
223
|
+
* never sent. If it is still there after the retry, the room is told rather
|
|
224
|
+
* than left to read the agent's silence as a choice.
|
|
225
|
+
*/
|
|
226
|
+
confirmSubmitted(text) {
|
|
227
|
+
this.sleep(80);
|
|
228
|
+
if (!composerStillHolds(this.captureScreen(), text)) return;
|
|
229
|
+
tmuxSendEnter(this.session);
|
|
230
|
+
this.sleep(120);
|
|
231
|
+
if (!composerStillHolds(this.captureScreen(), text)) return;
|
|
232
|
+
this.onUndelivered(text);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Inject while the user is typing:
|
|
236
|
+
* 1. Ctrl+U — cut line to kill ring
|
|
237
|
+
* 2. Inject our text + Enter
|
|
238
|
+
* 3. Ctrl+Y — paste the user's text back
|
|
239
|
+
*/
|
|
240
|
+
injectWhileTyping(text) {
|
|
241
|
+
tmuxSendKey(this.session, "C-u");
|
|
242
|
+
this.sleep(this.keystrokeDelayMs);
|
|
243
|
+
tmuxInjectText(this.session, text);
|
|
244
|
+
tmuxSendEnter(this.session);
|
|
245
|
+
this.confirmSubmitted(text);
|
|
246
|
+
this.sleep(this.keystrokeDelayMs);
|
|
247
|
+
tmuxSendKey(this.session, "C-y");
|
|
248
|
+
}
|
|
249
|
+
/** Add to queue and start polling if not already. */
|
|
250
|
+
enqueue(text, dedupe) {
|
|
251
|
+
if (dedupe && this.queue.includes(text)) return;
|
|
252
|
+
this.queue.push(text);
|
|
253
|
+
this.startPolling();
|
|
254
|
+
}
|
|
255
|
+
/** Start the polling timer to drain queued events. */
|
|
256
|
+
startPolling() {
|
|
257
|
+
if (this.pollTimer || this.stopped) return;
|
|
258
|
+
this.pollTimer = setInterval(() => this.drainQueue(), this.pollIntervalMs);
|
|
259
|
+
}
|
|
260
|
+
/** Stop the polling timer. */
|
|
261
|
+
stopPolling() {
|
|
262
|
+
if (this.pollTimer) {
|
|
263
|
+
clearInterval(this.pollTimer);
|
|
264
|
+
this.pollTimer = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Try to drain one queued event if the state is safe.
|
|
269
|
+
*
|
|
270
|
+
* Drains one event at a time rather than batching all into one multi-line
|
|
271
|
+
* string — multi-line text triggers Claude Code's paste detection which
|
|
272
|
+
* collapses it into "[Pasted text #1 +N lines]".
|
|
273
|
+
*
|
|
274
|
+
* After injecting one event, the poll continues. The next cycle re-checks
|
|
275
|
+
* state: if Claude is busy (streaming), remaining events wait. If idle,
|
|
276
|
+
* the next event is injected. Events are already flattened in inject().
|
|
277
|
+
*/
|
|
278
|
+
drainQueue() {
|
|
279
|
+
if (this.queue.length === 0) {
|
|
280
|
+
this.stopPolling();
|
|
281
|
+
this.blockedCount = 0;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const state = this.deliveryState();
|
|
285
|
+
if (state === "idle" || state === "typing") {
|
|
286
|
+
this.blockedCount = 0;
|
|
287
|
+
const text = this.queue.shift();
|
|
288
|
+
if (state === "idle") {
|
|
289
|
+
this.injectIdle(text);
|
|
290
|
+
} else {
|
|
291
|
+
this.injectWhileTyping(text);
|
|
292
|
+
}
|
|
293
|
+
if (this.queue.length === 0) {
|
|
294
|
+
this.stopPolling();
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
this.blockedCount++;
|
|
298
|
+
if (!this.attended && this.blockedCount >= _TmuxBridge.BLOCKED_THRESHOLD) {
|
|
299
|
+
this.blockedCount = 0;
|
|
300
|
+
const text = this.queue.shift();
|
|
301
|
+
this.injectIdle(text);
|
|
302
|
+
if (this.queue.length === 0) this.stopPolling();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** Cleanup. */
|
|
307
|
+
stop() {
|
|
308
|
+
this.stopped = true;
|
|
309
|
+
this.stopPolling();
|
|
310
|
+
this.queue.length = 0;
|
|
311
|
+
}
|
|
312
|
+
/** Synchronous sleep — only used for tiny keystroke delays. */
|
|
313
|
+
sleep(ms) {
|
|
314
|
+
if (ms <= 0) return;
|
|
315
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
var ACTIVITY_LIVE = /([A-Za-zÀ-ÿ]+…\s*\([^)\n]*\))/;
|
|
319
|
+
var ACTIVITY_COOK = /([A-Za-zÀ-ÿ]+ed\s+for\s+\d+s)/;
|
|
320
|
+
var ACTIVITY_PROG = /((?:Compacting|Thinking|Loading|Connecting)[^()]*?(?:\s+\d+%)?)(?=\s*[(\n]|\s*$)/;
|
|
321
|
+
function extractActivityLabel(lines) {
|
|
322
|
+
if (lines.length === 0) return null;
|
|
323
|
+
let best = null;
|
|
324
|
+
for (const line of lines.slice(-25)) {
|
|
325
|
+
const m = line.match(ACTIVITY_PROG) ?? line.match(ACTIVITY_COOK) ?? line.match(ACTIVITY_LIVE);
|
|
326
|
+
if (m) best = m[1].trim();
|
|
327
|
+
}
|
|
328
|
+
return best;
|
|
329
|
+
}
|
|
330
|
+
function detectStateFromLines(lines, opts) {
|
|
331
|
+
if (lines.length === 0) return "unknown";
|
|
332
|
+
const tail = lines.slice(-30);
|
|
333
|
+
const tailText = tail.join("\n");
|
|
334
|
+
if (opts?.ignorePrompts !== true) {
|
|
335
|
+
for (const pattern of DIALOG_PATTERNS) {
|
|
336
|
+
if (tailText.includes(pattern)) return "dialog";
|
|
337
|
+
}
|
|
338
|
+
for (const pattern of PERMISSION_PATTERNS) {
|
|
339
|
+
if (tailText.includes(pattern)) return "permission";
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const lastFew = tail.slice(-5).join("");
|
|
343
|
+
for (const ch of SPINNER_CHARS) {
|
|
344
|
+
if (lastFew.includes(ch)) return "streaming";
|
|
345
|
+
}
|
|
346
|
+
const statusArea = tail.slice(-10).join("\n");
|
|
347
|
+
for (const pattern of BUSY_PATTERNS) {
|
|
348
|
+
if (pattern.test(statusArea)) return "streaming";
|
|
349
|
+
}
|
|
350
|
+
const promptChar = /^[❯›](\s|$)/;
|
|
351
|
+
const footerChar = /^[❯›]{2}\s/;
|
|
352
|
+
const separatorLine = /^[─━─\-]{10,}/;
|
|
353
|
+
for (let i = tail.length - 1; i >= 0; i--) {
|
|
354
|
+
const line = tail[i].trimStart();
|
|
355
|
+
if (footerChar.test(line)) {
|
|
356
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
357
|
+
const above = tail[j].trimStart();
|
|
358
|
+
if (promptChar.test(above)) {
|
|
359
|
+
const content = above.replace(/^[❯›]\s*/, "").trim();
|
|
360
|
+
return content.length === 0 ? "idle" : "typing";
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (let i = tail.length - 1; i >= 0; i--) {
|
|
367
|
+
const line = tail[i].trimStart();
|
|
368
|
+
if (promptChar.test(line)) {
|
|
369
|
+
const above = i > 0 ? tail[i - 1].trimStart() : "";
|
|
370
|
+
const below = i < tail.length - 1 ? tail[i + 1].trimStart() : "";
|
|
371
|
+
if (separatorLine.test(above) || separatorLine.test(below)) {
|
|
372
|
+
const content = line.replace(/^[❯›]\s*/, "").trim();
|
|
373
|
+
return content.length === 0 ? "idle" : "typing";
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return "unknown";
|
|
378
|
+
}
|
|
379
|
+
function detectReportedStateFromLines(lines) {
|
|
380
|
+
const loose = detectStateFromLines(lines);
|
|
381
|
+
if (loose !== "dialog" && loose !== "permission") return loose;
|
|
382
|
+
if (matchesAny(lines.slice(-30).join("\n"), BLOCKING_PATTERNS)) return loose;
|
|
383
|
+
return detectStateFromLines(lines, { ignorePrompts: true });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/agent/wait-for-agent.ts
|
|
387
|
+
var DEFAULT_WAIT_MS = 6e4;
|
|
388
|
+
var MAX_WAIT_MS = 12e4;
|
|
389
|
+
var DEFAULT_POLL_MS = 2e3;
|
|
390
|
+
function resolveBudgetMs(timeoutMs, maxWaitMs = MAX_WAIT_MS) {
|
|
391
|
+
return Math.min(Math.max(timeoutMs ?? DEFAULT_WAIT_MS, 0), maxWaitMs);
|
|
392
|
+
}
|
|
393
|
+
function satisfies(state, until, initial) {
|
|
394
|
+
if (state === "unknown") return false;
|
|
395
|
+
if (until === "change") return state !== initial;
|
|
396
|
+
return state === until;
|
|
397
|
+
}
|
|
398
|
+
async function waitForAgent(opts) {
|
|
399
|
+
const {
|
|
400
|
+
readState,
|
|
401
|
+
until = "idle",
|
|
402
|
+
maxWaitMs = MAX_WAIT_MS,
|
|
403
|
+
pollIntervalMs = DEFAULT_POLL_MS,
|
|
404
|
+
sleep = (ms) => new Promise((r) => {
|
|
405
|
+
setTimeout(r, ms).unref?.();
|
|
406
|
+
}),
|
|
407
|
+
now = () => Date.now()
|
|
408
|
+
} = opts;
|
|
409
|
+
const budget = resolveBudgetMs(opts.timeoutMs, maxWaitMs);
|
|
410
|
+
const started = now();
|
|
411
|
+
const initial = await readState();
|
|
412
|
+
if (initial === "unknown") {
|
|
413
|
+
return { result: "not-reporting", state: "unknown", waitedMs: now() - started };
|
|
414
|
+
}
|
|
415
|
+
if (satisfies(initial, until, initial)) {
|
|
416
|
+
return { result: "satisfied", state: initial, waitedMs: now() - started };
|
|
417
|
+
}
|
|
418
|
+
let latest = initial;
|
|
419
|
+
const steps = Math.max(1, Math.ceil(budget / pollIntervalMs));
|
|
420
|
+
for (let step = 0; step < steps; step++) {
|
|
421
|
+
await sleep(pollIntervalMs);
|
|
422
|
+
latest = await readState();
|
|
423
|
+
if (satisfies(latest, until, initial)) {
|
|
424
|
+
return { result: "satisfied", state: latest, waitedMs: now() - started };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { result: "timeout", state: latest, waitedMs: now() - started };
|
|
428
|
+
}
|
|
429
|
+
function describeOutcome(participant, until, outcome) {
|
|
430
|
+
const secs = Math.round(outcome.waitedMs / 1e3);
|
|
431
|
+
switch (outcome.result) {
|
|
432
|
+
case "satisfied":
|
|
433
|
+
return `${participant} is now ${outcome.state} (waited ${secs}s).`;
|
|
434
|
+
case "timeout":
|
|
435
|
+
return `${participant} is still ${outcome.state} after ${secs}s \u2014 not ${until} yet. Call again to keep waiting, or get on with something else.`;
|
|
436
|
+
case "not-reporting":
|
|
437
|
+
return `${participant} does not report its state, so there is nothing to wait for. Only agents started by apiary with a Claude Code or Codex runtime report it. Ask them directly instead.`;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/cli/rejoin.ts
|
|
442
|
+
import { existsSync, readFileSync } from "fs";
|
|
443
|
+
import { homedir } from "os";
|
|
444
|
+
import { join } from "path";
|
|
445
|
+
function readLocalRoomSession(roomName) {
|
|
446
|
+
if (!/^[A-Za-z0-9._-]+$/.test(roomName) || roomName.includes("..")) return null;
|
|
447
|
+
const path = join(homedir(), ".apiary", "sessions", `room_${roomName}.json`);
|
|
448
|
+
if (!existsSync(path)) return null;
|
|
449
|
+
try {
|
|
450
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
451
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
452
|
+
return {
|
|
453
|
+
serverUrl: str(data.serverUrl),
|
|
454
|
+
publicUrl: str(data.publicUrl),
|
|
455
|
+
adminToken: str(data.adminToken),
|
|
456
|
+
memberToken: str(data.memberToken)
|
|
457
|
+
};
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function isLoopbackUrl(url) {
|
|
463
|
+
try {
|
|
464
|
+
const host = new URL(url).hostname;
|
|
465
|
+
return host === "127.0.0.1" || host === "localhost";
|
|
466
|
+
} catch {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function sameOrigin(a, b) {
|
|
471
|
+
try {
|
|
472
|
+
return new URL(a).origin === new URL(b).origin;
|
|
473
|
+
} catch {
|
|
474
|
+
return a === b;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
function localTierOf(shareToken, session) {
|
|
478
|
+
if (!shareToken || !session) return void 0;
|
|
479
|
+
if (session.adminToken === shareToken) return "admin";
|
|
480
|
+
if (session.memberToken === shareToken) return "member";
|
|
481
|
+
return void 0;
|
|
482
|
+
}
|
|
483
|
+
var RoomRejoiner = class {
|
|
484
|
+
constructor(room, deps) {
|
|
485
|
+
this.room = room;
|
|
486
|
+
this.deps = deps;
|
|
487
|
+
this.fetchFn = deps.fetch ?? ((...args) => fetch(...args));
|
|
488
|
+
this.now = deps.now ?? Date.now;
|
|
489
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
490
|
+
this.readSession = deps.readRoomSession ?? readLocalRoomSession;
|
|
491
|
+
}
|
|
492
|
+
inflight = null;
|
|
493
|
+
lost = null;
|
|
494
|
+
failures = 0;
|
|
495
|
+
cooldownUntil = 0;
|
|
496
|
+
lastRejoinAt = Number.NEGATIVE_INFINITY;
|
|
497
|
+
lastProbeAt = Number.NEGATIVE_INFINITY;
|
|
498
|
+
fetchFn;
|
|
499
|
+
now;
|
|
500
|
+
sleep;
|
|
501
|
+
readSession;
|
|
502
|
+
/** Why the room is lost for good, if it is. */
|
|
503
|
+
get lostError() {
|
|
504
|
+
return this.lost;
|
|
505
|
+
}
|
|
506
|
+
// ── SessionRecovery (RemoteRoomDataSource) ─────────────────────────────
|
|
507
|
+
async sessionInvalid(usedToken) {
|
|
508
|
+
if (usedToken !== void 0 && usedToken !== this.room.sessionToken) return;
|
|
509
|
+
await this.rejoin("session token rejected (401)");
|
|
510
|
+
}
|
|
511
|
+
async unreachable() {
|
|
512
|
+
if (this.lost || this.room.removedBy) return false;
|
|
513
|
+
const now = this.now();
|
|
514
|
+
if (now - this.lastProbeAt < (this.deps.probeIntervalMs ?? 5e3)) return false;
|
|
515
|
+
this.lastProbeAt = now;
|
|
516
|
+
const local = this.readSession(this.room.rejoin.serverRoomName)?.serverUrl;
|
|
517
|
+
if (!local || !isLoopbackUrl(local) || sameOrigin(local, this.room.serverUrl)) return false;
|
|
518
|
+
const base = local.replace(/\/$/, "");
|
|
519
|
+
let res;
|
|
520
|
+
try {
|
|
521
|
+
res = await this.fetchFn(`${base}/participants`, {
|
|
522
|
+
headers: { Authorization: `Bearer ${this.room.sessionToken}` },
|
|
523
|
+
signal: AbortSignal.timeout(5e3)
|
|
524
|
+
});
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
await res.body?.cancel().catch(() => {
|
|
529
|
+
});
|
|
530
|
+
if (res.ok) {
|
|
531
|
+
const from = this.room.serverUrl;
|
|
532
|
+
this.swapSession(base, this.room.sessionToken);
|
|
533
|
+
this.log(`room "${this.room.roomName}": ${from} unreachable, moved to ${base} (same session)`);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
if (res.status === 401) {
|
|
537
|
+
try {
|
|
538
|
+
await this.rejoin(`${this.room.serverUrl} unreachable`);
|
|
539
|
+
return true;
|
|
540
|
+
} catch {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
// ── SseRecoveryHandler (per room) ──────────────────────────────────────
|
|
547
|
+
async authRejected(status, usedToken) {
|
|
548
|
+
if (usedToken !== void 0 && usedToken !== this.room.sessionToken) return true;
|
|
549
|
+
try {
|
|
550
|
+
await this.rejoin(`event stream rejected (${status})`);
|
|
551
|
+
return true;
|
|
552
|
+
} catch (err) {
|
|
553
|
+
if (err instanceof RoomConnectionError && err.permanent) throw err;
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// ── Re-join ────────────────────────────────────────────────────────────
|
|
558
|
+
/**
|
|
559
|
+
* Join the room again and swap the new session in everywhere. Concurrent
|
|
560
|
+
* callers share one attempt. Throws RoomConnectionError: `permanent` when
|
|
561
|
+
* retrying cannot help, otherwise after bounded retries.
|
|
562
|
+
*/
|
|
563
|
+
rejoin(reason) {
|
|
564
|
+
if (this.lost) return Promise.reject(this.lost);
|
|
565
|
+
const roomLabel = `"${this.room.roomName}"`;
|
|
566
|
+
if (this.room.removedBy) return Promise.reject(this.removed());
|
|
567
|
+
if (this.inflight) return this.inflight;
|
|
568
|
+
const now = this.now();
|
|
569
|
+
if (now - this.lastRejoinAt < (this.deps.replacedWindowMs ?? 3e4)) {
|
|
570
|
+
return Promise.reject(this.loseFor(
|
|
571
|
+
`Your seat in room ${roomLabel} was taken over again right after apiary re-joined it \u2014 another session is probably using the name "${this.room.rejoin.name}". Not re-joining automatically, so the two do not keep evicting each other. Call join_room to take the seat back.`
|
|
572
|
+
));
|
|
573
|
+
}
|
|
574
|
+
if (now < this.cooldownUntil) {
|
|
575
|
+
const secs = Math.ceil((this.cooldownUntil - now) / 1e3);
|
|
576
|
+
return Promise.reject(new RoomConnectionError(
|
|
577
|
+
`Lost the session in room ${roomLabel} and the last re-join failed; apiary retries in ${secs}s. If this persists, call join_room again with the room's share URL.`,
|
|
578
|
+
false
|
|
579
|
+
));
|
|
580
|
+
}
|
|
581
|
+
this.inflight = this.runRejoin(reason).finally(() => {
|
|
582
|
+
this.inflight = null;
|
|
583
|
+
});
|
|
584
|
+
return this.inflight;
|
|
585
|
+
}
|
|
586
|
+
async runRejoin(reason) {
|
|
587
|
+
const roomLabel = `"${this.room.roomName}"`;
|
|
588
|
+
this.log(`room ${roomLabel}: ${reason}; re-joining as "${this.room.rejoin.name}"`);
|
|
589
|
+
const delays = [0, ...this.deps.retryDelaysMs ?? [500, 1500]];
|
|
590
|
+
let lastError = "";
|
|
591
|
+
for (const delay of delays) {
|
|
592
|
+
if (delay > 0) await this.sleep(delay);
|
|
593
|
+
if (this.room.removedBy) break;
|
|
594
|
+
const outcome = await this.attemptOnce();
|
|
595
|
+
if (outcome.permanent) {
|
|
596
|
+
throw this.loseFor(
|
|
597
|
+
`Room ${roomLabel} no longer accepts the share link this agent joined with (${outcome.error}). The room was recreated or the link revoked \u2014 ask for a new share URL and call join_room with it.`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (outcome.done) {
|
|
601
|
+
this.failures = 0;
|
|
602
|
+
this.cooldownUntil = 0;
|
|
603
|
+
this.lastRejoinAt = this.now();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
lastError = outcome.error;
|
|
607
|
+
}
|
|
608
|
+
if (this.room.removedBy) throw this.removed();
|
|
609
|
+
this.failures += 1;
|
|
610
|
+
const cooldown = Math.min(1e3 * 2 ** (this.failures - 1), this.deps.maxCooldownMs ?? 3e4);
|
|
611
|
+
this.cooldownUntil = this.now() + cooldown;
|
|
612
|
+
this.log(`room ${roomLabel}: re-join failed (${lastError}); next try in ${Math.ceil(cooldown / 1e3)}s`);
|
|
613
|
+
throw new RoomConnectionError(
|
|
614
|
+
`Lost the session in room ${roomLabel} (${reason}) and could not re-join yet: ${lastError}. apiary keeps retrying; if this persists, call join_room again with the room's share URL.`,
|
|
615
|
+
false
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
/** One pass over the candidate addresses and tokens. */
|
|
619
|
+
async attemptOnce() {
|
|
620
|
+
const info = this.room.rejoin;
|
|
621
|
+
const candidates = [
|
|
622
|
+
{ url: this.room.serverUrl, shareToken: info.shareToken }
|
|
623
|
+
];
|
|
624
|
+
const session = this.readSession(info.serverRoomName);
|
|
625
|
+
const local = session?.serverUrl && isLoopbackUrl(session.serverUrl) ? session.serverUrl.replace(/\/$/, "") : void 0;
|
|
626
|
+
if (local && !sameOrigin(local, this.room.serverUrl)) {
|
|
627
|
+
candidates.push({ url: local, shareToken: info.shareToken });
|
|
628
|
+
}
|
|
629
|
+
const fresh = info.localTier === "admin" ? session?.adminToken : info.localTier === "member" ? session?.memberToken : void 0;
|
|
630
|
+
if (local && fresh && fresh !== info.shareToken) {
|
|
631
|
+
candidates.push({ url: local, shareToken: fresh });
|
|
632
|
+
}
|
|
633
|
+
let shareRejected = "";
|
|
634
|
+
let transient = "";
|
|
635
|
+
let unreachable = "";
|
|
636
|
+
for (const c of candidates) {
|
|
637
|
+
const r = await this.joinAt(c.url, c.shareToken);
|
|
638
|
+
if (r.ok) {
|
|
639
|
+
this.apply(r.url, r.shareToken, r.data);
|
|
640
|
+
return { done: true, permanent: false, error: "" };
|
|
641
|
+
}
|
|
642
|
+
if (r.kind === "share-rejected") shareRejected = r.error;
|
|
643
|
+
else if (r.kind === "unreachable") unreachable ||= r.error;
|
|
644
|
+
else transient ||= r.error;
|
|
645
|
+
}
|
|
646
|
+
if (shareRejected && !transient) return { done: false, permanent: true, error: shareRejected };
|
|
647
|
+
return { done: false, permanent: false, error: transient || unreachable || shareRejected || "no address answered" };
|
|
648
|
+
}
|
|
649
|
+
async joinAt(url, shareToken) {
|
|
650
|
+
const body = { type: this.room.rejoin.type, name: this.room.rejoin.name };
|
|
651
|
+
if (shareToken) body.token = shareToken;
|
|
652
|
+
let res;
|
|
653
|
+
try {
|
|
654
|
+
res = await this.fetchFn(`${url}/join`, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
headers: { "Content-Type": "application/json" },
|
|
657
|
+
body: JSON.stringify(body),
|
|
658
|
+
signal: AbortSignal.timeout(1e4)
|
|
659
|
+
});
|
|
660
|
+
} catch (err) {
|
|
661
|
+
return { ok: false, kind: "unreachable", error: `${url} unreachable (${err instanceof Error ? err.message : String(err)})` };
|
|
662
|
+
}
|
|
663
|
+
if (!res.ok) {
|
|
664
|
+
const text = (await res.text().catch(() => "")).slice(0, 200);
|
|
665
|
+
if (res.status === 403 && /invalid share token/i.test(text)) {
|
|
666
|
+
return { ok: false, kind: "share-rejected", error: `403 Invalid share token at ${url}` };
|
|
667
|
+
}
|
|
668
|
+
return { ok: false, kind: "transient", error: `${res.status} from ${url}/join${text ? `: ${text}` : ""}` };
|
|
669
|
+
}
|
|
670
|
+
const data = await res.json().catch(() => ({}));
|
|
671
|
+
if (String(data.roomName ?? "") !== this.room.rejoin.serverRoomName || String(data.roomId ?? "") !== this.room.roomId) {
|
|
672
|
+
await this.fetchFn(`${url}/disconnect`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${String(data.sessionToken ?? "")}` },
|
|
675
|
+
body: "{}"
|
|
676
|
+
}).catch(() => {
|
|
677
|
+
});
|
|
678
|
+
return { ok: false, kind: "wrong-room", error: `${url} serves a different room (${String(data.roomName ?? "?")})` };
|
|
679
|
+
}
|
|
680
|
+
return { ok: true, url, shareToken, data };
|
|
681
|
+
}
|
|
682
|
+
/** Swap the new session into the JoinResult, data source, processor and SSE stream. */
|
|
683
|
+
apply(url, shareToken, data) {
|
|
684
|
+
const room = this.room;
|
|
685
|
+
const oldId = room.participantId;
|
|
686
|
+
const token = String(data.sessionToken ?? "");
|
|
687
|
+
const newId = String(data.participantId ?? "");
|
|
688
|
+
const participants = data.participants ?? [];
|
|
689
|
+
room.participantId = newId;
|
|
690
|
+
room.authority = String(data.authority ?? room.authority);
|
|
691
|
+
room.participants = participants;
|
|
692
|
+
if (shareToken !== void 0) room.rejoin.shareToken = shareToken;
|
|
693
|
+
room.dataSource.setParticipants(participants);
|
|
694
|
+
room.dataSource.setSelf(newId, room.rejoin.name);
|
|
695
|
+
const { processor } = this.deps;
|
|
696
|
+
processor.setRoomParticipantId(room.roomId, newId);
|
|
697
|
+
if (processor.participantId === oldId) processor.participantId = newId;
|
|
698
|
+
this.swapSession(url, token);
|
|
699
|
+
this.log(`room "${room.roomName}": re-joined as "${room.rejoin.name}" at ${url}`);
|
|
700
|
+
const mode = processor.getModeForRoom(room.roomId);
|
|
701
|
+
if (mode !== "everyone") {
|
|
702
|
+
this.fetchFn(`${url}/set-mode`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
705
|
+
body: JSON.stringify({ mode })
|
|
706
|
+
}).catch(() => {
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
swapSession(url, token) {
|
|
711
|
+
this.room.serverUrl = url;
|
|
712
|
+
this.room.sessionToken = token;
|
|
713
|
+
this.room.dataSource.setSession(url, token);
|
|
714
|
+
this.deps.sseMux.updateConnection(this.room.roomId, url, token);
|
|
715
|
+
}
|
|
716
|
+
removed() {
|
|
717
|
+
return this.loseFor(
|
|
718
|
+
`You were removed from room "${this.room.roomName}" by ${this.room.removedBy}, so apiary will not re-join it. Call join_room again only if you are invited back.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
loseFor(message) {
|
|
722
|
+
if (!this.lost) {
|
|
723
|
+
this.lost = new RoomConnectionError(message, true);
|
|
724
|
+
this.log(`room "${this.room.roomName}": giving up \u2014 ${message}`);
|
|
725
|
+
try {
|
|
726
|
+
this.deps.onLost?.(this.room, this.lost);
|
|
727
|
+
} catch {
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return this.lost;
|
|
731
|
+
}
|
|
732
|
+
log(line) {
|
|
733
|
+
this.deps.log?.(line);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// src/cli/runtime-handlers.ts
|
|
738
|
+
function trackRoomEvent(jr, event) {
|
|
739
|
+
if (event.type === "ParticipantJoined") {
|
|
740
|
+
jr.dataSource.addParticipant(event.participant);
|
|
741
|
+
} else if (event.type === "ParticipantLeft") {
|
|
742
|
+
jr.dataSource.removeParticipant(event.participant_id);
|
|
743
|
+
} else if (event.type === "ParticipantKicked") {
|
|
744
|
+
if (event.participant_id === jr.participantId) jr.removedBy = safeName(event.kicked_by);
|
|
745
|
+
jr.dataSource.removeParticipant(event.participant_id);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
function safeName(name) {
|
|
749
|
+
return String(name ?? "").replace(/[^A-Za-z0-9 _.-]/g, "").trim().slice(0, 40) || "an admin";
|
|
750
|
+
}
|
|
751
|
+
function buildRuntimeMcpOptions(ctx) {
|
|
752
|
+
const { processor, sseMux, joinResults, agentName, authority, onRoomJoined } = ctx;
|
|
753
|
+
const rejoinerFor = (roomId) => joinResults.find((j) => j.roomId === roomId)?.rejoiner;
|
|
754
|
+
sseMux.setRecoveryHandler({
|
|
755
|
+
authRejected: async (roomId, status, usedToken) => {
|
|
756
|
+
const r = rejoinerFor(roomId);
|
|
757
|
+
return r ? r.authRejected(status, usedToken) : false;
|
|
758
|
+
},
|
|
759
|
+
unreachable: async (roomId) => await rejoinerFor(roomId)?.unreachable() ?? false
|
|
760
|
+
});
|
|
761
|
+
const post = async (ds, path, body) => {
|
|
762
|
+
try {
|
|
763
|
+
const res = await ds.request(path, {
|
|
764
|
+
method: "POST",
|
|
765
|
+
headers: { "Content-Type": "application/json" },
|
|
766
|
+
body: JSON.stringify(body)
|
|
767
|
+
});
|
|
768
|
+
if (!res.ok) return { success: false, error: await res.text() };
|
|
769
|
+
return { success: true };
|
|
770
|
+
} catch (err) {
|
|
771
|
+
const msg = err instanceof Error && err.name === "RoomConnectionError" ? err.message : "Server unreachable.";
|
|
772
|
+
return { success: false, error: msg };
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const adminAction = (build) => async (room, participant) => {
|
|
776
|
+
const conn = processor.resolve(room);
|
|
777
|
+
if (!conn) return { success: false, error: `Unknown room "${room}".` };
|
|
778
|
+
const ds = conn.dataSource;
|
|
779
|
+
const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
|
|
780
|
+
if (!p) return { success: false, error: `Unknown participant "${participant}".` };
|
|
781
|
+
const { path, body } = build(p.id);
|
|
782
|
+
return post(ds, path, body);
|
|
783
|
+
};
|
|
784
|
+
return {
|
|
785
|
+
resolver: processor,
|
|
786
|
+
toolOptions: {
|
|
787
|
+
isEventSeen: (id) => processor.isEventSeen(id),
|
|
788
|
+
markEventsSeen: (ids) => processor.markEventsSeen(ids),
|
|
789
|
+
assignRef: (id) => processor.assignRef(id),
|
|
790
|
+
resolveRef: (ref) => processor.resolveRef(ref)
|
|
791
|
+
},
|
|
792
|
+
authority,
|
|
793
|
+
onSetMode: async (room, mode) => {
|
|
794
|
+
const conn = processor.resolve(room);
|
|
795
|
+
if (!conn) return { success: false, error: `Unknown room "${room}".` };
|
|
796
|
+
processor.setModeForRoom(conn.dataSource.roomId, mode, false);
|
|
797
|
+
const result = await post(conn.dataSource, "/set-mode", { mode });
|
|
798
|
+
if (!result.success && result.error !== "Server unreachable.") {
|
|
799
|
+
return { success: false, error: `Server rejected: ${result.error}` };
|
|
800
|
+
}
|
|
801
|
+
return { success: true };
|
|
802
|
+
},
|
|
803
|
+
/**
|
|
804
|
+
* Wait for another agent to reach a state, by polling the room server's
|
|
805
|
+
* participant list. Two seconds of HTTP is far cheaper than the LLM turn
|
|
806
|
+
* the agent would otherwise spend re-reading the room to check.
|
|
807
|
+
*/
|
|
808
|
+
onWaitForAgent: async (room, participant, until, timeoutSec) => {
|
|
809
|
+
const conn = processor.resolve(room);
|
|
810
|
+
if (!conn) return { success: false, error: `Unknown room "${room}".` };
|
|
811
|
+
const ds = conn.dataSource;
|
|
812
|
+
const target = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
|
|
813
|
+
if (!target) return { success: false, error: `Unknown participant "${participant}".` };
|
|
814
|
+
if (target.id === conn.dataSource.selfId) {
|
|
815
|
+
return { success: false, error: "You cannot wait for yourself." };
|
|
816
|
+
}
|
|
817
|
+
const readState = async () => {
|
|
818
|
+
try {
|
|
819
|
+
const res = await ds.request("/participants", { signal: AbortSignal.timeout(1e4) });
|
|
820
|
+
if (!res.ok) return "unknown";
|
|
821
|
+
const data = await res.json();
|
|
822
|
+
const found = data.participants?.find((pp) => pp.id === target.id);
|
|
823
|
+
const state = found?.agentState;
|
|
824
|
+
return state === "idle" || state === "working" || state === "blocked" ? state : "unknown";
|
|
825
|
+
} catch {
|
|
826
|
+
return "unknown";
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
const outcome = await waitForAgent({
|
|
830
|
+
readState,
|
|
831
|
+
until,
|
|
832
|
+
timeoutMs: timeoutSec === void 0 ? void 0 : timeoutSec * 1e3
|
|
833
|
+
});
|
|
834
|
+
return { success: true, message: describeOutcome(participant, until ?? "idle", outcome) };
|
|
835
|
+
},
|
|
836
|
+
onPing: async (room, participant) => {
|
|
837
|
+
const conn = processor.resolve(room);
|
|
838
|
+
if (!conn) return { success: false, error: `Unknown room "${room}".` };
|
|
839
|
+
const ds = conn.dataSource;
|
|
840
|
+
const p = conn.dataSource.listParticipants().find((pp) => pp.name === participant);
|
|
841
|
+
if (!p) return { success: false, error: `Unknown participant "${participant}".` };
|
|
842
|
+
return post(ds, "/ping", { participantId: p.id });
|
|
843
|
+
},
|
|
844
|
+
onJoinRoom: async (url, alias, nameOverride) => {
|
|
845
|
+
const token = extractToken(url);
|
|
846
|
+
let serverUrl;
|
|
847
|
+
try {
|
|
848
|
+
const parsed = new URL(url);
|
|
849
|
+
parsed.search = "";
|
|
850
|
+
serverUrl = parsed.toString().replace(/\/$/, "");
|
|
851
|
+
} catch {
|
|
852
|
+
serverUrl = url.replace(/\/$/, "");
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
const joinName = nameOverride ?? agentName;
|
|
856
|
+
const joinBody = { type: "agent", name: joinName };
|
|
857
|
+
if (token) joinBody.token = token;
|
|
858
|
+
const res = await fetch(`${serverUrl}/join`, {
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: { "Content-Type": "application/json" },
|
|
861
|
+
body: JSON.stringify(joinBody),
|
|
862
|
+
signal: AbortSignal.timeout(15e3)
|
|
863
|
+
});
|
|
864
|
+
if (!res.ok) return { success: false, error: `Failed to join: ${await res.text()}` };
|
|
865
|
+
const data = await res.json();
|
|
866
|
+
const sessionToken = String(data.sessionToken ?? "");
|
|
867
|
+
const roomName = alias ?? String(data.roomName ?? "");
|
|
868
|
+
const roomId = String(data.roomId ?? "");
|
|
869
|
+
const roomAuthority = String(data.authority ?? "member");
|
|
870
|
+
const participants = data.participants ?? [];
|
|
871
|
+
const newParticipantId = String(data.participantId ?? "");
|
|
872
|
+
const existingIdx = joinResults.findIndex((j) => j.roomId === roomId);
|
|
873
|
+
if (existingIdx >= 0) {
|
|
874
|
+
sseMux.removeConnection(roomId);
|
|
875
|
+
processor.disconnectRemoteRoom(roomId);
|
|
876
|
+
joinResults.splice(existingIdx, 1);
|
|
877
|
+
}
|
|
878
|
+
const dataSource = new RemoteRoomDataSource(serverUrl, sessionToken, roomId);
|
|
879
|
+
dataSource.setParticipants(participants);
|
|
880
|
+
dataSource.setSelf(newParticipantId, joinName);
|
|
881
|
+
if (joinResults.length === 0) {
|
|
882
|
+
processor.participantId = newParticipantId;
|
|
883
|
+
}
|
|
884
|
+
processor.setRoomParticipantId(roomId, newParticipantId);
|
|
885
|
+
const mode = processor.getModeForRoom(roomId) ?? "everyone";
|
|
886
|
+
processor.connectRemoteRoom(dataSource, roomName);
|
|
887
|
+
sseMux.addConnection(serverUrl, sessionToken, roomName, roomId);
|
|
888
|
+
const serverRoomName = String(data.roomName ?? "");
|
|
889
|
+
const jr = {
|
|
890
|
+
serverUrl,
|
|
891
|
+
sessionToken,
|
|
892
|
+
participantId: newParticipantId,
|
|
893
|
+
roomName,
|
|
894
|
+
roomId,
|
|
895
|
+
authority: roomAuthority,
|
|
896
|
+
participants,
|
|
897
|
+
dataSource,
|
|
898
|
+
rejoin: {
|
|
899
|
+
joinUrl: url,
|
|
900
|
+
shareToken: token ?? void 0,
|
|
901
|
+
name: joinName,
|
|
902
|
+
type: "agent",
|
|
903
|
+
serverRoomName,
|
|
904
|
+
localTier: localTierOf(
|
|
905
|
+
token ?? void 0,
|
|
906
|
+
(ctx.rejoinOverrides?.readRoomSession ?? readLocalRoomSession)(serverRoomName)
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
jr.rejoiner = new RoomRejoiner(jr, {
|
|
911
|
+
...ctx.rejoinOverrides,
|
|
912
|
+
sseMux,
|
|
913
|
+
processor,
|
|
914
|
+
log: ctx.log,
|
|
915
|
+
onLost: ctx.onRoomLost ? (_room, err) => ctx.onRoomLost(jr, err) : void 0
|
|
916
|
+
});
|
|
917
|
+
dataSource.setRecovery(jr.rejoiner);
|
|
918
|
+
joinResults.push(jr);
|
|
919
|
+
const conn = processor.resolve(roomName);
|
|
920
|
+
let recentLines = [];
|
|
921
|
+
if (conn) {
|
|
922
|
+
const catchUp = await buildCatchUpLines(conn, {
|
|
923
|
+
isEventSeen: (id) => processor.isEventSeen(id),
|
|
924
|
+
markEventsSeen: (ids) => processor.markEventsSeen(ids),
|
|
925
|
+
assignRef: (id) => processor.assignRef(id)
|
|
926
|
+
});
|
|
927
|
+
recentLines = catchUp.lines;
|
|
928
|
+
}
|
|
929
|
+
await onRoomJoined?.();
|
|
930
|
+
let rules;
|
|
931
|
+
try {
|
|
932
|
+
const rulesRes = await fetch(`${serverUrl}/rules`, {
|
|
933
|
+
headers: { Authorization: `Bearer ${sessionToken}` }
|
|
934
|
+
});
|
|
935
|
+
if (rulesRes.ok) {
|
|
936
|
+
const rulesData = await rulesRes.json();
|
|
937
|
+
if (Array.isArray(rulesData.rules) && rulesData.rules.length > 0) rules = rulesData.rules;
|
|
938
|
+
}
|
|
939
|
+
} catch {
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
success: true,
|
|
943
|
+
roomName,
|
|
944
|
+
agentName: joinName,
|
|
945
|
+
authority: roomAuthority,
|
|
946
|
+
mode,
|
|
947
|
+
participants: participants.filter((p) => p.id !== newParticipantId).map((p) => ({ name: p.name, authority: p.authority ?? "member" })),
|
|
948
|
+
recentLines,
|
|
949
|
+
rules
|
|
950
|
+
};
|
|
951
|
+
} catch (err) {
|
|
952
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
953
|
+
return { success: false, error: `Unable to connect. Is the server running? (${serverUrl}) \u2014 ${msg}` };
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
onLeaveRoom: async (room) => {
|
|
957
|
+
const conn = processor.resolve(room);
|
|
958
|
+
if (!conn) return { success: false, error: `Unknown room "${room}".` };
|
|
959
|
+
const roomId = conn.dataSource.roomId;
|
|
960
|
+
const idx = joinResults.findIndex((jr) => jr.roomId === roomId);
|
|
961
|
+
if (idx >= 0) {
|
|
962
|
+
const jr = joinResults[idx];
|
|
963
|
+
jr.dataSource.setRecovery(null);
|
|
964
|
+
sseMux.removeConnection(roomId);
|
|
965
|
+
processor.disconnectRemoteRoom(roomId);
|
|
966
|
+
try {
|
|
967
|
+
await fetch(`${jr.serverUrl}/disconnect`, {
|
|
968
|
+
method: "POST",
|
|
969
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jr.sessionToken}` },
|
|
970
|
+
body: JSON.stringify({})
|
|
971
|
+
});
|
|
972
|
+
} catch {
|
|
973
|
+
}
|
|
974
|
+
joinResults.splice(idx, 1);
|
|
975
|
+
}
|
|
976
|
+
return { success: true };
|
|
977
|
+
},
|
|
978
|
+
// Admin actions differ only by endpoint + target tier — one helper covers all.
|
|
979
|
+
onAdminSetModeFor: can(authority, "set_mode_for") ? (room, participant, mode) => adminAction((id) => ({ path: "/set-mode", body: { participantId: id, mode } }))(room, participant) : void 0,
|
|
980
|
+
onAdminMute: can(authority, "mute") ? adminAction((id) => ({ path: "/set-authority", body: { participantId: id, authority: "guest" } })) : void 0,
|
|
981
|
+
onAdminUnmute: can(authority, "unmute") ? adminAction((id) => ({ path: "/set-authority", body: { participantId: id, authority: "member" } })) : void 0,
|
|
982
|
+
onAdminKick: can(authority, "kick") ? adminAction((id) => ({ path: "/kick", body: { participantId: id } })) : void 0,
|
|
983
|
+
onAdminPromote: can(authority, "promote") ? adminAction((id) => ({ path: "/set-authority", body: { participantId: id, authority: "product_owner" } })) : void 0,
|
|
984
|
+
onAdminDemote: can(authority, "demote") ? adminAction((id) => ({ path: "/set-authority", body: { participantId: id, authority: "member" } })) : void 0
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export {
|
|
989
|
+
composerStillHolds,
|
|
990
|
+
TmuxBridge,
|
|
991
|
+
trackRoomEvent,
|
|
992
|
+
buildRuntimeMcpOptions
|
|
993
|
+
};
|
|
994
|
+
//# sourceMappingURL=chunk-OEAHTZCA.js.map
|