@agentshouse/kit 0.1.0-alpha.17 → 0.1.0-alpha.19

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.17/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.19/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -105,6 +105,14 @@ Forwarding installs no host-native Kit and gives the container no Docker socket
105
105
  or further mount; it refuses when no Kit is installed or the installed one
106
106
  differs from this bootstrap's.
107
107
 
108
+ The owner's working copy push is forwarded the same way: run
109
+ `bash <(curl -fsSL …/connect-linux.sh) house git push [commit] --owner` from a
110
+ directory beneath the workspace root, such as a Room copy under
111
+ `working-copies/rooms/`. The bootstrap runs the container's `house` in the
112
+ matching directory beneath `/agents/house`, through the resident's lease when
113
+ the container runs, and refuses a directory outside the workspace before
114
+ anything reaches House.
115
+
108
116
  ## Building the package
109
117
 
110
118
  The repository checkout is the development source. Build the package
@@ -829,7 +837,9 @@ nonzero exit status. The CLI holds
829
837
  no credential: it calls over its Session's bridge, which attaches the Session
830
838
  credential and refuses a caller without that Session's nonce as
831
839
  `kit_bridge_unauthorized`. House network calls use only the selected
832
- `2026-07-28` profile.
840
+ `2026-07-28` profile. `house git push` is the one local verb House does not list;
841
+ [working copies](#working-copies) describe it, and `house --help` and
842
+ `house git push --help` explain it.
833
843
 
834
844
  Two verbs name files on the Session's own computer: `upload_attachment` takes
835
845
  one `path`, and `append_record` takes `attachments` as local paths. The CLI
@@ -1248,14 +1258,54 @@ current grant. Every other file is read-only House content: the generated
1248
1258
  grants anything, and House refuses a change to a read-only path however it is
1249
1259
  committed.
1250
1260
 
1251
- Local edits, staging and commits change nothing in House. `refs/house/received`
1252
- records the House content last received. The per-file manifest, with each
1253
- file's House revision, digest and writability, and the commit it corresponds to
1254
- stay in Kit home under `state/working-copies/`, outside the repository. A local
1255
- commit is a local tree, never a House revision.
1261
+ Local edits, staging and commits change nothing in House until a commit is
1262
+ pushed. `refs/house/received` records the House content last received; each
1263
+ commit Kit wrote there, and each commit it acknowledged for a push, is a House
1264
+ state. The House revision of every file content Kit received, keyed by path and
1265
+ Git blob, stays in Kit home under `state/working-copies/`, outside the
1266
+ repository. A local commit is a local tree, never a House revision.
1267
+
1268
+ `house git push [commit]`, run anywhere inside a copy or one of its linked
1269
+ worktrees, submits one commit, `HEAD` by default, resolved once. Kit compares
1270
+ that commit's tree with the newest House state in its history, so every linked
1271
+ worktree keeps its own base, and sends the difference as one ordinary House edit:
1272
+ a changed field, section or preamble against its own prior value, any other
1273
+ change as the whole file against its exact revision, a new file as a create and
1274
+ a committed deletion as a removal. Staged and unstaged work, and files absent
1275
+ only from the working tree, are never sent. Native `git push` reaches no House
1276
+ remote. Inside an Agent Session the push goes through that Session's bridge,
1277
+ under its own authority and provenance; outside one it needs
1278
+ `house git push [commit] --owner`, which the resident Kit carries out under its
1279
+ control lease when a resident runs, and which otherwise acquires and releases
1280
+ its own. A Session refuses `--owner`, and a missing bridge is never replaced by
1281
+ owner authority.
1282
+
1283
+ House decides before any local Git step. A refusal changes nothing in House;
1284
+ after `edit_conflict` Kit records the current content House names for the
1285
+ refused paths in `refs/house/received`, reading by revision any content the
1286
+ refusal does not carry, so the agent merges or rebases onto it with native Git,
1287
+ commits, and pushes again. After acceptance Kit records House's result for the
1288
+ pushed paths, the contents an owner push receives or each accepted revision
1289
+ read by name, in a commit that covers the pushed one and in
1290
+ `refs/house/received`; a read-only path House regenerates, such as the Wiki
1291
+ index, stays out of Git. Kit then
1292
+ fast-forwards a clean branch still at the pushed commit, or rebases only its
1293
+ later commits. A result not yet readable, uncommitted tracked changes,
1294
+ a moved branch or a rebase conflict leave the branch as it was, with no stash or
1295
+ reset, and the push answers that House accepted it while local integration is
1296
+ pending; the next `house git push` finishes that integration without another
1297
+ House edit. A push whose answer was lost is recorded in Kit home before it is
1298
+ sent, and the next push by the same Session, or by the owner for an owner push,
1299
+ resends it under its original operation. Another caller cannot
1300
+ recover it: while the pushing Session lives it is refused as
1301
+ `kit_working_copy_push_pending`, and once that Session has ended Kit reads House
1302
+ again and drops it. When a retained push's operation has expired, Kit reads the
1303
+ Room again: if House holds the pushed content the push finishes as accepted,
1304
+ otherwise it is dropped and the next push submits it as a new operation.
1256
1305
 
1257
1306
  Deselecting a Room, or losing read access to it, marks its copy inactive:
1258
- launches omit it and nothing local is deleted. Selecting it again reads the Room
1307
+ launches omit it, a new push is refused as `kit_working_copy_inactive`, and
1308
+ nothing local is deleted; a push already sent still settles and integrates. Selecting it again reads the Room
1259
1309
  again, advances `refs/house/received`, and replaces only the read-only content
1260
1310
  Kit wrote, never the branch, the index or a tracked file. A Room the owner may
1261
1311
  read without sync is refused as `operation_denied` and stays uncopied, while its
@@ -1298,8 +1348,7 @@ Library paths are House metadata, not authored Library documents. `pull` present
1298
1348
  each recorded position with its log epoch, applies each enumerated path's whole
1299
1349
  content or removal, and advances those positions. A position older than House's
1300
1350
  declared retention, or one of a log epoch before House's database was restored,
1301
- is reported as `position_expired` with rematerialization as the remedy; only
1302
- `rebootstrap` rebuilds the directory.
1351
+ is reported as `position_expired`; only `rebootstrap` rebuilds the directory.
1303
1352
 
1304
1353
  `submit` sends field, section, and preamble operations with that value's prior
1305
1354
  content as base, and whole-file, rename, and removal operations with the file
@@ -9,6 +9,7 @@ MANUAL=0
9
9
  WORKSPACE_SELECTED=0
10
10
  HOUSE_SELECTED=0
11
11
  FORWARDING=0
12
+ FORWARDED=kit
12
13
  FORWARD=()
13
14
 
14
15
  refuse() {
@@ -41,8 +42,9 @@ start_resident() {
41
42
  docker run -d --name "$NAME" --restart unless-stopped --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident --house "$HOUSE" >/dev/null
42
43
  }
43
44
 
44
- if [[ "${1:-}" == kit ]]; then
45
+ if [[ "${1:-}" == kit || "${1:-}" == house ]]; then
45
46
  FORWARDING=1
47
+ FORWARDED="$1"
46
48
  FORWARD=("${@:2}")
47
49
  set --
48
50
  fi
@@ -129,10 +131,18 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
129
131
  [[ "$(docker inspect --format '{{index .Config.Labels "agentshouse.house"}}' "$NAME")" == "$HOUSE" ]] || refuse 'the installed Kit uses another House origin'
130
132
  [[ -f "$KIT_HOME/credential" && -f "$KIT_HOME/environment" && -f "$KIT_HOME/house" ]] || refuse 'the installed Kit has no enrolled authority'
131
133
  if ((FORWARDING)); then
132
- if [[ "${FORWARD[0]:-}" == login ]]; then
134
+ if [[ "$FORWARDED" == kit && "${FORWARD[0]:-}" == login ]]; then
133
135
  login_kit "${FORWARD[@]:1}"
134
136
  exit 0
135
137
  fi
138
+ PLACED=()
139
+ ENTRY=()
140
+ if [[ "$FORWARDED" == house ]]; then
141
+ HERE=$(pwd -P)
142
+ [[ "$HERE" == "$WORKSPACE" || "$HERE" == "$WORKSPACE"/* ]] || refuse "the current directory is outside the workspace $WORKSPACE"
143
+ PLACED=(--workdir "/agents/house${HERE#"$WORKSPACE"}")
144
+ ENTRY=(--entrypoint house)
145
+ fi
136
146
  TERMINAL=()
137
147
  if [[ -t 0 && -t 1 ]]; then TERMINAL=(-t); fi
138
148
  if [[ -t 0 && ! -t 1 ]]; then
@@ -141,9 +151,9 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
141
151
  stty -echo
142
152
  fi
143
153
  if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
144
- docker exec -i "${TERMINAL[@]}" "$NAME" kit "${FORWARD[@]}"
154
+ docker exec -i "${TERMINAL[@]}" "${PLACED[@]}" "$NAME" "$FORWARDED" "${FORWARD[@]}"
145
155
  else
146
- "${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
156
+ "${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${PLACED[@]}" "${ENTRY[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
147
157
  fi
148
158
  exit
149
159
  fi
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.17/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.19/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.17/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.19/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.17",
3
+ "version": "0.1.0-alpha.19",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,6 +9,7 @@
9
9
  "private": false,
10
10
  "type": "module",
11
11
  "bin": {
12
+ "house": "src/session-bin/house",
12
13
  "kit": "bin/kit.js"
13
14
  },
14
15
  "exports": {
package/src/bridge.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  import { operationId } from "./operation.js";
19
19
  import { houseFailed } from "./refusal.js";
20
20
 
21
- function bridgedFrame(request, operation) {
21
+ export function bridgedFrame(request, operation) {
22
22
  if (typeof request.method !== "string") return request;
23
23
  const params = typeof request.params === "object" && request.params !== null ? request.params : {};
24
24
  const declared = typeof params._meta === "object" && params._meta !== null ? params._meta : {};
package/src/contract.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CLAUDE_PASTED_CODE, CODEX_DEVICE_CODE, GROK_DEVICE_CODE } from "./agent-logins.js";
2
2
 
3
- export const KIT_CONTRACT_VERSION = "2026-09-28-first-prompt";
3
+ export const KIT_CONTRACT_VERSION = "1";
4
4
  export const KIT_CAPTURE_BATCH_MAX = 100;
5
5
  export const COMMAND_INPUT_MAX_BYTES = 4 * 1024;
6
6
  export const AUTHORIZATION_OUTPUT_MAX_BYTES = 4 * 1024;
@@ -292,17 +292,21 @@ export const KIT_DOOR_DISCOVER_PATH = "/kit/door/discover";
292
292
  export const KIT_DOOR_BOOTSTRAP_PATH = "/kit/door/bootstrap";
293
293
  export const KIT_DOOR_ENUMERATE_PATH = "/kit/door/enumerate";
294
294
  export const KIT_DOOR_BATCH_PATH = "/kit/door/batch";
295
+ export const KIT_DOOR_REVISIONS_PATH = "/kit/door/revisions";
295
296
  export const EXPLICIT_SYNC_VERSION = "explicit-sync/2";
296
297
  export const KIT_DOOR_UNMOUNT_PATH = "/kit/door/unmount";
297
298
 
298
299
  export const KIT_WORKING_COPY_SELECTION_PATH = "/kit/working-copy/selection";
299
- export const WORKING_COPY_VERSION = "working-copy-state/1";
300
+ export const WORKING_COPY_VERSION = "working-copy-state/2";
300
301
  export const WORKING_COPIES_HOME = "working-copies";
301
302
  export const WORKING_COPY_ROOM_HOMES = ["capture", "library", "collaboration"];
302
303
  export const WORKING_COPY_PRIVATE_HOMES = ["library", "collaboration", "rooms", "agents", "house"];
303
304
  export const WORKING_COPY_RECEIVED_REF = "refs/house/received";
304
305
  export const WORKING_COPY_BRANCH = "main";
305
306
  export const ROOM_REFERENCE = /^r_[0-9a-z]+\.[0-9a-z]$/;
307
+ export const WORKING_COPY_PUSH_METHOD = "house/git/push";
308
+ export const WORKING_COPY_PUSH_HELP =
309
+ "Submit a Room commit to House; default HEAD. Use --owner only outside an Agent Session. Native git push is not a House remote.";
306
310
 
307
311
  export const MIGRATOR_CONTRACT = "kit-migrator/1";
308
312
  export const MIGRATOR_CATEGORY = "migrator";
package/src/house-main.js CHANGED
@@ -1,9 +1,19 @@
1
- import { resolve } from "node:path";
2
- import { BRIDGE_NONCE_ENVIRONMENT, BRIDGE_SOCKET_ENVIRONMENT } from "./contract.js";
3
- import { bridgeCall } from "./mcp.js";
4
- import { KitRefusal } from "./refusal.js";
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import {
4
+ BRIDGE_NONCE_ENVIRONMENT,
5
+ BRIDGE_SOCKET_ENVIRONMENT,
6
+ WORKING_COPY_PUSH_HELP,
7
+ WORKING_COPY_PUSH_METHOD,
8
+ } from "./contract.js";
9
+ import { storedHouse } from "./login.js";
10
+ import { answerText, bridgeCall } from "./mcp.js";
11
+ import { formatCommandError, KitRefusal } from "./refusal.js";
12
+ import { ownerPush } from "./working-copy.js";
5
13
 
6
14
  const HELP = ["--help", "-h"];
15
+ const OWNER = "--owner";
16
+ const GIT_PUSH_USAGE = `usage: house git push [commit] [${OWNER}]`;
7
17
  const usage = (verb) => `usage: house ${verb} ['<arguments as one JSON object>' | - for standard input]`;
8
18
 
9
19
  async function answered(method, params) {
@@ -49,10 +59,59 @@ function located(verb, args) {
49
59
  return args;
50
60
  }
51
61
 
62
+ function printed(result) {
63
+ const text = answerText(result);
64
+ return result?.isError === true ? { err: text, status: 1 } : { out: text };
65
+ }
66
+
67
+ async function ownerPushed(request) {
68
+ const directory = process.env.HOUSE_KIT_HOME ?? join(homedir(), ".house-kit");
69
+ const cancellation = new AbortController();
70
+ const cancel = () => cancellation.abort();
71
+ process.once("SIGINT", cancel);
72
+ process.once("SIGTERM", cancel);
73
+ try {
74
+ const pushed = await ownerPush(
75
+ { house: await storedHouse(directory), directory, signal: cancellation.signal },
76
+ request,
77
+ );
78
+ return pushed.refused ? { err: pushed.text, status: 1 } : { out: pushed.text };
79
+ } catch (refusal) {
80
+ return { err: formatCommandError(refusal), status: 1 };
81
+ } finally {
82
+ process.removeListener("SIGINT", cancel);
83
+ process.removeListener("SIGTERM", cancel);
84
+ }
85
+ }
86
+
87
+ async function gitPush([action, ...rest]) {
88
+ if (action !== "push") throw new KitRefusal("kit_house_arguments_invalid", GIT_PUSH_USAGE);
89
+ if (rest.some((argument) => HELP.includes(argument))) return { out: `${GIT_PUSH_USAGE}\n\n${WORKING_COPY_PUSH_HELP}` };
90
+ const owner = rest.includes(OWNER);
91
+ const named = rest.filter((argument) => argument !== OWNER);
92
+ if (named.length > 1 || named.some((argument) => argument.startsWith("-"))) {
93
+ throw new KitRefusal("kit_house_arguments_invalid", GIT_PUSH_USAGE);
94
+ }
95
+ const request = { cwd: process.cwd(), commit: named[0] ?? "HEAD" };
96
+ if (process.env[BRIDGE_SOCKET_ENVIRONMENT] === undefined) {
97
+ if (!owner) throw new KitRefusal("kit_bridge_unavailable");
98
+ return ownerPushed(request);
99
+ }
100
+ if (owner) throw new KitRefusal("kit_house_arguments_invalid", `${OWNER} is only for use outside an Agent Session`);
101
+ return printed(await answered(WORKING_COPY_PUSH_METHOD, request));
102
+ }
103
+
52
104
  async function house([verb, argument, ...rest]) {
105
+ if (verb === "git") return gitPush([argument, ...rest].filter((part) => part !== undefined));
53
106
  if (verb === undefined || HELP.includes(verb)) {
54
107
  const tools = await listed();
55
- return { out: [usage("<verb>"), ...tools.map((tool) => `${tool.name}\n${tool.description}`)].join("\n\n") };
108
+ return {
109
+ out: [
110
+ usage("<verb>"),
111
+ ...tools.map((tool) => `${tool.name}\n${tool.description}`),
112
+ `git push [commit]\n${WORKING_COPY_PUSH_HELP}`,
113
+ ].join("\n\n"),
114
+ };
56
115
  }
57
116
  if (HELP.includes(argument)) {
58
117
  const tool = (await listed()).find((one) => one.name === verb);
@@ -61,9 +120,7 @@ async function house([verb, argument, ...rest]) {
61
120
  }
62
121
  if (rest.length > 0) throw new KitRefusal("kit_house_arguments_invalid", usage(verb));
63
122
  const args = located(verb, await argumentsOf(verb, argument));
64
- const result = await answered("tools/call", { name: verb, arguments: args });
65
- const text = (result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
66
- return result?.isError === true ? { err: text, status: 1 } : { out: text };
123
+ return printed(await answered("tools/call", { name: verb, arguments: args }));
67
124
  }
68
125
 
69
126
  house(process.argv.slice(2)).then(
package/src/house.js CHANGED
@@ -172,7 +172,7 @@ async function residentRelay(session, request, signal, draining) {
172
172
  }
173
173
  }
174
174
 
175
- export async function serveResident(session, directory, signal, drain) {
175
+ export async function serveResident(session, directory, signal, drain, push) {
176
176
  const socket = residentSocket(directory);
177
177
  await mkdir(dirname(socket), { recursive: true, mode: 0o700 });
178
178
  await rm(socket, { force: true });
@@ -229,12 +229,15 @@ export async function serveResident(session, directory, signal, drain) {
229
229
  }
230
230
  const cancelled = new AbortController();
231
231
  relayed.set(request.id, cancelled);
232
- void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal]), draining).then(
233
- (answer) => {
234
- relayed.delete(request.id);
235
- if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
236
- },
237
- );
232
+ const relaySignal = AbortSignal.any([signal, cancelled.signal]);
233
+ const answered =
234
+ request.push === undefined
235
+ ? residentRelay(session, request, relaySignal, draining)
236
+ : push(request.push, relaySignal).then((pushed) => ({ pushed }));
237
+ void answered.then((answer) => {
238
+ relayed.delete(request.id);
239
+ if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
240
+ });
238
241
  },
239
242
  onRefusal: () => connection.destroy(),
240
243
  });
@@ -302,45 +305,54 @@ async function residentDelegation(directory, fetcher) {
302
305
  }
303
306
  connection.unref();
304
307
  let next = 0;
308
+ function exchange(frame, signal) {
309
+ signal?.throwIfAborted();
310
+ if (connection.destroyed) return Promise.resolve(unavailable());
311
+ return new Promise((resolve, reject) => {
312
+ const id = (next += 1);
313
+ const cancel = () => {
314
+ pending.delete(id);
315
+ settle(id);
316
+ if (!connection.destroyed) writeFrame(connection, { id, cancel: true });
317
+ reject(signal.reason);
318
+ };
319
+ signal?.addEventListener("abort", cancel, { once: true });
320
+ pending.set(id, (answer) => {
321
+ signal?.removeEventListener("abort", cancel);
322
+ resolve(answer);
323
+ });
324
+ connection.ref();
325
+ writeFrame(connection, { id, ...frame });
326
+ });
327
+ }
328
+ async function push(request, signal) {
329
+ const answer = await exchange({ push: request }, signal);
330
+ if (answer.pushed === undefined) throw new KitRefusal("kit_resident_unavailable");
331
+ return answer.pushed;
332
+ }
305
333
  async function delegated(url, init = {}) {
306
334
  const { authorization, ...headers } = init.headers ?? {};
307
335
  if (authorization !== RESIDENT) return fetcher(url, init);
308
- init.signal?.throwIfAborted();
309
336
  const address = new URL(url);
310
- const answer = connection.destroyed
311
- ? unavailable()
312
- : await new Promise((resolve, reject) => {
313
- const id = (next += 1);
314
- const cancel = () => {
315
- pending.delete(id);
316
- settle(id);
317
- if (!connection.destroyed) writeFrame(connection, { id, cancel: true });
318
- reject(init.signal.reason);
319
- };
320
- init.signal?.addEventListener("abort", cancel, { once: true });
321
- pending.set(id, (answer) => {
322
- init.signal?.removeEventListener("abort", cancel);
323
- resolve(answer);
324
- });
325
- connection.ref();
326
- writeFrame(connection, {
327
- id,
328
- method: init.method ?? "GET",
329
- path: `${address.pathname}${address.search}`,
330
- headers,
331
- ...(typeof init.body === "string"
332
- ? { text: init.body }
333
- : init.body === undefined
334
- ? {}
335
- : { bytes: Buffer.from(init.body).toString("base64") }),
336
- });
337
- });
337
+ const answer = await exchange(
338
+ {
339
+ method: init.method ?? "GET",
340
+ path: `${address.pathname}${address.search}`,
341
+ headers,
342
+ ...(typeof init.body === "string"
343
+ ? { text: init.body }
344
+ : init.body === undefined
345
+ ? {}
346
+ : { bytes: Buffer.from(init.body).toString("base64") }),
347
+ },
348
+ init.signal,
349
+ );
338
350
  return new Response(answer.body === null ? null : Buffer.from(answer.body, "base64"), {
339
351
  status: answer.status,
340
352
  headers: answer.type === null ? {} : { "content-type": answer.type },
341
353
  });
342
354
  }
343
- return { connection, fetch: delegated };
355
+ return { connection, fetch: delegated, push };
344
356
  }
345
357
 
346
358
  export async function openSession(options, dependencies = {}) {
@@ -359,6 +371,7 @@ export async function openSession(options, dependencies = {}) {
359
371
  : await residentDelegation(options.directory, dependencies.fetch ?? fetch);
360
372
  if (resident !== null) {
361
373
  session.resident = resident.connection;
374
+ session.push = resident.push;
362
375
  session.dependencies = { ...dependencies, fetch: resident.fetch };
363
376
  return session;
364
377
  }
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
- import { parse, stringify } from "yaml";
2
+ import { stringify } from "yaml";
3
3
  import { AGENT_OPERATION_META, ATTACHMENT_FILE_META } from "./contract.js";
4
+ import { answerText, answerYaml } from "./mcp.js";
4
5
  import { derivedOperationId } from "./operation.js";
5
6
  import { localAttachment, sendLocalConversationFile, uploadLocalAttachment } from "./original.js";
6
7
  import { KitRefusal } from "./refusal.js";
@@ -12,18 +13,9 @@ class HouseRefused extends Error {
12
13
  }
13
14
  }
14
15
 
15
- function parsed(text) {
16
- try {
17
- return parse(text);
18
- } catch {
19
- return null;
20
- }
21
- }
22
-
23
16
  function answerOf(frame) {
24
17
  if (frame.error !== undefined || frame.result?.isError === true) throw new HouseRefused(frame);
25
- const text = (frame.result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
26
- const answer = parsed(text);
18
+ const answer = answerYaml(answerText(frame.result));
27
19
  if (answer === null || typeof answer !== "object") throw new KitRefusal("kit_house_answer_unreadable");
28
20
  return answer;
29
21
  }
package/src/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { parse } from "yaml";
2
3
  import { bridgePeer } from "./peer.js";
3
4
  import { KitRefusal } from "./refusal.js";
4
5
 
@@ -69,3 +70,15 @@ export async function bridgeCall(bridge, request, signal = undefined) {
69
70
  await peer.end();
70
71
  }
71
72
  }
73
+
74
+ export function answerText(result) {
75
+ return (result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
76
+ }
77
+
78
+ export function answerYaml(text) {
79
+ try {
80
+ return parse(text);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
@@ -100,13 +100,12 @@ function recordedMap(authority) {
100
100
  return new Map(authority.files.map((file) => [file.path, file]));
101
101
  }
102
102
 
103
- function structuredOrReplace(root, relative, recorded, content) {
103
+ export function structuredOrReplace(root, relative, recorded, content) {
104
104
  const path = housePath(root, relative);
105
+ const whole = [{ op: "replace", path, base: recorded.revision, content }];
105
106
  const before = parse(recorded.content, relative);
106
107
  const after = parse(content, relative);
107
- if (!before.ok || !after.ok) {
108
- return [{ op: "replace", path, base: recorded.revision, content }];
109
- }
108
+ if (!before.ok || !after.ok) return whole;
110
109
  const ops = [];
111
110
  if (before.document.preamble !== after.document.preamble) {
112
111
  ops.push({
@@ -124,9 +123,7 @@ function structuredOrReplace(root, relative, recorded, content) {
124
123
  if (JSON.stringify(before.document.fields[name]) === JSON.stringify(after.document.fields[name])) {
125
124
  continue;
126
125
  }
127
- if (after.document.fields[name] === undefined) {
128
- return [{ op: "replace", path, base: recorded.revision, content }];
129
- }
126
+ if (after.document.fields[name] === undefined) return whole;
130
127
  const change = { op: "set_field", path, field: name, value: after.document.fields[name] };
131
128
  if (before.document.fields[name] !== undefined) change.base = before.document.fields[name];
132
129
  ops.push(change);
@@ -139,12 +136,11 @@ function structuredOrReplace(root, relative, recorded, content) {
139
136
  const prior = before.document.sections[name];
140
137
  const next = after.document.sections[name];
141
138
  if (prior === next) continue;
142
- if (prior === undefined || next === undefined) {
143
- return [{ op: "replace", path, base: recorded.revision, content }];
144
- }
139
+ if (prior === undefined || next === undefined) return whole;
145
140
  ops.push({ op: "replace_section", path, section: name, base: prior, content: next });
146
141
  }
147
- return ops.length === 0 ? [{ op: "replace", path, base: recorded.revision, content }] : ops;
142
+ if (ops.length === 0) return whole;
143
+ return applyChanges({ [relative]: recorded.content }, ops, root)[relative] === content ? ops : whole;
148
144
  }
149
145
 
150
146
  export function deriveChanges(state, authority, localFiles) {
package/src/sync.js CHANGED
@@ -41,7 +41,7 @@ function sessionDependencies(options, dependencies) {
41
41
  };
42
42
  }
43
43
 
44
- async function withSession(options, dependencies, operation) {
44
+ export async function withSession(options, dependencies, operation) {
45
45
  const session = await openSession(options, sessionDependencies(options, dependencies));
46
46
  return maintainControl(session, (signal) => operation(session, signal), options.signal);
47
47
  }