@agentshouse/kit 0.2.1-caf3915e29919 → 0.2.1-caf8b8e5a324f

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 (90) hide show
  1. package/README.md +24 -23
  2. package/dist/agents.js +74 -7
  3. package/dist/api.js +5 -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 +51 -9
  9. package/dist/conversations.js +85 -28
  10. package/dist/copies.js +62 -27
  11. package/dist/end.js +5 -0
  12. package/dist/events.js +3 -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 -189
  18. package/dist/instructions.js +6 -30
  19. package/dist/kit-cli.js +68 -0
  20. package/dist/kit-main.js +8 -22
  21. package/dist/login.js +49 -14
  22. package/dist/logout.js +0 -2
  23. package/dist/parts.js +34 -16
  24. package/dist/placement.js +30 -0
  25. package/dist/relay.js +1 -1
  26. package/dist/resident.js +58 -13
  27. package/dist/skills.js +81 -12
  28. package/dist/stream.js +62 -13
  29. package/dist/transcription.js +3 -3
  30. package/dist/update.js +28 -6
  31. package/dist/wire.js +6 -0
  32. package/dist/work.js +3 -5
  33. package/how-we-work.md +13 -3
  34. package/package.json +2 -1
  35. package/skills/ask-matt/SKILL.md +17 -11
  36. package/skills/ask-matt/agents/openai.yaml +1 -0
  37. package/skills/code-review/SKILL.md +5 -5
  38. package/skills/codebase-design/DESIGN-IT-TWICE.md +1 -1
  39. package/skills/codebase-design/SKILL.md +0 -2
  40. package/skills/diagnosing-bugs/SKILL.md +2 -4
  41. package/skills/domain-modeling/{CONTEXT-FORMAT.md → GLOSSARY-FORMAT.md} +10 -10
  42. package/skills/domain-modeling/SKILL.md +12 -14
  43. package/skills/grill-me/SKILL.md +1 -2
  44. package/skills/grill-me/agents/openai.yaml +1 -0
  45. package/skills/grill-with-docs/SKILL.md +1 -2
  46. package/skills/grill-with-docs/agents/openai.yaml +1 -0
  47. package/skills/grilling/SKILL.md +2 -2
  48. package/skills/handoff/SKILL.md +2 -3
  49. package/skills/handoff/agents/openai.yaml +1 -0
  50. package/skills/implement/SKILL.md +5 -4
  51. package/skills/implement/agents/openai.yaml +1 -0
  52. package/skills/implement-spec/SKILL.md +40 -0
  53. package/skills/implement-spec/agents/openai.yaml +5 -0
  54. package/skills/improve-codebase-architecture/SKILL.md +6 -7
  55. package/skills/improve-codebase-architecture/agents/openai.yaml +1 -0
  56. package/skills/pr/CREDITS.md +3 -0
  57. package/skills/pr/LICENSE +21 -0
  58. package/skills/pr/SKILL.md +170 -0
  59. package/skills/pr/agents/openai.yaml +3 -0
  60. package/skills/prototype/SKILL.md +0 -2
  61. package/skills/research/SKILL.md +0 -2
  62. package/skills/retro/LICENSE +21 -0
  63. package/skills/retro/SKILL.md +44 -0
  64. package/skills/retro/agents/openai.yaml +5 -0
  65. package/skills/tdd/SKILL.md +2 -4
  66. package/skills/teach/SKILL.md +3 -4
  67. package/skills/teach/agents/openai.yaml +1 -0
  68. package/skills/to-questionnaire/SKILL.md +1 -2
  69. package/skills/to-questionnaire/agents/openai.yaml +1 -0
  70. package/skills/to-spec/SKILL.md +1 -2
  71. package/skills/to-spec/agents/openai.yaml +1 -0
  72. package/skills/to-tickets/SKILL.md +3 -4
  73. package/skills/to-tickets/agents/openai.yaml +1 -0
  74. package/skills/triage/AGENT-BRIEF.md +207 -0
  75. package/skills/triage/LICENSE +21 -0
  76. package/skills/triage/OUT-OF-SCOPE.md +105 -0
  77. package/skills/triage/SKILL.md +112 -0
  78. package/skills/triage/agents/openai.yaml +5 -0
  79. package/skills/wait-what/SKILL.md +2 -3
  80. package/skills/wait-what/agents/openai.yaml +1 -0
  81. package/skills/wayfinder/SKILL.md +5 -6
  82. package/skills/wayfinder/agents/openai.yaml +1 -0
  83. package/skills/wizard/LICENSE +21 -0
  84. package/skills/wizard/SKILL.md +44 -0
  85. package/skills/wizard/agents/openai.yaml +3 -0
  86. package/skills/wizard/template.sh +223 -0
  87. package/skills/writing-for-agents/SKILL.md +0 -2
  88. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  89. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  90. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
