@agentshouse/kit 0.2.1-c64c31420c9bf → 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,27 +1,39 @@
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
10
  import { answeredFailure, readerFor } from './events.js';
10
- import { placeFiles, uploadBytes } from './files.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';
24
+ const INTERRUPTED = 'the conversation was interrupted';
20
25
  const REFUSED = 'the CLI refused the prompt';
21
- const NO_CONNECTION = "the Kit could not open this conversation's connection";
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
+ };
22
33
  const NO_START = "the Agent's CLI did not start";
23
34
  const NO_SESSION = "the Agent's CLI did not open a session";
24
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";
25
37
  const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
26
38
  class NotStarted extends Error {
27
39
  }
@@ -30,9 +42,20 @@ async function step(reason, work) {
30
42
  return await work;
31
43
  }
32
44
  catch (error) {
33
- throw new NotStarted(reason, { cause: error });
45
+ throw new NotStarted(typeof reason === 'string' ? reason : reason(error), { cause: error });
34
46
  }
35
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
+ }
36
59
  const EXIT_PLAN_MODE = {
37
60
  parse(value) {
38
61
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -41,33 +64,51 @@ const EXIT_PLAN_MODE = {
41
64
  return value;
42
65
  },
43
66
  };
44
- export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
67
+ export const CONVERSATION_METHODS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
45
68
  function causeOf(error) {
46
69
  return error instanceof Error ? error.message : String(error);
47
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
+ }
48
77
  function answerMessage(request, response) {
49
78
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
50
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
+ }
51
86
  function logged(error) {
52
87
  process.stderr.write(`kit: ${causeOf(error)}\n`);
53
88
  }
54
89
  function refusal(conversation, error) {
55
90
  if (conversation.kills > 0)
56
91
  return KILLED;
57
- const cause = error instanceof NotStarted ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
92
+ const cause = error instanceof Error && error.cause !== undefined ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
58
93
  logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
59
94
  return causeOf(error);
60
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
+ }
61
102
  function marker(bridge) {
62
103
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
63
104
  }
64
105
  class Turn {
65
106
  id = randomUUID();
66
107
  parts;
67
- admitted = false;
68
108
  questions = 0;
69
109
  secrets = 0;
70
110
  prompted = false;
111
+ taken = null;
71
112
  ended = false;
72
113
  images = 0;
73
114
  uploads = [];
@@ -81,6 +122,8 @@ class Conversation {
81
122
  session = null;
82
123
  turn = null;
83
124
  commands = null;
125
+ options = null;
126
+ exitUntold = false;
84
127
  context = null;
85
128
  leftovers = new Set();
86
129
  waiting = [];
@@ -88,6 +131,9 @@ class Conversation {
88
131
  subagents = new Map();
89
132
  held = [];
90
133
  opening = null;
134
+ bridge = null;
135
+ launch = null;
136
+ prompted = null;
91
137
  kills = 0;
92
138
  killed = new AbortController();
93
139
  interrupts = 0;
@@ -95,6 +141,11 @@ class Conversation {
95
141
  open = 0;
96
142
  queue = Promise.resolve();
97
143
  reports = Promise.resolve();
144
+ starting = null;
145
+ preparing = null;
146
+ told = null;
147
+ untold = false;
148
+ shown = null;
98
149
  constructor(id) {
99
150
  this.id = id;
100
151
  }
@@ -110,9 +161,7 @@ function asksSecret(request) {
110
161
  export class Conversations {
111
162
  kit;
112
163
  conversations = new Map();
113
- acks = new Map();
114
164
  questions = new Map();
115
- carrying = 0;
116
165
  reporting = 0;
117
166
  watched = false;
118
167
  constructor(kit) {
@@ -120,38 +169,27 @@ export class Conversations {
120
169
  // A process or a durable schedule ends without a report, so the Kit looks at both again every two seconds.
121
170
  setInterval(() => this.look(), 2_000);
122
171
  }
123
- input(input) {
124
- this.carrying++;
125
- const carried = this.acks.get(input.input_id) ??
126
- (() => {
127
- const conversation = this.conversation(input.conversation_id);
128
- if (input.kind === 'kill') {
129
- conversation.kills++;
130
- conversation.killed.abort(new Error(KILLED));
131
- for (const waiting of conversation.waiting.splice(0))
132
- waiting.settle({ refused: KILLED });
133
- this.halt(conversation);
134
- }
135
- if (input.kind === 'interrupt') {
136
- conversation.interrupts++;
137
- conversation.interrupted.abort();
138
- }
139
- const handled = conversation.queue.then(() => this.carry(conversation, input));
140
- conversation.queue = handled.catch(() => undefined);
141
- const work = handled.then(({ ack }) => ack);
142
- this.acks.set(input.input_id, work);
143
- return work;
144
- })();
145
- carried
146
- .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
147
- .catch(logged)
148
- .finally(() => {
149
- this.carrying--;
150
- 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 }));
151
189
  });
152
190
  }
153
191
  idle() {
154
- if (this.carrying > 0 || this.reporting > 0)
192
+ if (this.reporting > 0)
155
193
  return false;
156
194
  for (const conversation of this.conversations.values()) {
157
195
  const turn = conversation.turn;
@@ -180,6 +218,14 @@ export class Conversations {
180
218
  }
181
219
  opened() {
182
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
+ }
183
229
  }
184
230
  watching(watched) {
185
231
  const resend = watched && !this.watched;
@@ -199,63 +245,99 @@ export class Conversations {
199
245
  }
200
246
  return conversation;
201
247
  }
202
- async carry(conversation, input) {
203
- if (input.kind === 'open')
204
- return { ack: Promise.resolve(await this.open(conversation, input)) };
205
- if (input.kind === 'message')
206
- return this.message(conversation, input);
207
- 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') {
208
255
  if (--conversation.interrupts === 0)
209
256
  conversation.interrupted = new AbortController();
210
- await this.interrupt(conversation, String(input.turn_id));
257
+ await this.interrupt(conversation, String(call.turn_id));
211
258
  }
212
- if (input.kind === 'kill') {
259
+ if (call.kind === 'kill') {
213
260
  await this.kill(conversation);
214
261
  await conversation.sending;
215
262
  if (--conversation.kills === 0)
216
263
  conversation.killed = new AbortController();
217
264
  }
218
- if (input.kind === 'option')
219
- await this.option(conversation, String(input.option), input.value);
220
- if (input.kind === 'answer') {
221
- 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));
222
269
  if (question === undefined) {
223
- const text = answerMessage(input.request, input.response);
224
- 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 });
225
272
  }
226
- question.answer(input.response);
273
+ question.answer(call.response);
227
274
  }
228
- return { ack: Promise.resolve({}) };
275
+ return { result: Promise.resolve({}) };
229
276
  }
230
- async open(conversation, input) {
277
+ open(conversation, call) {
231
278
  if (conversation.running !== null)
232
- return {};
233
- try {
234
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
235
- }
236
- catch (error) {
237
- return { refused: refusal(conversation, error) };
238
- }
239
- }
240
- async message(conversation, input) {
241
- 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) }];
242
321
  let route;
322
+ let instructing = null;
243
323
  try {
244
- route = await this.route(conversation, input.agent_id);
245
- const files = input.files;
246
- const paths = await placeFiles(this.kit.house, route.working_directory, files, conversation.killed.signal);
247
- const lines = await this.transcribed(conversation, files, paths);
248
- if (lines.length > 0)
249
- 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))));
250
329
  if (conversation.kills > 0)
