@agentshouse/kit 0.2.1-c8179f06883b6 → 0.2.1-c84a4fccf2835

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 (91) hide show
  1. package/README.md +19 -18
  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 +3 -6
  7. package/dist/chatgpt.js +32 -0
  8. package/dist/clis.js +13 -3
  9. package/dist/conversations.js +126 -58
  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 +8 -2
  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/inputs.js +37 -0
  19. package/dist/instructions.js +4 -28
  20. package/dist/kit-cli.js +68 -0
  21. package/dist/kit-main.js +8 -67
  22. package/dist/login.js +40 -16
  23. package/dist/logout.js +0 -2
  24. package/dist/placement.js +2 -20
  25. package/dist/resident.js +60 -15
  26. package/dist/sign-in.js +5 -23
  27. package/dist/skills.js +81 -12
  28. package/dist/stream.js +58 -12
  29. package/dist/transcription.js +3 -3
  30. package/dist/update.js +21 -23
  31. package/dist/wire.js +1 -1
  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/dist/codex.js +0 -61
  89. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  90. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  91. /package/skills/{resolving-merge-conflicts → implement-spec}/LICENSE +0 -0
@@ -1,32 +1,82 @@
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';
5
5
  import { unlessAborted } from './abort.js';
6
6
  import { startAdapter } from './acp.js';
7
+ import { finalRefusal } from './api.js';
7
8
  import { openBridge } from './bridge.js';
8
9
  import { CLIS } from './clis.js';
9
- import { readerFor } from './events.js';
10
+ import { answeredFailure, readerFor } from './events.js';
10
11
  import { placeFiles, uploadBytes } from './files.js';
11
12
  import { agentBase } from './home.js';
12
13
  import { instructions } from './instructions.js';
13
14
  import { Parts } from './parts.js';
15
+ import { refusalOf } from './refusals.js';
14
16
  import { killMarked, Scope } from './scope.js';
15
17
  import { holdSecretInput } from './secret-input.js';
16
18
  import { createHowWeWork } from './skills.js';
17
19
  import { transcribe } from './transcription.js';
18
20
  import { workFor } from './work.js';
21
+ const SIGNED_OUT = RequestError.authRequired().code;
19
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
+ };
20
56
  export const CONVERSATION_INPUTS = new Set(['open', 'message', 'interrupt', 'kill', 'option', 'answer']);
21
57
  function causeOf(error) {
22
58
  return error instanceof Error ? error.message : String(error);
23
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
+ }
24
67
  function answerMessage(request, response) {
25
68
  return `The User answered a question you asked earlier.\nQuestion: ${JSON.stringify(request)}\nAnswer: ${JSON.stringify(response)}`;
26
69
  }
27
70
  function logged(error) {
28
71
  process.stderr.write(`kit: ${causeOf(error)}\n`);
29
72
  }
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
+ }
30
80
  function marker(bridge) {
31
81
  return `HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`;
32
82
  }