@@ -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';
@@ -17,6 +17,31 @@ import { createHowWeWork } from './skills.js';
17
17
  import { transcribe } from './transcription.js';
18
18
  import { workFor } from './work.js';
19
19
  const KILLED = 'the conversation was killed';
20
+ const REFUSED = 'the CLI refused the prompt';
21
+ const NO_CONNECTION = "the Kit could not open this conversation's connection";
22
+ const NO_START = "the Agent's CLI did not start";
23
+ const NO_SESSION = "the Agent's CLI did not open a session";
24
+ const NO_RESUME = "the Agent's CLI could not resume this conversation";
25
+ const NO_SETTINGS = "the Agent's CLI refused the Agent's model, mode or effort";
26
+ class NotStarted extends Error {
27
+ }
28
+ async function step(reason, work) {
29
+ try {
30
+ return await work;
31
+ }
32
+ catch (error) {
33
+ throw new NotStarted(reason, { cause: error });
34
+ }
35
+ }
36
+ const EXIT_PLAN_MODE = {
37
+ parse(value) {
38
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
39
+ throw new Error('_x.ai/exit_plan_mode needs an object');
40
+ }
41
+ return value;
42
+ },
43
+ };
44
+ export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
20
45
  function causeOf(error) {
21
46
  return error instanceof Error ? error.message : String(error);
22
47
  }
@@ -26,6 +51,13 @@ function answerMessage(request, response) {
26
51
  function logged(error) {
27
52
  process.stderr.write(`kit: ${causeOf(error)}\n`);
28
53
  }
54
+ function refusal(conversation, error) {
55
+ if (conversation.kills > 0)
56
+ return KILLED;
57
+ const cause = error instanceof NotStarted ? `${error.message}: ${causeOf(error.cause)}` : causeOf(error);
58
+ logged(new Error(`conversation ${conversation.id} did not start: ${cause}`));
59
+ return causeOf(error);
60
+ }
29
61
  function marker(bridge) {
30
62
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
31
63
  }
@@ -56,6 +88,7 @@ class Conversation {
56
88
  subagents = new Map();
57
89
  held = [];
58
90
  opening = null;
91
+ prompted = null;
59
92
  kills = 0;
60
93
  killed = new AbortController();
61
94
  interrupts = 0;
@@ -199,10 +232,10 @@ export class Conversations {
199
232
  if (conversation.running !== null)
200
233
  return {};
201
234
  try {
202
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
235
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null, true) };
203
236
  }
204
237
  catch (error) {
205
- return { refused: causeOf(error) };
238
+ return { refused: refusal(conversation, error) };
206
239
  }
207
240
  }
