@agentshouse/kit 0.1.0-alpha.22 → 0.1.0-alpha.24

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
@@ -10,7 +10,7 @@ On Linux `amd64` or `arm64` with a working Docker Engine, run the published,
10
10
  versioned bootstrap once:
11
11
 
12
12
  ```sh
13
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.22/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.24/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -781,7 +781,18 @@ Kit reads what the Session was started for from House's
781
781
  its input, references, requester and the pinned revision's instruction, or the
782
782
  sealed conversation turn. It sends that as the first prompt block, followed by
783
783
  current House guidance fetched through the Run connection, so the Agent calls
784
- nothing to learn it. An unreadable request or guidance refuses the turn. It forwards only adapter-classified User-visible assistant
784
+ nothing to learn it. An unreadable request or guidance refuses the turn.
785
+ A session opened with `session/new` gets one more text block ahead of those,
786
+ the same for every adapter kind: the line
787
+ `The agents.house house CLI is available: run house --help.`, the base
788
+ instructions of the Execution attempt plan House acquired, and the current text
789
+ of the owner's How-we-work document `/private/library/how-we-work.md`, which the
790
+ launcher reads with the `shell` tool over the Session's own bridge, following
791
+ House's cursor to the whole text. An absent document, one the Session's ceiling
792
+ cannot read, and empty base instructions add nothing. Any other failed read
793
+ refuses the attempt as `kit_how_we_work_unreadable` with the bridge's cause
794
+ before a prompt is sent. A session reloaded with `session/load` already holds
795
+ the block and gets none. It forwards only adapter-classified User-visible assistant
785
796
  text back over the local channel, and leaves reasoning, tool traces, and
786
797
  terminal output where they are. The supervised process's own House calls travel
787
798
  that same channel through the `house` CLI. The Kit holds the WorkerRunToken, replaces it in place when
@@ -1312,6 +1323,19 @@ control lease when a resident runs, and which otherwise acquires and releases
1312
1323
  its own. A Session refuses `--owner`, and a missing bridge is never replaced by
1313
1324
  owner authority.
1314
1325
 
1326
+ A Session's edit too large for one MCP request of 4 MiB goes through House's
1327
+ `prepare_edit_upload` Command on that Session's bridge, naming every changed
1328
+ path, the byte count and the SHA-256 of the bytes to upload under the push's
1329
+ operation. Those bytes are the serialized changes, padded with trailing spaces
1330
+ to one byte over 4 MiB when only the request around them passed the bound,
1331
+ because House takes no smaller upload. Kit sends them to the upload address
1332
+ House returns and then calls `edit` with the paths and the returned upload
1333
+ instead of the changes, so House applies them as the same one edit. The upload
1334
+ is recorded in Kit home before the edit is sent, so a push whose answer was lost
1335
+ resends that same edit; when House no longer holds the uploaded bytes and has
1336
+ not applied the edit, the push uploads them again once under the same operation.
1337
+ A change set beyond House's batch bounds is refused whole and never split.
1338
+
1315
1339
  Inside an Agent Session `git` is Kit's shim in front of the installed Git, and
1316
1340
  every command runs exactly as that Git runs it. In a copy or one of its linked
1317
1341
  worktrees the shim compares that worktree's index before and after each
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.24",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.22/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.24/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.22/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.24/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.24",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,10 @@
1
- import { COMMAND_ADAPTER_INITIALIZE_TIMEOUT_MS, MCP_PROTOCOL_VERSION } from "./contract.js";
2
- import { bridgeCall } from "./mcp.js";
1
+ import {
2
+ COMMAND_ADAPTER_INITIALIZE_TIMEOUT_MS,
3
+ HOW_WE_WORK_PATH,
4
+ MCP_PROTOCOL_VERSION,
5
+ SHELL_RENDER,
6
+ } from "./contract.js";
7
+ import { answerText, bridgeCall } from "./mcp.js";
3
8
  import { KitRefusal } from "./refusal.js";
4
9
 
5
10
  function readableDiscovery(discovery) {
@@ -29,3 +34,53 @@ export async function commandHouseInstructions(bridge) {
29
34
  }
30
35
  return frame.result.instructions;
31
36
  }
37
+
38
+ function shellAnswer(text) {
39
+ const lines = text.split("\n");
40
+ if (!lines[0].startsWith(`protocol: render=${SHELL_RENDER} `)) return null;
41
+ const exit = Number(lines[2].slice("exit: ".length));
42
+ let at = lines[5]?.startsWith("authority: ") ? 6 : 5;
43
+ if (exit === 0) return { exit, stderr: [], stdout: lines.slice(at).join("\n"), cursor: null };
44
+ const stderr = [];
45
+ while (lines[at]?.startsWith("stderr: ")) stderr.push(lines[at++].slice("stderr: ".length));
46
+ const stdout = lines.slice(at);
47
+ const cursor = stdout.at(-1)?.startsWith("cursor: ") ? stdout.pop().slice("cursor: ".length) : null;
48
+ return { exit, stderr, stdout: stdout.join("\n"), cursor };
49
+ }
50
+
51
+ async function howWeWorkPage(bridge, cursor) {
52
+ const frame = await bridgeCall(bridge, {
53
+ method: "tools/call",
54
+ params: {
55
+ name: "shell",
56
+ arguments: { command: `cat ${HOW_WE_WORK_PATH}`, ...(cursor === null ? {} : { cursor }) },
57
+ },
58
+ });
59
+ if (frame.error !== undefined) {
60
+ throw new KitRefusal("kit_how_we_work_unreadable", frame.error.message ?? null);
61
+ }
62
+ const text = answerText(frame.result);
63
+ if (frame.result?.isError === true) {
64
+ if (text.split(":")[0] === "room_not_found") return null;
65
+ throw new KitRefusal("kit_how_we_work_unreadable", text);
66
+ }
67
+ const page = shellAnswer(text);
68
+ if (page === null) throw new KitRefusal("kit_how_we_work_unreadable", text);
69
+ if (page.cursor === null && page.exit !== 0) {
70
+ if (page.stderr.length === 1 && page.stderr[0].split(" ")[1] === "path_not_found") return null;
71
+ throw new KitRefusal("kit_how_we_work_unreadable", page.stderr.join("\n"));
72
+ }
73
+ return page;
74
+ }
75
+
76
+ export async function commandHouseHowWeWork(bridge) {
77
+ const pages = [];
78
+ let cursor = null;
79
+ do {
80
+ const page = await howWeWorkPage(bridge, cursor);
81
+ if (page === null) return "";
82
+ pages.push(page.stdout);
83
+ cursor = page.cursor;
84
+ } while (cursor !== null);
85
+ return pages.join("\n");
86
+ }
package/src/bytes.js CHANGED
@@ -31,6 +31,11 @@ async function downloadBody(response, signal) {
31
31
  return Buffer.concat(chunks, size);
32
32
  }
33
33
 
34
+ function framedBody(request) {
35
+ if (request.method !== "POST" || !Buffer.isBuffer(request.body)) throw new KitRefusal("kit_byte_body_invalid");
36
+ return Buffer.concat([Buffer.from(`${request.operation}\n`, "utf8"), request.body]);
37
+ }
38
+
34
39
  export async function transferBytes(request, dependencies = {}) {
35
40
  if (request.method !== "GET" && request.method !== "POST") {
36
41
  throw new KitRefusal("kit_byte_method_invalid", request.method);
@@ -41,23 +46,27 @@ export async function transferBytes(request, dependencies = {}) {
41
46
  if (typeof request.operation !== "string" || request.operation.length === 0) {
42
47
  throw new KitRefusal("kit_byte_operation_invalid");
43
48
  }
49
+ const framed = request.framed === true;
50
+ const body = framed ? framedBody(request) : request.body;
44
51
  if (request.method === "POST") {
45
- if (!Buffer.isBuffer(request.body)) throw new KitRefusal("kit_byte_body_invalid");
46
- if (request.body.length > BYTE_GRANT_POST_BYTES_MAX) {
52
+ if (!Buffer.isBuffer(body)) throw new KitRefusal("kit_byte_body_invalid");
53
+ if (body.length > BYTE_GRANT_POST_BYTES_MAX) {
47
54
  throw new KitRefusal("original_bytes_exceeded");
48
55
  }
49
56
  }
50
57
  const fetcher = dependencies.fetch ?? fetch;
51
- const headers = {
52
- [BYTE_GRANT_OPERATION_HEADER]: request.operation,
53
- };
54
- if (request.method === "POST") headers["content-type"] = "application/octet-stream";
58
+ const headers = framed
59
+ ? { "content-type": "text/plain; charset=utf-8" }
60
+ : {
61
+ [BYTE_GRANT_OPERATION_HEADER]: request.operation,
62
+ ...(request.method === "POST" ? { "content-type": "application/octet-stream" } : {}),
63
+ };
55
64
  let response;
56
65
  try {
57
66
  response = await fetcher(request.url, {
58
67
  method: request.method,
59
68
  headers,
60
- body: request.method === "POST" ? request.body : undefined,
69
+ body: request.method === "POST" ? body : undefined,
61
70
  redirect: "manual",
62
71
  signal: request.signal,
63
72
  });
package/src/contract.js CHANGED
@@ -118,6 +118,10 @@ export const BRIDGE_NONCE_ENVIRONMENT = "HOUSE_KIT_BRIDGE_NONCE";
118
118
  export const BRIDGE_RECONNECT_MS = 200;
119
119
  export const BRIDGE_RECONNECT_MAX_MS = 5_000;
120
120
 
121
+ export const HOUSE_CLI_LINE = "The agents.house house CLI is available: run house --help.";
122
+ export const HOW_WE_WORK_PATH = "/private/library/how-we-work.md";
123
+ export const SHELL_RENDER = "shell/1";
124
+
121
125
  export const ACP_PROTOCOL = 1;
122
126
  export const ACP_AUTH_REQUIRED = -32000;
123
127
  export const ADAPTER_EXIT_GRACE_MS = 2_000;
@@ -308,6 +312,8 @@ export const WORKING_COPY_PUSH_METHOD = "house/git/push";
308
312
  export const WORKING_COPY_INDEX_METHOD = "house/git/index";
309
313
  export const WORKING_COPY_PUSH_HELP =
310
314
  "Submit a Room commit to House; default HEAD. Use --owner only outside an Agent Session. Native git push is not a House remote.";
315
+ export const WORKING_COPY_UPLOAD_COMMAND = "prepare_edit_upload";
316
+ export const MCP_REQUEST_BODY_MAX_BYTES = 4 * 1024 * 1024;
311
317
 
312
318
  export const MIGRATOR_CONTRACT = "kit-migrator/1";
313
319
  export const MIGRATOR_CATEGORY = "migrator";
package/src/house.js CHANGED
@@ -855,9 +855,13 @@ function acquiredAssignment(value, declaration) {
855
855
  if (Number.isNaN(Date.parse(expiresAt))) {
856
856
  envelope.refuse(`${declaration}.session.bridge_token_expires_at`);
857
857
  }
858
+ if (typeof plan.base_instructions !== "string") {
859
+ envelope.refuse(`${declaration}.plan.base_instructions`);
860
+ }
858
861
  return {
859
862
  run: envelope.text(acquired.run, `${declaration}.run`, 128),
860
863
  agentId: envelope.text(plan.agent_id, `${declaration}.plan.agent_id`, 128),
864
+ baseInstructions: plan.base_instructions,
861
865
  workingDirectory: envelope.text(
862
866
  plan.working_directory,
863
867
  `${declaration}.plan.working_directory`,
package/src/launcher.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { commandHouseInstructions } from "./adapter-mcp.js";
2
+ import { commandHouseHowWeWork, commandHouseInstructions } from "./adapter-mcp.js";
3
3
  import {
4
4
  compiledContract,
5
5
  initializeCommandAdapter,
@@ -7,7 +7,7 @@ import {
7
7
  startCommandAdapter,
8
8
  stopCommandAdapter,
9
9
  } from "./adapter.js";
10
- import { PERMISSION_OPTION_KINDS, SESSION_SET_CONFIG_OPTION } from "./contract.js";
10
+ import { HOUSE_CLI_LINE, PERMISSION_OPTION_KINDS, SESSION_SET_CONFIG_OPTION } from "./contract.js";
11
11
  import { observedAssistantDrafts } from "./draft.js";
12
12
  import { observedPlans } from "./plan.js";
13
13
  import { bridgePeer } from "./peer.js";
@@ -129,6 +129,11 @@ async function configureSession(acp, contract, sessionId, adapter) {
129
129
  }
130
130
  }
131
131
 
132
+ async function sessionOpening(bridge, baseInstructions) {
133
+ const parts = [HOUSE_CLI_LINE, baseInstructions, await commandHouseHowWeWork(bridge)];
134
+ return [{ type: "text", text: parts.filter((part) => part.trim() !== "").join("\n\n") }];
135
+ }
136
+
132
137
  function workingCopyContext(copies) {
133
138
  if (!Array.isArray(copies) || copies.length === 0) return [];
134
139
  const lines = copies.map((copy) => `${copy.path}: ${copy.repository}`);
@@ -179,6 +184,8 @@ async function runTurn(channel, start, bridge, turn) {
179
184
  const { acp } = await initializeCommandAdapter(adapter, contract, handlers);
180
185
  const instructions = await commandHouseInstructions(bridge);
181
186
  let providerSessionId = start.provider_session ?? null;
187
+ const opening =
188
+ providerSessionId === null ? await sessionOpening(bridge, start.base_instructions) : [];
182
189
  const setup = { cwd: start.working_directory, mcpServers: [] };
183
190
  if (providerSessionId === null) {
184
191
  providerSessionId = openedSession(await acp.request("session/new", setup));
@@ -196,6 +203,7 @@ async function runTurn(channel, start, bridge, turn) {
196
203
  const prompted = await acp.request("session/prompt", {
197
204
  sessionId: providerSessionId,
198
205
  prompt: [
206
+ ...opening,
199
207
  { type: "text", text: JSON.stringify(start.request) },
200
208
  { type: "text", text: instructions },
201
209
  ...workingCopyContext(start.working_copies),
package/src/worker.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
@@ -11,6 +11,7 @@ import {
11
11
  import { readBindings, removeBinding, writeBinding } from "./binding.js";
12
12
  import { readContinuation, readContinuationRuns, writeContinuation, removeContinuation } from "./continuation.js";
13
13
  import { bridgedFrame, openBridge } from "./bridge.js";
14
+ import { transferBytes } from "./bytes.js";
14
15
  import { assistantBlocks } from "./blocks.js";
15
16
  import { retryConversationFiles } from "./conversation-originals.js";
16
17
  import { openAssistantDrafts } from "./draft.js";
@@ -25,6 +26,7 @@ import {
25
26
  ownerCaller,
26
27
  pushWorkingCopy,
27
28
  recordIndexChange,
29
+ uploadedPaths,
28
30
  workingCopyPaths,
29
31
  } from "./working-copy.js";
30
32
  import { readConfiguration } from "./configuration.js";
@@ -32,6 +34,7 @@ import { launchBase, readyLaunchDirectory } from "./directory.js";
32
34
  import {
33
35
  BRIDGE_NONCE_ENVIRONMENT,
34
36
  BRIDGE_SOCKET_ENVIRONMENT,
37
+ MCP_REQUEST_BODY_MAX_BYTES,
35
38
  PROVIDER_CONTINUATION_UNAVAILABLE,
36
39
  WORKER_COMMAND_BATCH_MAX,
37
40
  WORKER_COMMAND_POLL_MS,
@@ -42,6 +45,7 @@ import {
42
45
  WORKER_SUPERVISION_POLL_MS,
43
46
  WORKING_COPY_INDEX_METHOD,
44
47
  WORKING_COPY_PUSH_METHOD,
48
+ WORKING_COPY_UPLOAD_COMMAND,
45
49
  } from "./contract.js";
46
50
  import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
47
51
  import {
@@ -299,20 +303,61 @@ function acceptedWith(text) {
299
303
  return { refused: null, written: [...written.values()] };
300
304
  }
301
305
 
306
+ function toolFrame(name, args, operation) {
307
+ return bridgedFrame({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, operation);
308
+ }
309
+
310
+ function uploadBody(changes) {
311
+ const body = Buffer.from(JSON.stringify({ changes }), "utf8");
312
+ if (body.length > MCP_REQUEST_BODY_MAX_BYTES) return body;
313
+ return Buffer.concat([body, Buffer.alloc(MCP_REQUEST_BODY_MAX_BYTES + 1 - body.length, " ")]);
314
+ }
315
+
302
316
  async function sessionPush(context, bound, authority, frame, signal) {
303
- const edit = async (body, sent) => {
304
- const answer = await callHouseMcp(
305
- context.session,
306
- await sessionAuthority(context.session, bound.session, authority, sent),
307
- bridgedFrame(
308
- { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "edit", arguments: { changes: body.changes } } },
309
- body.operation_id,
310
- ),
317
+ const call = async (sent, called) =>
318
+ callHouseMcp(context.session, await sessionAuthority(context.session, bound.session, authority, sent), called, sent);
319
+ const refused = (answer) =>
320
+ answer.error !== undefined
321
+ ? refusedWith(answer.error.message ?? "kit_house_refused")
322
+ : answer.result?.isError === true
323
+ ? refusedWith(answerText(answer.result))
324
+ : null;
325
+ const stage = async (body, sent) => {
326
+ const bytes = uploadBody(body.changes);
327
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
328
+ const prepare = { paths: uploadedPaths(body.changes), bytes: bytes.length, sha256 };
329
+ const prepared = await call(
330
+ sent,
331
+ toolFrame("run_command", { command: WORKING_COPY_UPLOAD_COMMAND, arguments: prepare }, body.operation_id),
332
+ );
333
+ const refusal = refused(prepared);
334
+ if (refusal !== null) return refusal;
335
+ const transfer = answerYaml(answerText(prepared.result))?.transfer;
336
+ const staged = (
337
+ await transferBytes(
338
+ { url: transfer?.url, method: "POST", operation: transfer?.operation, body: bytes, framed: true, signal: sent },
339
+ context.session.dependencies,
340
+ )
341
+ ).transfer;
342
+ if (typeof staged?.url !== "string" || typeof staged.operation !== "string") {
343
+ throw new KitRefusal("kit_house_answer_unreadable", WORKING_COPY_UPLOAD_COMMAND);
344
+ }
345
+ return { upload: { url: staged.url, operation: staged.operation, bytes: bytes.length, sha256 } };
346
+ };
347
+ const edit = async (body, sent, retain) => {
348
+ let { upload } = body;
349
+ const inline = toolFrame("edit", { changes: body.changes }, body.operation_id);
350
+ if (upload === undefined && Buffer.byteLength(JSON.stringify(inline)) > MCP_REQUEST_BODY_MAX_BYTES) {
351
+ const staged = await stage(body, sent);
352
+ if (staged.upload === undefined) return staged;
353
+ ({ upload } = staged);
354
+ await retain(upload);
355
+ }
356
+ const answer = await call(
311
357
  sent,
358
+ upload === undefined ? inline : toolFrame("edit", { paths: uploadedPaths(body.changes), upload }, body.operation_id),
312
359
  );
313
- if (answer.error !== undefined) return refusedWith(answer.error.message ?? "kit_house_refused");
314
- const text = answerText(answer.result);
315
- return answer.result?.isError === true ? refusedWith(text) : acceptedWith(text);
360
+ return refused(answer) ?? acceptedWith(answerText(answer.result));
316
361
  };
317
362
  let outcome;
318
363
  try {
@@ -530,6 +575,7 @@ async function prepareAssignment(context, assignment, resumed) {
530
575
  };
531
576
  const authority = { token: assignment.session.token, expiresAt: assignment.session.expiresAt };
532
577
  const start = {
578
+ base_instructions: assignment.baseInstructions,
533
579
  working_directory: assignment.workingDirectory,
534
580
  working_copies: await appliedWorkingCopies(context),
535
581
  execution_authority: context.configuration.executionAuthority,
@@ -914,6 +914,10 @@ function pushedPaths(changes) {
914
914
  return new Set(changes.flatMap((change) => (change.op === "rename" ? [change.path, change.to] : [change.path])));
915
915
  }
916
916
 
917
+ export function uploadedPaths(changes) {
918
+ return [...pushedPaths(changes)].sort();
919
+ }
920
+
917
921
  async function enumerated(context, copy, since, signal) {
918
922
  const answer = await door(
919
923
  context.session,
@@ -1104,12 +1108,18 @@ async function expired(context, copy, text, signal) {
1104
1108
  return finish(context, accepted, signal);
1105
1109
  }
1106
1110
 
1107
- async function settle(context, copy, caller, signal) {
1111
+ async function settle(context, copy, caller, signal, restaged = false) {
1108
1112
  const { push } = copy;
1109
1113
  if (!sameCaller(push.caller, caller.binding)) return orphaned(context, copy, signal);
1114
+ let staged = push.upload !== undefined;
1115
+ const retain = async (upload) => {
1116
+ staged = true;
1117
+ await writeCopy(context.directory, { ...copy, push: { ...push, upload } }, signal);
1118
+ };
1110
1119
  let answer;
1111
1120
  try {
1112
- answer = await caller.submit({ operation_id: push.operation_id, changes: push.changes }, signal);
1121
+ const { operation_id, changes, upload } = push;
1122
+ answer = await caller.submit({ operation_id, changes, ...(upload === undefined ? {} : { upload }) }, signal, retain);
1113
1123
  } catch (error) {
1114
1124
  if (!definite(error)) {
1115
1125
  throw new KitRefusal("kit_working_copy_push_uncertain", "run house git push again to recover its outcome");
@@ -1118,6 +1128,12 @@ async function settle(context, copy, caller, signal) {
1118
1128
  answer = { refused: { code: error.code, text: formatCommandError(error), current: Array.isArray(current) ? current : null } };
1119
1129
  }
1120
1130
  const { refused } = answer;
1131
+ if (refused?.code === "upload_unavailable" && staged && !restaged) {
1132
+ const { upload, ...unstaged } = push;
1133
+ const cleared = { ...copy, push: unstaged };
1134
+ await writeCopy(context.directory, cleared, signal);
1135
+ return settle(context, cleared, caller, signal, true);
1136
+ }
1121
1137
  if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
1122
1138
  if (refused === null) {
1123
1139
  const pushed = pushedPaths(push.changes);