@agentshouse/kit 0.2.1-c681dfca497a8 → 0.2.1-c6ec4d4d781e7

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.
@@ -1,42 +1,114 @@
1
- import { client, } from '@agentclientprotocol/sdk';
1
+ import { client, RequestError, } from '@agentclientprotocol/sdk';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { unlessAborted } from './abort.js';
6
6
  import { startAdapter } from './acp.js';
7
+ import { finalRefusal, HouseRefusal } from './api.js';
7
8
  import { openBridge } from './bridge.js';
8
9
  import { CLIS } from './clis.js';
9
- import { readerFor } from './events.js';
10
- import { placeFiles, uploadBytes } from './files.js';
10
+ import { answeredFailure, readerFor } from './events.js';
11
+ import { inspectedFile, linkedPaths, storeRefusal, uploaded } from './files.js';
11
12
  import { agentBase } from './home.js';
12
13
  import { instructions } from './instructions.js';
13
14
  import { Parts } from './parts.js';
15
+ import { refusalOf } from './refusals.js';
16
+ import { relayed } from './relay.js';
14
17
  import { killMarked, Scope } from './scope.js';
15
18
  import { holdSecretInput } from './secret-input.js';
16
19
  import { createHowWeWork } from './skills.js';
17
20
  import { transcribe } from './transcription.js';
18
21
  import { workFor } from './work.js';
22
+ const SIGNED_OUT = RequestError.authRequired().code;
19
23
  const KILLED = 'the conversation was killed';
20
- export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
24
+ const INTERRUPTED = 'the conversation was interrupted';
25
+ const REFUSED = 'the CLI refused the prompt';
26
+ const NO_CONNECTION = "the Agent's computer could not open this conversation; try again";
27
+ const GONE = 'House no longer holds this conversation; start a new one';
28
+ const RETIRED = "this conversation's Agent is retired, so it cannot start";
29
+ const ENDED = {
30
+ conversation_not_found: GONE,
31
+ conversation_historical: RETIRED,
32
+ };
33
+ const NO_START = "the Agent's CLI did not start";
34
+ const NO_SESSION = "the Agent's CLI did not open a session";
35
+ const NO_RESUME = "the Agent's CLI could not resume this conversation";
36
+ const NOT_SIGNED_IN = "the Agent's CLI is not signed in; sign it in on the Agent's machine";
37
+ const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
38
+ class NotStarted extends Error {
39
+ }
40
+ async function step(reason, work) {
41
+ try {
42
+ return await work;
43
+ }
44
+ catch (error) {
45
+ throw new NotStarted(typeof reason === 'string' ? reason : reason(error), { cause: error });
46
+ }
47
+ }
48
+ function signedOut(error) {
49
+ return typeof error === 'object' && error !== null && error.code === SIGNED_OUT;
50
+ }
51
+ function unopened(reason) {
52
+ return (error) => (signedOut(error) ? NOT_SIGNED_IN : reason);
53
+ }
54
+ // A start-up step is shown only once it has lasted a second, so a quick start shows none of them.
55
+ const STARTING_SHOWN_AFTER_MS = 1_000;
56
+ function unbridged(error) {
57
+ return (finalRefusal(error) ? ENDED[refusalOf(error.text)?.code ?? ''] : undefined) ?? NO_CONNECTION;
58
+ }
59
+ const EXIT_PLAN_MODE = {
60
+ parse(value) {
61
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
62
+ throw new Error('_x.ai/exit_plan_mode needs an object');
63
+ }
64
+ return value;
65
+ },
66
+ };
67
+ export const CONVERSATION_METHODS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
21
68
  function causeOf(error) {
22
69
  return error instanceof Error ? error.message : String(error);
23
70
  }
71
+ function answeredWith(error) {
72
+ return { failure: signedOut(error) ? 'signed_out' : 'error', cause: causeOf(error) };
73
+ }
74
+ function exitedWith(running, cause) {
75
+ return running.killed ? { failure: 'killed', cause: KILLED } : { failure: 'exited', cause };
76
+ }
24
77
  function answerMessage(request, response) {
25
78
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
26
79
  }
