@agentshouse/kit 0.2.1-cd9cbd8b6428c → 0.2.1-cde47fd72fe59

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 (103) hide show
  1. package/README.md +32 -31
  2. package/dist/acp.js +46 -103
  3. package/dist/agents.js +178 -49
  4. package/dist/api.js +34 -2
  5. package/dist/app-git.js +48 -0
  6. package/dist/bootstrap.json +4 -4
  7. package/dist/bridge.js +38 -58
  8. package/dist/chatgpt.js +32 -0
  9. package/dist/clis.js +137 -65
  10. package/dist/codex-main.js +58 -0
  11. package/dist/conversations.js +388 -276
  12. package/dist/copies.js +213 -145
  13. package/dist/end.js +23 -0
  14. package/dist/enrol-main.js +7 -1
  15. package/dist/events.js +525 -0
  16. package/dist/files.js +22 -2
  17. package/dist/git.js +10 -8
  18. package/dist/home.js +96 -7
  19. package/dist/house-cli.js +291 -0
  20. package/dist/house-main.js +7 -159
  21. package/dist/inputs.js +37 -0
  22. package/dist/instructions.js +10 -32
  23. package/dist/kit-cli.js +68 -0
  24. package/dist/kit-main.js +8 -19
  25. package/dist/login.js +118 -103
  26. package/dist/logout.js +14 -13
  27. package/dist/parts.js +82 -0
  28. package/dist/placement.js +30 -0
  29. package/dist/refusals.js +12 -0
  30. package/dist/relay.js +12 -2
  31. package/dist/resident.js +91 -35
  32. package/dist/scope.js +203 -0
  33. package/dist/secret-input.js +4 -1
  34. package/dist/service-main.js +9 -0
  35. package/dist/service.js +55 -0
  36. package/dist/shell.js +3 -0
  37. package/dist/sign-in.js +34 -27
  38. package/dist/skills.js +81 -12
  39. package/dist/stream.js +87 -11
  40. package/dist/transcription.js +57 -9
  41. package/dist/translate.js +2 -8
  42. package/dist/update.js +50 -29
  43. package/dist/wire.js +6 -0
  44. package/dist/work.js +346 -0
  45. package/how-we-work.md +13 -3
  46. package/package.json +3 -2
  47. package/skills/ask-matt/SKILL.md +17 -11
  48. package/skills/ask-matt/agents/openai.yaml +1 -0
  49. package/skills/code-review/SKILL.md +5 -5
  50. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  51. package/skills/codebase-design/SKILL.md +0 -2
  52. package/skills/diagnosing-bugs/SKILL.md +2 -4
  53. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  54. package/skills/domain-modeling/SKILL.md +12 -14
  55. package/skills/grill-me/SKILL.md +1 -2
  56. package/skills/grill-me/agents/openai.yaml +1 -0
  57. package/skills/grill-with-docs/SKILL.md +1 -2
  58. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  59. package/skills/grilling/SKILL.md +2 -2
  60. package/skills/handoff/SKILL.md +2 -3
  61. package/skills/handoff/agents/openai.yaml +1 -0
  62. package/skills/implement/SKILL.md +5 -4
  63. package/skills/implement/agents/openai.yaml +1 -0
  64. package/skills/implement-spec/SKILL.md +40 -0
  65. package/skills/implement-spec/agents/openai.yaml +5 -0
  66. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  67. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  68. package/skills/pr/CREDITS.md +3 -0
  69. package/skills/pr/LICENSE +21 -0
  70. package/skills/pr/SKILL.md +170 -0
  71. package/skills/pr/agents/openai.yaml +3 -0
  72. package/skills/prototype/SKILL.md +0 -2
  73. package/skills/research/SKILL.md +0 -2
  74. package/skills/retro/LICENSE +21 -0
  75. package/skills/retro/SKILL.md +44 -0
  76. package/skills/retro/agents/openai.yaml +5 -0
  77. package/skills/tdd/SKILL.md +2 -4
  78. package/skills/teach/SKILL.md +3 -4
  79. package/skills/teach/agents/openai.yaml +1 -0
  80. package/skills/to-questionnaire/SKILL.md +1 -2
  81. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  82. package/skills/to-spec/SKILL.md +1 -2
  83. package/skills/to-spec/agents/openai.yaml +1 -0
  84. package/skills/to-tickets/SKILL.md +3 -4
  85. package/skills/to-tickets/agents/openai.yaml +1 -0
  86. package/skills/triage/AGENT-BRIEF.md +207 -0
  87. package/skills/triage/LICENSE +21 -0
  88. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  89. package/skills/triage/SKILL.md +112 -0
  90. package/skills/triage/agents/openai.yaml +5 -0
  91. package/skills/wait-what/SKILL.md +2 -3
  92. package/skills/wait-what/agents/openai.yaml +1 -0
  93. package/skills/wayfinder/SKILL.md +5 -6
  94. package/skills/wayfinder/agents/openai.yaml +1 -0
  95. package/skills/wizard/LICENSE +21 -0
  96. package/skills/wizard/SKILL.md +44 -0
  97. package/skills/wizard/agents/openai.yaml +3 -0
  98. package/skills/wizard/template.sh +223 -0
  99. package/skills/writing-for-agents/SKILL.md +0 -2
  100. package/dist/blocks.js +0 -51
  101. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  102. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  103. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
