@agentshouse/kit 0.2.1-alpha.5 → 0.2.1-alpha.7

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.
package/README.md CHANGED
@@ -16,7 +16,10 @@ Environment's credential in `~/.house-kit`, or in `$HOUSE_KIT_HOME` when it is
16
16
  set. Pass `--manual` to paste the code by hand on a machine without a browser,
17
17
  and `--environment <id>` to reconnect an Environment you already have.
18
18
 
19
- `kit resident` keeps the Environment connected.
19
+ `kit resident` keeps the Environment connected, and keeps the Rooms you chose
20
+ for this Environment as Git repositories under `/agents/house/working-copies`.
21
+ Commit in one and run `house git push --owner` there to send the commit to
22
+ House; inside an Agent conversation the Agent runs `house git push`.
20
23
 
21
24
  ## License
22
25
 
package/dist/api.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { readEnrolment } from './home.js';
2
2
  export class HouseRefusal extends Error {
3
3
  status;
4
+ text;
4
5
  constructor(path, status, text) {
5
6
  super(`House refused ${path} with ${status}: ${text}`);
6
7
  this.status = status;
8
+ this.text = text;
7
9
  }
8
10
  }
9
11
  export async function post(house, path, body, credential, signal) {
package/dist/bridge.js CHANGED
@@ -1,22 +1,18 @@
1
- import { randomBytes, randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { once } from 'node:events';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { createServer, request as httpRequest } from 'node:http';
5
5
  import { request as httpsRequest } from 'node:https';
6
- import { join } from 'node:path';
6
+ import { delimiter, join } from 'node:path';
7
+ import { parse } from 'yaml';
7
8
  import { KIT_VERSION } from './clis.js';
9
+ import { changedPaths, refusalCode, } from './copies.js';
8
10
  import { kitHome, readEnrolment } from './home.js';
11
+ import { operationId } from './operation.js';
9
12
  const MCP_PROTOCOL_VERSION = '2026-07-28';
13
+ const MCP_REQUEST_BYTES = 4 * 1024 * 1024;
10
14
  const FORWARDED = new Set(['/', '/kit/attachments/upload']);
11
- function operationId() {
12
- const bytes = randomBytes(16);
13
- bytes.writeUIntBE(Date.now(), 0, 6);
14
- bytes[6] = 0x70 | (bytes[6] & 0x0f);
15
- bytes[8] = 0x80 | (bytes[8] & 0x3f);
16
- const hex = bytes.toString('hex');
17
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
18
- }
19
- async function received(request) {
15
+ export async function received(request) {
20
16
  let text = '';
21
17
  request.setEncoding('utf8');
22
18
  for await (const chunk of request)
@@ -35,46 +31,129 @@ function relay(request, response, target, authorization) {
35
31
  forwarded.on('error', () => response.destroy());
36
32
  request.pipe(forwarded);
37
33
  }
38
- export async function openBridge(house, conversationId, signal) {
34
+ function answered(forwarded) {
35
+ if (forwarded.status >= 500)
36
+ throw new Error(`House answered ${forwarded.status}`);
37
+ if (forwarded.status !== 200)
38
+ return { refused: { code: refusalCode(forwarded.text), text: forwarded.text } };
39
+ const answer = JSON.parse(forwarded.text);
40
+ if (answer.error !== undefined)
41
+ return { refused: { code: refusalCode(answer.error.message), text: answer.error.message } };
42
+ const text = answer.result.content.map((content) => content.text).join('\n');
43
+ return answer.result.isError === true ? { refused: { code: refusalCode(text), text } } : { text };
44
+ }
45
+ function editAnswer(forwarded) {
46
+ const answer = answered(forwarded);
47
+ if ('refused' in answer)
48
+ return answer;
49
+ const { results } = parse(answer.text);
50
+ return {
51
+ accepted: results.flatMap((result) => [
52
+ ...(result.from === undefined ? [] : [{ path: result.from, revision: null, content: null }]),
53
+ { path: result.path, revision: result.op === 'remove' ? null : result.revision },
54
+ ]),
55
+ };
56
+ }
57
+ export async function openBridge(house, copies, conversationId, signal) {
39
58
  const origin = (await readEnrolment()).house;
40
59
  const { credential } = await house.post(`/kit/conversations/${conversationId}/credential`, {}, signal);
41
60
  const closed = new AbortController();
42
- const mcp = async (message) => {
43
- const params = message.params ?? {};
44
- const tool = message.method === 'tools/call' ? String(params.name) : null;
45
- const writes = tool !== null &&
46
- (await house.post('/kit/tools/mutations', {}, closed.signal)).tools.some((listed) => listed.name === tool && listed.writes);
47
- return {
61
+ const body = (message, operation) => JSON.stringify({
62
+ ...message,
63
+ params: {
64
+ ...message.params,
65
+ _meta: {
66
+ 'io.modelcontextprotocol/protocolVersion': MCP_PROTOCOL_VERSION,
67
+ 'io.modelcontextprotocol/clientInfo': { name: '@agentshouse/kit', version: KIT_VERSION },
68
+ 'io.modelcontextprotocol/clientCapabilities': {},
69
+ ...(operation === null ? {} : { 'agents.house/agent-operation': operation }),
70
+ },
71
+ },
72
+ });
73
+ const send = async (message, operation) => {
74
+ const tool = message.method === 'tools/call' ? String(message.params?.name) : null;
75
+ const answer = await fetch(new URL('/', origin), {
76
+ method: 'POST',
48
77
  headers: {
78
+ 'content-type': 'application/json',
79
+ authorization: `Bearer ${credential}`,
49
80
  accept: 'application/json, text/event-stream',
50
81
  'mcp-protocol-version': MCP_PROTOCOL_VERSION,
51
82
  'mcp-method': message.method,
52
83
  ...(tool === null ? {} : { 'mcp-name': tool }),
53
84
  },
54
- body: {
55
- ...message,
56
- params: {
57
- ...params,
58
- _meta: {
59
- 'io.modelcontextprotocol/protocolVersion': MCP_PROTOCOL_VERSION,
60
- 'io.modelcontextprotocol/clientInfo': { name: '@agentshouse/kit', version: KIT_VERSION },
61
- 'io.modelcontextprotocol/clientCapabilities': {},
62
- ...(writes ? { 'agents.house/agent-operation': operationId() } : {}),
63
- },
64
- },
65
- },
66
- };
85
+ body: body(message, operation),
86
+ signal: closed.signal,
87
+ });
88
+ return { status: answer.status, text: await answer.text() };
89
+ };
90
+ const call = (name, args) => ({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } });
91
+ const staged = async (operation, changes) => {
92
+ const bytes = Buffer.from(JSON.stringify({ changes }));
93
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
94
+ const prepared = answered(await send(call('run_command', {
95
+ command: 'prepare_edit_upload',
96
+ arguments: { paths: changedPaths(changes), bytes: bytes.length, sha256 },
97
+ }), operation));
98
+ if ('refused' in prepared)
99
+ return prepared;
100
+ const { transfer } = parse(prepared.text);
101
+ const posted = await fetch(transfer.url, {
102
+ method: 'POST',
103
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
104
+ body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
105
+ signal: closed.signal,
106
+ });
107
+ const text = await posted.text();
108
+ if (posted.status >= 500)
109
+ throw new Error(`House answered ${posted.status}`);
110
+ if (!posted.ok)
111
+ return { refused: { code: refusalCode(text), text } };
112
+ const held = JSON.parse(text).transfer;
113
+ return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
67
114
  };
68
115
  const forward = async (path, text) => {
69
- const call = path === '/' ? await mcp(JSON.parse(text)) : null;
116
+ if (path === '/') {
117
+ const message = JSON.parse(text);
118
+ const tool = message.method === 'tools/call' ? String(message.params?.name) : null;
119
+ const writes = tool !== null &&
120
+ (await house.post('/kit/tools/mutations', {}, closed.signal)).tools.some((listed) => listed.name === tool && listed.writes);
121
+ return send(message, writes ? operationId() : null);
122
+ }
70
123
  const answer = await fetch(new URL(path, origin), {
71
124
  method: 'POST',
72
- headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}`, ...call?.headers },
73
- body: call === null ? text : JSON.stringify(call.body),
125
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${credential}` },
126
+ body: text,
74
127
  signal: closed.signal,
75
128
  });
76
129
  return { status: answer.status, text: await answer.text() };
77
130
  };
131
+ const caller = {
132
+ key: `conversation:${conversationId}`,
133
+ submit: async (operation, changes, held, retain) => {
134
+ const inline = call('edit', { changes });
135
+ let upload = held;
136
+ if (upload === undefined && Buffer.byteLength(body(inline, operation)) > MCP_REQUEST_BYTES) {
137
+ const prepared = await staged(operation, changes);
138
+ if ('refused' in prepared)
139
+ return prepared;
140
+ upload = prepared;
141
+ await retain(upload);
142
+ }
143
+ return editAnswer(await send(upload === undefined ? inline : call('edit', { paths: changedPaths(changes), upload }), operation));
144
+ },
145
+ };
146
+ const local = async (path, text) => {
147
+ if (path === '/git/push') {
148
+ const { cwd, commit } = JSON.parse(text);
149
+ return { status: 200, text: JSON.stringify(await copies.push(cwd, commit, caller)) };
150
+ }
151
+ if (path === '/git/observed') {
152
+ await copies.observed(JSON.parse(text));
153
+ return { status: 200, text: '{}' };
154
+ }
155
+ return FORWARDED.has(path) ? forward(path, text) : { status: 404, text: '' };
156
+ };
78
157
  const sockets = join(kitHome(), 'bridges');
79
158
  await mkdir(sockets, { recursive: true, mode: 0o700 });
80
159
  const socketPath = join(sockets, `${randomUUID()}.sock`);
@@ -85,8 +164,8 @@ export async function openBridge(house, conversationId, signal) {
85
164
  relay(request, response, new URL(path));
86
165
  return;
87
166
  }
88
- const forwarded = FORWARDED.has(path) ? await forward(path, await received(request)) : { status: 404, text: '' };
89
- response.writeHead(forwarded.status, { 'content-type': 'application/json' }).end(forwarded.text);
167
+ const answered = await local(path, await received(request));
168
+ response.writeHead(answered.status, { 'content-type': 'application/json' }).end(answered.text);
90
169
  }
91
170
  catch (error) {
92
171
  response.writeHead(502).end(error instanceof Error ? error.message : String(error));
@@ -110,6 +189,7 @@ export async function openBridge(house, conversationId, signal) {
110
189
  return {
111
190
  env: {
112
191
  HOUSE_BRIDGE: socketPath,
192
+ PATH: `${join(kitHome(), 'shim')}${delimiter}${process.env.PATH ?? ''}`,
113
193
  GIT_CONFIG_COUNT: '1',
114
194
  GIT_CONFIG_KEY_0: `url.${proxy}/app/.insteadOf`,
115
195
  GIT_CONFIG_VALUE_0: app,
@@ -174,7 +174,10 @@ export class Conversations {
174
174
  ack = { provider_session_id: opened };
175
175
  }
176
176
  if (input.first === true) {
177
- prompt.push({ type: 'text', text: await instructions(conversation.running.bridge, route.base_instructions) });
177
+ prompt.push({
178
+ type: 'text',
179
+ text: await instructions(conversation.running.bridge, route.base_instructions, await this.kit.copies.mapping()),
180
+ });
178
181
  }
179
182
  prompt.push({ type: 'text', text: String(input.text) });
180
183
  if (paths.length > 0)
@@ -247,7 +250,7 @@ export class Conversations {
247
250
  .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
248
251
  .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
249
252
  await unlessKilled(this.kit.agents.installed(), conversation.killed.signal);
250
- const bridge = await openBridge(this.kit.house, conversation.id, conversation.killed.signal);
253
+ const bridge = await openBridge(this.kit.house, this.kit.copies, conversation.id, conversation.killed.signal);
251
254
  if (conversation.kills > 0) {
252
255
  bridge.close();
253
256
  throw new Error(KILLED);