@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.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
- * Node's WebSocket hides the status of a refused upgrade, so ask plainly first:
38
- * an authorized agent-host route answers a plain GET with 426 Upgrade Required.
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 (err) {
52
- return fail('unreachable', `cannot reach ${http}: ${err.message}`);
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
- const res = await fetchImpl(new URL(`users/${encodeURIComponent(user)}`, api), { headers });
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 fetchImpl(new URL(`users/${encodeURIComponent(user)}/servers/${encodeURIComponent(serverName)}`, api), { method: 'POST', headers });
76
- if (spawn.status === 401) {
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
- const msg = JSON.parse(String(ev.data));
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).resolve(msg);
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.closed?.(`connection closed (${ev.code ?? 'unknown'})`));
174
+ ws.addEventListener('close', ev => this.stop(`connection closed (${ev.code ?? 'unknown'})`));
175
+ ws.addEventListener('error', () => this.stop('host transport failed'));
120
176
  }
121
- call(method, params) {
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.pending.set(id, { resolve });
125
- this.ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
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.ws.close();
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
- conn.actions.push(async (action, channel) => {
157
- if (channel !== chat) {
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.confirmationTitle !== undefined) {
169
- const title = typeof action.confirmationTitle === 'string' ? action.confirmationTitle : action.confirmationTitle?.markdown ?? 'Run tool';
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
- finish('turnComplete');
344
+ end('turnComplete');
188
345
  }
189
346
  break;
190
347
  case 'chat/error':
191
- io.write(`\n[turn error] ${JSON.stringify(action.error ?? action).slice(0, 200)}\n`);
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
- finish('error');
351
+ end('error');
194
352
  }
195
353
  break;
196
354
  }
197
- });
198
- conn.closed = detail => finish(detail);
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 => rl.question(`\n${title}\n ${input}\nApprove? [y/N] `, answer => {
225
- rl.close();
226
- resolve(/^y(es)?$/i.test(answer.trim()));
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 token = env[opts.tokenEnv];
241
- const headers = token ? { Authorization: `token ${token}` } : {};
242
- let url = opts.url;
243
- if (!url && opts.hub && opts.user) {
244
- if (!token) {
245
- return fallback(fail('auth', `set ${opts.tokenEnv} to a hub API token`));
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
- const resolved = await resolveHubHost({ hubUrl: opts.hub, user: opts.user, token });
248
- if (!resolved.ok) {
249
- return fallback(resolved);
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
- url = resolved.url;
252
- }
253
- if (!url) {
254
- err('usage: calliope attach (--url <ws-url> | --hub <url> --user <name>) [session-uri] [--read-only]');
255
- return 1;
256
- }
257
- const refused = await preflight(url, headers);
258
- if (refused) {
259
- return fallback(refused);
260
- }
261
- const ws = new WebSocket(url, { headers });
262
- const opened = await new Promise(resolve => {
263
- ws.addEventListener('open', () => resolve(true));
264
- ws.addEventListener('error', () => resolve(false));
265
- });
266
- if (!opened) {
267
- return fallback(fail('unreachable', 'the WebSocket upgrade failed'));
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
- const conn = new AhpConnection(ws);
270
- const init = await conn.call('initialize', {
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
- if (!opts.session) {
280
- const list = await conn.call('listSessions', { channel: ROOT });
281
- if (list.error) {
282
- err(`calliope attach: the host could not list sessions: ${list.error.message.split('\n')[0]}`);
283
- conn.close();
284
- return 1;
285
- }
286
- const items = (list.result?.items ?? []);
287
- process.stdout.write(items.length ? '' : 'No sessions on this host.\n');
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