@@ -1,56 +1,112 @@
1
- import { client, } from '@agentclientprotocol/sdk';
2
- import { randomUUID } from 'node:crypto';
1
+ import { client, RequestError, } from '@agentclientprotocol/sdk';
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
- import { killMarked, startAdapter, TURN_ENDED, TURN_STARTED } from './acp.js';
7
- import { blocksOf, firstChange } from './blocks.js';
6
+ import { startAdapter } from './acp.js';
7
+ import { finalRefusal } from './api.js';
8
8
  import { openBridge } from './bridge.js';
9
9
  import { CLIS } from './clis.js';
10
- import { placeFiles } from './files.js';
10
+ import { answeredFailure, readerFor } from './events.js';
11
+ import { placeFiles, uploadBytes } from './files.js';
11
12
  import { agentBase } from './home.js';
12
13
  import { instructions } from './instructions.js';
14
+ import { Parts } from './parts.js';
15
+ import { refusalOf } from './refusals.js';
16
+ import { killMarked, Scope } from './scope.js';
13
17
  import { holdSecretInput } from './secret-input.js';
14
18
  import { createHowWeWork } from './skills.js';
15
19
  import { transcribe } from './transcription.js';
20
+ import { workFor } from './work.js';
21
+ const SIGNED_OUT = RequestError.authRequired().code;
16
22
  const KILLED = 'the conversation was killed';
23
+ const REFUSED = 'the CLI refused the prompt';
24
+ const NO_CONNECTION = "the Agent's computer could not open this conversation; try again";
25
+ const GONE = 'House no longer holds this conversation; start a new one';
26
+ const RETIRED = "this conversation's Agent is retired, so it cannot start";
27
+ const ENDED = {
28
+ conversation_not_found: GONE,
29
+ conversation_historical: RETIRED,
30
+ };
31
+ const NO_START = "the Agent's CLI did not start";
32
+ const NO_SESSION = "the Agent's CLI did not open a session";
33
+ const NO_RESUME = "the Agent's CLI could not resume this conversation";
34
+ const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
35
+ class NotStarted extends Error {
36
+ }
37
+ async function step(reason, work) {
38
+ try {
39
+ return await work;
40
+ }
41
+ catch (error) {
42
+ throw new NotStarted(typeof reason === 'string' ? reason : reason(error), { cause: error });
43
+ }
44
+ }
45
+ function unbridged(error) {
46
+ return (finalRefusal(error) ? ENDED[refusalOf(error.text)?.code ?? ''] : undefined) ?? NO_CONNECTION;
47
+ }
48
+ const EXIT_PLAN_MODE = {
49
+ parse(value) {
50
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
51
+ throw new Error('_x.ai/exit_plan_mode needs an object');
52
+ }
53
+ return value;
54
+ },
55
+ };
56
+ export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
17
57
  function causeOf(error) {
18
58
  return error instanceof Error ? error.message : String(error);
19
59
  }
60
+ function answeredWith(error) {
61
+ const signedOut = typeof error === 'object' && error !== null && error.code === SIGNED_OUT;
62
+ return { failure: signedOut ? 'signed_out' : 'error', cause: causeOf(error) };
63
+ }
64
+ function exitedWith(running, cause) {
65
+ return running.killed ? { failure: 'killed', cause: KILLED } : { failure: 'exited', cause };
66
+ }
20
67
  function answerMessage(request, response) {
21
68
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
22
69
  }
23
70
  function logged(error) {
24
71
  process.stderr.write(`kit: ${causeOf(error)}\n`);
25
72
  }
