@agentshouse/kit 0.2.1-alpha.9 → 0.2.1-c073f4e9391b2

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.
Files changed (48) hide show
  1. package/README.md +44 -26
  2. package/dist/abort.js +9 -0
  3. package/dist/acp.js +18 -75
  4. package/dist/agents.js +266 -53
  5. package/dist/api.js +40 -4
  6. package/dist/app-git.js +64 -0
  7. package/dist/bootstrap.json +10 -14
  8. package/dist/bridge.js +89 -71
  9. package/dist/chatgpt.js +25 -0
  10. package/dist/clis-main.js +23 -0
  11. package/dist/clis.js +203 -71
  12. package/dist/codex-main.js +58 -0
  13. package/dist/conversations.js +328 -199
  14. package/dist/copies.js +188 -161
  15. package/dist/end.js +22 -0
  16. package/dist/enrol-main.js +7 -1
  17. package/dist/events.js +521 -0
  18. package/dist/files.js +16 -1
  19. package/dist/git-main.js +2 -2
  20. package/dist/git.js +6 -2
  21. package/dist/home.js +72 -5
  22. package/dist/house-main.js +181 -57
  23. package/dist/instructions.js +16 -13
  24. package/dist/kit-main.js +55 -4
  25. package/dist/login.js +108 -97
  26. package/dist/logout.js +21 -0
  27. package/dist/mcp.js +39 -0
  28. package/dist/operation.js +7 -4
  29. package/dist/parts.js +82 -0
  30. package/dist/placement.js +48 -0
  31. package/dist/refusals.js +53 -0
  32. package/dist/relay.js +20 -0
  33. package/dist/resident.js +70 -34
  34. package/dist/scope.js +203 -0
  35. package/dist/secret-input.js +7 -2
  36. package/dist/service-main.js +9 -0
  37. package/dist/service.js +33 -0
  38. package/dist/shell.js +3 -0
  39. package/dist/sign-in.js +48 -11
  40. package/dist/skills.js +5 -10
  41. package/dist/stream.js +37 -7
  42. package/dist/transcription.js +170 -0
  43. package/dist/update.js +140 -0
  44. package/dist/wire.js +6 -0
  45. package/dist/work.js +348 -0
  46. package/how-we-work.md +10 -12
  47. package/package.json +4 -7
  48. package/dist/blocks.js +0 -51
@@ -1,17 +1,23 @@
1
1
  import { client, } from '@agentclientprotocol/sdk';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { killMarked, startAdapter, TURN_ENDED, TURN_STARTED } from './acp.js';
6
- import { blocksOf, firstChange } from './blocks.js';
5
+ import { unlessAborted } from './abort.js';
6
+ import { startAdapter } from './acp.js';
7
7
  import { openBridge } from './bridge.js';
8
8
  import { CLIS } from './clis.js';
9
- import { placeFiles } from './files.js';
9
+ import { readerFor } from './events.js';
10
+ import { placeFiles, uploadBytes } from './files.js';
11
+ import { agentBase } from './home.js';
10
12
  import { instructions } from './instructions.js';
13
+ import { Parts } from './parts.js';
14
+ import { killMarked, Scope } from './scope.js';
11
15
  import { holdSecretInput } from './secret-input.js';
12
16
  import { createHowWeWork } from './skills.js';
13
- const LAUNCH_BASE = '/agents/house';
17
+ import { transcribe } from './transcription.js';
18
+ import { workFor } from './work.js';
14
19
  const KILLED = 'the conversation was killed';
20
+ export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
15
21
  function causeOf(error) {
16
22
  return error instanceof Error ? error.message : String(error);
17
23
  }