@@ -57,6 +107,7 @@ class Conversation {
57
107
  subagents = new Map();
58
108
  held = [];
59
109
  opening = null;
110
+ prompted = null;
60
111
  kills = 0;
61
112
  killed = new AbortController();
62
113
  interrupts = 0;
@@ -79,9 +130,7 @@ function asksSecret(request) {
79
130
  export class Conversations {
80
131
  kit;
81
132
  conversations = new Map();
82
- acks = new Map();
83
133
  questions = new Map();
84
- carrying = 0;
85
134
  reporting = 0;
86
135
  watched = false;
87
136
  constructor(kit) {
@@ -90,37 +139,26 @@ export class Conversations {
90
139
  setInterval(() => this.look(), 2_000);
91
140
  }
92
141
  input(input) {
93
- this.carrying++;
94
- const carried = this.acks.get(input.input_id) ??
95
- (() => {
96
- const conversation = this.conversation(input.conversation_id);
97
- if (input.kind === 'kill') {
98
- conversation.kills++;
99
- conversation.killed.abort(new Error(KILLED));
100
- for (const waiting of conversation.waiting.splice(0))
101
- waiting.settle({ refused: KILLED });
102
- this.halt(conversation);
103
- }
104
- if (input.kind === 'interrupt') {
105
- conversation.interrupts++;
106
- conversation.interrupted.abort();
107
- }
108
- const handled = conversation.queue.then(() => this.carry(conversation, input));
109
- conversation.queue = handled.catch(() => undefined);
110
- const work = handled.then(({ ack }) => ack);
111
- this.acks.set(input.input_id, work);
112
- return work;
113
- })();
114
- carried
115
- .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
116
- .catch(logged)
117
- .finally(() => {
118
- this.carrying--;
119
- 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 }));
120
158
  });
121
159
  }
122
160
  idle() {
123
- if (this.carrying > 0 || this.reporting > 0)
161
+ if (this.reporting > 0)
124
162
  return false;
125
163
  for (const conversation of this.conversations.values()) {
126
164
  const turn = conversation.turn;
@@ -200,10 +238,10 @@ export class Conversations {
200
238
  if (conversation.running !== null)
201
239
  return {};
202
240
  try {
203
- 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) };
204
242
  }
205
243
  catch (error) {
206
- return { refused: causeOf(error) };
244
+ return { refused: refusal(conversation, error) };
207
245
  }
208
246
  }
209
247
  async message(conversation, input) {
@@ -220,7 +258,7 @@ export class Conversations {
220
258
  throw new Error(KILLED);
221
259
  }
222
260
  catch (error) {
223
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
261
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
224
262
  }
225
263
  return {
226
264
  ack: new Promise((settle) => {
@@ -243,13 +281,17 @@ export class Conversations {
243
281
  async send(conversation, waiting) {
244
282
  const { input } = waiting;
245
283
  let ack = {};
284
+ let started = false;
246
285
  const prompt = [];
247
286
  try {
248
287
  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 };
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 };
253
295
  }
254
296
  if (input.first === true) {
255
297
  prompt.push({
@@ -261,11 +303,11 @@ export class Conversations {
261
303
  throw new Error(KILLED);
262
304
  }
263
305
  catch (error) {
264
- if ('provider_session_id' in ack) {
306
+ if (started) {
265
307
  conversation.session = null;
266
308
  await this.kill(conversation);
267
309
  }
268
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
310
+ waiting.settle({ refused: refusal(conversation, error) });
269
311
  return;
270
312
  }
271
313
  this.prompt(conversation, [...prompt, ...waiting.content]);
@@ -343,14 +385,15 @@ export class Conversations {
343
385
  }
344
386
  return route;
345
387
  }
346
- async start(conversation, route, sessionId) {
388
+ async start(conversation, route, sessionId, empty) {
347
389
  const reader = readerFor(route.kind);
348
390
  reader.session = sessionId;
349
391
  const app = client({ name: '@agentshouse/kit' })
350
392
  .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));
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));
352
395
  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);
396
+ const bridge = await step(unbridged, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
354
397
  if (conversation.kills > 0) {
355
398
  bridge.close();
356
399
  throw new Error(KILLED);
@@ -361,24 +404,24 @@ export class Conversations {
361
404
  try {
362
405
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
363
406
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
364
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
407
+ const streamed = await step(NO_CONNECTION, work.open(cli));
408
+ const adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
365
409
  work.read(message);
366
410
  this.read(conversation, reader, message);
367
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
411
+ }, { ...bridge.env, ...streamed }, undefined, scope));
368
412
  void adapter.exited.then(() => bridge.close());
369
413
  const agent = adapter.connection.agent;
370
414
  const cwd = route.working_directory;
371
415
  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 };
416
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
417
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
375
418
  reader.session = opened.sessionId;
376
- if (sessionId === null && CLIS[route.kind].renames) {
419
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
377
420
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
378
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
421
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
379
422
  }
380
423
  const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
381
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
424
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
382
425
  void work.settled().then(() => scope.record());
383
426
  conversation.running = running;
384
427
  conversation.session = running.sessionId;
@@ -400,6 +443,18 @@ export class Conversations {
400
443
  conversation.opening = null;
401
444
  }
402
445
  }
446
+ async resume(conversation, kind, adapter, request, empty) {
447
+ try {
448
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
449
+ }
450
+ catch (error) {
451
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
452
+ throw new NotStarted(NO_RESUME, { cause: error });
453
+ }
454
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
455
+ return null;
456
+ }
457
+ }
403
458
  async launchSettings(running, route, offered) {
404
459
  let options = offered;
405
460
  const settings = [
@@ -427,7 +482,7 @@ export class Conversations {
427
482
  return;
428
483
  this.detach(conversation, running);
429
484
  if (conversation.turn !== null) {
430
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
485
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
431
486
  }
432
487
  this.next(conversation);
433
488
  this.kit.changed();
@@ -491,13 +546,17 @@ export class Conversations {
491
546
  this.kit.changed();
492
547
  return turn;
493
548
  }
494
- end(conversation, turn, failed) {
549
+ end(conversation, turn, failed, notice = null) {
495
550
  if (turn.ended)
496
551
  return;
552
+ if (failed !== null)
553
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
497
554
  if (conversation.turn === turn) {
498
555
  for (const event of conversation.running?.reader.closeTurn() ?? [])
499
556
  this.apply(conversation, turn, event);
500
557
  }
558
+ if (notice !== null)
559
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
501
560
  turn.ended = true;
502
561
  conversation.open--;
503
562
  if (conversation.turn === turn)
@@ -505,7 +564,7 @@ export class Conversations {
505
564
  const body = Promise.allSettled(turn.uploads).then(() => ({
506
565
  parts: turn.parts.saved(),
507
566
  context: turn.parts.context,
508
- ...failed,
567
+ ...(failed === null ? {} : { failed: failed.failure }),
509
568
  }));
510
569
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
511
570
  this.next(conversation);
@@ -515,16 +574,25 @@ export class Conversations {
515
574
  const running = conversation.running;
516
575
  const turn = this.begin(conversation);
517
576
  turn.prompted = true;
518
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
577
+ conversation.prompted = running.sessionId;
578
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
579
+ const notice = answeredFailure(answer);
580
+ if (notice !== null) {
581
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
582
+ }
583
+ else {
584
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
585
+ }
586
+ }, async (error) => {
519
587
  if (running.adapter.connection.signal.aborted) {
520
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
588
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
521
589
  }
522
590
  else {
523
591
  if (conversation.open === 1 && conversation.running === running) {
524
592
  running.scope.kill();
525
593
  this.detach(conversation, running);
526
594
  }
527
- this.end(conversation, turn, { failed: causeOf(error) });
595
+ this.end(conversation, turn, answeredWith(error));
528
596
  }
529
597
  });
530
598
  }
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,11 +1,17 @@
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) {
8
- const original = await house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal);
14
+ const original = await retrying(() => house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal), signal);
9
15
  const answer = await fetch(relayed(original.download.url), { signal });
10
16
  if (!answer.ok)
11
17
  throw new Error(`${file.name} did not download: ${answer.status}`);
@@ -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;