@agentshouse/kit 0.2.1-c5883630417e1 → 0.2.1-c59ffcda991c1

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.
@@ -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;
@@ -200,10 +251,10 @@ export class Conversations {
200
251
  if (conversation.running !== null)
201
252
  return {};
202
253
  try {
203
- return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
254
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null, true) };
204
255
  }
205
256
  catch (error) {
206
- return { refused: causeOf(error) };
257
+ return { refused: refusal(conversation, error) };
207
258
  }
208
259
  }
209
260
  async message(conversation, input) {
@@ -220,7 +271,7 @@ export class Conversations {
220
271
  throw new Error(KILLED);
221
272
  }
222
273
  catch (error) {
223
- return { ack: Promise.resolve({ refused: conversation.kills > 0 ? KILLED : causeOf(error) }) };
274
+ return { ack: Promise.resolve({ refused: refusal(conversation, error) }) };
224
275
  }
225
276
  return {
226
277
  ack: new Promise((settle) => {
@@ -243,13 +294,17 @@ export class Conversations {
243
294
  async send(conversation, waiting) {
244
295
  const { input } = waiting;
245
296
  let ack = {};
297
+ let started = false;
246
298
  const prompt = [];
247
299
  try {
248
300
  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 };
301
+ const stored = conversation.session ?? input.provider_session_id;
302
+ const empty = input.first === true && conversation.prompted !== stored;
303
+ started = (await this.start(conversation, waiting.route, stored, empty)) !== stored;
304
+ }
305
+ const session = conversation.running.sessionId;
306
+ if (started || (input.first === true && session !== input.provider_session_id)) {
307
+ ack = { provider_session_id: session };
253
308
  }
254
309
  if (input.first === true) {
255
310
  prompt.push({
@@ -261,11 +316,11 @@ export class Conversations {
261
316
  throw new Error(KILLED);
262
317
  }
263
318
  catch (error) {
264
- if ('provider_session_id' in ack) {
319
+ if (started) {
265
320
  conversation.session = null;
266
321
  await this.kill(conversation);
267
322
  }
268
- waiting.settle({ refused: conversation.kills > 0 ? KILLED : causeOf(error) });
323
+ waiting.settle({ refused: refusal(conversation, error) });
269
324
  return;
270
325
  }
271
326
  this.prompt(conversation, [...prompt, ...waiting.content]);
@@ -343,14 +398,15 @@ export class Conversations {
343
398
  }
344
399
  return route;
345
400
  }
346
- async start(conversation, route, sessionId) {
401
+ async start(conversation, route, sessionId, empty) {
347
402
  const reader = readerFor(route.kind);
348
403
  reader.session = sessionId;
349
404
  const app = client({ name: '@agentshouse/kit' })
350
405
  .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));
406
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal))
407
+ .onRequest('_x.ai/exit_plan_mode', EXIT_PLAN_MODE, ({ params, signal }) => this.ask(conversation, '_x.ai/exit_plan_mode', params, false, signal));
352
408
  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);
409
+ const bridge = await step(unbridged, openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal));
354
410
  if (conversation.kills > 0) {
355
411
  bridge.close();
356
412
  throw new Error(KILLED);
@@ -361,24 +417,24 @@ export class Conversations {
361
417
  try {
362
418
  await unlessAborted(createHowWeWork(bridge), conversation.killed.signal);
363
419
  await CLIS[route.kind].allowHouse(bridge.env.PATH);
364
- const adapter = await startAdapter(route.kind, cli, route.working_directory, app, (message) => {
420
+ const streamed = await step(NO_CONNECTION, work.open(cli));
421
+ const adapter = await step(NO_START, startAdapter(route.kind, cli, route.working_directory, app, (message) => {
365
422
  work.read(message);
366
423
  this.read(conversation, reader, message);
367
- }, { ...bridge.env, ...(await work.open(cli)) }, undefined, scope);
424
+ }, { ...bridge.env, ...streamed }, undefined, scope));
368
425
  void adapter.exited.then(() => bridge.close());
369
426
  const agent = adapter.connection.agent;
370
427
  const cwd = route.working_directory;
371
428
  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 };
429
+ const resumed = sessionId === null ? null : await this.resume(conversation, route.kind, adapter, { sessionId, cwd, mcpServers: [], _meta }, empty);
430
+ const opened = resumed ?? (await step(NO_SESSION, agent.request('session/new', { cwd, mcpServers: [], _meta })));
375
431
  reader.session = opened.sessionId;
376
- if (sessionId === null && CLIS[route.kind].renames) {
432
+ if (opened.sessionId !== sessionId && CLIS[route.kind].renames) {
377
433
  const prompt = [{ type: 'text', text: `/rename ${conversation.id}` }];
378
- await agent.request('session/prompt', { sessionId: opened.sessionId, prompt });
434
+ await step(NO_SESSION, agent.request('session/prompt', { sessionId: opened.sessionId, prompt }));
379
435
  }
380
436
  const running = { adapter, bridge, sessionId: opened.sessionId, killed: false, reader, work, scope };
381
- const options = await this.launchSettings(running, route, opened.configOptions ?? []);
437
+ const options = await step(NO_SETTINGS, this.launchSettings(running, route, opened.configOptions ?? []));
382
438
  void work.settled().then(() => scope.record());
383
439
  conversation.running = running;
384
440
  conversation.session = running.sessionId;
@@ -400,6 +456,18 @@ export class Conversations {
400
456
  conversation.opening = null;
401
457
  }
402
458
  }
459
+ async resume(conversation, kind, adapter, request, empty) {
460
+ try {
461
+ return { ...(await adapter.connection.agent.request('session/resume', request)), sessionId: request.sessionId };
462
+ }
463
+ catch (error) {
464
+ if (!empty || conversation.kills > 0 || !CLIS[kind].unknownSession(error)) {
465
+ throw new NotStarted(NO_RESUME, { cause: error });
466
+ }
467
+ logged(new Error(`conversation ${conversation.id} opens a new session: ${NO_RESUME}: ${causeOf(error)}`));
468
+ return null;
469
+ }
470
+ }
403
471
  async launchSettings(running, route, offered) {
404
472
  let options = offered;
405
473
  const settings = [
@@ -427,7 +495,7 @@ export class Conversations {
427
495
  return;
428
496
  this.detach(conversation, running);
429
497
  if (conversation.turn !== null) {
430
- this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
498
+ this.end(conversation, conversation.turn, exitedWith(running, cause));
431
499
  }
432
500
  this.next(conversation);
433
501
  this.kit.changed();
@@ -491,13 +559,17 @@ export class Conversations {
491
559
  this.kit.changed();
492
560
  return turn;
493
561
  }
494
- end(conversation, turn, failed) {
562
+ end(conversation, turn, failed, notice = null) {
495
563
  if (turn.ended)
496
564
  return;
565
+ if (failed !== null)
566
+ logged(new Error(`conversation ${conversation.id} turn ${turn.id} failed: ${failed.cause}`));
497
567
  if (conversation.turn === turn) {
498
568
  for (const event of conversation.running?.reader.closeTurn() ?? [])
499
569
  this.apply(conversation, turn, event);
500
570
  }
571
+ if (notice !== null)
572
+ turn.parts.start({ type: 'marker', marker: 'retry', text: notice });
501
573
  turn.ended = true;
502
574
  conversation.open--;
503
575
  if (conversation.turn === turn)
@@ -505,7 +577,7 @@ export class Conversations {
505
577
  const body = Promise.allSettled(turn.uploads).then(() => ({
506
578
  parts: turn.parts.saved(),
507
579
  context: turn.parts.context,
508
- ...failed,
580
+ ...(failed === null ? {} : { failed: failed.failure }),
509
581
  }));
510
582
  void this.deliver(conversation, turn, 'ended', body).catch(logged);
511
583
  this.next(conversation);
@@ -515,16 +587,25 @@ export class Conversations {
515
587
  const running = conversation.running;
516
588
  const turn = this.begin(conversation);
517
589
  turn.prompted = true;
518
- running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.end(conversation, turn, null), async (error) => {
590
+ conversation.prompted = running.sessionId;
591
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then((answer) => {
592
+ const notice = answeredFailure(answer);
593
+ if (notice !== null) {
594
+ this.end(conversation, turn, { failure: notice.limit ? 'limit' : 'error', cause: notice.title }, notice.title);
595
+ }
596
+ else {
597
+ this.end(conversation, turn, answer.stopReason === 'refusal' ? { failure: 'refused', cause: REFUSED } : null);
598
+ }
599
+ }, async (error) => {
519
600
  if (running.adapter.connection.signal.aborted) {
520
- this.end(conversation, turn, { failed: running.killed ? KILLED : await running.adapter.exited });
601
+ this.end(conversation, turn, exitedWith(running, await running.adapter.exited));
521
602
  }
522
603
  else {
523
604
  if (conversation.open === 1 && conversation.running === running) {
524
605
  running.scope.kill();
525
606
  this.detach(conversation, running);
526
607
  }
527
- this.end(conversation, turn, { failed: causeOf(error) });
608
+ this.end(conversation, turn, answeredWith(error));
528
609
  }
529
610
  });
530
611
  }
package/dist/copies.js CHANGED
@@ -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
  }
@@ -33,6 +36,9 @@ function textOnly(paths) {
33
36
  const [named, them] = paths.length === 1 ? [`${paths[0]} is`, 'it'] : [`${paths.join(', ')} are`, 'them'];
34
37
  return `${named} not text, and house push sends text only; remove ${them} from the commit and place ${them} with house upload`;
35
38
  }
39
+ function outside(path) {
40
+ return { refused: true, text: `${path} is not a House Local copy; run house push inside one` };
41
+ }
36
42
  function short(commit) {
37
43
  return commit.slice(0, 12);
38
44
  }
@@ -60,6 +66,9 @@ function sidecar(path) {
60
66
  segments.splice(-1, 0, PROVENANCE);
61
67
  return segments.join('/');
62
68
  }
69
+ function recordOf(key) {
70
+ return join(kitHome(), 'local-copies', `${key}.json`);
71
+ }
63
72
  function rootOf(handle) {
64
73
  return `/rooms/${handle}`;
65
74
  }
@@ -206,7 +215,7 @@ export class LocalCopies {
206
215
  async mapping() {
207
216
  await this.answering;
208
217
  return [...this.copies.values()]
209
- .filter((copy) => copy.active)
218
+ .filter((copy) => copy.erasing === undefined)
210
219
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
211
220
  .sort();
212
221
  }
@@ -229,25 +238,30 @@ export class LocalCopies {
229
238
  return this.holding([...new Set([...this.copies.keys(), ...synced.keys()])].sort(), async () => {
230
239
  const said = [];
231
240
  let refused = false;
232
- for (const copy of this.copies.values()) {
233
- 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)
234
244
  continue;
235
- await this.save({ ...copy, active: false });
236
- 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
+ }
237
253
  }
238
254
  const current = [];
239
255
  for (const [key, handle] of synced) {
240
256
  try {
241
257
  const held = this.copies.get(key);
258
+ if (held?.erasing)
259
+ continue;
242
260
  if (held === undefined || !(await exists(held.repository))) {
243
261
  said.push(await this.materialize(key, handle));
244
262
  continue;
245
263
  }
246
- const copy = held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle });
247
- if (copy.active)
248
- current.push(copy);
249
- else
250
- 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 }));
251
265
  }
252
266
  catch (error) {
253
267
  refused = true;
@@ -261,7 +275,7 @@ export class LocalCopies {
261
275
  }
262
276
  catch (error) {
263
277
  refused = true;
264
- said.push(error instanceof HouseRefusal ? (refusalOf(error.text)?.text ?? causeOf(error)) : causeOf(error));
278
+ said.push(houseCause(error));
265
279
  }
266
280
  }
267
281
  return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
@@ -273,6 +287,8 @@ export class LocalCopies {
273
287
  return;
274
288
  await this.exclusive(key, async () => {
275
289
  const copy = this.copies.get(key);
290
+ if (copy === undefined || copy.erasing)
291
+ return;
276
292
  let moves = copy.moves;
277
293
  for (const { from, to } of report.moves) {
278
294
  const held = moves.find((move) => move.worktree === report.worktree && move.to === from);
@@ -295,11 +311,11 @@ export class LocalCopies {
295
311
  async push(cwd, selector, caller) {
296
312
  const located = await run(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel']);
297
313
  if (located.status !== 0)
298
- return { refused: true, text: `${cwd} is not a House Local copy; run house push inside one` };
314
+ return outside(cwd);
299
315
  const [common, worktree] = located.stdout.toString('utf8').split('\n');
300
316
  const key = await this.keyAt(dirname(common));
301
317
  if (key === undefined)
302
- return { refused: true, text: `${worktree} is not a House Local copy; run house push inside one` };
318
+ return outside(worktree);
303
319
  return this.exclusive(key, () => this.pushed(key, worktree, selector, caller));
304
320
  }
305
321
  async keyAt(repository) {
@@ -320,7 +336,7 @@ export class LocalCopies {
320
336
  return key === undefined ? work() : this.exclusive(key, () => this.holding(rest, work));
321
337
  }
322
338
  async save(copy) {
323
- const path = join(kitHome(), 'local-copies', `${copy.key}.json`);
339
+ const path = recordOf(copy.key);
324
340
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
325
341
  const temporary = `${path}.${process.pid}`;
326
342
  await writeFile(temporary, JSON.stringify(copy), { mode: 0o600 });
@@ -328,6 +344,12 @@ export class LocalCopies {
328
344
  this.copies.set(copy.key, copy);
329
345
  return copy;
330
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
+ }
331
353
  async read(handle) {
332
354
  const prefixes = [rootOf(handle)];
333
355
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
@@ -394,7 +416,6 @@ export class LocalCopies {
394
416
  room_ref: read.anchor,
395
417
  room_handle: handle,
396
418
  repository,
397
- active: true,
398
419
  house,
399
420
  position: read.position,
400
421
  log_epoch: read.log_epoch,
@@ -523,6 +544,8 @@ export class LocalCopies {
523
544
  }
524
545
  async pushed(key, worktree, selector, caller) {
525
546
  let copy = this.copies.get(key);
547
+ if (copy === undefined || copy.erasing)
548
+ return outside(worktree);
526
549
  if (copy.push?.state === 'submitted')
527
550
  return this.settle(copy, caller);
528
551
  const selected = await run(worktree, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${selector}^{commit}`]);
@@ -536,9 +559,6 @@ export class LocalCopies {
536
559
  }
537
560
  copy = await this.save(without(copy));
538
561
  }
539
- if (!copy.active) {
540
- return { refused: true, text: `${rootOf(copy.room_handle)} is no longer synced to this Environment, so House takes no push from it` };
541
- }
542
562
  const listed = (await git(copy.repository, ['rev-list', '--topo-order', commit])).split('\n');
543
563
  const base = listed.find((ancestor) => ancestor in copy.manifests);
544
564
  if (base === undefined) {
@@ -549,7 +569,7 @@ export class LocalCopies {
549
569
  changes = await this.derived(copy, base, commit);
550
570
  }
551
571
  catch (error) {
552
- return { refused: true, text: causeOf(error) };
572
+ return { refused: true, text: houseCause(error) };
553
573
  }
554
574
  if (changes.length === 0)
555
575
  return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
@@ -576,12 +596,17 @@ export class LocalCopies {
576
596
  const after = await tree(copy.repository, commit);
577
597
  const moved = movedPaths(copy.moves, before, after);
578
598
  const arrived = new Set(moved.values());
579
- const binary = [];
580
- for (const [path, entry] of after)
581
- if (before.get(path) !== entry && !(await isText(copy.repository, entry)))
582
- binary.push(path);
583
- if (binary.length > 0)
584
- throw new Error(textOnly(binary));
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
+ }
585
610
  const text = (entry) => blobText(copy.repository, entry);
586
611
  const changes = [];
587
612
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
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
@@ -11,7 +11,7 @@ export function verified(bytes, sha256, name) {
11
11
  export async function placeFiles(house, directory, files, signal) {
12
12
  const paths = [];
13
13
  for (const file of files) {
14
- 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);
15
15
  const answer = await fetch(relayed(original.download.url), { signal });
16
16
  if (!answer.ok)
17
17
  throw new Error(`${file.name} did not download: ${answer.status}`);
package/dist/git.js CHANGED
@@ -49,16 +49,15 @@ export async function tree(repository, commit) {
49
49
  }
50
50
  return entries;
51
51
  }
52
- const TEXT_MAX_BYTES = 16 * 1024 * 1024;
53
52
  async function blob(repository, object) {
54
53
  return (await run(repository, ['cat-file', 'blob', object])).stdout;
55
54
  }
56
55
  export async function blobText(repository, object) {
57
56
  return (await blob(repository, object)).toString('utf8');
58
57
  }
59
- export async function isText(repository, object) {
58
+ export async function isText(repository, object, maxBytes) {
60
59
  const bytes = await blob(repository, object);
61
- return bytes.length <= TEXT_MAX_BYTES && isUtf8(bytes) && !bytes.includes(0);
60
+ return bytes.length <= maxBytes && isUtf8(bytes) && !bytes.includes(0);
62
61
  }
63
62
  export function blobId(content) {
64
63
  const bytes = Buffer.from(content, 'utf8');
package/dist/home.js CHANGED
@@ -1,8 +1,8 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { accessSync, chmodSync, constants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { accessSync, chmodSync, constants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
3
3
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { homedir, tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { join, resolve } from 'node:path';
6
6
  import { selectRelay } from './relay.js';
7
7
  const ENROLMENT = 'credential.json';
8
8
  const DISCONNECTED = 'disconnected.json';
@@ -19,24 +19,26 @@ function writable(directory) {
19
19
  return false;
20
20
  }
21
21
  }
22
- function ownTemporaryHome() {
23
- const home = join(tmpdir(), `house-kit-${process.getuid()}`);
22
+ function privateDirectory(path) {
24
23
  try {
25
- mkdirSync(home, { mode: 0o700 });
24
+ mkdirSync(path, { mode: 0o700 });
26
25
  }
27
26
  catch (error) {
28
27
  if (error.code !== 'EEXIST')
29
28
  throw error;
30
29
  }
31
- const found = lstatSync(home);
30
+ const found = lstatSync(path);
32
31
  if (found.isSymbolicLink())
33
- throw new Error(`${home} is a symbolic link`);
32
+ throw new Error(`${path} is a symbolic link`);
34
33
  if (!found.isDirectory())
35
- throw new Error(`${home} is not a directory`);
36
- if (found.uid !== process.getuid())
37
- throw new Error(`${home} is not owned by this User`);
38
- chmodSync(home, 0o700);
39
- return home;
34
+ throw new Error(`${path} is not a directory`);
35
+ if (found.uid !== process.geteuid())
36
+ throw new Error(`${path} is not owned by this User`);
37
+ chmodSync(path, 0o700);
38
+ return path;
39
+ }
40
+ function ownTemporaryHome() {
41
+ return privateDirectory(join(tmpdir(), `house-kit-${process.getuid()}`));
40
42
  }
41
43
  function defaultHome() {
42
44
  const home = join(homedir(), '.house-kit');
@@ -45,6 +47,18 @@ function defaultHome() {
45
47
  export function kitHome() {
46
48
  return process.env.HOUSE_KIT_HOME || (defaulted ??= defaultHome());
47
49
  }
50
+ export function socketDirectory() {
51
+ const sockets = privateDirectory(join(realpathSync('/tmp'), `hk-${process.geteuid()}`));
52
+ const home = kitHome();
53
+ const physical = existsSync(home) ? realpathSync(home) : resolve(home);
54
+ return privateDirectory(join(sockets, createHash('sha256').update(physical).digest('hex').slice(0, 12)));
55
+ }
56
+ export function ownerSocket() {
57
+ return join(socketDirectory(), 'owner.sock');
58
+ }
59
+ export function ownerLock() {
60
+ return join(socketDirectory(), 'owner.lock');
61
+ }
48
62
  export function workspaceRoot() {
49
63
  return process.env.HOUSE_KIT_WORKSPACE ?? '/agents/house';
50
64
  }