26
- function stop(bridge) {
27
- killMarked(`HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`);
73
+ function refusal(conversation, error) {
74
+ if (conversation.kills > 0)
75
+ return KILLED;
76
+ const cause = error instanceof NotStarted ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
77
+ logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
78
+ return causeOf(error);
79
+ }
80
+ function marker(bridge) {
81
+ return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
28
82
  }
29
83
  class Turn {
30
84
  id = randomUUID();
31
- text = '';
32
- segment = null;
33
- admitted = false;
34
- notes = [];
35
- sent = [];
36
- unsentFrom = null;
37
- draftSequence = 0;
38
- planSequence = 0;
39
- entries = null;
85
+ parts;
40
86
  questions = 0;
41
87
  secrets = 0;
42
- prompt = null;
88
+ prompted = false;
43
89
  ended = false;
90
+ images = 0;
91
+ uploads = [];
92
+ constructor(emit) {
93
+ this.parts = new Parts(emit);
94
+ }
44
95
  }
45
96
  class Conversation {
46
97
  id;
47
98
  running = null;
99
+ session = null;
48
100
  turn = null;
49
101
  commands = null;
50
- jobs = new Set();
51
- queued = [];
52
- late = [];
102
+ context = null;
103
+ leftovers = new Set();
104
+ waiting = [];
105
+ sending = null;
106
+ subagents = new Map();
107
+ held = [];
53
108
  opening = null;
109
+ prompted = null;
54
110
  kills = 0;
55
111
  killed = new AbortController();
56
112
  interrupts = 0;
@@ -73,55 +129,72 @@ function asksSecret(request) {
73
129
  export class Conversations {
74
130
  kit;
75
131
  conversations = new Map();
76
- acks = new Map();
77
132
  questions = new Map();
78
- carrying = 0;
79
133
  reporting = 0;
134
+ watched = false;
80
135
  constructor(kit) {
81
136
  this.kit = kit;
137
+ // A process or a durable schedule ends without a report, so the Kit looks at both again every two seconds.
138
+ setInterval(() => this.look(), 2_000);
82
139
  }
83
140
  input(input) {
84
- this.carrying++;
85
- const carried = this.acks.get(input.input_id) ??
86
- (() => {
87
- const conversation = this.conversation(input.conversation_id);
88
- if (input.kind === 'kill') {
89
- conversation.kills++;
90
- conversation.killed.abort(new Error(KILLED));
91
- this.halt(conversation);
92
- }
93
- if (input.kind === 'interrupt') {
94
- conversation.interrupts++;
95
- conversation.interrupted.abort();
96
- }
97
- const work = conversation.queue.then(() => this.carry(conversation, input));
98
- conversation.queue = work.catch(() => undefined);
99
- this.acks.set(input.input_id, work);
100
- return work;
101
- })();
102
- carried
103
- .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
104
- .catch(logged)
105
- .finally(() => {
106
- this.carrying--;
107
- this.kit.changed();
141
+ void this.kit.inputs.carry(input.input_id, () => {
142
+ const conversation = this.conversation(input.conversation_id);
143
+ if (input.kind === 'kill') {
144
+ conversation.kills++;
145
+ conversation.killed.abort(new Error(KILLED));
146
+ for (const waiting of conversation.waiting.splice(0))
147
+ waiting.settle({ refused: KILLED });
148
+ this.halt(conversation);
149
+ }
150
+ if (input.kind === 'interrupt') {
151
+ conversation.interrupts++;
152
+ conversation.interrupted.abort();
153
+ }
154
+ const handled = conversation.queue.then(() => this.carry(conversation, input));
155
+ conversation.queue = handled.catch(() => undefined);
156
+ return handled.then(async ({ ack }) => ({ ack: await ack }));
108
157
  });
109
158
  }
110
159
  idle() {
111
- if (this.carrying > 0 || this.reporting > 0)
160
+ if (this.reporting > 0)
112
161
  return false;
113
162
  for (const conversation of this.conversations.values()) {
114
163
  const turn = conversation.turn;
115
164
  const asking = turn !== null && turn.questions > 0 && turn.secrets === 0;
116
- if (conversation.jobs.size > 0 || conversation.open > (asking ? 1 : 0))
165
+ if (conversation.open > (asking ? 1 : 0))
117
166
  return false;
118
167
  }
119
- return true;
168
+ const conversations = [...this.conversations.values()];
169
+ if (conversations.some((conversation) => conversation.running?.work.busy()))
170
+ return false;
171
+ return !conversations.some((conversation) => conversation.running?.scope.working() || [...conversation.leftovers].some((scope) => scope.working()));
172
+ }
173
+ quiet() {
174
+ return this.idle() && [...this.conversations.values()].every((conversation) => conversation.open === 0);
175
+ }
176
+ look() {
177
+ for (const conversation of this.conversations.values()) {
178
+ for (const scope of conversation.leftovers) {
179
+ if (!scope.empty())
180
+ continue;
181
+ scope.close();
182
+ conversation.leftovers.delete(scope);
183
+ }
184
+ }
185
+ this.kit.changed();
120
186
  }
121
187
  opened() {
188
+ this.watched = false;
189
+ }
190
+ watching(watched) {
191
+ const resend = watched && !this.watched;
192
+ this.watched = watched;
193
+ if (!resend)
194
+ return;
122
195
  for (const conversation of this.conversations.values()) {
123
196
  if (conversation.turn !== null)
124
- conversation.turn.unsentFrom = 0;
197
+ this.replay(conversation, conversation.turn);
125
198
  }
126
199
  }
127
200
  conversation(id) {
@@ -134,7 +207,7 @@ export class Conversations {
134
207
  }
135
208
  async carry(conversation, input) {
136
209
  if (input.kind === 'open')
137
- return this.open(conversation, input);
210
+ return { ack: Promise.resolve(await this.open(conversation, input)) };
138
211
  if (input.kind === 'message')
139
212
  return this.message(conversation, input);
140
213
  if (input.kind === 'interrupt') {
@@ -144,6 +217,7 @@ export class Conversations {
144
217
  }
145
218
  if (input.kind === 'kill') {
146
219
  await this.kill(conversation);
220
+ await conversation.sending;
147
221
  if (--conversation.kills === 0)
148
222
  conversation.killed = new AbortController();
149
223
  }
@@ -152,54 +226,91 @@ export class Conversations {
152
226
  if (input.kind === 'answer') {
153
227
  const question = this.questions.get(String(input.interaction_id));
154
228
  if (question === undefined) {
155
- return this.message(conversation, { ...input, text: answerMessage(input.request, input.response), files: [], first: false });
229
+ const text = answerMessage(input.request, input.response);
230
+ return this.message(conversation, { ...input, text, files: [], first: false });
156
231
  }
157
232
  question.answer(input.response);
158
233
  }
159
- return {};
234
+ return { ack: Promise.resolve({}) };
160
235
  }
161
236
  async open(conversation, input) {
162
237
  if (conversation.running !== null)
163
238
  return {};
164
239
  try {
165
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
240
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null, true) };
166
241
  }
167
242
  catch (error) {
168
- return { refused: causeOf(error) };
243
+ return { refused: refusal(conversation, error) };
169
244
  }
170
245
  }
171
246
  async message(conversation, input) {
172
- let ack = {};
173
- const prompt = [];
247
+ const content = [{ type: 'text', text: String(input.text) }];
248
+ let route;
174
249
  try {
175
- const route = await this.route(conversation, input.agent_id);
250
+ route = await this.route(conversation, input.agent_id);
176
251
  const files = input.files;
177
252
  const paths = await placeFiles(this.kit.house, route.working_directory, files, conversation.killed.signal);
178
253
  const lines = await this.transcribed(conversation, files, paths);
254
+ if (lines.length > 0)
255
+ content.push({ type: 'text', text: lines.join('\n') });
256
+ if (conversation.kills > 0)
257
+ throw new Error(KILLED);
258
+ }
259
+ catch (error) {
260
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
261
+ }
262
+ return {
263
+ ack: new Promise((settle) => {
264
+ conversation.waiting.push({ input, route, content, settle });
265
+ this.next(conversation);
266
+ }),
267
+ };
268
+ }
269
+ next(conversation) {
270
+ if (conversation.sending !== null || conversation.open > 0)
271
+ return;
272
+ const waiting = conversation.waiting.shift();
273
+ if (waiting === undefined)
274
+ return;
275
+ conversation.sending = this.send(conversation, waiting).finally(() => {
276
+ conversation.sending = null;
277
+ this.next(conversation);
278
+ });
279
+ }
280
+ async send(conversation, waiting) {
281
+ const { input } = waiting;
282
+ let ack = {};
283
+ let started = false;
284
+ const prompt = [];
285
+ try {
179
286
  if (conversation.running === null) {
180
- const opened = await this.start(conversation, route, input.provider_session_id);
181
- if (input.provider_session_id === null)
182
- ack = { provider_session_id: opened };
287
+ const stored = conversation.session ?? input.provider_session_id;
288
+ const empty = input.first === true && conversation.prompted !== stored;
289
+ started = (await this.start(conversation, waiting.route, stored, empty)) !== stored;
290
+ }
291
+ const session = conversation.running.sessionId;
292
+ if (started || (input.first === true && session !== input.provider_session_id)) {
293
+ ack = { provider_session_id: session };
183
294
  }
184
295
  if (input.first === true) {
185
296
  prompt.push({
186
297
  type: 'text',
187
- text: await instructions(conversation.running.bridge, route.base_instructions, await this.kit.copies.mapping()),
298
+ text: await instructions(conversation.running.bridge, waiting.route.base_instructions, await this.kit.copies.mapping()),
188
299
  });
189
300
  }
190
- prompt.push({ type: 'text', text: String(input.text) });
191
- if (lines.length > 0)
192
- prompt.push({ type: 'text', text: lines.join('\n') });
193
301
  if (conversation.kills > 0)
194
302
  throw new Error(KILLED);
195
303
  }
196
304
  catch (error) {
197
- if ('provider_session_id' in ack)
305
+ if (started) {
306
+ conversation.session = null;
198
307
  await this.kill(conversation);
199
- return { refused: conversation.kills > 0 ? KILLED : causeOf(error) };
308
+ }
309
+ waiting.settle({ refused: refusal(conversation, error) });
310
+ return;
200
311
  }
201
- this.prompt(conversation, prompt);
202
- return ack;
312
+ this.prompt(conversation, [...prompt, ...waiting.content]);
313
+ waiting.settle(ack);
203
314
  }
204
315
  async transcribed(conversation, files, paths) {
205
316
  const lines = [];
@@ -209,7 +320,7 @@ export class Conversations {
209
320
  continue;
210
321
  try {
211
322
  const text = await transcribe(path, AbortSignal.any([conversation.killed.signal, conversation.interrupted.signal]));
212
- if (text !== '')
323
+ if (text !== null && text !== '')
213
324
  lines.push(`Transcript: ${text}`);
214
325
  }
215
326
  catch (error) {
@@ -229,11 +340,13 @@ export class Conversations {
229
340
  }
230
341
  halt(conversation) {
231
342
  if (conversation.opening !== null)
232
- stop(conversation.opening);
343
+ killMarked(marker(conversation.opening));
344
+ for (const scope of conversation.leftovers)
345
+ scope.kill();
233
346
  if (conversation.running === null)
234
347
  return;
235
348
  conversation.running.killed = true;
236
- stop(conversation.running.bridge);
349
+ conversation.running.scope.kill();
237
350
  }
238
351
  async kill(conversation) {
239
352
  const running = conversation.running;
@@ -245,6 +358,7 @@ export class Conversations {
245
358
  if (running === null)
246
359
  return;
247
360
  try {
361
+ running.reader.requested(option, value);
248
362
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
249
363
  sessionId: running.sessionId,
250
364
  configId: option,
@@ -253,6 +367,7 @@ export class Conversations {
253
367
  this.options(conversation, answered.configOptions);
254
368
  }
255
369
  catch (error) {
370
+ running.reader.refused(option);
256
371
  logged(error);
257
372
  }
258
373
  }
@@ -269,48 +384,58 @@ export class Conversations {
269
384
  }
270
385
  return route;
271
386
  }
272
- async start(conversation, route, sessionId) {
387
+ async start(conversation, route, sessionId, empty) {
388
+ const reader = readerFor(route.kind);
389
+ reader.session = sessionId;
273
390
  const app = client({ name: '@agentshouse/kit' })
274
- .onNotification('session/update', ({ params }) => this.update(conversation, params))
275
- .onNotification(TURN_STARTED, (params) => params, () => void this.started(conversation))
276
- .onNotification(TURN_ENDED, (params) => params, () => this.ended(conversation))
277
391
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
278
- .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
392
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
393
+ .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
279
394
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
280
- const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
395
+ const bridge = await step(unbridged, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
281
396
  if (conversation.kills > 0) {
282
397
  bridge.close();
283
398
  throw new Error(KILLED);
284
399
  }
285
400
  conversation.opening = bridge;
401
+ const work = workFor(route.kind, route.working_directory, () => this.kit.changed());
402
+ const scope = new Scope(marker(bridge), randomUUID(), cli);
403
+ let adapter = null;
286
404
  try {
287
405
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
288
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (job) => this.job(conversation, job), bridge.env);
406
+ await CLIS[route.kind].allowHouse(bridge.env.PATH);
407
+ const streamed = await step(NO_CONNECTION, work.open(cli));
408
+ adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
409
+ work.read(message);
410
+ this.read(conversation, reader, message);
411
+ }, { ...bridge.env, ...streamed }, undefined, scope));
289
412
  void adapter.exited.then(() => bridge.close());
290
413
  const agent = adapter.connection.agent;
291
414
  const cwd = route.working_directory;
292
- const opened = sessionId === null
293
- ? await agent.request('session/new', { cwd, mcpServers: [] })
294
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [] })), sessionId };
295
- const running = {
296
- adapter,
297
- bridge,
298
- sessionId: opened.sessionId,
299
- killed: false,
300
- queues: CLIS[route.kind].queues,
301
- phase: CLIS[route.kind].phase,
302
- };
303
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
415
+ const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
416
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
417
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
418
+ reader.session = opened.sessionId;
419
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
420
+ const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
421
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
422
+ }
423
+ const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
424
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
425
+ void work.settled().then(() => scope.record());
304
426
  conversation.running = running;
427
+ conversation.session = running.sessionId;
305
428
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
306
429
  if (conversation.commands !== null)
307
430
  this.commands(conversation, conversation.commands);
308
431
  this.options(conversation, options);
309
- void adapter.exited.then((cause) => this.exited(conversation, adapter, cause));
432
+ void adapter.lost.then((cause) => this.exited(conversation, running, cause));
310
433
  return running.sessionId;
311
434
  }
312
435
  catch (error) {
313
- stop(bridge);
436
+ adapter?.retire();
437
+ work.close();
438
+ conversation.leftovers.add(scope);
314
439
  bridge.close();
315
440
  throw conversation.kills > 0 ? new Error(KILLED) : error;
316
441
  }
@@ -318,9 +443,22 @@ export class Conversations {
318
443
  conversation.opening = null;
319
444
  }
320
445
  }
446
+ async resume(conversation, kind, adapter, request, empty) {
447
+ try {
448
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
449
+ }
450
+ catch (error) {
451
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
452
+ throw new NotStarted(NO_RESUME, { cause: error });
453
+ }
454
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
455
+ return null;
456
+ }
457
+ }
321
458
  async launchSettings(running, route, offered) {
322
459
  let options = offered;
323
460
  const settings = [
461
+ ['mode', route.mode ?? CLIS[route.kind].fullAccess],
324
462
  ['model', route.model],
325
463
  ['thought_level', route.effort],
326
464
  ];
@@ -328,6 +466,7 @@ export class Conversations {
328
466
  if (value === null)
329
467
  continue;
330
468
  const option = options.find((candidate) => candidate.category === category);
469
+ running.reader.requested(option?.id ?? category, value);
331
470
  const answered = await running.adapter.connection.agent.request('session/set_config_option', {
332
471
  sessionId: running.sessionId,
333
472
  configId: option?.id ?? category,
@@ -337,23 +476,24 @@ export class Conversations {
337
476
  }
338
477
  return options;
339
478
  }
340
- exited(conversation, adapter, cause) {
341
- const running = conversation.running;
342
- if (running?.adapter !== adapter)
343
- return;
479
+ exited(conversation, running, cause) {
480
+ this.detach(conversation, running);
481
+ if (conversation.turn !== null) {
482
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
483
+ }
484
+ this.next(conversation);
485
+ this.kit.changed();
486
+ }
487
+ detach(conversation, running) {
488
+ running.work.close();
489
+ conversation.leftovers.add(running.scope);
344
490
  conversation.running = null;
345
491
  conversation.commands = null;
346
- conversation.jobs.clear();
347
- conversation.queued.length = 0;
348
492
  for (const [id, question] of this.questions) {
349
493
  if (question.conversation === conversation)
350
494
  this.questions.delete(id);
351
495
  }
352
496
  this.kit.send({ type: 'process', conversation_id: conversation.id, running: false });
353
- if (conversation.turn !== null) {
354
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
355
- }
356
- this.kit.changed();
357
497
  }
358
498
  commands(conversation, commands) {
359
499
  conversation.commands = commands;
@@ -368,7 +508,7 @@ export class Conversations {
368
508
  }
369
509
  deliver(conversation, turn, event, body) {
370
510
  this.reporting++;
371
- const delivered = conversation.reports.then(() => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, body));
511
+ const delivered = conversation.reports.then(async () => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, await body));
372
512
  conversation.reports = delivered
373
513
  .catch(() => undefined)
374
514
  .finally(() => {
@@ -377,119 +517,90 @@ export class Conversations {
377
517
  });
378
518
  return delivered;
379
519
  }
380
- report(conversation) {
381
- const turn = new Turn();
382
- conversation.open++;
383
- void this.deliver(conversation, turn, 'started', {})
384
- .then(() => this.admitted(conversation, turn))
385
- .catch(logged);
386
- return turn;
520
+ write(conversation, turn, operation) {
521
+ if (turn.ended || !this.watched)
522
+ return;
523
+ this.kit.send({ type: 'turn', conversation_id: conversation.id, turn_id: turn.id, ...operation });
387
524
  }
388
- admitted(conversation, turn) {
389
- turn.admitted = true;
390
- for (const note of turn.notes.splice(0))
391
- this.kit.send(note);
392
- if (turn.text !== '') {
393
- turn.unsentFrom = 0;
394
- this.draft(conversation, turn);
395
- }
396
- if (turn.entries !== null)
397
- this.plan(conversation, turn);
525
+ replay(conversation, turn) {
526
+ for (const operation of turn.parts.operations())
527
+ this.write(conversation, turn, operation);
398
528
  }
399
529
  begin(conversation) {
400
- const turn = this.report(conversation);
530
+ const turn = new Turn((operation) => this.write(conversation, turn, operation));
531
+ conversation.open++;
532
+ void this.deliver(conversation, turn, 'started', {}).catch(logged);
401
533
  conversation.turn = turn;
534
+ if (conversation.context !== null)
535
+ turn.parts.measure(conversation.context);
536
+ for (const part of conversation.held.splice(0))
537
+ turn.parts.start(part);
402
538
  this.kit.changed();
403
539
  return turn;
404
540
  }
405
- finish(conversation, turn) {
406
- if (turn.ended)
407
- return;
408
- const segment = turn.segment;
409
- turn.segment = null;
410
- if (segment?.phase === 'note')
411
- this.note(conversation, turn, segment.text);
412
- else if (segment !== null)
413
- this.answer(conversation, turn, segment.text);
414
- this.end(conversation, turn, { text: turn.text });
415
- }
416
- end(conversation, turn, outcome) {
541
+ end(conversation, turn, failed, notice = null) {
417
542
  if (turn.ended)
418
543
  return;
544
+ if (failed !== null)
545
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
546
+ if (conversation.turn === turn) {
547
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
548
+ this.apply(conversation, turn, event);
549
+ }
550
+ if (notice !== null)
551
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
419
552
  turn.ended = true;
420
553
  conversation.open--;
421
554
  if (conversation.turn === turn)
422
555
  conversation.turn = null;
423
- void this.deliver(conversation, turn, 'ended', outcome).catch(logged);
424
- const late = conversation.open === 0 ? conversation.late.shift() : undefined;
425
- if (late !== undefined)
426
- this.failed(conversation, late);
556
+ const body = Promise.allSettled(turn.uploads).then(() => ({
557
+ parts: turn.parts.saved(),
558
+ context: turn.parts.context,
559
+ ...(failed === null ? {} : { failed: failed.failure }),
560
+ }));
561
+ void this.deliver(conversation, turn, 'ended', body).catch(logged);
562
+ this.next(conversation);
427
563
  this.kit.changed();
428
564
  }
429
- failed(conversation, cause) {
430
- if (conversation.open > 0)
431
- conversation.late.push(cause);
432
- else
433
- this.end(conversation, this.report(conversation), { failed: cause });
434
- }
435
565
  prompt(conversation, prompt) {
436
566
  const running = conversation.running;
437
- const sent = { turn: null };
438
- if (running.queues && (conversation.turn !== null || conversation.queued.length > 0)) {
439
- conversation.queued.push(sent);
440
- }
441
- else {
442
- this.attach(conversation.turn ?? this.begin(conversation), sent);
443
- }
444
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.answered(conversation, sent, null), async (error) => {
445
- if (!running.adapter.connection.signal.aborted)
446
- this.answered(conversation, sent, causeOf(error));
447
- else if (sent.turn !== null) {
448
- this.end(conversation, sent.turn, { failed: running.killed ? KILLED : await running.adapter.exited });
567
+ const turn = this.begin(conversation);
568
+ turn.prompted = true;
569
+ conversation.prompted = running.sessionId;
570
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
571
+ const notice = answeredFailure(answer);
572
+ if (notice !== null) {
573
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
574
+ }
575
+ else {
576
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
577
+ }
578
+ }, async (error) => {
579
+ if (running.adapter.connection.signal.aborted) {
580
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
581
+ }
582
+ else {
583
+ if (conversation.open === 1 && conversation.running === running) {
584
+ running.adapter.retire();
585
+ this.detach(conversation, running);
586
+ }
587
+ this.end(conversation, turn, answeredWith(error));
449
588
  }
450
589
  });
451
590
  }
452
- attach(turn, sent) {
453
- turn.prompt = sent;
454
- sent.turn = turn;
455
- }
456
- answered(conversation, sent, failed) {
457
- const turn = sent.turn;
458
- if (turn === null) {
459
- const queued = conversation.queued.indexOf(sent);
460
- if (queued < 0)
461
- return;
462
- conversation.queued.splice(queued, 1);
463
- if (failed !== null)
464
- this.failed(conversation, failed);
465
- }
466
- else if (failed !== null) {
467
- if (turn.ended)
468
- this.failed(conversation, failed);
469
- else
470
- this.end(conversation, turn, { failed });
471
- }
472
- else if (turn.prompt === sent) {
473
- this.finish(conversation, turn);
474
- }
475
- }
476
591
  started(conversation) {
477
- if (conversation.turn !== null)
478
- return conversation.turn;
479
- const turn = this.begin(conversation);
480
- const queued = conversation.queued.shift();
481
- if (queued !== undefined)
482
- this.attach(turn, queued);
483
- return turn;
592
+ return conversation.turn ?? this.begin(conversation);
484
593
  }
485
594
  ended(conversation) {
486
595
  const turn = conversation.turn;
487
596
  if (turn === null)
488
597
  return;
489
- if (turn.prompt === null) {
490
- this.finish(conversation, turn);
598
+ if (!turn.prompted) {
599
+ this.end(conversation, turn, null);
491
600
  }
492
601
  else {
602
+ for (const event of conversation.running?.reader.closeTurn() ?? [])
603
+ this.apply(conversation, turn, event);
493
604
  conversation.turn = null;
494
605
  this.kit.changed();
495
606
  }
@@ -538,101 +649,102 @@ export class Conversations {
538
649
  if (content !== null)
539
650
  this.questions.get(interactionId)?.answer({ action: 'accept', content });
540
651
  }
541
- update(conversation, notification) {
542
- const update = notification.update;
543
- if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
544
- this.chunk(conversation, this.started(conversation), update, update.content.text);
545
- }
546
- else if (update.sessionUpdate === 'plan') {
547
- const turn = this.started(conversation);
548
- this.close(conversation, turn);
549
- turn.entries = update.entries;
550
- this.plan(conversation, turn);
551
- }
552
- else if (update.sessionUpdate === 'tool_call') {
553
- this.close(conversation, this.started(conversation));
554
- }
555
- else if (update.sessionUpdate === 'agent_thought_chunk') {
556
- const turn = this.started(conversation);
557
- if (turn.segment?.messageId !== (update.messageId ?? null))
558
- this.close(conversation, turn);
559
- }
560
- else if (update.sessionUpdate === 'available_commands_update') {
561
- this.commands(conversation, update.availableCommands);
562
- }
563
- else if (update.sessionUpdate === 'config_option_update') {
564
- this.options(conversation, update.configOptions);
565
- }
652
+ read(conversation, reader, message) {
653
+ for (const event of reader.read(message))
654
+ this.event(conversation, event);
566
655
  }
567
- job(conversation, job) {
568
- if (job.running)
569
- conversation.jobs.add(job.id);
570
- else
571
- conversation.jobs.delete(job.id);
572
- this.kit.changed();
656
+ event(conversation, event) {
657
+ switch (event.kind) {
658
+ case 'started':
659
+ this.started(conversation);
660
+ return;
661
+ case 'ended':
662
+ this.ended(conversation);
663
+ return;
664
+ case 'commands':
665
+ this.commands(conversation, event.commands);
666
+ return;
667
+ case 'options':
668
+ this.options(conversation, event.options);
669
+ return;
670
+ case 'context':
671
+ conversation.context = { used: event.used, window: event.window };
672
+ conversation.turn?.parts.measure(conversation.context);
673
+ return;
674
+ case 'subagent-done':
675
+ this.subagentDone(conversation, event.id, event.result);
676
+ return;
677
+ case 'marker':
678
+ case 'image':
679
+ if (conversation.turn !== null)
680
+ break;
681
+ if (event.kind === 'marker' && event.marker === 'job')
682
+ conversation.held.push({ type: 'marker', marker: 'job', text: event.text });
683
+ return;
684
+ }
685
+ this.apply(conversation, this.started(conversation), event);
573
686
  }
574
- chunk(conversation, turn, chunk, text) {
575
- const phase = conversation.running?.phase(chunk) ?? null;
576
- const messageId = chunk.messageId ?? null;
577
- if (turn.segment?.messageId !== messageId || turn.segment.phase !== phase)
578
- this.close(conversation, turn);
579
- if (phase === 'answer') {
580
- this.answer(conversation, turn, text);
581
- return;
687
+ apply(conversation, turn, event) {
688
+ switch (event.kind) {
689
+ case 'text':
690
+ case 'reasoning':
691
+ turn.parts.write(event.kind, event.id, event.text);
692
+ return;
693
+ case 'command':
694
+ turn.parts.command();
695
+ return;
696
+ case 'subagent': {
697
+ const index = turn.parts.start({ type: 'subagent', description: event.description, status: 'running', result: null });
698
+ conversation.subagents.set(event.id, { description: event.description, turn, index });
699
+ return;
700
+ }
701
+ case 'message':
702
+ turn.parts.start({ type: 'message', command: event.command, target: event.target });
703
+ return;
704
+ case 'marker':
705
+ turn.parts.start({ type: 'marker', marker: event.marker, text: event.text });
706
+ return;
707
+ case 'plan':
708
+ turn.parts.plan(event.entries);
709
+ return;
710
+ case 'image':
711
+ turn.uploads.push(this.image(conversation, turn, turn.parts.reserve(), event.data, event.mediaType));
712
+ this.kit.changed();
713
+ return;
582
714
  }
583
- turn.segment ??= { messageId, phase, text: '' };
584
- turn.segment.text += text;
585
715
  }
586
- close(conversation, turn) {
587
- const segment = turn.segment;
588
- if (segment === null)
716
+ subagentDone(conversation, id, result) {
717
+ const subagent = conversation.subagents.get(id);
718
+ if (subagent === undefined)
589
719
  return;
590
- turn.segment = null;
591
- this.note(conversation, turn, segment.text);
592
- }
593
- note(conversation, turn, text) {
594
- if (text.trim() === '')
720
+ conversation.subagents.delete(id);
721
+ if (subagent.turn === conversation.turn) {
722
+ subagent.turn.parts.done(subagent.index, result);
595
723
  return;
596
- const note = { type: 'working-note', conversation_id: conversation.id, note_id: randomUUID(), text };
597
- if (turn.admitted)
598
- this.kit.send(note);
724
+ }
725
+ const part = { type: 'subagent', description: subagent.description, status: 'done', result };
726
+ if (conversation.turn === null)
727
+ conversation.held.push(part);
599
728
  else
600
- turn.notes.push(note);
601
- }
602
- answer(conversation, turn, text) {
603
- turn.text += text;
604
- this.draft(conversation, turn);
605
- }
606
- draft(conversation, turn) {
607
- const blocks = blocksOf(turn.text);
608
- const from = Math.min(firstChange(turn.sent, blocks), turn.unsentFrom ?? Infinity);
609
- const sent = this.kit.send({
610
- type: 'draft',
611
- conversation_id: conversation.id,
612
- turn_id: turn.id,
613
- sequence: turn.draftSequence + 1,
614
- from,
615
- blocks: blocks.slice(from),
616
- });
617
- if (sent) {
618
- turn.draftSequence++;
619
- turn.sent = blocks;
620
- turn.unsentFrom = null;
729
+ conversation.turn.parts.start(part);
730
+ }
731
+ async image(conversation, turn, reserved, data, mediaType) {
732
+ const bytes = Buffer.from(data, 'base64');
733
+ const name = `image-${++turn.images}.${mediaType.split('/')[1]}`;
734
+ try {
735
+ const declared = (await this.deliver(conversation, turn, 'images', {
736
+ name,
737
+ media_type: mediaType,
738
+ bytes: bytes.length,
739
+ sha256: createHash('sha256').update(bytes).digest('hex'),
740
+ }));
741
+ const saved = declared.upload === undefined ? declared : await uploadBytes(declared.upload, bytes);
742
+ if (saved.save.status !== 'saved')
743
+ throw new Error(saved.save.failure ?? saved.save.status);
744
+ turn.parts.arrive({ type: 'image', file: saved.version, name, media_type: mediaType }, reserved);
621
745
  }
622
- else {
623
- turn.unsentFrom = from;
746
+ catch (error) {
747
+ turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` }, reserved);
624
748
  }
625
749
  }
626
- plan(conversation, turn) {
627
- const sent = this.kit.send({
628
- type: 'plan',
629
- conversation_id: conversation.id,
630
- turn_id: turn.id,
631
- sequence: turn.planSequence + 1,
632
- from: 0,
633
- steps: turn.entries.map((entry) => ({ label: entry.content, priority: entry.priority, status: entry.status })),
634
- });
635
- if (sent)
636
- turn.planSequence++;
637
- }
638
750
  }