80
+ async function sentPart(url, bytes) {
81
+ const answer = await fetch(relayed(url), { method: 'PUT', body: bytes });
82
+ await answer.arrayBuffer();
83
+ if (!answer.ok)
84
+ throw storeRefusal('upload', answer.status);
85
+ }
27
86
  function logged(error) {
28
87
  process.stderr.write(`kit: ${causeOf(error)}\n`);
29
88
  }
89
+ function refusal(conversation, error) {
90
+ if (conversation.kills > 0)
91
+ return KILLED;
92
+ const cause = error instanceof Error && error.cause !== undefined ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
93
+ logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
94
+ return causeOf(error);
95
+ }
96
+ function audioOf(result) {
97
+ if (result.isError === true)
98
+ return null;
99
+ const file = inspectedFile(result.content.map((content) => content.text).join('\n'));
100
+ return file?.media_type?.startsWith('audio/') === true ? file : null;
101
+ }
30
102
  function marker(bridge) {
31
103
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
32
104
  }
33
105
  class Turn {
34
106
  id = randomUUID();
35
107
  parts;
36
- admitted = false;
37
108
  questions = 0;
38
109
  secrets = 0;
39
110
  prompted = false;
111
+ taken = null;
40
112
  ended = false;
41
113
  images = 0;
42
114
  uploads = [];
@@ -50,6 +122,8 @@ class Conversation {
50
122
  session = null;
51
123
  turn = null;
52
124
  commands = null;
125
+ options = null;
126
+ exitUntold = false;
53
127
  context = null;
54
128
  leftovers = new Set();
55
129
  waiting = [];
@@ -57,6 +131,9 @@ class Conversation {
57
131
  subagents = new Map();
58
132
  held = [];
59
133
  opening = null;
134
+ bridge = null;
135
+ launch = null;
136
+ prompted = null;
60
137
  kills = 0;
61
138
  killed = new AbortController();
62
139
  interrupts = 0;
@@ -64,6 +141,11 @@ class Conversation {
64
141
  open = 0;
65
142
  queue = Promise.resolve();
66
143
  reports = Promise.resolve();
144
+ starting = null;
145
+ preparing = null;
146
+ told = null;
147
+ untold = false;
148
+ shown = null;
67
149
  constructor(id) {
68
150
  this.id = id;
69
151
  }
@@ -79,9 +161,7 @@ function asksSecret(request) {
79
161
  export class Conversations {
80
162
  kit;
81
163
  conversations = new Map();
82
- acks = new Map();
83
164
  questions = new Map();
84
- carrying = 0;
85
165
  reporting = 0;
86
166
  watched = false;
87
167
  constructor(kit) {
@@ -89,38 +169,27 @@ export class Conversations {
89
169
  // A process or a durable schedule ends without a report, so the Kit looks at both again every two seconds.
90
170
  setInterval(() => this.look(), 2_000);
91
171
  }
92
- input(input) {
93
- this.carrying++;
94
- const carried = this.acks.get(input.input_id) ??
95
- (() => {
96
- const conversation = this.conversation(input.conversation_id);
97
- if (input.kind === 'kill') {
98
- conversation.kills++;
99
- conversation.killed.abort(new Error(KILLED));
100
- for (const waiting of conversation.waiting.splice(0))
101
- waiting.settle({ refused: KILLED });
102
- this.halt(conversation);
103
- }
104
- if (input.kind === 'interrupt') {
105
- conversation.interrupts++;
106
- conversation.interrupted.abort();
107
- }
108
- const handled = conversation.queue.then(() => this.carry(conversation, input));
109
- conversation.queue = handled.catch(() => undefined);
110
- const work = handled.then(({ ack }) => ack);
111
- this.acks.set(input.input_id, work);
112
- return work;
113
- })();
114
- carried
115
- .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
116
- .catch(logged)
117
- .finally(() => {
118
- this.carrying--;
119
- this.kit.changed();
172
+ call(call) {
173
+ void this.kit.calls.carry(call.input_id, () => {
174
+ const conversation = this.conversation(call.conversation_id);
175
+ if (call.kind === 'kill') {
176
+ conversation.kills++;
177
+ conversation.killed.abort(new Error(KILLED));
178
+ for (const waiting of conversation.waiting.splice(0))
179
+ waiting.settle({ refused: KILLED });
180
+ this.halt(conversation);
181
+ }
182
+ if (call.kind === 'interrupt') {
183
+ conversation.interrupts++;
184
+ conversation.interrupted.abort();
185
+ }
186
+ const handled = conversation.queue.then(() => this.carry(conversation, call));
187
+ conversation.queue = handled.catch(() => undefined);
188
+ return handled.then(async ({ result }) => ({ result: await result }));
120
189
  });
121
190
  }
122
191
  idle() {
123
- if (this.carrying > 0 || this.reporting > 0)
192
+ if (this.reporting > 0)
124
193
  return false;
125
194
  for (const conversation of this.conversations.values()) {
126
195
  const turn = conversation.turn;
@@ -149,6 +218,14 @@ export class Conversations {
149
218
  }
150
219
  opened() {
151
220
  this.watched = false;
221
+ for (const conversation of this.conversations.values()) {
222
+ if (conversation.running !== null)
223
+ this.facts(conversation);
224
+ else if (conversation.exitUntold)
225
+ this.process(conversation, false);
226
+ if (conversation.untold || conversation.told !== null)
227
+ this.retell(conversation);
228
+ }
152
229
  }
153
230
  watching(watched) {
154
231
  const resend = watched && !this.watched;
@@ -168,63 +245,99 @@ export class Conversations {
168
245
  }
169
246
  return conversation;
170
247
  }
171
- async carry(conversation, input) {
172
- if (input.kind === 'open')
173
- return { ack: Promise.resolve(await this.open(conversation, input)) };
174
- if (input.kind === 'message')
175
- return this.message(conversation, input);
176
- if (input.kind === 'interrupt') {
248
+ async carry(conversation, call) {
249
+ if (call.kind === 'open')
250
+ return { result: this.open(conversation, call) };
251
+ if (call.kind === 'message')
252
+ return this.message(conversation, call);
253
+ await this.launched(conversation);
254
+ if (call.kind === 'interrupt') {
177
255
  if (--conversation.interrupts === 0)
178
256
  conversation.interrupted = new AbortController();
179
- await this.interrupt(conversation, String(input.turn_id));
257
+ await this.interrupt(conversation, String(call.turn_id));
180
258
  }
181
- if (input.kind === 'kill') {
259
+ if (call.kind === 'kill') {
182
260
  await this.kill(conversation);
183
261
  await conversation.sending;
184
262
  if (--conversation.kills === 0)
185
263
  conversation.killed = new AbortController();
186
264
  }
187
- if (input.kind === 'option')
188
- await this.option(conversation, String(input.option), input.value);
189
- if (input.kind === 'answer') {
190
- const question = this.questions.get(String(input.interaction_id));
265
+ if (call.kind === 'option')
266
+ await this.option(conversation, String(call.option), call.value);
267
+ if (call.kind === 'answer') {
268
+ const question = this.questions.get(String(call.interaction_id));
191
269
  if (question === undefined) {
192
- const text = answerMessage(input.request, input.response);
193
- return this.message(conversation, { ...input, text, files: [], first: false });
270
+ const text = answerMessage(call.request, call.response);
271
+ return this.message(conversation, { ...call, text, first: false, message_id: null });
194
272
  }
195
- question.answer(input.response);
273
+ question.answer(call.response);
196
274
  }
197
- return { ack: Promise.resolve({}) };
275
+ return { result: Promise.resolve({}) };
198
276
  }
199
- async open(conversation, input) {
277
+ open(conversation, call) {
200
278
  if (conversation.running !== null)
201
- return {};
202
- try {
203
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
204
- }
205
- catch (error) {
206
- return { refused: causeOf(error) };
207
- }
208
- }
209
- async message(conversation, input) {
210
- const content = [{ type: 'text', text: String(input.text) }];
279
+ return Promise.resolve({});
280
+ const launch = conversation.launch ?? this.launch(conversation, this.route(conversation, call.agent_id), null, true);
281
+ return launch.session.then((session) => ({ provider_session_id: session }), (error) => ({ refused: refusal(conversation, error) }));
282
+ }
283
+ launch(conversation, route, sessionId, empty) {
284
+ let opened;
285
+ let failed;
286
+ const bridge = new Promise((resolve, reject) => {
287
+ opened = resolve;
288
+ failed = reject;
289
+ });
290
+ bridge.catch(() => undefined);
291
+ const session = this.start(conversation, route, sessionId, empty, opened);
292
+ session.catch(failed);
293
+ const launch = { bridge, session };
294
+ conversation.launch = launch;
295
+ void session
296
+ .catch(() => undefined)
297
+ .then(() => {
298
+ if (conversation.launch !== launch)
299
+ return;
300
+ conversation.launch = null;
301
+ this.closeIdleBridge(conversation);
302
+ this.next(conversation);
303
+ });
304
+ this.next(conversation);
305
+ return launch;
306
+ }
307
+ async launched(conversation) {
308
+ while (conversation.launch !== null)
309
+ await conversation.launch.session.catch(() => undefined);
310
+ }
311
+ instructed(conversation, route) {
312
+ const bridge = conversation.running === null ? conversation.launch?.bridge : Promise.resolve(conversation.running.bridge);
313
+ if (bridge === undefined)
314
+ return null;
315
+ const read = Promise.all([bridge, this.kit.copies.mapping()]).then(([opened, copies]) => instructions(opened, route.base_instructions, copies));
316
+ read.catch(() => undefined);
317
+ return read;
318
+ }
319
+ async message(conversation, call) {
320
+ const content = [{ type: 'text', text: String(call.text) }];
211
321
  let route;
322
+ let instructing = null;
212
323
  try {
213
- route = await this.route(conversation, input.agent_id);
214
- const files = input.files;
215
- const paths = await placeFiles(this.kit.house, route.working_directory, files, conversation.killed.signal);
216
- const lines = await this.transcribed(conversation, files, paths);
217
- if (lines.length > 0)
218
- content.push({ type: 'text', text: lines.join('\n') });
324
+ route = await this.route(conversation, call.agent_id);
325
+ if (call.first === true)
326
+ instructing = this.instructed(conversation, route);
327
+ if (call.message_id !== null)
328
+ content.push(...(await this.transcribed(conversation, String(call.message_id), String(call.text))));
219
329
  if (conversation.kills > 0)
220
330
  throw new Error(KILLED);
221
331
  }
222
332
  catch (error) {
223
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
333
+ this.closeIdleBridge(conversation);
334
+ this.prepares(conversation, null);
335
+ return { result: Promise.resolve({ refused: refusal(conversation, error) }) };
224
336
  }
337
+ conversation.preparing = null;
225
338
  return {
226
- ack: new Promise((settle) => {
227
- conversation.waiting.push({ input, route, content, settle });
339
+ result: new Promise((settle) => {
340
+ conversation.waiting.push({ call, route, content, instructing, settle });
228
341
  this.next(conversation);
229
342
  }),
230
343
  };
@@ -233,62 +346,146 @@ export class Conversations {
233
346
  if (conversation.sending !== null || conversation.open > 0)
234
347
  return;
235
348
  const waiting = conversation.waiting.shift();
236
- if (waiting === undefined)
349
+ if (waiting === undefined) {
350
+ const step = conversation.preparing ?? (conversation.launch === null ? null : 'session');
351
+ if (conversation.starting !== step)
352
+ this.starts(conversation, step);
237
353
  return;
354
+ }
238
355
  conversation.sending = this.send(conversation, waiting).finally(() => {
239
356
  conversation.sending = null;
240
357
  this.next(conversation);
241
358
  });
242
359
  }
243
360
  async send(conversation, waiting) {
244
- const { input } = waiting;
245
- let ack = {};
361
+ const { call } = waiting;
362
+ let result = {};
363
+ let started = false;
364
+ let instructing = waiting.instructing;
246
365
  const prompt = [];
247
366
  try {
248
367
  if (conversation.running === null) {
249
- const resumed = input.provider_session_id ?? conversation.session;
250
- const opened = await this.start(conversation, waiting.route, resumed);
251
- if (resumed === null)
252
- ack = { provider_session_id: opened };
368
+ this.starts(conversation, 'session');
369
+ await this.launched(conversation);
370
+ }
371
+ if (conversation.running === null) {
372
+ const stored = conversation.session ?? call.provider_session_id;
373
+ const empty = call.first === true && conversation.prompted !== stored;
374
+ const launch = this.launch(conversation, Promise.resolve(waiting.route), stored, empty);
375
+ if (call.first === true)
376
+ instructing = this.instructed(conversation, waiting.route);
377
+ started = (await launch.session) !== stored;
253
378
  }
254
- if (input.first === true) {
255
- prompt.push({
256
- type: 'text',
257
- text: await instructions(conversation.running.bridge, waiting.route.base_instructions, await this.kit.copies.mapping()),
258
- });
379
+ const session = conversation.running.sessionId;
380
+ if (started || (call.first === true && session !== call.provider_session_id)) {
381
+ result = { provider_session_id: session };
382
+ }
383
+ if (call.first === true) {
384
+ prompt.push({ type: 'text', text: await (instructing ?? this.instructed(conversation, waiting.route)) });
259
385
  }
260
386
  if (conversation.kills > 0)
261
387
  throw new Error(KILLED);
262
388
  }
263
389
  catch (error) {
264
- if ('provider_session_id' in ack) {
390
+ this.starts(conversation, null);
391
+ if (started) {
265
392
  conversation.session = null;
266
393
  await this.kill(conversation);
267
394
  }
268
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
395
+ waiting.settle({ refused: refusal(conversation, error) });
396
+ return;
397
+ }
398
+ this.starts(conversation, null);
399
+ this.prompt(conversation, [...prompt, ...waiting.content], () => this.kit.calls.keepResult(String(call.input_id), result));
400
+ waiting.settle(result);
401
+ }
402
+ starts(conversation, step) {
403
+ conversation.starting = step;
404
+ if (conversation.shown !== null)
405
+ clearTimeout(conversation.shown);
406
+ conversation.shown = null;
407
+ if (step === null || conversation.told !== null) {
408
+ this.tell(conversation, step);
269
409
  return;
270
410
  }
271
- this.prompt(conversation, [...prompt, ...waiting.content]);
272
- waiting.settle(ack);
411
+ conversation.shown = setTimeout(() => {
412
+ conversation.shown = null;
413
+ if (conversation.starting === step)
414
+ this.tell(conversation, step);
415
+ }, STARTING_SHOWN_AFTER_MS);
273
416
  }
274
- async transcribed(conversation, files, paths) {
275
- const lines = [];
276
- for (const [index, path] of paths.entries()) {
277
- lines.push(path);
278
- if (files[index].media_type?.startsWith('audio/') !== true)
279
- continue;
280
- try {
281
- const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
282
- if (text !== null && text !== '')
283
- lines.push(`Transcript: ${text}`);
284
- }
285
- catch (error) {
286
- if (conversation.kills > 0)
287
- throw error;
288
- logged(new Error(`${files[index].name} was not transcribed: ${causeOf(error)}`));
417
+ prepares(conversation, step) {
418
+ conversation.preparing = step;
419
+ this.next(conversation);
420
+ }
421
+ tell(conversation, step) {
422
+ if (conversation.told === step && !conversation.untold)
423
+ return;
424
+ conversation.told = step;
425
+ this.retell(conversation);
426
+ }
427
+ retell(conversation) {
428
+ conversation.untold = !this.kit.send({ type: 'starting', conversation_id: conversation.id, step: conversation.told });
429
+ }
430
+ async transcribed(conversation, messageId, text) {
431
+ const paths = linkedPaths(text);
432
+ if (paths.length === 0)
433
+ return [];
434
+ const killed = conversation.killed.signal;
435
+ const interrupted = conversation.interrupted.signal;
436
+ const transcribing = AbortSignal.any([killed, interrupted]);
437
+ let signal = killed;
438
+ const blocks = [];
439
+ try {
440
+ const bridge = await step(unbridged, unlessAborted(this.bridged(conversation), killed));
441
+ const read = async (path) => audioOf(await step(unbridged, unlessAborted(bridge.tool('inspect', { path }), signal)));
442
+ for (const path of paths) {
443
+ let answered = await read(path);
444
+ if (answered === null)
445
+ continue;
446
+ signal = transcribing;
447
+ this.prepares(conversation, 'transcription');
448
+ const said = await transcribe(async () => {
449
+ const address = answered ?? (await read(path));
450
+ answered = null;
451
+ if (address === null)
452
+ throw new Error(`${path} is no longer an audio file`);
453
+ return address;
454
+ }, signal);
455
+ const transcript = `/kit/conversations/${conversation.id}/messages/${messageId}/transcripts`;
456
+ await step(unbridged, unlessAborted(this.kit.house.deliver(transcript, { path, text: said }, () => !signal.aborted), signal));
457
+ blocks.push({ type: 'text', text: `${path}\nTranscript: ${said}` });
289
458
  }
290
459
  }
291
- return lines;
460
+ catch (error) {
461
+ if (signal === transcribing && interrupted.aborted && conversation.kills === 0)
462
+ throw new NotStarted(INTERRUPTED, { cause: error });
463
+ throw error;
464
+ }
465
+ return blocks;
466
+ }
467
+ bridged(conversation) {
468
+ if (conversation.bridge === null) {
469
+ const bridging = openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
470
+ conversation.bridge = bridging;
471
+ bridging.catch(() => {
472
+ if (conversation.bridge === bridging)
473
+ conversation.bridge = null;
474
+ });
475
+ }
476
+ return conversation.bridge;
477
+ }
478
+ closeBridge(conversation, bridging, bridge) {
479
+ bridge.close();
480
+ if (conversation.bridge === bridging)
481
+ conversation.bridge = null;
482
+ }
483
+ closeIdleBridge(conversation) {
484
+ const bridging = conversation.bridge;
485
+ if (bridging === null || conversation.running !== null || conversation.launch !== null)
486
+ return;
487
+ conversation.bridge = null;
488
+ void bridging.then((bridge) => bridge.close(), () => undefined);
292
489
  }
293
490
  async interrupt(conversation, turnId) {
294
491
  if (conversation.running === null || conversation.turn?.id !== turnId)
@@ -343,63 +540,78 @@ export class Conversations {
343
540
  }
344
541
  return route;
345
542
  }
346
- async start(conversation, route, sessionId) {
543
+ async start(conversation, routed, sessionId, empty, bridged) {
544
+ const route = await routed;
347
545
  const reader = readerFor(route.kind);
348
546
  reader.session = sessionId;
349
547
  const app = client({ name: '@agentshouse/kit' })
350
548
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
351
- .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
549
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
550
+ .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
352
551
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
353
- const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
552
+ const bridging = this.bridged(conversation);
553
+ const bridge = await step(unbridged, bridging);
354
554
  if (conversation.kills > 0) {
355
- bridge.close();
555
+ this.closeBridge(conversation, bridging, bridge);
356
556
  throw new Error(KILLED);
357
557
  }
358
558
  conversation.opening = bridge;
359
559
  const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
360
560
  const scope = new Scope(marker(bridge), randomUUID(), cli);
561
+ let adapter = null;
361
562
  try {
362
563
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
564
+ bridged(bridge);
363
565
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
364
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
566
+ const streamed = await step(NO_CONNECTION, work.open(cli));
567
+ adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
365
568
  work.read(message);
366
569
  this.read(conversation, reader, message);
367
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
368
- void adapter.exited.then(() => bridge.close());
570
+ }, { ...bridge.env, ...streamed }, undefined, scope));
571
+ void adapter.exited.then(() => this.closeBridge(conversation, bridging, bridge));
369
572
  const agent = adapter.connection.agent;
370
573
  const cwd = route.working_directory;
371
574
  const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
372
- const opened = sessionId === null
373
- ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
374
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
575
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
576
+ const opened = resumed ?? (await step(unopened(NO_SESSION), agent.request('session/new', { cwd, mcpServers: [], _meta })));
375
577
  reader.session = opened.sessionId;
376
- if (sessionId === null && CLIS[route.kind].renames) {
578
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
377
579
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
378
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
580
+ await step(unopened(NO_SESSION), agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
379
581
  }
380
- const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
381
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
582
+ const running = { adapter, bridging, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
583
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
382
584
  void work.settled().then(() => scope.record());
383
585
  conversation.running = running;
384
586
  conversation.session = running.sessionId;
385
- this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
386
- if (conversation.commands !== null)
387
- this.commands(conversation, conversation.commands);
388
- this.options(conversation, options);
389
- void adapter.exited.then((cause) => this.exited(conversation, adapter, cause));
587
+ conversation.options = options;
588
+ this.facts(conversation);
589
+ void adapter.lost.then((cause) => this.exited(conversation, running, cause));
390
590
  return running.sessionId;
391
591
  }
392
592
  catch (error) {
393
- scope.kill();
593
+ adapter?.retire();
394
594
  work.close();
395
595
  conversation.leftovers.add(scope);
396
- bridge.close();
596
+ this.closeBridge(conversation, bridging, bridge);
397
597
  throw conversation.kills > 0 ? new Error(KILLED) : error;
398
598
  }
399
599
  finally {
400
600
  conversation.opening = null;
401
601
  }
402
602
  }
603
+ async resume(conversation, kind, adapter, request, empty) {
604
+ try {
605
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
606
+ }
607
+ catch (error) {
608
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
609
+ throw new NotStarted(unopened(NO_RESUME)(error), { cause: error });
610
+ }
611
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
612
+ return null;
613
+ }
614
+ }
403
615
  async launchSettings(running, route, offered) {
404
616
  let options = offered;
405
617
  const settings = [
@@ -421,27 +633,37 @@ export class Conversations {
421
633
  }
422
634
  return options;
423
635
  }
424
- exited(conversation, adapter, cause) {
425
- const running = conversation.running;
426
- if (running?.adapter !== adapter)
427
- return;
636
+ exited(conversation, running, cause) {
428
637
  this.detach(conversation, running);
429
638
  if (conversation.turn !== null) {
430
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
639
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
431
640
  }
432
641
  this.next(conversation);
433
642
  this.kit.changed();
434
643
  }
435
644
  detach(conversation, running) {
645
+ if (conversation.bridge === running.bridging)
646
+ conversation.bridge = null;
436
647
  running.work.close();
437
648
  conversation.leftovers.add(running.scope);
438
649
  conversation.running = null;
439
650
  conversation.commands = null;
651
+ conversation.options = null;
440
652
  for (const [id, question] of this.questions) {
441
653
  if (question.conversation === conversation)
442
654
  this.questions.delete(id);
443
655
  }
444
- this.kit.send({ type: 'process', conversation_id: conversation.id, running: false });
656
+ this.process(conversation, false);
657
+ }
658
+ process(conversation, running) {
659
+ conversation.exitUntold = !this.kit.send({ type: 'process', conversation_id: conversation.id, running }) && !running;
660
+ }
661
+ facts(conversation) {
662
+ this.process(conversation, true);
663
+ if (conversation.commands !== null)
664
+ this.commands(conversation, conversation.commands);
665
+ if (conversation.options !== null)
666
+ this.options(conversation, conversation.options);
445
667
  }
446
668
  commands(conversation, commands) {
447
669
  conversation.commands = commands;
@@ -452,6 +674,7 @@ export class Conversations {
452
674
  options(conversation, options) {
453
675
  if (conversation.running === null)
454
676
  return;
677
+ conversation.options = options;
455
678
  this.kit.send({ type: 'options', conversation_id: conversation.id, options });
456
679
  }
457
680
  deliver(conversation, turn, event, body) {
@@ -466,7 +689,7 @@ export class Conversations {
466
689
  return delivered;
467
690
  }
468
691
  write(conversation, turn, operation) {
469
- if (!turn.admitted || turn.ended || !this.watched)
692
+ if (turn.ended || !this.watched)
470
693
  return;
471
694
  this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
472
695
  }
@@ -477,12 +700,7 @@ export class Conversations {
477
700
  begin(conversation) {
478
701
  const turn = new Turn((operation) => this.write(conversation, turn, operation));
479
702
  conversation.open++;
480
- void this.deliver(conversation, turn, 'started', {})
481
- .then(() => {
482
- turn.admitted = true;
483
- this.replay(conversation, turn);
484
- })
485
- .catch(logged);
703
+ void this.deliver(conversation, turn, 'started', {}).catch(logged);
486
704
  conversation.turn = turn;
487
705
  if (conversation.context !== null)
488
706
  turn.parts.measure(conversation.context);
@@ -491,13 +709,17 @@ export class Conversations {
491
709
  this.kit.changed();
492
710
  return turn;
493
711
  }
494
- end(conversation, turn, failed) {
712
+ end(conversation, turn, failed, notice = null) {
495
713
  if (turn.ended)
496
714
  return;
715
+ if (failed !== null)
716
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
497
717
  if (conversation.turn === turn) {
498
718
  for (const event of conversation.running?.reader.closeTurn() ?? [])
499
719
  this.apply(conversation, turn, event);
500
720
  }
721
+ if (notice !== null)
722
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
501
723
  turn.ended = true;
502
724
  conversation.open--;
503
725
  if (conversation.turn === turn)
@@ -505,31 +727,45 @@ export class Conversations {
505
727
  const body = Promise.allSettled(turn.uploads).then(() => ({
506
728
  parts: turn.parts.saved(),
507
729
  context: turn.parts.context,
508
- ...failed,
730
+ ...(failed === null ? {} : { failed: failed.failure }),
509
731
  }));
510
732
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
511
733
  this.next(conversation);
512
734
  this.kit.changed();
513
735
  }
514
- prompt(conversation, prompt) {
736
+ prompt(conversation, prompt, taken) {
515
737
  const running = conversation.running;
516
738
  const turn = this.begin(conversation);
517
739
  turn.prompted = true;
518
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
740
+ turn.taken = taken;
741
+ conversation.prompted = running.sessionId;
742
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
743
+ this.took(turn);
744
+ const notice = answeredFailure(answer);
745
+ if (notice !== null) {
746
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
747
+ }
748
+ else {
749
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
750
+ }
751
+ }, async (error) => {
519
752
  if (running.adapter.connection.signal.aborted) {
520
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
753
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
521
754
  }
522
755
  else {
756
+ this.took(turn);
523
757
  if (conversation.open === 1 && conversation.running === running) {
524
- running.scope.kill();
758
+ running.adapter.retire();
525
759
  this.detach(conversation, running);
526
760
  }
527
- this.end(conversation, turn, { failed: causeOf(error) });
761
+ this.end(conversation, turn, answeredWith(error));
528
762
  }
529
763
  });
530
764
  }
531
765
  started(conversation) {
532
- return conversation.turn ?? this.begin(conversation);
766
+ const turn = conversation.turn ?? this.begin(conversation);
767
+ this.took(turn);
768
+ return turn;
533
769
  }
534
770
  ended(conversation) {
535
771
  const turn = conversation.turn;
@@ -624,6 +860,11 @@ export class Conversations {
624
860
  }
625
861
  this.apply(conversation, this.started(conversation), event);
626
862
  }
863
+ took(turn) {
864
+ const taken = turn.taken;
865
+ turn.taken = null;
866
+ taken?.();
867
+ }
627
868
  apply(conversation, turn, event) {
628
869
  switch (event.kind) {
629
870
  case 'text':
@@ -678,13 +919,16 @@ export class Conversations {
678
919
  bytes: bytes.length,
679
920
  sha256: createHash('sha256').update(bytes).digest('hex'),
680
921
  }));
681
- const saved = declared.upload === undefined ? declared : await uploadBytes(declared.upload, bytes);
682
- if (saved.save.status !== 'saved')
683
- throw new Error(saved.save.failure ?? saved.save.status);
684
- turn.parts.arrive({ type: 'image', file: saved.version, name, media_type: mediaType }, reserved);
922
+ const saved = await uploaded(declared.upload, {
923
+ bytes: bytes.length,
924
+ send: (url, start, end) => sentPart(url, bytes.subarray(start, end)),
925
+ finish: (upload) => this.kit.house.deliver('/kit/uploads/finish', { upload }),
926
+ });
927
+ turn.parts.arrive({ type: 'image', file: saved.path, name, media_type: mediaType }, reserved);
685
928
  }
686
929
  catch (error) {
687
- turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` }, reserved);
930
+ const cause = error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error);
931
+ turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${cause}` }, reserved);
688
932
  }
689
933
  }
690
934
  }