251
330
  throw new Error(KILLED);
252
331
  }
253
332
  catch (error) {
254
- return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
333
+ this.closeIdleBridge(conversation);
334
+ this.prepares(conversation, null);
335
+ return { result: Promise.resolve({ refused: refusal(conversation, error) }) };
255
336
  }
337
+ conversation.preparing = null;
256
338
  return {
257
- ack: new Promise((settle) => {
258
- conversation.waiting.push({ input, route, content, settle });
339
+ result: new Promise((settle) => {
340
+ conversation.waiting.push({ call, route, content, instructing, settle });
259
341
  this.next(conversation);
260
342
  }),
261
343
  };
@@ -264,62 +346,146 @@ export class Conversations {
264
346
  if (conversation.sending !== null || conversation.open > 0)
265
347
  return;
266
348
  const waiting = conversation.waiting.shift();
267
- 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);
268
353
  return;
354
+ }
269
355
  conversation.sending = this.send(conversation, waiting).finally(() => {
270
356
  conversation.sending = null;
271
357
  this.next(conversation);
272
358
  });
273
359
  }
274
360
  async send(conversation, waiting) {
275
- const { input } = waiting;
276
- let ack = {};
361
+ const { call } = waiting;
362
+ let result = {};
363
+ let started = false;
364
+ let instructing = waiting.instructing;
277
365
  const prompt = [];
278
366
  try {
279
367
  if (conversation.running === null) {
280
- const resumed = input.provider_session_id ?? conversation.session;
281
- const opened = await this.start(conversation, waiting.route, resumed);
282
- if (resumed === null)
283
- ack = { provider_session_id: opened };
368
+ this.starts(conversation, 'session');
369
+ await this.launched(conversation);
284
370
  }
285
- if (input.first === true) {
286
- prompt.push({
287
- type: 'text',
288
- text: await instructions(conversation.running.bridge, waiting.route.base_instructions, await this.kit.copies.mapping()),
289
- });
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;
378
+ }
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)) });
290
385
  }
