@agentshouse/kit 0.2.1-cb6679d048e60 → 0.2.1-cc719fffeec71

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