@agentshouse/kit 0.2.1-c7252abc8a583 → 0.2.1-c7f53ff9eaf98

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 (88) hide show
  1. package/README.md +20 -19
  2. package/dist/agents.js +70 -7
  3. package/dist/api.js +1 -1
  4. package/dist/app-git.js +10 -26
  5. package/dist/bootstrap.json +4 -4
  6. package/dist/bridge.js +2 -5
  7. package/dist/chatgpt.js +32 -0
  8. package/dist/clis.js +19 -3
  9. package/dist/conversations.js +98 -28
  10. package/dist/copies.js +62 -27
  11. package/dist/end.js +3 -2
  12. package/dist/events.js +4 -0
  13. package/dist/files.js +7 -1
  14. package/dist/git.js +10 -8
  15. package/dist/home.js +56 -5
  16. package/dist/house-cli.js +291 -0
  17. package/dist/house-main.js +7 -228
  18. package/dist/instructions.js +4 -28
  19. package/dist/kit-cli.js +68 -0
  20. package/dist/kit-main.js +8 -64
  21. package/dist/login.js +44 -27
  22. package/dist/logout.js +0 -2
  23. package/dist/placement.js +2 -20
  24. package/dist/resident.js +54 -11
  25. package/dist/skills.js +81 -12
  26. package/dist/stream.js +58 -12
  27. package/dist/transcription.js +3 -3
  28. package/dist/update.js +1 -0
  29. package/dist/wire.js +1 -1
  30. package/dist/work.js +3 -5
  31. package/how-we-work.md +13 -3
  32. package/package.json +2 -1
  33. package/skills/ask-matt/SKILL.md +17 -11
  34. package/skills/ask-matt/agents/openai.yaml +1 -0
  35. package/skills/code-review/SKILL.md +5 -5
  36. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  37. package/skills/codebase-design/SKILL.md +0 -2
  38. package/skills/diagnosing-bugs/SKILL.md +2 -4
  39. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  40. package/skills/domain-modeling/SKILL.md +12 -14
  41. package/skills/grill-me/SKILL.md +1 -2
  42. package/skills/grill-me/agents/openai.yaml +1 -0
  43. package/skills/grill-with-docs/SKILL.md +1 -2
  44. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  45. package/skills/grilling/SKILL.md +2 -2
  46. package/skills/handoff/SKILL.md +2 -3
  47. package/skills/handoff/agents/openai.yaml +1 -0
  48. package/skills/implement/SKILL.md +5 -4
  49. package/skills/implement/agents/openai.yaml +1 -0
  50. package/skills/implement-spec/SKILL.md +40 -0
  51. package/skills/implement-spec/agents/openai.yaml +5 -0
  52. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  53. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  54. package/skills/pr/CREDITS.md +3 -0
  55. package/skills/pr/LICENSE +21 -0
  56. package/skills/pr/SKILL.md +170 -0
  57. package/skills/pr/agents/openai.yaml +3 -0
  58. package/skills/prototype/SKILL.md +0 -2
  59. package/skills/research/SKILL.md +0 -2
  60. package/skills/retro/LICENSE +21 -0
  61. package/skills/retro/SKILL.md +44 -0
  62. package/skills/retro/agents/openai.yaml +5 -0
  63. package/skills/tdd/SKILL.md +2 -4
  64. package/skills/teach/SKILL.md +3 -4
  65. package/skills/teach/agents/openai.yaml +1 -0
  66. package/skills/to-questionnaire/SKILL.md +1 -2
  67. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  68. package/skills/to-spec/SKILL.md +1 -2
  69. package/skills/to-spec/agents/openai.yaml +1 -0
  70. package/skills/to-tickets/SKILL.md +3 -4
  71. package/skills/to-tickets/agents/openai.yaml +1 -0
  72. package/skills/triage/AGENT-BRIEF.md +207 -0
  73. package/skills/triage/LICENSE +21 -0
  74. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  75. package/skills/triage/SKILL.md +112 -0
  76. package/skills/triage/agents/openai.yaml +5 -0
  77. package/skills/wait-what/SKILL.md +2 -3
  78. package/skills/wait-what/agents/openai.yaml +1 -0
  79. package/skills/wayfinder/SKILL.md +5 -6
  80. package/skills/wayfinder/agents/openai.yaml +1 -0
  81. package/skills/wizard/LICENSE +21 -0
  82. package/skills/wizard/SKILL.md +44 -0
  83. package/skills/wizard/agents/openai.yaml +3 -0
  84. package/skills/wizard/template.sh +223 -0
  85. package/skills/writing-for-agents/SKILL.md +0 -2
  86. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  87. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  88. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
