@calliopelabs/cli 3.3.0 → 3.4.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/dist/attach.d.ts +85 -16
- package/dist/attach.d.ts.map +1 -1
- package/dist/attach.js +378 -113
- package/dist/attach.js.map +1 -1
- package/dist/bin.d.ts +1 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +4 -2
- package/dist/bin.js.map +1 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +26 -36
- package/dist/policy.js.map +1 -1
- package/dist/runtime/permissions.d.ts +2 -0
- package/dist/runtime/permissions.d.ts.map +1 -1
- package/dist/runtime/permissions.js +33 -0
- package/dist/runtime/permissions.js.map +1 -1
- package/dist/runtime/turn.js +39 -14
- package/dist/runtime/turn.js.map +1 -1
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +7 -2
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/ui/error-boundary.js +1 -1
- package/dist/ui/error-boundary.js.map +1 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +3 -0
- package/dist/ui/index.js.map +1 -1
- package/package.json +5 -1
package/dist/attach.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `calliope attach` connects to a running agent host over the Agent Host
|
|
5
5
|
* Protocol (JSON-RPC over WebSocket) and follows a session another surface
|
|
6
6
|
* started (the IDE, desktop, AGTerm, Chat Studio), answering its tool-call
|
|
7
|
-
* confirmations from the terminal
|
|
7
|
+
* confirmations from the terminal, including any already waiting when it joins
|
|
8
|
+
* and those inside a subagent, which the session's `inputNeeded` lists (#394).
|
|
8
9
|
*
|
|
9
10
|
* calliope attach --url <ws-url> list the host's sessions
|
|
10
11
|
* calliope attach --url <ws-url> <session-uri> follow one, approve its tool calls
|
|
@@ -20,8 +21,10 @@
|
|
|
20
21
|
* CALLIOPE_AHP=off is the kill switch.
|
|
21
22
|
*/
|
|
22
23
|
import * as readline from 'readline';
|
|
24
|
+
import WebSocket from 'ws';
|
|
23
25
|
/** Protocol versions offered in `initialize`; keep in step with calliope-vscode scripts/ahp-supported-versions.json. */
|
|
24
|
-
export const SUPPORTED_VERSIONS = ['1.0.0', '0.6.0'];
|
|
26
|
+
export const SUPPORTED_VERSIONS = ['1.0.0', '0.9.0', '0.6.0'];
|
|
27
|
+
export const ATTACH_TIMEOUT_MS = 60_000;
|
|
25
28
|
export const FALLBACK_EXIT = 3;
|
|
26
29
|
const ROOT = 'ahp-root://';
|
|
27
30
|
const UNSUPPORTED_PROTOCOL_VERSION = -32005;
|
|
@@ -34,13 +37,17 @@ export function defaultChatUri(session) {
|
|
|
34
37
|
return `ahp-chat://default/${Buffer.from(session).toString('base64url')}`;
|
|
35
38
|
}
|
|
36
39
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
40
|
+
* Classify authentication refusals before upgrading the transport. An authorized
|
|
41
|
+
* agent-host route answers a plain GET with 426 Upgrade Required.
|
|
39
42
|
*/
|
|
40
|
-
export async function preflight(wsUrl, headers, fetchImpl = fetch) {
|
|
43
|
+
export async function preflight(wsUrl, headers, fetchImpl = fetch, options = {}) {
|
|
41
44
|
const http = wsUrl.replace(/^ws/, 'http');
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? ATTACH_TIMEOUT_MS);
|
|
47
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
42
48
|
try {
|
|
43
|
-
const res = await fetchImpl(http, { headers, redirect: 'manual' });
|
|
49
|
+
const res = await fetchImpl(http, { headers, redirect: 'manual', signal });
|
|
50
|
+
void res.body?.cancel().catch(() => { });
|
|
44
51
|
if (res.status === 426 || res.ok) {
|
|
45
52
|
return undefined;
|
|
46
53
|
}
|
|
@@ -48,8 +55,12 @@ export async function preflight(wsUrl, headers, fetchImpl = fetch) {
|
|
|
48
55
|
const toLogin = res.status >= 300 && res.status < 400 && /\/(hub\/)?login|oauth/.test(res.headers.get('location') ?? '');
|
|
49
56
|
return fail(res.status === 401 || res.status === 403 || toLogin ? 'auth' : 'unreachable', `the host route answered HTTP ${res.status}`);
|
|
50
57
|
}
|
|
51
|
-
catch
|
|
52
|
-
return fail('unreachable',
|
|
58
|
+
catch {
|
|
59
|
+
return fail('unreachable', signal.aborted ? 'host preflight timed out' : 'host preflight failed');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
controller.abort();
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
/** Find (or start) the user's agent host on a JupyterHub; returns its WebSocket URL. */
|
|
@@ -57,10 +68,30 @@ export async function resolveHubHost(opts) {
|
|
|
57
68
|
const { hubUrl, user, token, serverName = 'agenthost', fetchImpl = fetch, timeoutMs = 60_000 } = opts;
|
|
58
69
|
const api = new URL('hub/api/', hubUrl.endsWith('/') ? hubUrl : `${hubUrl}/`);
|
|
59
70
|
const headers = { Authorization: `token ${token}` };
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
73
|
+
const request = (url, init = {}) => fetchImpl(url, {
|
|
74
|
+
...init, signal: controller.signal, redirect: 'error', credentials: 'omit',
|
|
75
|
+
});
|
|
60
76
|
const deadline = Date.now() + timeoutMs;
|
|
61
77
|
try {
|
|
78
|
+
const tap = await request(new URL('/agent-host/status', hubUrl), { headers });
|
|
79
|
+
if (tap.status === 401)
|
|
80
|
+
return fail('auth', 'hub tap refused the credential');
|
|
81
|
+
if (tap.status !== 404) {
|
|
82
|
+
const body = await tap.json();
|
|
83
|
+
if (tap.ok && body?.enabled === true && body.endpoint === '/agent-host/') {
|
|
84
|
+
const ws = new URL('/agent-host/', hubUrl);
|
|
85
|
+
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
86
|
+
return { ok: true, url: ws.toString() };
|
|
87
|
+
}
|
|
88
|
+
const reason = body?.reason === 'disabled' || body?.reason === 'unentitled' || body?.reason === 'auth'
|
|
89
|
+
? body.reason : tap.status === 403 ? 'auth' : 'unreachable';
|
|
90
|
+
return fail(reason, `hub tap answered ${tap.status}`);
|
|
91
|
+
}
|
|
62
92
|
for (let spawned = false;;) {
|
|
63
|
-
|
|
93
|
+
controller.signal.throwIfAborted();
|
|
94
|
+
const res = await request(new URL(`users/${encodeURIComponent(user)}`, api), { headers });
|
|
64
95
|
if (!res.ok) {
|
|
65
96
|
return fail(res.status === 401 || res.status === 403 ? 'auth' : 'unreachable', `hub user lookup answered ${res.status}`);
|
|
66
97
|
}
|
|
@@ -68,30 +99,35 @@ export async function resolveHubHost(opts) {
|
|
|
68
99
|
const server = body.servers?.[serverName];
|
|
69
100
|
if (server?.ready && server.url) {
|
|
70
101
|
const ws = new URL(`${server.url}agent-host/`, hubUrl);
|
|
102
|
+
if (ws.origin !== new URL(hubUrl).origin)
|
|
103
|
+
return fail('unreachable', 'hub answered a foreign server URL');
|
|
71
104
|
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
72
105
|
return { ok: true, url: ws.toString() };
|
|
73
106
|
}
|
|
74
|
-
if (!spawned) {
|
|
75
|
-
const spawn = await
|
|
76
|
-
|
|
107
|
+
if (!spawned && !server?.pending) {
|
|
108
|
+
const spawn = await request(new URL(`users/${encodeURIComponent(user)}/servers/${encodeURIComponent(serverName)}`, api), {
|
|
109
|
+
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: serverName }),
|
|
110
|
+
});
|
|
111
|
+
if (spawn.status === 401)
|
|
77
112
|
return fail('auth', 'hub refused the token');
|
|
78
|
-
}
|
|
79
113
|
if (spawn.status === 403 || (spawn.status === 400 && !server)) {
|
|
80
114
|
return fail('unentitled', `hub refused to start ${serverName}: ${spawn.status}`);
|
|
81
115
|
}
|
|
82
|
-
if (!spawn.ok && spawn.status !== 400)
|
|
116
|
+
if (!spawn.ok && spawn.status !== 400)
|
|
83
117
|
return fail('unreachable', `hub could not start ${serverName}: ${spawn.status}`);
|
|
84
|
-
}
|
|
85
118
|
spawned = true;
|
|
86
119
|
}
|
|
87
|
-
if (Date.now() >= deadline)
|
|
120
|
+
if (Date.now() >= deadline)
|
|
88
121
|
return fail('unreachable', `${serverName} not ready within ${Math.round(timeoutMs / 1000)}s`);
|
|
89
|
-
|
|
90
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
122
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(1000, Math.max(0, deadline - Date.now()))));
|
|
91
123
|
}
|
|
92
124
|
}
|
|
93
125
|
catch (err) {
|
|
94
|
-
return fail('unreachable', `hub unreachable: ${err.message}`);
|
|
126
|
+
return fail('unreachable', controller.signal.aborted ? 'hub discovery timed out' : `hub unreachable: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
controller.abort();
|
|
95
131
|
}
|
|
96
132
|
}
|
|
97
133
|
/** The minimal JSON-RPC client attach needs, over any WebSocket-shaped object. */
|
|
@@ -99,37 +135,87 @@ export class AhpConnection {
|
|
|
99
135
|
ws;
|
|
100
136
|
nextId = 1;
|
|
101
137
|
pending = new Map();
|
|
138
|
+
failure;
|
|
139
|
+
onClosed;
|
|
102
140
|
actions = [];
|
|
103
|
-
closed
|
|
141
|
+
set closed(callback) {
|
|
142
|
+
this.onClosed = callback;
|
|
143
|
+
if (this.failure)
|
|
144
|
+
callback?.(this.failure);
|
|
145
|
+
}
|
|
104
146
|
constructor(ws) {
|
|
105
147
|
this.ws = ws;
|
|
106
148
|
ws.addEventListener('message', ev => {
|
|
107
|
-
|
|
149
|
+
if (this.failure)
|
|
150
|
+
return;
|
|
151
|
+
let msg;
|
|
152
|
+
try {
|
|
153
|
+
msg = JSON.parse(String(ev.data));
|
|
154
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg))
|
|
155
|
+
throw new Error('invalid frame');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
this.stop('invalid host message');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
108
161
|
if (msg.id !== undefined && this.pending.has(msg.id)) {
|
|
109
|
-
this.pending.get(msg.id)
|
|
162
|
+
const pending = this.pending.get(msg.id);
|
|
110
163
|
this.pending.delete(msg.id);
|
|
164
|
+
pending.cleanup();
|
|
165
|
+
pending.resolve(msg);
|
|
111
166
|
return;
|
|
112
167
|
}
|
|
113
168
|
if (msg.method === 'action' && msg.params?.action) {
|
|
114
169
|
for (const fn of this.actions) {
|
|
115
|
-
fn(msg.params.action, msg.params.channel);
|
|
170
|
+
fn(msg.params.action, msg.params.channel, msg.params.serverSeq);
|
|
116
171
|
}
|
|
117
172
|
}
|
|
118
173
|
});
|
|
119
|
-
ws.addEventListener('close', ev => this.
|
|
174
|
+
ws.addEventListener('close', ev => this.stop(`connection closed (${ev.code ?? 'unknown'})`));
|
|
175
|
+
ws.addEventListener('error', () => this.stop('host transport failed'));
|
|
120
176
|
}
|
|
121
|
-
|
|
177
|
+
stop(detail) {
|
|
178
|
+
if (this.failure)
|
|
179
|
+
return;
|
|
180
|
+
this.failure = detail;
|
|
181
|
+
for (const pending of this.pending.values()) {
|
|
182
|
+
pending.cleanup();
|
|
183
|
+
pending.resolve({ error: { code: -32000, message: detail } });
|
|
184
|
+
}
|
|
185
|
+
this.pending.clear();
|
|
186
|
+
this.onClosed?.(detail);
|
|
187
|
+
try {
|
|
188
|
+
this.ws.terminate ? this.ws.terminate() : this.ws.close();
|
|
189
|
+
}
|
|
190
|
+
catch { /* Already closed. */ }
|
|
191
|
+
}
|
|
192
|
+
call(method, params, options = {}) {
|
|
193
|
+
if (this.failure)
|
|
194
|
+
return Promise.resolve({ error: { code: -32000, message: this.failure } });
|
|
195
|
+
if (options.signal?.aborted) {
|
|
196
|
+
this.stop('host setup timed out');
|
|
197
|
+
return Promise.resolve({ error: { code: -32000, message: this.failure } });
|
|
198
|
+
}
|
|
122
199
|
const id = this.nextId++;
|
|
123
200
|
return new Promise(resolve => {
|
|
124
|
-
this.
|
|
125
|
-
|
|
201
|
+
const aborted = () => this.stop('host setup timed out');
|
|
202
|
+
const timer = setTimeout(() => this.stop('host request timed out'), options.timeoutMs ?? ATTACH_TIMEOUT_MS);
|
|
203
|
+
const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', aborted); };
|
|
204
|
+
this.pending.set(id, { resolve, cleanup });
|
|
205
|
+
options.signal?.addEventListener('abort', aborted, { once: true });
|
|
206
|
+
try {
|
|
207
|
+
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
this.stop('host request failed');
|
|
211
|
+
}
|
|
126
212
|
});
|
|
127
213
|
}
|
|
128
214
|
dispatch(channel, clientSeq, action) {
|
|
129
215
|
this.ws.send(JSON.stringify({ jsonrpc: '2.0', method: 'dispatchAction', params: { channel, clientSeq, action } }));
|
|
130
216
|
}
|
|
131
217
|
close() {
|
|
132
|
-
this.
|
|
218
|
+
this.stop('connection closed');
|
|
133
219
|
}
|
|
134
220
|
}
|
|
135
221
|
/** Classify the `initialize` answer; undefined when the host is usable. */
|
|
@@ -144,19 +230,88 @@ export function checkInitialize(msg, supported = SUPPORTED_VERSIONS) {
|
|
|
144
230
|
}
|
|
145
231
|
return undefined;
|
|
146
232
|
}
|
|
233
|
+
const text = (value, fallback) => typeof value === 'string' ? value : value?.markdown ?? fallback;
|
|
147
234
|
/**
|
|
148
235
|
* Render a session's chat actions and answer tool confirmations. Resolves when
|
|
149
236
|
* the connection closes, or after the next turn with `once`.
|
|
237
|
+
*
|
|
238
|
+
* `subscribed` is the chat channel's `subscribe` snapshot for a client joining
|
|
239
|
+
* a session already under way: the confirmations it shows waiting are asked
|
|
240
|
+
* first. `sessionSubscribed` is the session channel's: its `inputNeeded` lists
|
|
241
|
+
* the confirmations waiting in the session's other chats (a subagent's), which
|
|
242
|
+
* are answered on their own chat. Each channel's actions that arrive before its
|
|
243
|
+
* snapshot are held, then applied after it, skipping any the snapshot already
|
|
244
|
+
* includes.
|
|
150
245
|
*/
|
|
151
|
-
export function follow(conn, session, io) {
|
|
246
|
+
export function follow(conn, session, io, subscribed, sessionSubscribed) {
|
|
152
247
|
const chat = defaultChatUri(session);
|
|
153
248
|
let seq = 0;
|
|
154
249
|
let finish;
|
|
250
|
+
let ended = false;
|
|
155
251
|
const done = new Promise(resolve => { finish = resolve; });
|
|
156
|
-
|
|
157
|
-
|
|
252
|
+
// Confirmations this client knows are open, by tool call, each able to
|
|
253
|
+
// withdraw its prompt. One prompt at a time: a snapshot can hold several, and
|
|
254
|
+
// two readline prompts on one stdin garble each other. `request` is the
|
|
255
|
+
// session's `inputNeeded` id of a confirmation asked for another chat.
|
|
256
|
+
const open = new Map();
|
|
257
|
+
let prompts = Promise.resolve();
|
|
258
|
+
const settled = (toolCallId) => {
|
|
259
|
+
open.get(toolCallId)?.asked.abort();
|
|
260
|
+
open.delete(toolCallId);
|
|
261
|
+
};
|
|
262
|
+
const settleAll = () => {
|
|
263
|
+
for (const toolCallId of [...open.keys()]) {
|
|
264
|
+
settled(toolCallId);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
// The default chat's turn is over, and so are the prompts asked on it. One
|
|
268
|
+
// for another chat closes when the session removes its request: the host ends
|
|
269
|
+
// subagent turns with a cancelled parent's, and a background subagent's turn
|
|
270
|
+
// can outlive the parent's.
|
|
271
|
+
const settleTurn = () => {
|
|
272
|
+
for (const [toolCallId, { request }] of [...open]) {
|
|
273
|
+
if (request === undefined) {
|
|
274
|
+
settled(toolCallId);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
// Following is over: no prompt outlives it, or its readline keeps the process alive.
|
|
279
|
+
const end = (reason) => {
|
|
280
|
+
if (ended)
|
|
281
|
+
return;
|
|
282
|
+
ended = true;
|
|
283
|
+
settleAll();
|
|
284
|
+
finish(reason);
|
|
285
|
+
};
|
|
286
|
+
const confirm = (target, turnId, toolCallId, confirmationTitle, toolInput, request) => {
|
|
287
|
+
if (ended || open.has(toolCallId)) {
|
|
158
288
|
return;
|
|
159
289
|
}
|
|
290
|
+
const asked = new AbortController();
|
|
291
|
+
open.set(toolCallId, { asked, request });
|
|
292
|
+
const title = text(confirmationTitle, 'Run tool');
|
|
293
|
+
const input = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput ?? '');
|
|
294
|
+
const approve = io.approve;
|
|
295
|
+
if (!approve) {
|
|
296
|
+
io.write(`\n[waiting for approval elsewhere] ${title}: ${input}\n`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
prompts = prompts.then(async () => {
|
|
300
|
+
if (asked.signal.aborted) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const approved = await approve(title, input, asked.signal);
|
|
304
|
+
if (asked.signal.aborted) {
|
|
305
|
+
io.write('\n[approval no longer needed; nothing sent]\n');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
open.delete(toolCallId);
|
|
309
|
+
conn.dispatch(target, ++seq, approved
|
|
310
|
+
? { type: 'chat/toolCallConfirmed', turnId, toolCallId, approved: true, confirmed: 'user-action' }
|
|
311
|
+
: { type: 'chat/toolCallConfirmed', turnId, toolCallId, approved: false, reason: 'denied' });
|
|
312
|
+
}).catch(err => io.write(`\n[approval prompt failed] ${err.message}\n`));
|
|
313
|
+
};
|
|
314
|
+
const apply = (action) => {
|
|
160
315
|
switch (action.type) {
|
|
161
316
|
case 'chat/turnStarted':
|
|
162
317
|
io.write(`\n› ${action.message?.text ?? ''}\n`);
|
|
@@ -165,37 +320,110 @@ export function follow(conn, session, io) {
|
|
|
165
320
|
io.write(action.content ?? '');
|
|
166
321
|
break;
|
|
167
322
|
case 'chat/toolCallReady':
|
|
168
|
-
if (action.
|
|
169
|
-
|
|
170
|
-
const input = typeof action.toolInput === 'string' ? action.toolInput : JSON.stringify(action.toolInput ?? '');
|
|
171
|
-
if (!io.approve) {
|
|
172
|
-
io.write(`\n[waiting for approval elsewhere] ${title}: ${input}\n`);
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
const approved = await io.approve(title, input);
|
|
176
|
-
conn.dispatch(chat, ++seq, approved
|
|
177
|
-
? { type: 'chat/toolCallConfirmed', turnId: action.turnId, toolCallId: action.toolCallId, approved: true, confirmed: 'user-action' }
|
|
178
|
-
: { type: 'chat/toolCallConfirmed', turnId: action.turnId, toolCallId: action.toolCallId, approved: false, reason: 'denied' });
|
|
323
|
+
if (!action.confirmed) {
|
|
324
|
+
confirm(chat, action.turnId, action.toolCallId, action.confirmationTitle ?? action.invocationMessage, action.toolInput);
|
|
179
325
|
}
|
|
180
326
|
break;
|
|
327
|
+
case 'chat/toolCallConfirmed':
|
|
328
|
+
settled(action.toolCallId);
|
|
329
|
+
break;
|
|
181
330
|
case 'chat/toolCallComplete':
|
|
331
|
+
settled(action.toolCallId);
|
|
182
332
|
io.write(`\n[tool ${action.result?.success === false ? 'failed' : 'done'}]\n`);
|
|
183
333
|
break;
|
|
334
|
+
case 'chat/turnCancelled':
|
|
335
|
+
settleTurn();
|
|
336
|
+
io.write('\n[turn cancelled]\n');
|
|
337
|
+
if (io.once)
|
|
338
|
+
end('cancelled');
|
|
339
|
+
break;
|
|
184
340
|
case 'chat/turnComplete':
|
|
341
|
+
settleTurn();
|
|
185
342
|
io.write('\n[turn complete]\n');
|
|
186
343
|
if (io.once) {
|
|
187
|
-
|
|
344
|
+
end('turnComplete');
|
|
188
345
|
}
|
|
189
346
|
break;
|
|
190
347
|
case 'chat/error':
|
|
191
|
-
|
|
348
|
+
settleTurn();
|
|
349
|
+
io.write(`\n[turn error] ${JSON.stringify(action.part?.error ?? action.error ?? action).slice(0, 200)}\n`);
|
|
192
350
|
if (io.once) {
|
|
193
|
-
|
|
351
|
+
end('error');
|
|
194
352
|
}
|
|
195
353
|
break;
|
|
196
354
|
}
|
|
197
|
-
}
|
|
198
|
-
|
|
355
|
+
};
|
|
356
|
+
// The session's `inputNeeded` lists the input waiting in every chat. A
|
|
357
|
+
// confirmation on the default chat is there too, and is asked through that
|
|
358
|
+
// chat above: a host without calliope-vscode#823 drops its entry while the
|
|
359
|
+
// call still waits (the provider's late ready moves it to running), so that
|
|
360
|
+
// removal is no answer. The session is how attach hears about the other
|
|
361
|
+
// chats, a subagent's.
|
|
362
|
+
const ask = (request) => {
|
|
363
|
+
const call = request?.toolCall;
|
|
364
|
+
if (request?.kind === 'toolConfirmation' && call?.status === 'pending-confirmation'
|
|
365
|
+
&& typeof request.chat === 'string' && request.chat !== chat && typeof request.turnId === 'string') {
|
|
366
|
+
confirm(request.chat, request.turnId, call.toolCallId, call.confirmationTitle ?? call.invocationMessage, call.toolInput, request.id);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const applySession = (action) => {
|
|
370
|
+
switch (action.type) {
|
|
371
|
+
case 'session/inputNeededSet':
|
|
372
|
+
ask(action.request);
|
|
373
|
+
break;
|
|
374
|
+
case 'session/inputNeededRemoved':
|
|
375
|
+
for (const [toolCallId, { request }] of [...open]) {
|
|
376
|
+
if (request === action.id) {
|
|
377
|
+
settled(toolCallId);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
// Apply a channel's actions from its snapshot on: those that arrive first are
|
|
384
|
+
// held, then applied after it, skipping any it already includes.
|
|
385
|
+
const track = (channel, onAction, seed, snapshot) => {
|
|
386
|
+
let held = snapshot ? [] : undefined;
|
|
387
|
+
conn.actions.push((action, from, serverSeq) => {
|
|
388
|
+
if (ended || from !== channel) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (held) {
|
|
392
|
+
held.push({ action, serverSeq });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
onAction(action);
|
|
396
|
+
});
|
|
397
|
+
const join = (joined) => {
|
|
398
|
+
if (ended)
|
|
399
|
+
return;
|
|
400
|
+
seed(joined);
|
|
401
|
+
const early = held ?? [];
|
|
402
|
+
held = undefined;
|
|
403
|
+
const fromSeq = joined?.fromSeq;
|
|
404
|
+
for (const { action, serverSeq } of early) {
|
|
405
|
+
if (fromSeq === undefined || serverSeq === undefined || serverSeq > fromSeq) {
|
|
406
|
+
onAction(action);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
snapshot?.then(join, () => join(undefined));
|
|
411
|
+
};
|
|
412
|
+
track(chat, apply, snapshot => {
|
|
413
|
+
const turn = snapshot?.state?.activeTurn;
|
|
414
|
+
for (const part of turn?.responseParts ?? []) {
|
|
415
|
+
const call = part.kind === 'toolCall' ? part.toolCall : undefined;
|
|
416
|
+
if (turn && call?.status === 'pending-confirmation') {
|
|
417
|
+
confirm(chat, turn.id, call.toolCallId, call.confirmationTitle ?? call.invocationMessage, call.toolInput);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}, subscribed);
|
|
421
|
+
track(session, applySession, snapshot => {
|
|
422
|
+
for (const request of snapshot?.state?.inputNeeded ?? []) {
|
|
423
|
+
ask(request);
|
|
424
|
+
}
|
|
425
|
+
}, sessionSubscribed);
|
|
426
|
+
conn.closed = end;
|
|
199
427
|
return { done };
|
|
200
428
|
}
|
|
201
429
|
function parseArgs(args) {
|
|
@@ -219,14 +447,18 @@ function parseArgs(args) {
|
|
|
219
447
|
}
|
|
220
448
|
return out;
|
|
221
449
|
}
|
|
222
|
-
function askYesNo(title, input) {
|
|
450
|
+
function askYesNo(title, input, signal) {
|
|
223
451
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
224
|
-
return new Promise(resolve =>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
452
|
+
return new Promise(resolve => {
|
|
453
|
+
const end = (approved) => {
|
|
454
|
+
rl.close();
|
|
455
|
+
resolve(approved);
|
|
456
|
+
};
|
|
457
|
+
signal.addEventListener('abort', () => end(false), { once: true });
|
|
458
|
+
rl.question(`\n${title}\n ${input}\nApprove? [y/N] `, answer => end(/^y(es)?$/i.test(answer.trim())));
|
|
459
|
+
});
|
|
228
460
|
}
|
|
229
|
-
export async function runAttach(args, env = process.env) {
|
|
461
|
+
export async function runAttach(args, env = process.env, options = {}) {
|
|
230
462
|
const opts = parseArgs(args);
|
|
231
463
|
const err = (s) => process.stderr.write(`${s}\n`);
|
|
232
464
|
const fallback = (f) => {
|
|
@@ -234,73 +466,106 @@ export async function runAttach(args, env = process.env) {
|
|
|
234
466
|
err('Falling back: run `calliope` to work locally.');
|
|
235
467
|
return FALLBACK_EXIT;
|
|
236
468
|
};
|
|
237
|
-
if (isDisabled(env))
|
|
469
|
+
if (isDisabled(env))
|
|
238
470
|
return fallback(fail('disabled', 'CALLIOPE_AHP is off'));
|
|
471
|
+
const timeoutMs = options.timeoutMs ?? ATTACH_TIMEOUT_MS;
|
|
472
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) {
|
|
473
|
+
return fallback(fail('unreachable', 'invalid attach setup timeout'));
|
|
239
474
|
}
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
475
|
+
const controller = new AbortController();
|
|
476
|
+
const deadline = Date.now() + timeoutMs;
|
|
477
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
478
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
479
|
+
let ws;
|
|
480
|
+
let conn;
|
|
481
|
+
try {
|
|
482
|
+
const token = env[opts.tokenEnv];
|
|
483
|
+
const headers = token ? { Authorization: `token ${token}` } : {};
|
|
484
|
+
let url = opts.url;
|
|
485
|
+
if (!url && opts.hub && opts.user) {
|
|
486
|
+
if (!token)
|
|
487
|
+
return fallback(fail('auth', `set ${opts.tokenEnv} to a hub API token`));
|
|
488
|
+
const resolved = await resolveHubHost({ hubUrl: opts.hub, user: opts.user, token, timeoutMs: remaining() });
|
|
489
|
+
if (!resolved.ok)
|
|
490
|
+
return fallback(resolved);
|
|
491
|
+
url = resolved.url;
|
|
246
492
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return
|
|
493
|
+
if (!url) {
|
|
494
|
+
err('usage: calliope attach (--url <ws-url> | --hub <url> --user <name>) [session-uri] [--read-only]');
|
|
495
|
+
return 1;
|
|
250
496
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
497
|
+
const refused = await preflight(url, headers, fetch, { signal: controller.signal, timeoutMs: remaining() });
|
|
498
|
+
if (refused)
|
|
499
|
+
return fallback(refused);
|
|
500
|
+
controller.signal.throwIfAborted();
|
|
501
|
+
ws = options.createSocket ? options.createSocket(url, headers) : new WebSocket(url, { headers });
|
|
502
|
+
const socket = ws;
|
|
503
|
+
const opened = await new Promise(resolve => {
|
|
504
|
+
const finish = (ok) => { controller.signal.removeEventListener('abort', aborted); resolve(ok); };
|
|
505
|
+
const aborted = () => { try {
|
|
506
|
+
socket.terminate ? socket.terminate() : socket.close();
|
|
507
|
+
}
|
|
508
|
+
catch { /* Already closed. */ } finish(false); };
|
|
509
|
+
socket.addEventListener('open', () => finish(true));
|
|
510
|
+
socket.addEventListener('error', () => finish(false));
|
|
511
|
+
socket.addEventListener('close', () => finish(false));
|
|
512
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
513
|
+
if (controller.signal.aborted)
|
|
514
|
+
aborted();
|
|
515
|
+
});
|
|
516
|
+
if (!opened)
|
|
517
|
+
return fallback(fail('unreachable', controller.signal.aborted ? 'host setup timed out' : 'the WebSocket upgrade failed'));
|
|
518
|
+
conn = new AhpConnection(socket);
|
|
519
|
+
const call = (method, params) => conn.call(method, params, { signal: controller.signal, timeoutMs: remaining() });
|
|
520
|
+
const init = await call('initialize', {
|
|
521
|
+
channel: ROOT, protocolVersions: SUPPORTED_VERSIONS, clientId: `calliope-cli-${process.pid}`,
|
|
522
|
+
clientInfo: { name: 'calliope-cli', version: '1' }, initialSubscriptions: [ROOT],
|
|
523
|
+
});
|
|
524
|
+
const bad = checkInitialize(init);
|
|
525
|
+
if (bad)
|
|
526
|
+
return fallback(bad);
|
|
527
|
+
if (!opts.session) {
|
|
528
|
+
const list = await call('listSessions', { channel: ROOT });
|
|
529
|
+
if (list.error)
|
|
530
|
+
return fallback(fail('unreachable', 'the host could not list sessions'));
|
|
531
|
+
const items = (list.result?.items ?? []);
|
|
532
|
+
process.stdout.write(items.length ? '' : 'No sessions on this host.\n');
|
|
533
|
+
for (const session of items)
|
|
534
|
+
process.stdout.write(`${session.resource} ${session.title ?? ''} ${session.modifiedAt ?? ''}\n`);
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
// Register follow before either subscription answers: frames may follow a
|
|
538
|
+
// snapshot in the same network read. Keep the setup deadline through both.
|
|
539
|
+
const chat = defaultChatUri(opts.session);
|
|
540
|
+
const sessionAnswer = call('subscribe', { channel: opts.session });
|
|
541
|
+
const chatAnswer = sessionAnswer.then(answer => answer.error ? answer : call('subscribe', { channel: chat }));
|
|
542
|
+
const { done } = follow(conn, opts.session, {
|
|
543
|
+
write: s => process.stdout.write(s), approve: opts.readOnly ? undefined : askYesNo, once: opts.once,
|
|
544
|
+
}, chatAnswer.then(answer => answer.result?.snapshot), sessionAnswer.then(answer => answer.result?.snapshot));
|
|
545
|
+
const answers = await Promise.all([sessionAnswer, chatAnswer]);
|
|
546
|
+
if (answers.some(answer => answer.error))
|
|
547
|
+
return fallback(fail('unreachable', 'the host could not subscribe to the session'));
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
process.stderr.write(`attached to ${opts.session} (AHP ${init.result.protocolVersion}); Ctrl+C to detach\n`);
|
|
550
|
+
const ended = await done;
|
|
551
|
+
if (ended.startsWith('connection') || ended.startsWith('host') || ended.startsWith('invalid')) {
|
|
552
|
+
return fallback(fail('unreachable', 'the host connection was lost; the run remains on its host'));
|
|
553
|
+
}
|
|
554
|
+
return ended === 'turnComplete' ? 0 : 1;
|
|
268
555
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
channel: ROOT, protocolVersions: SUPPORTED_VERSIONS, clientId: `calliope-cli-${process.pid}`,
|
|
272
|
-
clientInfo: { name: 'calliope-cli', version: '1' }, initialSubscriptions: [ROOT],
|
|
273
|
-
});
|
|
274
|
-
const bad = checkInitialize(init);
|
|
275
|
-
if (bad) {
|
|
276
|
-
conn.close();
|
|
277
|
-
return fallback(bad);
|
|
556
|
+
catch {
|
|
557
|
+
return fallback(fail('unreachable', controller.signal.aborted ? 'host setup timed out' : 'host attachment failed'));
|
|
278
558
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
for (const s of items) {
|
|
289
|
-
process.stdout.write(`${s.resource} ${s.title ?? ''} ${s.modifiedAt ?? ''}\n`);
|
|
559
|
+
finally {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
controller.abort();
|
|
562
|
+
conn?.close();
|
|
563
|
+
if (!conn && ws) {
|
|
564
|
+
try {
|
|
565
|
+
ws.terminate ? ws.terminate() : ws.close();
|
|
566
|
+
}
|
|
567
|
+
catch { /* Already closed. */ }
|
|
290
568
|
}
|
|
291
|
-
conn.close();
|
|
292
|
-
return 0;
|
|
293
569
|
}
|
|
294
|
-
await conn.call('subscribe', { channel: opts.session });
|
|
295
|
-
await conn.call('subscribe', { channel: defaultChatUri(opts.session) });
|
|
296
|
-
process.stderr.write(`attached to ${opts.session} (AHP ${init.result.protocolVersion}); Ctrl+C to detach\n`);
|
|
297
|
-
const { done } = follow(conn, opts.session, {
|
|
298
|
-
write: s => process.stdout.write(s),
|
|
299
|
-
approve: opts.readOnly ? undefined : askYesNo,
|
|
300
|
-
once: opts.once,
|
|
301
|
-
});
|
|
302
|
-
const ended = await done;
|
|
303
|
-
conn.close();
|
|
304
|
-
return ended === 'turnComplete' ? 0 : 1;
|
|
305
570
|
}
|
|
306
571
|
//# sourceMappingURL=attach.js.map
|