291
386
  if (conversation.kills > 0)
292
387
  throw new Error(KILLED);
293
388
  }
294
389
  catch (error) {
295
- if ('provider_session_id' in ack) {
390
+ this.starts(conversation, null);
391
+ if (started) {
296
392
  conversation.session = null;
297
393
  await this.kill(conversation);
298
394
  }
299
395
  waiting.settle({ refused: refusal(conversation, error) });
300
396
  return;
301
397
  }
302
- this.prompt(conversation, [...prompt, ...waiting.content]);
303
- waiting.settle(ack);
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);
409
+ return;
410
+ }
411
+ conversation.shown = setTimeout(() => {
412
+ conversation.shown = null;
413
+ if (conversation.starting === step)
414
+ this.tell(conversation, step);
415
+ }, STARTING_SHOWN_AFTER_MS);
304
416
  }
305
- async transcribed(conversation, files, paths) {
306
- const lines = [];
307
- for (const [index, path] of paths.entries()) {
308
- lines.push(path);
309
- if (files[index].media_type?.startsWith('audio/') !== true)
310
- continue;
311
- try {
312
- const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
313
- if (text !== null && text !== '')
314
- lines.push(`Transcript: ${text}`);
315
- }
316
- catch (error) {
317
- if (conversation.kills > 0)
318
- throw error;
319
- 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}` });
320
458
  }
321
459
  }
322
- 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);
323
489
  }
324
490
  async interrupt(conversation, turnId) {
325
491
  if (conversation.running === null || conversation.turn?.id !== turnId)
@@ -374,7 +540,8 @@ export class Conversations {
374
540
  }
375
541
  return route;
376
542
  }
377
- async start(conversation, route, sessionId) {
543
+ async start(conversation, routed, sessionId, empty, bridged) {
544
+ const route = await routed;
378
545
  const reader = readerFor(route.kind);
379
546
  reader.session = sessionId;
380
547
  const app = client({ name: '@agentshouse/kit' })
@@ -382,57 +549,69 @@ export class Conversations {
382
549
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
383
550
  .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
384
551
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
385
- const bridge = await step(NO_CONNECTION, 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);
386
554
  if (conversation.kills > 0) {
387
- bridge.close();
555
+ this.closeBridge(conversation, bridging, bridge);
388
556
  throw new Error(KILLED);
389
557
  }
390
558
  conversation.opening = bridge;
391
559
  const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
392
560
  const scope = new Scope(marker(bridge), randomUUID(), cli);
561
+ let adapter = null;
393
562
  try {
394
563
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
564
+ bridged(bridge);
395
565
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
396
566
  const streamed = await step(NO_CONNECTION, work.open(cli));
397
- const adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
567
+ adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
398
568
  work.read(message);
399
569
  this.read(conversation, reader, message);
400
570
  }, { ...bridge.env, ...streamed }, undefined, scope));
401
- void adapter.exited.then(() => bridge.close());
571
+ void adapter.exited.then(() => this.closeBridge(conversation, bridging, bridge));
402
572
  const agent = adapter.connection.agent;
403
573
  const cwd = route.working_directory;
404
574
  const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
405
- const opened = sessionId === null
406
- ? await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta }))
407
- : { ...(await step(NO_RESUME, 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 })));
408
577
  reader.session = opened.sessionId;
409
- if (sessionId === null && CLIS[route.kind].renames) {
578
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
410
579
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
411
- await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
580
+ await step(unopened(NO_SESSION), agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
412
581
  }
413
- const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
582
+ const running = { adapter, bridging, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
414
583
  const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
415
584
  void work.settled().then(() => scope.record());
416
585
  conversation.running = running;
417
586
  conversation.session = running.sessionId;
418
- this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
419
- if (conversation.commands !== null)
420
- this.commands(conversation, conversation.commands);
421
- this.options(conversation, options);
422
- 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));
423
590
  return running.sessionId;
424
591
  }
425
592
  catch (error) {
426
- scope.kill();
593
+ adapter?.retire();
427
594
  work.close();
428
595
  conversation.leftovers.add(scope);
429
- bridge.close();
596
+ this.closeBridge(conversation, bridging, bridge);
430
597
  throw conversation.kills > 0 ? new Error(KILLED) : error;
431
598
  }
432
599
  finally {
433
600
  conversation.opening = null;
434
601
  }
435
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
+ }
436
615
  async launchSettings(running, route, offered) {
437
616
  let options = offered;
438
617
  const settings = [
@@ -454,27 +633,37 @@ export class Conversations {
454
633
  }
455
634
  return options;
456
635
  }
457
- exited(conversation, adapter, cause) {
458
- const running = conversation.running;
459
- if (running?.adapter !== adapter)
460
- return;
636
+ exited(conversation, running, cause) {
461
637
  this.detach(conversation, running);
462
638
  if (conversation.turn !== null) {
463
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
639
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
464
640
  }
465
641
  this.next(conversation);
466
642
  this.kit.changed();
467
643
  }
468
644
  detach(conversation, running) {
645
+ if (conversation.bridge === running.bridging)
646
+ conversation.bridge = null;
469
647
  running.work.close();
470
648
  conversation.leftovers.add(running.scope);
471
649
  conversation.running = null;
472
650
  conversation.commands = null;
651
+ conversation.options = null;
473
652
  for (const [id, question] of this.questions) {
474
653
  if (question.conversation === conversation)
475
654
  this.questions.delete(id);
476
655
  }
477
- 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);
478
667
  }
479
668
  commands(conversation, commands) {
480
669
  conversation.commands = commands;
@@ -485,6 +674,7 @@ export class Conversations {
485
674
  options(conversation, options) {
486
675
  if (conversation.running === null)
487
676
  return;
677
+ conversation.options = options;
488
678
  this.kit.send({ type: 'options', conversation_id: conversation.id, options });
489
679
  }
490
680
  deliver(conversation, turn, event, body) {
@@ -499,7 +689,7 @@ export class Conversations {
499
689
  return delivered;
500
690
  }
501
691
  write(conversation, turn, operation) {
502
- if (!turn.admitted || turn.ended || !this.watched)
692
+ if (turn.ended || !this.watched)
503
693
  return;
504
694
  this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
505
695
  }
@@ -510,12 +700,7 @@ export class Conversations {
510
700
  begin(conversation) {
511
701
  const turn = new Turn((operation) => this.write(conversation, turn, operation));
512
702
  conversation.open++;
513
- void this.deliver(conversation, turn, 'started', {})
514
- .then(() => {
515
- turn.admitted = true;
516
- this.replay(conversation, turn);
517
- })
518
- .catch(logged);
703
+ void this.deliver(conversation, turn, 'started', {}).catch(logged);
519
704
  conversation.turn = turn;
520
705
  if (conversation.context !== null)
521
706
  turn.parts.measure(conversation.context);
@@ -527,6 +712,8 @@ export class Conversations {
527
712
  end(conversation, turn, failed, notice = null) {
528
713
  if (turn.ended)
529
714
  return;
715
+ if (failed !== null)
716
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
530
717
  if (conversation.turn === turn) {
531
718
  for (const event of conversation.running?.reader.closeTurn() ?? [])
532
719
  this.apply(conversation, turn, event);
@@ -540,35 +727,45 @@ export class Conversations {
540
727
  const body = Promise.allSettled(turn.uploads).then(() => ({
541
728
  parts: turn.parts.saved(),
542
729
  context: turn.parts.context,
543
- ...failed,
730
+ ...(failed === null ? {} : { failed: failed.failure }),
544
731
  }));
545
732
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
546
733
  this.next(conversation);
547
734
  this.kit.changed();
548
735
  }
549
- prompt(conversation, prompt) {
736
+ prompt(conversation, prompt, taken) {
550
737
  const running = conversation.running;
551
738
  const turn = this.begin(conversation);
552
739
  turn.prompted = true;
740
+ turn.taken = taken;
741
+ conversation.prompted = running.sessionId;
553
742
  running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
743
+ this.took(turn);
554
744
  const notice = answeredFailure(answer);
555
- const failed = notice ?? (answer.stopReason === 'refusal' ? REFUSED : null);
556
- this.end(conversation, turn, failed === null ? null : { failed }, notice);
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
+ }
557
751
  }, async (error) => {
558
752
  if (running.adapter.connection.signal.aborted) {
559
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
753
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
560
754
  }
561
755
  else {
756
+ this.took(turn);
562
757
  if (conversation.open === 1 && conversation.running === running) {
563
- running.scope.kill();
758
+ running.adapter.retire();
564
759
  this.detach(conversation, running);
565
760
  }
566
- this.end(conversation, turn, { failed: causeOf(error) });
761
+ this.end(conversation, turn, answeredWith(error));
567
762
  }
568
763
  });
569
764
  }
570
765
  started(conversation) {
571
- return conversation.turn ?? this.begin(conversation);
766
+ const turn = conversation.turn ?? this.begin(conversation);
767
+ this.took(turn);
768
+ return turn;
572
769
  }
573
770
  ended(conversation) {
574
771
  const turn = conversation.turn;
@@ -663,6 +860,11 @@ export class Conversations {
663
860
  }
664
861
  this.apply(conversation, this.started(conversation), event);
665
862
  }
863
+ took(turn) {
864
+ const taken = turn.taken;
865
+ turn.taken = null;
866
+ taken?.();
867
+ }
666
868
  apply(conversation, turn, event) {
667
869
  switch (event.kind) {
668
870
  case 'text':
@@ -717,13 +919,16 @@ export class Conversations {
717
919
  bytes: bytes.length,
718
920
  sha256: createHash('sha256').update(bytes).digest('hex'),
719
921
  }));
720
- const saved = declared.upload === undefined ? declared : await uploadBytes(declared.upload, bytes);
721
- if (saved.save.status !== 'saved')
722
- throw new Error(saved.save.failure ?? saved.save.status);
723
- 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);
724
928
  }
725
929
  catch (error) {
726
- 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);
727
932
  }
728
933
  }
729
934
  }