@@ -1,4 +1,4 @@
1
- import { client, } from '@agentclientprotocol/sdk';
1
+ import { client, RequestError, } from '@agentclientprotocol/sdk';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
@@ -6,7 +6,7 @@ import { unlessAborted } from './abort.js';
6
6
  import { startAdapter } from './acp.js';
7
7
  import { openBridge } from './bridge.js';
8
8
  import { CLIS } from './clis.js';
9
- import { readerFor } from './events.js';
9
+ import { answeredFailure, readerFor } from './events.js';
10
10
  import { placeFiles, uploadBytes } from './files.js';
11
11
  import { agentBase } from './home.js';
12
12
  import { instructions } from './instructions.js';
@@ -16,17 +16,56 @@ import { holdSecretInput } from './secret-input.js';
16
16
  import { createHowWeWork } from './skills.js';
17
17
  import { transcribe } from './transcription.js';
18
18
  import { workFor } from './work.js';
19
+ const SIGNED_OUT = RequestError.authRequired().code;
19
20
  const KILLED = 'the conversation was killed';
21
+ const REFUSED = 'the CLI refused the prompt';
22
+ const NO_CONNECTION = "the Kit could not open this conversation's connection";
23
+ const NO_START = "the Agent's CLI did not start";
24
+ const NO_SESSION = "the Agent's CLI did not open a session";
25
+ const NO_RESUME = "the Agent's CLI could not resume this conversation";
26
+ const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
27
+ class NotStarted extends Error {
28
+ }
29
+ async function step(reason, work) {
30
+ try {
31
+ return await work;
32
+ }
33
+ catch (error) {
34
+ throw new NotStarted(reason, { cause: error });
35
+ }
36
+ }
37
+ const EXIT_PLAN_MODE = {
38
+ parse(value) {
39
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
40
+ throw new Error('_x.ai/exit_plan_mode needs an object');
41
+ }
42
+ return value;
43
+ },
44
+ };
20
45
  export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
21
46
  function causeOf(error) {
22
47
  return error instanceof Error ? error.message : String(error);
23
48
  }
49
+ function answeredWith(error) {
50
+ const signedOut = typeof error === 'object' && error !== null && error.code === SIGNED_OUT;
51
+ return { failure: signedOut ? 'signed_out' : 'error', cause: causeOf(error) };
52
+ }
53
+ function exitedWith(running, cause) {
54
+ return running.killed ? { failure: 'killed', cause: KILLED } : { failure: 'exited', cause };
55
+ }
24
56
  function answerMessage(request, response) {
25
57
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
26
58
  }
27
59
  function logged(error) {
28
60
  process.stderr.write(`kit: ${causeOf(error)}\n`);
29
61
  }
62
+ function refusal(conversation, error) {
63
+ if (conversation.kills > 0)
64
+ return KILLED;
65
+ const cause = error instanceof NotStarted ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
66
+ logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
67
+ return causeOf(error);
68
+ }
30
69
  function marker(bridge) {
31
70
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
32
71
  }
@@ -57,6 +96,7 @@ class Conversation {
57
96
  subagents = new Map();
58
97
  held = [];
59
98
  opening = null;
99
+ prompted = null;
60
100
  kills = 0;
61
101
  killed = new AbortController();
62
102
  interrupts = 0;
@@ -200,10 +240,10 @@ export class Conversations {
200
240
  if (conversation.running !== null)
201
241
  return {};
202
242
  try {
203
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
243
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null, true) };
204
244
  }
205
245
  catch (error) {
206
- return { refused: causeOf(error) };
246
+ return { refused: refusal(conversation, error) };
207
247
  }
208
248
  }