208
241
  async message(conversation, input) {
@@ -219,7 +252,7 @@ export class Conversations {
219
252
  throw new Error(KILLED);
220
253
  }
221
254
  catch (error) {
222
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
255
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
223
256
  }
224
257
  return {
225
258
  ack: new Promise((settle) => {
@@ -242,13 +275,17 @@ export class Conversations {
242
275
  async send(conversation, waiting) {
243
276
  const { input } = waiting;
244
277
  let ack = {};
278
+ let started = false;
245
279
  const prompt = [];
246
280
  try {
247
281
  if (conversation.running === null) {
248
- const resumed = input.provider_session_id ?? conversation.session;
249
- const opened = await this.start(conversation, waiting.route, resumed);
250
- if (resumed === null)
251
- ack = { provider_session_id: opened };
282
+ const stored = conversation.session ?? input.provider_session_id;
283
+ const empty = input.first === true && conversation.prompted !== stored;
284
+ started = (await this.start(conversation, waiting.route, stored, empty)) !== stored;
285
+ }
286
+ const session = conversation.running.sessionId;
287
+ if (started || (input.first === true && session !== input.provider_session_id)) {
288
+ ack = { provider_session_id: session };
252
289
  }
253
290
  if (input.first === true) {
254
291
  prompt.push({
@@ -260,11 +297,11 @@ export class Conversations {
260
297
  throw new Error(KILLED);
261
298
  }
262
299
  catch (error) {
263
- if ('provider_session_id' in ack) {
300
+ if (started) {
264
301
  conversation.session = null;
265
302
  await this.kill(conversation);
266
303
  }
267
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
304
+ waiting.settle({ refused: refusal(conversation, error) });
268
305
  return;
269
306
  }
270
307
  this.prompt(conversation, [...prompt, ...waiting.content]);
@@ -342,14 +379,15 @@ export class Conversations {
342
379
  }
343
380
  return route;
344
381
  }
345
- async start(conversation, route, sessionId) {
382
+ async start(conversation, route, sessionId, empty) {
346
383
  const reader = readerFor(route.kind);
347
384
  reader.session = sessionId;
348
385
  const app = client({ name: '@agentshouse/kit' })
349
386
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
350
- .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
387
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
388
+ .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
351
389
  const cli = await unlessAborted(this.kit.agents.cli(route.kind), conversation.killed.signal);
352
- const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
390
+ const bridge = await step(NO_CONNECTION, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
353
391
  if (conversation.kills > 0) {
354
392
  bridge.close();
355
393
  throw new Error(KILLED);
@@ -360,24 +398,24 @@ export class Conversations {
360
398
  try {
361
399
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
362
400
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
363
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
401
+ const streamed = await step(NO_CONNECTION, work.open(cli));
402
+ const adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
364
403
  work.read(message);
365
404
  this.read(conversation, reader, message);
366
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
405
+ }, { ...bridge.env, ...streamed }, undefined, scope));
367
406
  void adapter.exited.then(() => bridge.close());
368
407
  const agent = adapter.connection.agent;
369
408
  const cwd = route.working_directory;
370
409
  const _meta = CLIS[route.kind].sessionMeta(bridge.env.HOUSE_BRIDGE, conversation.id);
371
- const opened = sessionId === null
372
- ? await agent.request('session/new', { cwd, mcpServers: [], _meta })
373
- : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [], _meta })), sessionId };
410
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
411
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
374
412
  reader.session = opened.sessionId;
375
- if (sessionId === null && CLIS[route.kind].renames) {
413
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
376
414
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
377
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
415
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
378
416
  }
379
417
  const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
380
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
418
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
381
419
  void work.settled().then(() => scope.record());
382
420
  conversation.running = running;
383
421
  conversation.session = running.sessionId;
@@ -399,6 +437,18 @@ export class Conversations {
399
437
  conversation.opening = null;
400
438
  }
401
439
  }
440
+ async resume(conversation, kind, adapter, request, empty) {
441
+ try {
442
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
443
+ }
444
+ catch (error) {
445
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
446
+ throw new NotStarted(NO_RESUME, { cause: error });
447
+ }
448
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
449
+ return null;
450
+ }
451
+ }
402
452
  async launchSettings(running, route, offered) {
403
453
  let options = offered;
404
454
  const settings = [
@@ -490,19 +540,21 @@ export class Conversations {
490
540
  this.kit.changed();
491
541
  return turn;
492
542
  }
493
- end(conversation, turn, failed) {
543
+ end(conversation, turn, failed, notice = null) {
494
544
  if (turn.ended)
495
545
  return;
496
546
  if (conversation.turn === turn) {
497
547
  for (const event of conversation.running?.reader.closeTurn() ?? [])
498
548
  this.apply(conversation, turn, event);
499
549
  }
550
+ if (notice !== null)
551
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
500
552
  turn.ended = true;
501
553
  conversation.open--;
502
554
  if (conversation.turn === turn)
503
555
  conversation.turn = null;
504
556
  const body = Promise.allSettled(turn.uploads).then(() => ({
505
- parts: turn.parts.parts,
557
+ parts: turn.parts.saved(),
506
558
  context: turn.parts.context,
507
559
  ...failed,
508
560
  }));
@@ -514,7 +566,12 @@ export class Conversations {
514
566
  const running = conversation.running;
515
567
  const turn = this.begin(conversation);
516
568
  turn.prompted = true;
517
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
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
+ const failed = notice ?? (answer.stopReason === 'refusal' ? REFUSED : null);
573
+ this.end(conversation, turn, failed === null ? null : { failed }, notice);
574
+ }, async (error) => {
518
575
  if (running.adapter.connection.signal.aborted) {
519
576
  this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
520
577
  }
@@ -647,7 +704,7 @@ export class Conversations {
647
704
  turn.parts.plan(event.entries);
648
705
  return;
649
706
  case 'image':
650
- turn.uploads.push(this.image(conversation, turn, event.data, event.mediaType));
707
+ turn.uploads.push(this.image(conversation, turn, turn.parts.reserve(), event.data, event.mediaType));
651
708
  this.kit.changed();
652
709
  return;
653
710
  }
@@ -667,7 +724,7 @@ export class Conversations {
667
724
  else
668
725
  conversation.turn.parts.start(part);
669
726
  }
670
- async image(conversation, turn, data, mediaType) {
727
+ async image(conversation, turn, reserved, data, mediaType) {
671
728
  const bytes = Buffer.from(data, 'base64');
672
729
  const name = `image-${++turn.images}.${mediaType.split('/')[1]}`;
673
730
  try {
@@ -680,10 +737,10 @@ export class Conversations {
680
737
  const saved = declared.upload === undefined ? declared : await uploadBytes(declared.upload, bytes);
681
738
  if (saved.save.status !== 'saved')
682
739
  throw new Error(saved.save.failure ?? saved.save.status);
683
- turn.parts.start({ type: 'image', file: saved.version, name, media_type: mediaType });
740
+ turn.parts.arrive({ type: 'image', file: saved.version, name, media_type: mediaType }, reserved);
684
741
  }
685
742
  catch (error) {
686
- turn.parts.start({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` });
743
+ turn.parts.arrive({ type: 'marker', marker: 'retry', text: `${name} was not saved: ${causeOf(error)}` }, reserved);
687
744
  }
688
745
  }
689
746
  }
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
@@ -1,4 +1,5 @@
1
1
  import { forgetRejected } from './home.js';
2
+ import { installCommand } from './placement.js';
2
3
  import { killDescendants } from './scope.js';
3
4
  export const REPLACED = 'another Kit started for this Environment';
4
5
  function exit(reason, status) {
@@ -12,6 +13,10 @@ export function end(reason) {
12
13
  export function restart(reason) {
13
14
  exit(reason, 1);
14
15
  }
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}`);
19
+ }
15
20
  export async function signInAgain(rejected) {
16
21
  await forgetRejected(rejected);
17
22
  restart('sign in again');
package/dist/events.js CHANGED
@@ -512,6 +512,9 @@ class GrokReader extends Reader {
512
512
  return [];
513
513
  }
514
514
  }
515
+ export function answeredFailure(answer) {
516
+ return answer._meta?.jetbrains?.air?.sessionFailure?.title ?? null;
517
+ }
515
518
  export function readerFor(kind) {
516
519
  if (kind === 'claude-agent-acp')
517
520
  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');