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

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.23/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -1312,6 +1312,19 @@ control lease when a resident runs, and which otherwise acquires and releases
1312
1312
  its own. A Session refuses `--owner`, and a missing bridge is never replaced by
1313
1313
  owner authority.
1314
1314
 
1315
+ A Session's edit too large for one MCP request of 4 MiB goes through House's
1316
+ `prepare_edit_upload` Command on that Session's bridge, naming every changed
1317
+ path, the byte count and the SHA-256 of the bytes to upload under the push's
1318
+ operation. Those bytes are the serialized changes, padded with trailing spaces
1319
+ to one byte over 4 MiB when only the request around them passed the bound,
1320
+ because House takes no smaller upload. Kit sends them to the upload address
1321
+ House returns and then calls `edit` with the paths and the returned upload
1322
+ instead of the changes, so House applies them as the same one edit. The upload
1323
+ is recorded in Kit home before the edit is sent, so a push whose answer was lost
1324
+ resends that same edit; when House no longer holds the uploaded bytes and has
1325
+ not applied the edit, the push uploads them again once under the same operation.
1326
+ A change set beyond House's batch bounds is refused whole and never split.
1327
+
1315
1328
  Inside an Agent Session `git` is Kit's shim in front of the installed Git, and
1316
1329
  every command runs exactly as that Git runs it. In a copy or one of its linked
1317
1330
  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.23",
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.23/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.23/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.23",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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
@@ -308,6 +308,8 @@ export const WORKING_COPY_PUSH_METHOD = "house/git/push";
308
308
  export const WORKING_COPY_INDEX_METHOD = "house/git/index";
309
309
  export const WORKING_COPY_PUSH_HELP =
310
310
  "Submit a Room commit to House; default HEAD. Use --owner only outside an Agent Session. Native git push is not a House remote.";
311
+ export const WORKING_COPY_UPLOAD_COMMAND = "prepare_edit_upload";
312
+ export const MCP_REQUEST_BODY_MAX_BYTES = 4 * 1024 * 1024;
311
313
 
312
314
  export const MIGRATOR_CONTRACT = "kit-migrator/1";
313
315
  export const MIGRATOR_CATEGORY = "migrator";
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 {
@@ -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);