209
249
  async message(conversation, input) {
@@ -220,7 +260,7 @@ export class Conversations {
220
260
  throw new Error(KILLED);
221
261
  }
222
262
  catch (error) {
223
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
263
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
224
264
  }
225
265
  return {
226
266
  ack: new Promise((settle) => {
@@ -243,13 +283,17 @@ export class Conversations {
243
283
  async send(conversation, waiting) {
244
284
  const { input } = waiting;
245
285
  let ack = {};
286
+ let started = false;
246
287
  const prompt = [];
247
288
  try {
248
289
  if (conversation.running === null) {
249
- const resumed = input.provider_session_id ?? conversation.session;
250
- const opened = await this.start(conversation, waiting.route, resumed);
251
- if (resumed === null)
252
- ack = { provider_session_id: opened };
290
+ const stored = conversation.session ?? input.provider_session_id;
291
+ const empty = input.first === true && conversation.prompted !== stored;
292
+ started = (await this.start(conversation, waiting.route, stored, empty)) !== stored;
293
+ }
294
+ const session = conversation.running.sessionId;
295
+ if (started || (input.first === true && session !== input.provider_session_id)) {
296
+ ack = { provider_session_id: session };
253
297
  }
254
298
  if (input.first === true) {
255
299
  prompt.push({
@@ -261,11 +305,11 @@ export class Conversations {
261
305
  throw new Error(KILLED);
262
306
  }
263
307
  catch (error) {
264
- if ('provider_session_id' in ack) {
308
+ if (started) {
265
309
  conversation.session = null;
266
310
  await this.kill(conversation);
267
311
  }
268
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
312
+ waiting.settle({ refused: refusal(conversation, error) });
269
313
  return;
270
314
  }
271
315
  this.prompt(conversation, [...prompt, ...waiting.content]);
@@ -343,14 +387,15 @@ export class Conversations {
343
387
  }
344
388
  return route;
345
389
  }
346
- async start(conversation, route, sessionId) {
390
+ async start(conversation, route, sessionId, empty) {
347
391
  const reader = readerFor(route.kind);
348
392
  reader.session = sessionId;
349
393
  const app = client({ name: '@agentshouse/kit' })
350
394
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
351
- .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
395
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
396
+ .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
352
397
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
353
- const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
398
+ const bridge = await step(NO_CONNECTION, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
354
399
  if (conversation.kills > 0) {
355
400
  bridge.close();
356
401
  throw new Error(KILLED);
@@ -361,24 +406,24 @@ export class Conversations {
361
406
  try {
362
407
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
363
408
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
364
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
409
+ const streamed = await step(NO_CONNECTION, work.open(cli));
410
+ const adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
365
411
  work.read(message);
366
412
  this.read(conversation, reader, message);
367
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
413
+ }, { ...bridge.env, ...streamed }, undefined, scope));
368
414
  void adapter.exited.then(() => bridge.close());
369
415
  const agent = adapter.connection.agent;
370
416
  const cwd = route.working_directory;
371
417
  const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
372
- const opened = sessionId === null
373
- ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
374
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
418
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
419
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
375
420
  reader.session = opened.sessionId;
376
- if (sessionId === null && CLIS[route.kind].renames) {
421
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
377
422
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
378
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
423
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
379
424
  }
380
425
  const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
381
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
426
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
382
427
  void work.settled().then(() => scope.record());
383
428
  conversation.running = running;
384
429
  conversation.session = running.sessionId;
@@ -400,6 +445,18 @@ export class Conversations {
400
445
  conversation.opening = null;
401
446
  }
402
447
  }
448
+ async resume(conversation, kind, adapter, request, empty) {
449
+ try {
450
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
451
+ }
452
+ catch (error) {
453
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
454
+ throw new NotStarted(NO_RESUME, { cause: error });
455
+ }
456
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
457
+ return null;
458
+ }
459
+ }
403
460
  async launchSettings(running, route, offered) {
404
461
  let options = offered;
405
462
  const settings = [
@@ -427,7 +484,7 @@ export class Conversations {
427
484
  return;
428
485
  this.detach(conversation, running);
429
486
  if (conversation.turn !== null) {
430
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
487
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
431
488
  }
432
489
  this.next(conversation);
433
490
  this.kit.changed();
@@ -491,13 +548,17 @@ export class Conversations {
491
548
  this.kit.changed();
492
549
  return turn;
493
550
  }
494
- end(conversation, turn, failed) {
551
+ end(conversation, turn, failed, notice = null) {
495
552
  if (turn.ended)
496
553
  return;
554
+ if (failed !== null)
555
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
497
556
  if (conversation.turn === turn) {
498
557
  for (const event of conversation.running?.reader.closeTurn() ?? [])
499
558
  this.apply(conversation, turn, event);
500
559
  }
560
+ if (notice !== null)
561
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
501
562
  turn.ended = true;
502
563
  conversation.open--;
503
564
  if (conversation.turn === turn)
@@ -505,7 +566,7 @@ export class Conversations {
505
566
  const body = Promise.allSettled(turn.uploads).then(() => ({
506
567
  parts: turn.parts.saved(),
507
568
  context: turn.parts.context,
508
- ...failed,
569
+ ...(failed === null ? {} : { failed: failed.failure }),
509
570
  }));
510
571
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
511
572
  this.next(conversation);
@@ -515,16 +576,25 @@ export class Conversations {
515
576
  const running = conversation.running;
516
577
  const turn = this.begin(conversation);
517
578
  turn.prompted = true;
518
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
579
+ conversation.prompted = running.sessionId;
580
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
581
+ const notice = answeredFailure(answer);
582
+ if (notice !== null) {
583
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
584
+ }
585
+ else {
586
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
587
+ }
588
+ }, async (error) => {
519
589
  if (running.adapter.connection.signal.aborted) {
520
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
590
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
521
591
  }
522
592
  else {
523
593
  if (conversation.open === 1 && conversation.running === running) {
524
594
  running.scope.kill();
525
595
  this.detach(conversation, running);
526
596
  }
527
- this.end(conversation, turn, { failed: causeOf(error) });
597
+ this.end(conversation, turn, answeredWith(error));
528
598
  }
529
599
  });
530
600
  }
package/dist/copies.js CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { finalRefusal, HouseRefusal, retrying } from './api.js';
6
- import { blobId, blobText, git, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
6
+ import { blobId, blobText, git, isText, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
7
7
  import { kitHome } from './home.js';
8
8
  import { operationId } from './operation.js';
9
9
  import { refusalOf } from './refusals.js';
@@ -15,6 +15,9 @@ const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-
15
15
  function causeOf(error) {
16
16
  return error instanceof Error ? error.message : String(error);
17
17
  }
18
+ function houseCause(error) {
19
+ return error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error);
20
+ }
18
21
  function logged(error) {
19
22
  process.stderr.write(`kit: ${causeOf(error)}\n`);
20
23
  }
@@ -29,6 +32,13 @@ const REBASED = new Set([
29
32
  function rebaseFixes(refusal) {
30
33
  return refusal.code === 'edit_conflict' && refusal.conflicts.some((code) => REBASED.has(code));
31
34
  }
35
+ function textOnly(paths) {
36
+ const [named, them] = paths.length === 1 ? [`${paths[0]} is`, 'it'] : [`${paths.join(', ')} are`, 'them'];
37
+ return `${named} not text, and house push sends text only; remove ${them} from the commit and place ${them} with house upload`;
38
+ }
39
+ function outside(path) {
40
+ return { refused: true, text: `${path} is not a House Local copy; run house push inside one` };
41
+ }
32
42
  function short(commit) {
33
43
  return commit.slice(0, 12);
34
44
  }
@@ -56,6 +66,9 @@ function sidecar(path) {
56
66
  segments.splice(-1, 0, PROVENANCE);
57
67
  return segments.join('/');
58
68
  }
69
+ function recordOf(key) {
70
+ return join(kitHome(), 'local-copies', `${key}.json`);
71
+ }
59
72
  function rootOf(handle) {
60
73
  return `/rooms/${handle}`;
61
74
  }
@@ -202,7 +215,7 @@ export class LocalCopies {
202
215
  async mapping() {
203
216
  await this.answering;
204
217
  return [...this.copies.values()]
205
- .filter((copy) => copy.active)
218
+ .filter((copy) => copy.erasing === undefined)
206
219
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
207
220
  .sort();
208
221
  }
@@ -225,25 +238,30 @@ export class LocalCopies {
225
238
  return this.holding([...new Set([...this.copies.keys(), ...synced.keys()])].sort(), async () => {
226
239
  const said = [];
227
240
  let refused = false;
228
- for (const copy of this.copies.values()) {
229
- if (synced.has(copy.key) || !copy.active)
241
+ for (const copy of [...this.copies.values()]) {
242
+ const returned = synced.has(copy.key);
243
+ if (returned && copy.erasing === undefined)
230
244
  continue;
231
- await this.save({ ...copy, active: false });
232
- said.push(`${rootOf(copy.room_handle)}: no longer synced to this Environment; its files stay`);
245
+ try {
246
+ await this.erase(copy);
247
+ said.push(`${rootOf(copy.room_handle)}: ${returned ? '' : 'no longer synced to this Environment; '}erased from the Local copy`);
248
+ }
249
+ catch (error) {
250
+ refused = true;
251
+ said.push(`${rootOf(copy.room_handle)}: ${causeOf(error)}`);
252
+ }
233
253
  }
234
254
  const current = [];
235
255
  for (const [key, handle] of synced) {
236
256
  try {
237
257
  const held = this.copies.get(key);
258
+ if (held?.erasing)
259
+ continue;
238
260
  if (held === undefined || !(await exists(held.repository))) {
239
261
  said.push(await this.materialize(key, handle));
240
262
  continue;
241
263
  }
242
- const copy = held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle });
243
- if (copy.active)
244
- current.push(copy);
245
- else
246
- said.push(...moved(copy, await this.save(await this.received({ ...copy, active: true }))));
264
+ current.push(held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle }));
247
265
  }
248
266
  catch (error) {
249
267
  refused = true;
@@ -257,7 +275,7 @@ export class LocalCopies {
257
275
  }
258
276
  catch (error) {
259
277
  refused = true;
260
- said.push(error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error));
278
+ said.push(houseCause(error));
261
279
  }
262
280
  }
263
281
  return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
@@ -269,6 +287,8 @@ export class LocalCopies {
269
287
  return;
270
288
  await this.exclusive(key, async () => {
271
289
  const copy = this.copies.get(key);
290
+ if (copy === undefined || copy.erasing)
291
+ return;
272
292
  let moves = copy.moves;
273
293
  for (const { from, to } of report.moves) {
274
294
  const held = moves.find((move) => move.worktree === report.worktree && move.to === from);
@@ -291,11 +311,11 @@ export class LocalCopies {
291
311
  async push(cwd, selector, caller) {
292
312
  const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
293
313
  if (located.status !== 0)
294
- return { refused: true, text: `${cwd} is not a House Local copy; run house push inside one` };
314
+ return outside(cwd);
295
315
  const [common, worktree] = located.stdout.toString('utf8').split('\n');
296
316
  const key = await this.keyAt(dirname(common));
297
317
  if (key === undefined)
298
- return { refused: true, text: `${worktree} is not a House Local copy; run house push inside one` };
318
+ return outside(worktree);
299
319
  return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
300
320
  }
301
321
  async keyAt(repository) {
@@ -316,7 +336,7 @@ export class LocalCopies {
316
336
  return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
317
337
  }
318
338
  async save(copy) {
319
- const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
339
+ const path = recordOf(copy.key);
320
340
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
321
341
  const temporary = `${path}.${process.pid}`;
322
342
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -324,6 +344,12 @@ export class LocalCopies {
324
344
  this.copies.set(copy.key, copy);
325
345
  return copy;
326
346
  }
347
+ async erase(copy) {
348
+ await this.save({ ...copy, erasing: true });
349
+ await rm(copy.repository, { recursive: true, force: true });
350
+ await rm(recordOf(copy.key), { force: true });
351
+ this.copies.delete(copy.key);
352
+ }
327
353
  async read(handle) {
328
354
  const prefixes = [rootOf(handle)];
329
355
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
@@ -390,7 +416,6 @@ export class LocalCopies {
390
416
  room_ref: read.anchor,
391
417
  room_handle: handle,
392
418
  repository,
393
- active: true,
394
419
  house,
395
420
  position: read.position,
396
421
  log_epoch: read.log_epoch,
@@ -519,6 +544,8 @@ export class LocalCopies {
519
544
  }
520
545
  async pushed(key, worktree, selector, caller) {
521
546
  let copy = this.copies.get(key);
547
+ if (copy === undefined || copy.erasing)
548
+ return outside(worktree);
522
549
  if (copy.push?.state === 'submitted')
523
550
  return this.settle(copy, caller);
524
551
  const selected = await run(worktree, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${selector}^{commit}`]);
@@ -532,9 +559,6 @@ export class LocalCopies {
532
559
  }
533
560
  copy = await this.save(without(copy));
534
561
  }
535
- if (!copy.active) {
536
- return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
537
- }
538
562
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
539
563
  const base = listed.find((ancestor) => ancestor in copy.manifests);
540
564
  if (base === undefined) {
@@ -545,7 +569,7 @@ export class LocalCopies {
545
569
  changes = await this.derived(copy, base, commit);
546
570
  }
547
571
  catch (error) {
548
- return { refused: true, text: causeOf(error) };
572
+ return { refused: true, text: houseCause(error) };
549
573
  }
550
574
  if (changes.length === 0)
551
575
  return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
@@ -572,7 +596,18 @@ export class LocalCopies {
572
596
  const after = await tree(copy.repository, commit);
573
597
  const moved = movedPaths(copy.moves, before, after);
574
598
  const arrived = new Set(moved.values());
575
- const text = (entry, path) => blobText(copy.repository, entry, path);
599
+ const arriving = [...after].filter(([path, entry]) => before.get(path) !== entry);
600
+ if (arriving.length > 0) {
601
+ const { bounds } = await this.house.post('/kit/door/discover', { prefixes: [root] });
602
+ const binary = [];
603
+ for (const [path, entry] of arriving) {
604
+ if (!(await isText(copy.repository, entry, bounds.max_authored_file_bytes)))
605
+ binary.push(path);
606
+ }
607
+ if (binary.length > 0)
608
+ throw new Error(textOnly(binary));
609
+ }
610
+ const text = (entry) => blobText(copy.repository, entry);
576
611
  const changes = [];
577
612
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
578
613
  if (arrived.has(path))
@@ -585,7 +620,7 @@ export class LocalCopies {
585
620
  changes.push({ op: 'rename', path: `${root}/${path}`, to: `${root}/${to}`, base: known });
586
621
  const moving = after.get(to);
587
622
  if (moving !== prior) {
588
- changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving, to) });
623
+ changes.push({ op: 'replace', path: `${root}/${to}`, base: known, content: await text(moving) });
589
624
  }
590
625
  continue;
591
626
  }
@@ -594,9 +629,9 @@ export class LocalCopies {
594
629
  if (next === undefined)
595
630
  changes.push({ op: 'remove', path: `${root}/${path}`, base: known });
596
631
  else if (prior === undefined)
597
- changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next, path) });
632
+ changes.push({ op: 'create', path: `${root}/${path}`, content: await text(next) });
598
633
  else
599
- changes.push(...changed(`${root}/${path}`, path, known, await text(prior, path), await text(next, path)));
634
+ changes.push(...changed(`${root}/${path}`, path, known, await text(prior), await text(next)));
600
635
  }
601
636
  return changes;
602
637
  }
@@ -663,7 +698,7 @@ export class LocalCopies {
663
698
  written.push({
664
699
  path: `${root}/${path}`,
665
700
  revision: entry === undefined ? null : (revisionAt(current, current.house, path) ?? null),
666
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
701
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
667
702
  });
668
703
  }
669
704
  return this.finished(await this.save({ ...current, push: { ...push, state: 'accepted', written } }));
@@ -707,7 +742,7 @@ export class LocalCopies {
707
742
  {
708
743
  path: `${rootOf(copy.room_handle)}/${path}`,
709
744
  revision: entry === undefined ? null : (revisionAt(copy, copy.house, path) ?? null),
710
- content: entry === undefined ? null : await blobText(copy.repository, entry, path),
745
+ content: entry === undefined ? null : await blobText(copy.repository, entry),
711
746
  },
712
747
  ];
713
748
  }
@@ -752,7 +787,7 @@ export class LocalCopies {
752
787
  async landed(repository, changes, house, selected) {
753
788
  const text = async (path) => {
754
789
  const entry = house.get(relative(path));
755
- return entry === undefined ? undefined : blobText(repository, entry, path);
790
+ return entry === undefined ? undefined : blobText(repository, entry);
756
791
  };
757
792
  for (const change of changes) {
758
793
  const current = await text(change.path);
package/dist/end.js CHANGED
@@ -13,8 +13,9 @@ export function end(reason) {
13
13
  export function restart(reason) {
14
14
  exit(reason, 1);
15
15
  }
16
- export function updateRequired(house) {
17
- end(`This Kit is too old for House. Update it: ${installCommand(house)}`);
16
+ export function updateRequired() {
17
+ const command = installCommand();
18
+ end(command === null ? 'This Kit is too old for House; install it again.' : `This Kit is too old for House. Update it: ${command}`);
18
19
  }
19
20
  export async function signInAgain(rejected) {
20
21
  await forgetRejected(rejected);
package/dist/events.js CHANGED
@@ -512,6 +512,10 @@ class GrokReader extends Reader {
512
512
  return [];
513
513
  }
514
514
  }
515
+ export function answeredFailure(answer) {
516
+ const failure = answer._meta?.jetbrains?.air?.sessionFailure;
517
+ return typeof failure?.title === 'string' ? { title: failure.title, limit: failure.category === 'limit' } : null;
518
+ }
515
519
  export function readerFor(kind) {
516
520
  if (kind === 'claude-agent-acp')
517
521
  return new ClaudeReader();
package/dist/files.js CHANGED
@@ -1,7 +1,13 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import { HouseRefusal, retrying } from './api.js';
4
5
  import { relayed } from './relay.js';
6
+ export function verified(bytes, sha256, name) {
7
+ if (createHash('sha256').update(bytes).digest('hex') !== sha256)
8
+ throw new Error(`${name} did not arrive whole; download it again`);
9
+ return bytes;
10
+ }
5
11
  export async function placeFiles(house, directory, files, signal) {
6
12
  const paths = [];
7
13
  for (const file of files) {
@@ -16,7 +22,7 @@ export async function placeFiles(house, directory, files, signal) {
16
22
  });
17
23
  await mkdir(folder, { recursive: true });
18
24
  const path = join(folder, file.name);
19
- await writeFile(path, Buffer.from(await answer.arrayBuffer()));
25
+ await writeFile(path, verified(Buffer.from(await answer.arrayBuffer()), file.sha256, file.name));
20
26
  paths.push(path);
21
27
  }
22
28
  return paths;
package/dist/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { isUtf8 } from 'node:buffer';
2
3
  import { createHash } from 'node:crypto';
3
4
  import { join } from 'node:path';
4
5
  import { withoutProxy } from './clis.js';
@@ -48,14 +49,15 @@ export async function tree(repository, commit) {
48
49
  }
49
50
  return entries;
50
51
  }
51
- export async function blobText(repository, object, path) {
52
- const bytes = (await run(repository, ['cat-file', 'blob', object])).stdout;
53
- try {
54
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
55
- }
56
- catch {
57
- throw new Error(`${path} is binary and House stores only text; remove it from the commit`);
58
- }
52
+ async function blob(repository, object) {
53
+ return (await run(repository, ['cat-file', 'blob', object])).stdout;
54
+ }
55
+ export async function blobText(repository, object) {
56
+ return (await blob(repository, object)).toString('utf8');
57
+ }
58
+ export async function isText(repository, object, maxBytes) {
59
+ const bytes = await blob(repository, object);
60
+ return bytes.length <= maxBytes && isUtf8(bytes) && !bytes.includes(0);
59
61
  }
60
62
  export function blobId(content) {
61
63
  const bytes = Buffer.from(content, 'utf8');