@agentshouse/kit 0.2.1-cca9e1f43de44 → 0.2.1-ccb257fa8a318

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 (132) hide show
  1. package/README.md +42 -40
  2. package/dist/acp.js +92 -115
  3. package/dist/agents.js +112 -24
  4. package/dist/api.js +10 -6
  5. package/dist/app-git.js +10 -26
  6. package/dist/authority-main.js +2 -2
  7. package/dist/bridge.js +11 -25
  8. package/dist/building.js +46 -0
  9. package/dist/calls.js +71 -0
  10. package/dist/chatgpt.js +32 -0
  11. package/dist/clis-main.js +2 -2
  12. package/dist/clis.js +131 -27
  13. package/dist/codex-main.js +58 -0
  14. package/dist/connection.js +116 -0
  15. package/dist/conversations.js +523 -202
  16. package/dist/copies.js +185 -123
  17. package/dist/end.js +24 -5
  18. package/dist/enrol-main.js +7 -1
  19. package/dist/events.js +16 -78
  20. package/dist/files.js +139 -31
  21. package/dist/git.js +13 -11
  22. package/dist/home.js +96 -7
  23. package/dist/house-cli.js +299 -0
  24. package/dist/house-main.js +7 -177
  25. package/dist/instructions.js +8 -10
  26. package/dist/kit-cli.js +68 -0
  27. package/dist/kit-main.js +8 -22
  28. package/dist/login.js +118 -107
  29. package/dist/logout.js +14 -16
  30. package/dist/mcp.js +6 -2
  31. package/dist/parts.js +34 -16
  32. package/dist/placement.js +39 -0
  33. package/dist/relay.js +33 -2
  34. package/dist/resident.js +118 -51
  35. package/dist/scope.js +206 -0
  36. package/dist/scripts.js +255 -0
  37. package/dist/service-main.js +9 -0
  38. package/dist/service.js +55 -0
  39. package/dist/sign-in.js +23 -40
  40. package/dist/silence.js +28 -0
  41. package/dist/skills.js +49 -35
  42. package/dist/transcription.js +85 -45
  43. package/dist/translate.js +2 -8
  44. package/dist/update.js +115 -53
  45. package/dist/wire.js +22 -0
  46. package/dist/work.js +346 -0
  47. package/package.json +20 -20
  48. package/dist/bootstrap.json +0 -24
  49. package/dist/stream.js +0 -72
  50. package/how-we-work.md +0 -23
  51. package/skills/ask-matt/LICENSE +0 -21
  52. package/skills/ask-matt/PHASE-BOUNDARIES.md +0 -55
  53. package/skills/ask-matt/SKILL.md +0 -91
  54. package/skills/ask-matt/agents/openai.yaml +0 -4
  55. package/skills/code-review/LICENSE +0 -21
  56. package/skills/code-review/SKILL.md +0 -89
  57. package/skills/code-review/agents/openai.yaml +0 -3
  58. package/skills/codebase-design/DEEPENING.md +0 -37
  59. package/skills/codebase-design/DESIGN-IT-TWICE.md +0 -44
  60. package/skills/codebase-design/LICENSE +0 -21
  61. package/skills/codebase-design/SKILL.md +0 -116
  62. package/skills/codebase-design/agents/openai.yaml +0 -3
  63. package/skills/diagnosing-bugs/LICENSE +0 -21
  64. package/skills/diagnosing-bugs/SKILL.md +0 -140
  65. package/skills/diagnosing-bugs/agents/openai.yaml +0 -3
  66. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +0 -44
  67. package/skills/domain-modeling/ADR-FORMAT.md +0 -47
  68. package/skills/domain-modeling/CONTEXT-FORMAT.md +0 -60
  69. package/skills/domain-modeling/LICENSE +0 -21
  70. package/skills/domain-modeling/SKILL.md +0 -76
  71. package/skills/domain-modeling/agents/openai.yaml +0 -3
  72. package/skills/grill-me/LICENSE +0 -21
  73. package/skills/grill-me/SKILL.md +0 -8
  74. package/skills/grill-me/agents/openai.yaml +0 -4
  75. package/skills/grill-with-docs/LICENSE +0 -21
  76. package/skills/grill-with-docs/SKILL.md +0 -8
  77. package/skills/grill-with-docs/agents/openai.yaml +0 -4
  78. package/skills/grilling/LICENSE +0 -21
  79. package/skills/grilling/SKILL.md +0 -30
  80. package/skills/grilling/agents/openai.yaml +0 -3
  81. package/skills/handoff/LICENSE +0 -21
  82. package/skills/handoff/SKILL.md +0 -17
  83. package/skills/handoff/agents/openai.yaml +0 -4
  84. package/skills/implement/LICENSE +0 -21
  85. package/skills/implement/SKILL.md +0 -16
  86. package/skills/implement/agents/openai.yaml +0 -4
  87. package/skills/improve-codebase-architecture/HTML-REPORT.md +0 -123
  88. package/skills/improve-codebase-architecture/LICENSE +0 -21
  89. package/skills/improve-codebase-architecture/SKILL.md +0 -72
  90. package/skills/improve-codebase-architecture/agents/openai.yaml +0 -4
  91. package/skills/prototype/LICENSE +0 -21
  92. package/skills/prototype/LOGIC.md +0 -67
  93. package/skills/prototype/SKILL.md +0 -28
  94. package/skills/prototype/UI.md +0 -112
  95. package/skills/prototype/agents/openai.yaml +0 -3
  96. package/skills/research/LICENSE +0 -21
  97. package/skills/research/SKILL.md +0 -14
  98. package/skills/research/agents/openai.yaml +0 -3
  99. package/skills/resolving-merge-conflicts/LICENSE +0 -21
  100. package/skills/resolving-merge-conflicts/SKILL.md +0 -16
  101. package/skills/resolving-merge-conflicts/agents/openai.yaml +0 -3
  102. package/skills/tdd/LICENSE +0 -21
  103. package/skills/tdd/SKILL.md +0 -40
  104. package/skills/tdd/agents/openai.yaml +0 -3
  105. package/skills/tdd/mocking.md +0 -59
  106. package/skills/tdd/tests.md +0 -77
  107. package/skills/teach/GLOSSARY-FORMAT.md +0 -35
  108. package/skills/teach/LEARNING-RECORD-FORMAT.md +0 -46
  109. package/skills/teach/LICENSE +0 -21
  110. package/skills/teach/MISSION-FORMAT.md +0 -31
  111. package/skills/teach/RESOURCES-FORMAT.md +0 -32
  112. package/skills/teach/SKILL.md +0 -141
  113. package/skills/teach/agents/openai.yaml +0 -4
  114. package/skills/to-questionnaire/LICENSE +0 -21
  115. package/skills/to-questionnaire/SKILL.md +0 -55
  116. package/skills/to-questionnaire/agents/openai.yaml +0 -4
  117. package/skills/to-spec/LICENSE +0 -21
  118. package/skills/to-spec/SKILL.md +0 -76
  119. package/skills/to-spec/agents/openai.yaml +0 -4
  120. package/skills/to-tickets/LICENSE +0 -21
  121. package/skills/to-tickets/SKILL.md +0 -106
  122. package/skills/to-tickets/agents/openai.yaml +0 -4
  123. package/skills/wait-what/LICENSE +0 -21
  124. package/skills/wait-what/SKILL.md +0 -8
  125. package/skills/wait-what/agents/openai.yaml +0 -4
  126. package/skills/wayfinder/LICENSE +0 -21
  127. package/skills/wayfinder/SKILL.md +0 -129
  128. package/skills/wayfinder/agents/openai.yaml +0 -4
  129. package/skills/writing-for-agents/LICENSE +0 -21
  130. package/skills/writing-for-agents/SKILL-MECHANICS.md +0 -22
  131. package/skills/writing-for-agents/SKILL.md +0 -83
  132. package/skills/writing-for-agents/agents/openai.yaml +0 -3