@@ -21,42 +27,40 @@ function answerMessage(request, response) {
21
27
  function logged(error) {
22
28
  process.stderr.write(`kit: ${causeOf(error)}\n`);
23
29
  }
24
- function unlessKilled(work, signal) {
25
- return new Promise((resolve, reject) => {
26
- const killed = () => reject(signal.reason);
27
- signal.addEventListener('abort', killed, { once: true });
28
- if (signal.aborted)
29
- killed();
30
- work.then(resolve, reject).finally(() => signal.removeEventListener('abort', killed));
31
- });
32
- }
33
- function stop(bridge) {
34
- killMarked(`HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`);
30
+ function marker(bridge) {
31
+ return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
35
32
  }
36
33
  class Turn {
37
34
  id = randomUUID();
38
- text = '';
39
- sent = [];
40
- unsentFrom = null;
41
- draftSequence = 0;
42
- planSequence = 0;
43
- entries = null;
35
+ parts;
36
+ admitted = false;
44
37
  questions = 0;
45
38
  secrets = 0;
46
- prompt = null;
39
+ prompted = false;
47
40
  ended = false;
41
+ images = 0;
42
+ uploads = [];
43
+ constructor(emit) {
44
+ this.parts = new Parts(emit);
45
+ }
48
46
  }
49
47
  class Conversation {
50
48
  id;
51
49
  running = null;
50
+ session = null;
52
51
  turn = null;
53
52
  commands = null;
54
- jobs = new Set();
55
- queued = [];
56
- late = [];
53
+ context = null;
54
+ leftovers = new Set();
55
+ waiting = [];
56
+ sending = null;
57
+ subagents = new Map();
58
+ held = [];
57
59
  opening = null;
58
60
  kills = 0;
59
61
  killed = new AbortController();
62
+ interrupts = 0;
63
+ interrupted = new AbortController();
60
64
  open = 0;
61
65
  queue = Promise.resolve();
62
66
  reports = Promise.resolve();
@@ -79,8 +83,11 @@ export class Conversations {
79
83
  questions = new Map();
80
84
  carrying = 0;
81
85
  reporting = 0;
86
+ watched = false;
82
87
  constructor(kit) {
83
88
  this.kit = kit;
89
+ // A process or a durable schedule ends without a report, so the Kit looks at both again every two seconds.
90
+ setInterval(() => this.look(), 2_000);
84
91
  }
85
92
  input(input) {
86
93
  this.carrying++;
@@ -90,10 +97,17 @@ export class Conversations {
90
97
  if (input.kind === 'kill') {
91
98
  conversation.kills++;
92
99
  conversation.killed.abort(new Error(KILLED));
100
+ for (const waiting of conversation.waiting.splice(0))
101
+ waiting.settle({ refused: KILLED });
93
102
  this.halt(conversation);
94
103
  }
95
- const work = conversation.queue.then(() => this.carry(conversation, input));
96
- conversation.queue = work.catch(() => undefined);
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);
97
111
  this.acks.set(input.input_id, work);
98
112
  return work;
99
113
  })();
@@ -111,15 +125,39 @@ export class Conversations {
111
125
  for (const conversation of this.conversations.values()) {
112
126
  const turn = conversation.turn;
113
127
  const asking = turn !== null && turn.questions > 0 && turn.secrets === 0;
114
- if (conversation.jobs.size > 0 || conversation.open > (asking ? 1 : 0))
128
+ if (conversation.open > (asking ? 1 : 0))
115
129
  return false;
116
130
  }
117
- return true;
131
+ const conversations = [...this.conversations.values()];
132
+ if (conversations.some((conversation) => conversation.running?.work.busy()))
133
+ return false;
134
+ return !conversations.some((conversation) => conversation.running?.scope.working() || [...conversation.leftovers].some((scope) => scope.working()));
135
+ }
136
+ quiet() {
137
+ return this.idle() && [...this.conversations.values()].every((conversation) => conversation.open === 0);
138
+ }
139
+ look() {
140
+ for (const conversation of this.conversations.values()) {
141
+ for (const scope of conversation.leftovers) {
142
+ if (!scope.empty())
143
+ continue;
144
+ scope.close();
145
+ conversation.leftovers.delete(scope);
146
+ }
147
+ }
148
+ this.kit.changed();
118
149
  }
119
150
  opened() {
151
+ this.watched = false;
152
+ }
153
+ watching(watched) {
154
+ const resend = watched && !this.watched;
155
+ this.watched = watched;
156
+ if (!resend)
157
+ return;
120
158
  for (const conversation of this.conversations.values()) {
121
159
  if (conversation.turn !== null)
122
- conversation.turn.unsentFrom = 0;
160
+ this.replay(conversation, conversation.turn);
123
161
  }
124
162
  }
125
163
  conversation(id) {
@@ -132,13 +170,17 @@ export class Conversations {
132
170
  }
133
171
  async carry(conversation, input) {
134
172
  if (input.kind === 'open')
135
- return this.open(conversation, input);
173
+ return { ack: Promise.resolve(await this.open(conversation, input)) };
136
174
  if (input.kind === 'message')
137
175
  return this.message(conversation, input);
138
- if (input.kind === 'interrupt')
176
+ if (input.kind === 'interrupt') {
177
+ if (--conversation.interrupts === 0)
178
+ conversation.interrupted = new AbortController();
139
179
  await this.interrupt(conversation, String(input.turn_id));
180
+ }
140
181
  if (input.kind === 'kill') {
141
182
  await this.kill(conversation);
183
+ await conversation.sending;
142
184
  if (--conversation.kills === 0)
143
185
  conversation.killed = new AbortController();
144
186
  }
@@ -147,11 +189,12 @@ export class Conversations {
147
189
  if (input.kind === 'answer') {
148
190
  const question = this.questions.get(String(input.interaction_id));
149
191
  if (question === undefined) {
150
- return this.message(conversation, { ...input, text: answerMessage(input.request, input.response), files: [], first: false });
192
+ const text = answerMessage(input.request, input.response);
193
+ return this.message(conversation, { ...input, text, files: [], first: false });
151
194
  }
152
195
  question.answer(input.response);
153
196
  }
154
- return {};
197
+ return { ack: Promise.resolve({}) };
155
198
  }
156
199
  async open(conversation, input) {
157
200
  if (conversation.running !== null)
@@ -164,35 +207,88 @@ export class Conversations {
164
207
  }
165
208
  }
166
209
  async message(conversation, input) {
210
+ const content = [{ type: 'text', text: String(input.text) }];
211
+ let route;
212
+ 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') });
219
+ if (conversation.kills > 0)
220
+ throw new Error(KILLED);
221
+ }
222
+ catch (error) {
223
+ return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
224
+ }
225
+ return {
226
+ ack: new Promise((settle) => {
227
+ conversation.waiting.push({ input, route, content, settle });
228
+ this.next(conversation);
229
+ }),
230
+ };
231
+ }
232
+ next(conversation) {
233
+ if (conversation.sending !== null || conversation.open > 0)
234
+ return;
235
+ const waiting = conversation.waiting.shift();
236
+ if (waiting === undefined)
237
+ return;
238
+ conversation.sending = this.send(conversation, waiting).finally(() => {
239
+ conversation.sending = null;
240
+ this.next(conversation);
241
+ });
242
+ }
243
+ async send(conversation, waiting) {
244
+ const { input } = waiting;
167
245
  let ack = {};
168
246
  const prompt = [];
169
247
  try {
170
- const route = await this.route(conversation, input.agent_id);
171
- const paths = await placeFiles(this.kit.house, route.working_directory, input.files, conversation.killed.signal);
172
248
  if (conversation.running === null) {
173
- const opened = await this.start(conversation, route, input.provider_session_id);
174
- if (input.provider_session_id === null)
249
+ const resumed = input.provider_session_id ?? conversation.session;
250
+ const opened = await this.start(conversation, waiting.route, resumed);
251
+ if (resumed === null)
175
252
  ack = { provider_session_id: opened };
176
253
  }
177
254
  if (input.first === true) {
178
255
  prompt.push({
179
256
  type: 'text',
180
- text: await instructions(conversation.running.bridge, route.base_instructions, await this.kit.copies.mapping()),
257
+ text: await instructions(conversation.running.bridge, waiting.route.base_instructions, await this.kit.copies.mapping()),
181
258
  });
182
259
  }
183
- prompt.push({ type: 'text', text: String(input.text) });
184
- if (paths.length > 0)
185
- prompt.push({ type: 'text', text: paths.join('\n') });
186
260
  if (conversation.kills > 0)
187
261
  throw new Error(KILLED);
188
262
  }
189
263
  catch (error) {
190
- if ('provider_session_id' in ack)
264
+ if ('provider_session_id' in ack) {
265
+ conversation.session = null;
191
266
  await this.kill(conversation);
192
- return { refused: conversation.kills > 0 ? KILLED : causeOf(error) };
267
+ }
268
+ waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
269
+ return;
270
+ }
271
+ this.prompt(conversation, [...prompt, ...waiting.content]);
272
+ waiting.settle(ack);
273
+ }
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)}`));
289
+ }
193
290
  }
194
- this.prompt(conversation, prompt);
195
- return ack;
291
+ return lines;
196
292
  }
197
293
  async interrupt(conversation, turnId) {
198
294
  if (conversation.running === null || conversation.turn?.id !== turnId)
@@ -203,11 +299,13 @@ export class Conversations {
203
299
  }
204
300
  halt(conversation) {
205
301
  if (conversation.opening !== null)
206
- stop(conversation.opening);
302
+ killMarked(marker(conversation.opening));
303
+ for (const scope of conversation.leftovers)
304
+ scope.kill();
207
305
  if (conversation.running === null)
208
306
  return;
209
307
  conversation.running.killed = true;
210
- stop(conversation.running.bridge);
308
+ conversation.running.scope.kill();
211
309
  }
212
310
  async kill(conversation) {
213
311
  const running = conversation.running;
@@ -219,6 +317,7 @@ export class Conversations {
219
317
  if (running === null)
220
318
  return;
221
319
  try {
320
+ running.reader.requested(option, value);
222
321
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
223
322
  sessionId: running.sessionId,
224
323
  configId: option,
@@ -227,54 +326,62 @@ export class Conversations {
227
326
  this.options(conversation, answered.configOptions);
228
327
  }
229
328
  catch (error) {
329
+ running.reader.refused(option);
230
330
  logged(error);
231
331
  }
232
332
  }
233
333
  async route(conversation, agentId) {
234
334
  const signal = conversation.killed.signal;
235
- await unlessKilled(this.kit.agents.read(), signal);
335
+ await unlessAborted(this.kit.agents.read(), signal);
236
336
  if (this.kit.agents.route(agentId) === undefined)
237
- await unlessKilled(this.kit.agents.refresh(), signal);
337
+ await unlessAborted(this.kit.agents.refresh(), signal);
238
338
  const route = this.kit.agents.route(agentId);
239
339
  if (route === undefined)
240
340
  throw new Error(`this Environment hosts no Agent ${agentId}`);
241
- if (route.working_directory === join(LAUNCH_BASE, route.agent_id)) {
341
+ if (route.working_directory === join(agentBase(), route.agent_id)) {
242
342
  await mkdir(route.working_directory, { recursive: true });
243
343
  }
244
344
  return route;
245
345
  }
246
346
  async start(conversation, route, sessionId) {
347
+ const reader = readerFor(route.kind);
348
+ reader.session = sessionId;
247
349
  const app = client({ name: '@agentshouse/kit' })
248
- .onNotification('session/update', ({ params }) => this.update(conversation, params))
249
- .onNotification(TURN_STARTED, (params) => params, () => void this.started(conversation))
250
- .onNotification(TURN_ENDED, (params) => params, () => this.ended(conversation))
251
350
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
252
351
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
253
- await unlessKilled(this.kit.agents.installed(), conversation.killed.signal);
352
+ const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
254
353
  const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
255
354
  if (conversation.kills > 0) {
256
355
  bridge.close();
257
356
  throw new Error(KILLED);
258
357
  }
259
358
  conversation.opening = bridge;
359
+ const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
360
+ const scope = new Scope(marker(bridge), randomUUID(), cli);
260
361
  try {
261
- await unlessKilled(createHowWeWork(bridge), conversation.killed.signal);
262
- const adapter = await startAdapter(route.kind, route.working_directory, app, (job) => this.job(conversation, job), bridge.env);
362
+ await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
363
+ await CLIS[route.kind].allowHouse(bridge.env.PATH);
364
+ const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
365
+ work.read(message);
366
+ this.read(conversation, reader, message);
367
+ }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
263
368
  void adapter.exited.then(() => bridge.close());
264
369
  const agent = adapter.connection.agent;
265
370
  const cwd = route.working_directory;
371
+ const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
266
372
  const opened = sessionId === null
267
- ? await agent.request('session/new', { cwd, mcpServers: [] })
268
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [] })), sessionId };
269
- const running = {
270
- adapter,
271
- bridge,
272
- sessionId: opened.sessionId,
273
- killed: false,
274
- queues: CLIS[route.kind].queues,
275
- };
373
+ ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
374
+ : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
375
+ reader.session = opened.sessionId;
376
+ if (sessionId === null && CLIS[route.kind].renames) {
377
+ const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
378
+ await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
379
+ }
380
+ const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
276
381
  const options = await this.launchSettings(running, route, opened.configOptions ?? []);
382
+ void work.settled().then(() => scope.record());
277
383
  conversation.running = running;
384
+ conversation.session = running.sessionId;
278
385
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
279
386
  if (conversation.commands !== null)
280
387
  this.commands(conversation, conversation.commands);
@@ -283,7 +390,9 @@ export class Conversations {
283
390
  return running.sessionId;
284
391
  }
285
392
  catch (error) {
286
- stop(bridge);
393
+ scope.kill();
394
+ work.close();
395
+ conversation.leftovers.add(scope);
287
396
  bridge.close();
288
397
  throw conversation.kills > 0 ? new Error(KILLED) : error;
289
398
  }
@@ -294,6 +403,7 @@ export class Conversations {
294
403
  async launchSettings(running, route, offered) {
295
404
  let options = offered;
296
405
  const settings = [
406
+ ['mode', route.mode ?? CLIS[route.kind].fullAccess],
297
407
  ['model', route.model],
298
408
  ['thought_level', route.effort],
299
409
  ];
@@ -301,6 +411,7 @@ export class Conversations {
301
411
  if (value === null)
302
412
  continue;
303
413
  const option = options.find((candidate) => candidate.category === category);
414
+ running.reader.requested(option?.id ?? category, value);
304
415
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
305
416
  sessionId: running.sessionId,
306
417
  configId: option?.id ?? category,
@@ -314,19 +425,23 @@ export class Conversations {
314
425
  const running = conversation.running;
315
426
  if (running?.adapter !== adapter)
316
427
  return;
428
+ this.detach(conversation, running);
429
+ if (conversation.turn !== null) {
430
+ this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
431
+ }
432
+ this.next(conversation);
433
+ this.kit.changed();
434
+ }
435
+ detach(conversation, running) {
436
+ running.work.close();
437
+ conversation.leftovers.add(running.scope);
317
438
  conversation.running = null;
318
439
  conversation.commands = null;
319
- conversation.jobs.clear();
320
- conversation.queued.length = 0;
321
440
  for (const [id, question] of this.questions) {
322
441
  if (question.conversation === conversation)
323
442
  this.questions.delete(id);
324
443
  }
325
444
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: false });
326
- if (conversation.turn !== null) {
327
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
328
- }
329
- this.kit.changed();
330
445
  }
331
446
  commands(conversation, commands) {
332
447
  conversation.commands = commands;
@@ -341,7 +456,7 @@ export class Conversations {
341
456
  }
342
457
  deliver(conversation, turn, event, body) {
343
458
  this.reporting++;
344
- const delivered = conversation.reports.then(() => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, body));
459
+ const delivered = conversation.reports.then(async () => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, await body));
345
460
  conversation.reports = delivered
346
461
  .catch(() => undefined)
347
462
  .finally(() => {
@@ -350,105 +465,82 @@ export class Conversations {
350
465
  });
351
466
  return delivered;
352
467
  }
353
- report(conversation) {
354
- const turn = new Turn();
355
- conversation.open++;
356
- void this.deliver(conversation, turn, 'started', {})
357
- .then(() => this.admitted(conversation, turn))
358
- .catch(logged);
359
- return turn;
468
+ write(conversation, turn, operation) {
469
+ if (!turn.admitted || turn.ended || !this.watched)
470
+ return;
471
+ this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
360
472
  }
361
- admitted(conversation, turn) {
362
- if (turn.text !== '') {
363
- turn.unsentFrom = 0;
364
- this.draft(conversation, turn);
365
- }
366
- if (turn.entries !== null)
367
- this.plan(conversation, turn);
473
+ replay(conversation, turn) {
474
+ for (const operation of turn.parts.operations())
475
+ this.write(conversation, turn, operation);
368
476
  }
369
477
  begin(conversation) {
370
- const turn = this.report(conversation);
478
+ const turn = new Turn((operation) => this.write(conversation, turn, operation));
479
+ conversation.open++;
480
+ void this.deliver(conversation, turn, 'started', {})
481
+ .then(() => {
482
+ turn.admitted = true;
483
+ this.replay(conversation, turn);
484
+ })
485
+ .catch(logged);
371
486
  conversation.turn = turn;
487
+ if (conversation.context !== null)
488
+ turn.parts.measure(conversation.context);
489
+ for (const part of conversation.held.splice(0))
490
+ turn.parts.start(part);
372
491
  this.kit.changed();
373
492
  return turn;
374
493
  }
375
- end(conversation, turn, outcome) {
494
+ end(conversation, turn, failed) {
376
495
  if (turn.ended)
377
496
  return;
497
+ if (conversation.turn === turn) {
498
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
499
+ this.apply(conversation, turn, event);
500
+ }
378
501
  turn.ended = true;
379
502
  conversation.open--;
380
503
  if (conversation.turn === turn)
381
504
  conversation.turn = null;
382
- void this.deliver(conversation, turn, 'ended', outcome).catch(logged);
383
- const late = conversation.open === 0 ? conversation.late.shift() : undefined;
384
- if (late !== undefined)
385
- this.failed(conversation, late);
505
+ const body = Promise.allSettled(turn.uploads).then(() => ({
506
+ parts: turn.parts.saved(),
507
+ context: turn.parts.context,
508
+ ...failed,
509
+ }));
510
+ void this.deliver(conversation, turn, 'ended', body).catch(logged);
511
+ this.next(conversation);
386
512
  this.kit.changed();
387
513
  }
388
- failed(conversation, cause) {
389
- if (conversation.open > 0)
390
- conversation.late.push(cause);
391
- else
392
- this.end(conversation, this.report(conversation), { failed: cause });
393
- }
394
514
  prompt(conversation, prompt) {
395
515
  const running = conversation.running;
396
- const sent = { turn: null };
397
- if (running.queues && (conversation.turn !== null || conversation.queued.length > 0)) {
398
- conversation.queued.push(sent);
399
- }
400
- else {
401
- this.attach(conversation.turn ?? this.begin(conversation), sent);
402
- }
403
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.answered(conversation, sent, null), async (error) => {
404
- if (!running.adapter.connection.signal.aborted)
405
- this.answered(conversation, sent, causeOf(error));
406
- else if (sent.turn !== null) {
407
- this.end(conversation, sent.turn, { failed: running.killed ? KILLED : await running.adapter.exited });
516
+ const turn = this.begin(conversation);
517
+ turn.prompted = true;
518
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
519
+ if (running.adapter.connection.signal.aborted) {
520
+ this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
521
+ }
522
+ else {
523
+ if (conversation.open === 1 && conversation.running === running) {
524
+ running.scope.kill();
525
+ this.detach(conversation, running);
526
+ }
527
+ this.end(conversation, turn, { failed: causeOf(error) });
408
528
  }
409
529
  });
410
530
  }
411
- attach(turn, sent) {
412
- turn.prompt = sent;
413
- sent.turn = turn;
414
- }
415
- answered(conversation, sent, failed) {
416
- const turn = sent.turn;
417
- if (turn === null) {
418
- const queued = conversation.queued.indexOf(sent);
419
- if (queued < 0)
420
- return;
421
- conversation.queued.splice(queued, 1);
422
- if (failed !== null)
423
- this.failed(conversation, failed);
424
- }
425
- else if (failed !== null) {
426
- if (turn.ended)
427
- this.failed(conversation, failed);
428
- else
429
- this.end(conversation, turn, { failed });
430
- }
431
- else if (turn.prompt === sent) {
432
- this.end(conversation, turn, { text: turn.text });
433
- }
434
- }
435
531
  started(conversation) {
436
- if (conversation.turn !== null)
437
- return conversation.turn;
438
- const turn = this.begin(conversation);
439
- const queued = conversation.queued.shift();
440
- if (queued !== undefined)
441
- this.attach(turn, queued);
442
- return turn;
532
+ return conversation.turn ?? this.begin(conversation);
443
533
  }
444
534
  ended(conversation) {
445
535
  const turn = conversation.turn;
446
536
  if (turn === null)
447
537
  return;
448
- if (turn.prompt === null) {
449
- this.end(conversation, turn, { text: turn.text });
538
+ if (!turn.prompted) {
539
+ this.end(conversation, turn, null);
450
540
  }
451
541
  else {
542
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
543
+ this.apply(conversation, turn, event);
452
544
  conversation.turn = null;
453
545
  this.kit.changed();
454
546
  }
@@ -497,65 +589,102 @@ export class Conversations {
497
589
  if (content !== null)
498
590
  this.questions.get(interactionId)?.answer({ action: 'accept', content });
499
591
  }
500
- update(conversation, notification) {
501
- const update = notification.update;
502
- if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
503
- const turn = this.started(conversation);
504
- turn.text += update.content.text;
505
- this.draft(conversation, turn);
506
- }
507
- else if (update.sessionUpdate === 'plan') {
508
- const turn = this.started(conversation);
509
- turn.entries = update.entries;
510
- this.plan(conversation, turn);
511
- }
512
- else if (update.sessionUpdate === 'agent_thought_chunk' || update.sessionUpdate === 'tool_call') {
513
- this.started(conversation);
514
- }
515
- else if (update.sessionUpdate === 'available_commands_update') {
516
- this.commands(conversation, update.availableCommands);
592
+ read(conversation, reader, message) {
593
+ for (const event of reader.read(message))
594
+ this.event(conversation, event);
595
+ }
596
+ event(conversation, event) {
597
+ switch (event.kind) {
598
+ case 'started':
599
+ this.started(conversation);
600
+ return;
601
+ case 'ended':
602
+ this.ended(conversation);
603
+ return;
604
+ case 'commands':
605
+ this.commands(conversation, event.commands);
606
+ return;
607
+ case 'options':
608
+ this.options(conversation, event.options);
609
+ return;
610
+ case 'context':
611
+ conversation.context = { used: event.used, window: event.window };
612
+ conversation.turn?.parts.measure(conversation.context);
613
+ return;
614
+ case 'subagent-done':
615
+ this.subagentDone(conversation, event.id, event.result);
616
+ return;
617
+ case 'marker':
618
+ case 'image':
619
+ if (conversation.turn !== null)
620
+ break;
621
+ if (event.kind === 'marker' && event.marker === 'job')
622
+ conversation.held.push({ type: 'marker', marker: 'job', text: event.text });
623
+ return;
517
624
  }
518
- else if (update.sessionUpdate === 'config_option_update') {
519
- this.options(conversation, update.configOptions);
625
+ this.apply(conversation, this.started(conversation), event);
626
+ }
627
+ apply(conversation, turn, event) {
628
+ switch (event.kind) {
629
+ case 'text':
630
+ case 'reasoning':
631
+ turn.parts.write(event.kind, event.id, event.text);
632
+ return;
633
+ case 'command':
634
+ turn.parts.command();
635
+ return;
636
+ case 'subagent': {
637
+ const index = turn.parts.start({ type: 'subagent', description: event.description, status: 'running', result: null });
638
+ conversation.subagents.set(event.id, { description: event.description, turn, index });
639
+ return;
640
+ }
641
+ case 'message':
642
+ turn.parts.start({ type: 'message', command: event.command, target: event.target });
643
+ return;
644
+ case 'marker':
645
+ turn.parts.start({ type: 'marker', marker: event.marker, text: event.text });
646
+ return;
647
+ case 'plan':
648
+ turn.parts.plan(event.entries);
649
+ return;
650
+ case 'image':
651
+ turn.uploads.push(this.image(conversation, turn, turn.parts.reserve(), event.data, event.mediaType));
652
+ this.kit.changed();
653
+ return;
520
654
  }
521
655
  }
522
- job(conversation, job) {
523
- if (job.running)
524
- conversation.jobs.add(job.id);
656
+ subagentDone(conversation, id, result) {
657
+ const subagent = conversation.subagents.get(id);
658
+ if (subagent === undefined)
659
+ return;
660
+ conversation.subagents.delete(id);
661
+ if (subagent.turn === conversation.turn) {
662
+ subagent.turn.parts.done(subagent.index, result);
663
+ return;
664
+ }
665
+ const part = { type: 'subagent', description: subagent.description, status: 'done', result };
666
+ if (conversation.turn === null)
667
+ conversation.held.push(part);
525
668
  else
526
- conversation.jobs.delete(job.id);
527
- this.kit.changed();
669
+ conversation.turn.parts.start(part);
528
670
  }
529
- draft(conversation, turn) {
530
- const blocks = blocksOf(turn.text);
531
- const from = Math.min(firstChange(turn.sent, blocks), turn.unsentFrom ?? Infinity);
532
- const sent = this.kit.send({
533
- type: 'draft',
534
- conversation_id: conversation.id,
535
- turn_id: turn.id,
536
- sequence: turn.draftSequence + 1,
537
- from,
538
- blocks: blocks.slice(from),
539
- });
540
- if (sent) {
541
- turn.draftSequence++;
542
- turn.sent = blocks;
543
- turn.unsentFrom = null;
671
+ async image(conversation, turn, reserved, data, mediaType) {
672
+ const bytes = Buffer.from(data, 'base64');
673
+ const name = `image-${++turn.images}.${mediaType.split('/')[1]}`;
674
+ try {
675
+ const declared = (await this.deliver(conversation, turn, 'images', {
676
+ name,
677
+ media_type: mediaType,
678
+ bytes: bytes.length,
679
+ sha256: createHash('sha256').update(bytes).digest('hex'),
680
+ }));
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);
544
685
  }
545
- else {
546
- turn.unsentFrom = from;
686
+ catch (error) {
687
+ turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` }, reserved);
547
688
  }
548
689
  }
549
- plan(conversation, turn) {
550
- const sent = this.kit.send({
551
- type: 'plan',
552
- conversation_id: conversation.id,
553
- turn_id: turn.id,
554
- sequence: turn.planSequence + 1,
555
- from: 0,
556
- steps: turn.entries.map((entry) => ({ label: entry.content, priority: entry.priority, status: entry.status })),
557
- });
558
- if (sent)
559
- turn.planSequence++;
560
- }
561
690
  }