package/dist/events.js CHANGED
@@ -1,8 +1,8 @@
1
1
  const COMMAND = { shown: 'command' };
2
2
  const HIDDEN = { shown: 'hidden' };
3
3
  const FINISHED = new Set(['completed', 'failed']);
4
- const RUNNING_JOB = new Set(['running', 'paused']);
5
- const ROUTINE_TARGETS = { send_routine_request: 'access', cancel_routine_request: 'request' };
4
+ export const RUNNING_JOB = new Set(['running', 'paused']);
5
+ const MESSAGE_TARGETS = { send_routine_request: 'access', cancel_routine_request: 'request', send_mail: 'request' };
6
6
  function words(line) {
7
7
  const found = [];
8
8
  let word = null;
@@ -48,7 +48,7 @@ export function houseMessage(line) {
48
48
  catch {
49
49
  return null;
50
50
  }
51
- const argument = ROUTINE_TARGETS[call.command];
51
+ const argument = MESSAGE_TARGETS[call.command];
52
52
  if (argument === undefined)
53
53
  return null;
54
54
  const target = call.arguments?.[argument];
@@ -113,9 +113,6 @@ class Reader {
113
113
  refused(option) {
114
114
  this.requestedOptions.delete(option);
115
115
  }
116
- turnStarted() {
117
- return [];
118
- }
119
116
  closeTurn() {
120
117
  const events = [];
121
118
  for (const [id, call] of this.calls) {
@@ -187,10 +184,7 @@ class Reader {
187
184
  const label = this.jobs.get(id) ?? '';
188
185
  if (state !== 'running')
189
186
  this.jobs.delete(id);
190
- return [
191
- { kind: 'busy', key: `job:${id}`, running: state === 'running' },
192
- { kind: 'marker', marker: 'job', text: `${label}: ${state}` },
193
- ];
187
+ return [{ kind: 'marker', marker: 'job', text: `${label}: ${state}` }];
194
188
  }
195
189
  optionsChanged(options) {
196
190
  const events = [];
@@ -275,7 +269,6 @@ class Reader {
275
269
  class ClaudeReader extends Reader {
276
270
  watchedOptions = { model: 'model' };
277
271
  tasks = new Map();
278
- scheduled = new Map();
279
272
  own(message) {
280
273
  if (message.params?.update?._meta?.claudeCode?.parentToolUseId !== undefined)
281
274
  return [];
@@ -295,10 +288,6 @@ class ClaudeReader extends Reader {
295
288
  return [];
296
289
  const sdk = message.params.message;
297
290
  switch (sdk.subtype) {
298
- case 'background_tasks_changed':
299
- return [{ kind: 'busy', key: 'tasks', running: sdk.tasks.length > 0 }];
300
- case 'session_state_changed':
301
- return [{ kind: 'busy', key: 'state', running: sdk.state === 'running' }];
302
291
  case 'task_started':
303
292
  if (sdk.tool_use_id !== undefined)
304
293
  this.tasks.set(sdk.task_id, sdk.tool_use_id);
@@ -309,16 +298,6 @@ class ClaudeReader extends Reader {
309
298
  return [];
310
299
  }
311
300
  }
312
- turnStarted() {
313
- const events = [];
314
- for (const [id, entry] of this.scheduled) {
315
- if (entry.recurring)
316
- continue;
317
- this.scheduled.delete(id);
318
- events.push({ kind: 'busy', key: `scheduled:${id}`, running: false });
319
- }
320
- return events;
321
- }
322
301
  decide(call, final) {
323
302
  const tool = call.meta.claudeCode?.toolName;
324
303
  if (tool === 'Agent' || tool === 'Task' || call.meta.jetbrains?.air?.subagent === true) {
@@ -350,22 +329,7 @@ class ClaudeReader extends Reader {
350
329
  if (call.status !== 'completed')
351
330
  return [];
352
331
  if (tool === 'CronCreate' || (tool === 'ScheduleWakeup' && call.rawInput?.stop !== true)) {
353
- this.scheduled.set(call.id, {
354
- recurring: tool === 'CronCreate' && call.rawInput?.recurring !== false,
355
- text: contentText(call.content) ?? '',
356
- });
357
- return [
358
- { kind: 'busy', key: `scheduled:${call.id}`, running: true },
359
- { kind: 'marker', marker: 'job', text: `${call.rawInput?.prompt ?? call.title ?? ''}: scheduled` },
360
- ];
361
- }
362
- if (tool === 'CronDelete') {
363
- for (const [id, entry] of this.scheduled) {
364
- if (!entry.text.includes(String(call.rawInput?.id)))
365
- continue;
366
- this.scheduled.delete(id);
367
- return [{ kind: 'busy', key: `scheduled:${id}`, running: false }];
368
- }
332
+ return [{ kind: 'marker', marker: 'job', text: `${call.rawInput?.prompt ?? call.title ?? ''}: scheduled` }];
369
333
  }
370
334
  return [];
371
335
  }
@@ -402,15 +366,9 @@ class CodexReader extends Reader {
402
366
  case 'subagent_spawned':
403
367
  this.children.set(update.subagentSessionId, { id: null, text: '' });
404
368
  this.subagents.add(update.subagentSessionId);
405
- return [
406
- { kind: 'busy', key: `subagent:${update.subagentSessionId}`, running: true },
407
- { kind: 'subagent', id: update.subagentSessionId, description: update.name },
408
- ];
369
+ return [{ kind: 'subagent', id: update.subagentSessionId, description: update.name }];
409
370
  case 'subagent_state_update':
410
- return [
411
- { kind: 'busy', key: `subagent:${update.subagentSessionId}`, running: false },
412
- ...this.finish(update.subagentSessionId, this.children.get(update.subagentSessionId)?.text || null),
413
- ];
371
+ return this.finish(update.subagentSessionId, this.children.get(update.subagentSessionId)?.text || null);
414
372
  default:
415
373
  return super.update(update);
416
374
  }
@@ -427,10 +385,6 @@ class CodexReader extends Reader {
427
385
  child.id = id;
428
386
  child.text += update.content.text;
429
387
  }
430
- const status = update._meta?.codex?.threadStatus?.type;
431
- if (update.sessionUpdate === 'session_info_update' && status !== undefined) {
432
- return [{ kind: 'busy', key: `thread:${session}`, running: status === 'active' }];
433
- }
434
388
  return [];
435
389
  }
436
390
  decide(call) {
@@ -460,7 +414,6 @@ class CodexReader extends Reader {
460
414
  class GrokReader extends Reader {
461
415
  watchedOptions = {};
462
416
  children = new Map();
463
- queues = new Map();
464
417
  models = [];
465
418
  model = null;
466
419
  answered(result) {
@@ -473,15 +426,10 @@ class GrokReader extends Reader {
473
426
  if (this.session !== null && session !== undefined && session !== this.session && !this.children.has(session))
474
427
  return [];
475
428
  if (message.method === '_x.ai/queue/changed') {
476
- const running = message.params.runningPromptId ?? null;
477
- this.queues.set(session, { running, entries: message.params.entries.length });
478
- return [this.queued(session), ...(running !== null && session === this.session ? [{ kind: 'started' }] : [])];
429
+ return (message.params.runningPromptId ?? null) !== null && session === this.session ? [{ kind: 'started' }] : [];
479
430
  }
480
431
  if (message.params?.update?.sessionUpdate === 'turn_completed') {
481
- const queue = this.queues.get(session);
482
- if (queue !== undefined && queue.running === message.params.update.prompt_id)
483
- queue.running = null;
484
- return [this.queued(session), ...(session === this.session ? [{ kind: 'ended' }] : [])];
432
+ return session === this.session ? [{ kind: 'ended' }] : [];
485
433
  }
486
434
  if (message.method === '_x.ai/models/update') {
487
435
  this.offered(message.params);
@@ -489,10 +437,6 @@ class GrokReader extends Reader {
489
437
  }
490
438
  return super.read(message);
491
439
  }
492
- queued(session) {
493
- const queue = this.queues.get(session);
494
- return { kind: 'busy', key: `queue:${session}`, running: queue !== undefined && (queue.running !== null || queue.entries > 0) };
495
- }
496
440
  offered(models) {
497
441
  this.models = models.availableModels ?? this.models;
498
442
  this.model = models.currentModelId ?? this.model;
@@ -508,16 +452,9 @@ class GrokReader extends Reader {
508
452
  case '_x.ai/task_completed':
509
453
  return this.job(update.task_snapshot.task_id, null, update.task_snapshot.exit_code === 0 ? 'completed' : 'failed');
510
454
  case '_x.ai/scheduled_task_created':
511
- return [
512
- { kind: 'busy', key: `scheduled:${update.task_id}`, running: true },
513
- { kind: 'marker', marker: 'job', text: `${update.prompt}: next run ${update.next_fire_at}` },
514
- ];
515
- case '_x.ai/scheduled_task_deleted':
516
- return [{ kind: 'busy', key: `scheduled:${update.task_id}`, running: false }];
455
+ return [{ kind: 'marker', marker: 'job', text: `${update.prompt}: next run ${update.next_fire_at}` }];
517
456
  case '_x.ai/session_notification':
518
457
  return this.notification(update);
519
- case '_x.ai/mcp_initialized':
520
- return [{ kind: 'servers' }];
521
458
  default:
522
459
  return [];
523
460
  }
@@ -538,14 +475,11 @@ class GrokReader extends Reader {
538
475
  candidate.decided.description === update.description) ??
539
476
  [...this.calls.values()].find((candidate) => candidate.decided?.shown === 'subagent' && ![...this.children.values()].includes(candidate.id));
540
477
  this.children.set(update.child_session_id, call?.id ?? null);
541
- return [{ kind: 'busy', key: `subagent:${update.child_session_id}`, running: true }];
478
+ return [];
542
479
  }
543
480
  case 'subagent_finished': {
544
481
  const call = this.children.get(update.child_session_id);
545
- return [
546
- { kind: 'busy', key: `subagent:${update.child_session_id}`, running: false },
547
- ...(typeof call === 'string' ? this.finish(call, update.output ?? null) : []),
548
- ];
482
+ return typeof call === 'string' ? this.finish(call, update.output ?? null) : [];
549
483
  }
550
484
  case 'auto_compact_completed':
551
485
  return [{ kind: 'marker', marker: 'compaction', text: '' }];
@@ -578,6 +512,10 @@ class GrokReader extends Reader {
578
512
  return [];
579
513
  }
580
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
+ }
581
519
  export function readerFor(kind) {
582
520
  if (kind === 'claude-agent-acp')
583
521
  return new ClaudeReader();
package/dist/files.js CHANGED
@@ -1,36 +1,144 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { HouseRefusal, retrying } from './api.js';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, rename, rm } from 'node:fs/promises';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { parseDocument } from 'yaml';
8
+ import { retryDelay } from './api.js';
9
+ import { Unsettled } from './mcp.js';
4
10
  import { relayed } from './relay.js';
5
- export async function placeFiles(house, directory, files, signal) {
6
- const paths = [];
7
- for (const file of files) {
8
- const original = await house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal);
9
- const answer = await fetch(relayed(original.download.url), { signal });
10
- if (!answer.ok)
11
- throw new Error(`${file.name} did not download: ${answer.status}`);
12
- const folder = join(directory, '.house', 'files', original.message, file.version);
13
- await mkdir(join(directory, '.house')).catch((error) => {
14
- if (error.code !== 'EEXIST')
11
+ const STALLED_ROUNDS = 8;
12
+ const PART_CHECKSUM = 'x-amz-checksum-sha256';
13
+ const LINK = /\]\((\/rooms\/[^/)\s]+\/[^)\s]+)\)/gu;
14
+ export function linkedPaths(text) {
15
+ return [...new Set(Array.from(text.matchAll(LINK), (link) => link[1]))];
16
+ }
17
+ export function inspectedFile(answer) {
18
+ const document = parseDocument(answer);
19
+ if (document.errors.length > 0)
20
+ return null;
21
+ const inspected = document.toJS();
22
+ if (typeof inspected?.download !== 'string')
23
+ return null;
24
+ return {
25
+ download: inspected.download,
26
+ sha256: String(inspected.sha256),
27
+ media_type: typeof inspected.media_type === 'string' ? inspected.media_type : null,
28
+ };
29
+ }
30
+ function paused(milliseconds) {
31
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
32
+ }
33
+ export function storeRefusal(act, status) {
34
+ return new Error(`the ${act} answered ${status}; ${act} the file again`);
35
+ }
36
+ async function partDigests(uploading, partBytes) {
37
+ const whole = createHash('sha256');
38
+ const digests = [];
39
+ for (let start = 0; digests.length === 0 || start < uploading.bytes; start += partBytes) {
40
+ const end = Math.min(start + partBytes, uploading.bytes);
41
+ const digest = createHash('sha256');
42
+ for await (const chunk of uploading.read(start, end)) {
43
+ digest.update(chunk);
44
+ whole.update(chunk);
45
+ }
46
+ digests.push(digest.digest());
47
+ }
48
+ if (whole.digest('hex') !== uploading.sha256)
49
+ throw new Error(`${uploading.name} changed while it was uploaded; upload it again`);
50
+ return digests;
51
+ }
52
+ export async function uploaded(declared, uploading) {
53
+ const partBytes = declared.part_bytes;
54
+ const digests = await partDigests(uploading, partBytes);
55
+ const partSha256 = digests.map((digest) => digest.toString('hex'));
56
+ let answer = await finishing(uploading, declared.id, partSha256);
57
+ let idle = 0;
58
+ let stalled = 0;
59
+ while ('upload' in answer) {
60
+ const { id, parts } = answer.upload;
61
+ let sent = 0;
62
+ let failure = null;
63
+ for (const { part, url } of parts ?? []) {
64
+ const start = (part - 1) * partBytes;
65
+ try {
66
+ await uploading.send(url, start, Math.min(start + partBytes, uploading.bytes), {
67
+ [PART_CHECKSUM]: digests[part - 1].toString('base64'),
68
+ });
69
+ sent++;
70
+ }
71
+ catch (error) {
72
+ failure = error;
73
+ break;
74
+ }
75
+ }
76
+ if (sent > 0) {
77
+ idle = 0;
78
+ stalled = 0;
79
+ }
80
+ else {
81
+ if (failure !== null && ++stalled >= STALLED_ROUNDS)
82
+ throw failure;
83
+ // A round that sent no part, its store unreachable, is followed by the next finishing call on the jittered curve.
84
+ await paused(retryDelay(idle++));
85
+ }
86
+ answer = await finishing(uploading, id, partSha256);
87
+ }
88
+ return answer;
89
+ }
90
+ async function finishing(uploading, upload, partSha256) {
91
+ for (let unsettled = 1;; unsettled++) {
92
+ try {
93
+ return await uploading.finish(upload, partSha256);
94
+ }
95
+ catch (error) {
96
+ if (!(error instanceof Unsettled) || unsettled === STALLED_ROUNDS)
15
97
  throw error;
16
- });
17
- await mkdir(folder, { recursive: true });
18
- const path = join(folder, file.name);
19
- await writeFile(path, Buffer.from(await answer.arrayBuffer()));
20
- paths.push(path);
98
+ }
99
+ // A finishing call House did not settle, unreached or refused short of final, is sent again on the jittered curve as a conversation resends it, so a dropped connection or a House restart resumes the upload instead of ending it.
100
+ await paused(retryDelay(unsettled - 1));
21
101
  }
22
- return paths;
23
- }
24
- export function uploadBytes(upload, bytes) {
25
- return retrying(async () => {
26
- const answer = await fetch(relayed(upload.url), {
27
- method: 'POST',
28
- headers: { 'content-type': 'application/octet-stream', 'x-house-byte-operation': upload.operation },
29
- body: bytes,
102
+ }
103
+ async function drained(body) {
104
+ for await (const _chunk of body)
105
+ ;
106
+ }
107
+ export async function downloaded(address, fetching, destination, name) {
108
+ for (let asked = 1;; asked++) {
109
+ const { download, sha256 } = await address();
110
+ const answer = await fetching(download);
111
+ if (answer.status === 403 && asked === 1) {
112
+ await drained(answer.body);
113
+ continue;
114
+ }
115
+ if (answer.status !== 200) {
116
+ await drained(answer.body);
117
+ throw storeRefusal('download', answer.status);
118
+ }
119
+ await mkdir(dirname(destination), { recursive: true });
120
+ const partial = join(dirname(destination), `.${basename(destination)}.${randomUUID()}`);
121
+ const digest = createHash('sha256');
122
+ const hashed = new Transform({
123
+ transform(chunk, _encoding, done) {
124
+ digest.update(chunk);
125
+ done(null, chunk);
126
+ },
30
127
  });
31
- const text = await answer.text();
32
- if (!answer.ok)
33
- throw new HouseRefusal(upload.url, answer.status, text);
34
- return JSON.parse(text);
35
- });
128
+ try {
129
+ await pipeline(answer.body, hashed, createWriteStream(partial, { flags: 'wx' }));
130
+ if (digest.digest('hex') !== sha256)
131
+ throw new Error(`${name} did not arrive whole; download it again`);
132
+ await rename(partial, destination);
133
+ }
134
+ catch (error) {
135
+ await rm(partial, { force: true });
136
+ throw error;
137
+ }
138
+ return;
139
+ }
140
+ }
141
+ export async function fetched(url, signal) {
142
+ const answer = await fetch(relayed(url), { signal });
143
+ return { status: answer.status, body: answer.body ?? Readable.from([]) };
36
144
  }
package/dist/git.js CHANGED
@@ -1,16 +1,17 @@
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
- import { withoutProxy } from './clis.js';
5
5
  import { workspaceRoot } from './home.js';
6
+ import { childEnvironment } from './relay.js';
6
7
  export function localCopies() {
7
8
  return join(workspaceRoot(), 'local-copies');
8
9
  }
9
10
  export const HOUSE_REF = 'refs/house/received';
10
- const COMMITTER = 'House Kit <house-kit@localhost>';
11
+ const COMMITTER = 'Kit <house-kit@localhost>';
11
12
  export function run(cwd, args, input, env = {}) {
12
13
  return new Promise((resolve, reject) => {
13
- const child = spawn('git', args, { cwd, env: { ...withoutProxy(), ...env } });
14
+ const child = spawn('git', args, { cwd, env: { ...childEnvironment(), ...env } });
14
15
  const stdout = [];
15
16
  let stderr = '';
16
17
  child.stdout.on('data', (chunk) => stdout.push(chunk));
@@ -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');
package/dist/home.js CHANGED
@@ -1,13 +1,63 @@
1
- import { existsSync } 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';
2
3
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { selectRelay } from './relay.js';
5
7
  const ENROLMENT = 'credential.json';
6
8
  const DISCONNECTED = 'disconnected.json';
7
9
  const CONFIGURATION = 'kit.json';
8
10
  const OWN_AGENT = 'own-agent.json';
11
+ const RESTART = 'restart';
12
+ let defaulted;
13
+ function writable(directory) {
14
+ try {
15
+ accessSync(directory, constants.W_OK | constants.X_OK);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function privateDirectory(path) {
23
+ try {
24
+ mkdirSync(path, { mode: 0o700 });
25
+ }
26
+ catch (error) {
27
+ if (error.code !== 'EEXIST')
28
+ throw error;
29
+ }
30
+ const found = lstatSync(path);
31
+ if (found.isSymbolicLink())
32
+ throw new Error(`${path} is a symbolic link`);
33
+ if (!found.isDirectory())
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()}`));
42
+ }
43
+ function defaultHome() {
44
+ const home = join(homedir(), '.house-kit');
45
+ return existsSync(home) || writable(homedir()) ? home : ownTemporaryHome();
46
+ }
9
47
  export function kitHome() {
10
- return process.env.HOUSE_KIT_HOME ?? join(homedir(), '.house-kit');
48
+ return process.env.HOUSE_KIT_HOME || (defaulted ??= defaultHome());
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');
11
61
  }
12
62
  export function workspaceRoot() {
13
63
  return process.env.HOUSE_KIT_WORKSPACE ?? '/agents/house';
@@ -28,12 +78,15 @@ export async function readHome(name) {
28
78
  export async function writeHome(name, value) {
29
79
  const home = kitHome();
30
80
  await mkdir(home, { recursive: true, mode: 0o700 });
31
- const temporary = join(home, `${name}.${process.pid}`);
81
+ const temporary = join(home, `${name}.${randomUUID()}`);
32
82
  await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
33
83
  await rename(temporary, join(home, name));
34
84
  }
35
- export function readEnrolment() {
36
- return readHome(ENROLMENT);
85
+ export async function readEnrolment() {
86
+ const enrolment = await readHome(ENROLMENT);
87
+ if (enrolment !== null)
88
+ selectRelay(process.env.HOUSE_KIT_RELAY || enrolment.relay);
89
+ return enrolment;
37
90
  }
38
91
  export async function writeEnrolment(enrolment) {
39
92
  await writeHome(ENROLMENT, enrolment);
@@ -42,13 +95,49 @@ export async function writeEnrolment(enrolment) {
42
95
  export function readDisconnected() {
43
96
  return readHome(DISCONNECTED);
44
97
  }
98
+ export function forgetDisconnected() {
99
+ return rm(join(kitHome(), DISCONNECTED), { force: true });
100
+ }
45
101
  export async function forgetEnrolment({ house, environment }) {
46
102
  await writeHome(DISCONNECTED, { house, environment });
47
103
  await rm(join(kitHome(), ENROLMENT), { force: true });
48
104
  }
105
+ export async function forgetRejected(rejected) {
106
+ const held = await readHome(ENROLMENT);
107
+ if (held?.credential !== rejected)
108
+ return;
109
+ await writeHome(DISCONNECTED, { house: held.house, environment: held.environment });
110
+ const enrolment = join(kitHome(), ENROLMENT);
111
+ const taken = `${enrolment}.${process.pid}`;
112
+ try {
113
+ renameSync(enrolment, taken);
114
+ }
115
+ catch (error) {
116
+ if (error.code === 'ENOENT')
117
+ return;
118
+ throw error;
119
+ }
120
+ if (JSON.parse(readFileSync(taken, 'utf8')).credential !== rejected) {
121
+ try {
122
+ linkSync(taken, enrolment);
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'EEXIST')
126
+ throw error;
127
+ }
128
+ rmSync(join(kitHome(), DISCONNECTED), { force: true });
129
+ }
130
+ rmSync(taken);
131
+ }
49
132
  export function enrolled() {
50
133
  return existsSync(join(kitHome(), ENROLMENT));
51
134
  }
135
+ export function restartAsked() {
136
+ return existsSync(join(kitHome(), RESTART));
137
+ }
138
+ export function forgetRestart() {
139
+ return rm(join(kitHome(), RESTART), { force: true });
140
+ }
52
141
  export function readOwnAgent() {
53
142
  return readHome(OWN_AGENT);
54
143
  }