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

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.18/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
@@ -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.18",
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.18/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.18/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.18",
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
@@ -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
  }
package/src/worker.js CHANGED
@@ -10,15 +10,16 @@ import {
10
10
  } from "./agents.js";
11
11
  import { readBindings, removeBinding, writeBinding } from "./binding.js";
12
12
  import { readContinuation, readContinuationRuns, writeContinuation, removeContinuation } from "./continuation.js";
13
- import { openBridge } from "./bridge.js";
13
+ import { bridgedFrame, openBridge } from "./bridge.js";
14
14
  import { assistantBlocks } from "./blocks.js";
15
15
  import { retryConversationFiles } from "./conversation-originals.js";
16
16
  import { openAssistantDrafts } from "./draft.js";
17
17
  import { localRequest, reconcileInbound } from "./inbound.js";
18
18
  import { localFiles } from "./local-files.js";
19
+ import { answerText, answerYaml } from "./mcp.js";
19
20
  import { inheritedEnvironment } from "./command.js";
20
21
  import { watchKitWork } from "./kit-work-stream.js";
21
- import { applyWorkingCopies, workingCopyPaths } from "./working-copy.js";
22
+ import { applyWorkingCopies, ownerCaller, pushWorkingCopy, workingCopyPaths } from "./working-copy.js";
22
23
  import { readConfiguration } from "./configuration.js";
23
24
  import { launchBase, readyLaunchDirectory } from "./directory.js";
24
25
  import {
@@ -32,8 +33,9 @@ import {
32
33
  WORKER_HEARTBEAT_MS,
33
34
  WORKER_INHERITED_ENVIRONMENT,
34
35
  WORKER_SUPERVISION_POLL_MS,
36
+ WORKING_COPY_PUSH_METHOD,
35
37
  } from "./contract.js";
36
- import { EpochReportRequired, houseFailed, KitRefusal } from "./refusal.js";
38
+ import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
37
39
  import {
38
40
  acceptAssistantText,
39
41
  acquireCommands,
@@ -270,6 +272,52 @@ function endOnEpochReport(ended, act) {
270
272
  };
271
273
  }
272
274
 
275
+ function refusedWith(text) {
276
+ const conflicts = answerYaml(text.slice(text.indexOf("\n") + 1))?.conflicts;
277
+ const current = Array.isArray(conflicts)
278
+ ? conflicts.map(({ path, state, revision }) =>
279
+ state === "absent" ? { path, revision: null, content: null } : { path, revision },
280
+ )
281
+ : null;
282
+ return { refused: { code: text.split(":")[0].trim(), text, current } };
283
+ }
284
+
285
+ function acceptedWith(text) {
286
+ const written = answerYaml(text).results.map(({ path, op, revision }) =>
287
+ op === "remove" ? { path, revision: null, content: null } : { path, revision },
288
+ );
289
+ return { refused: null, written };
290
+ }
291
+
292
+ async function sessionPush(context, bound, authority, frame, signal) {
293
+ const edit = async (body, sent) => {
294
+ const answer = await callHouseMcp(
295
+ context.session,
296
+ await sessionAuthority(context.session, bound.session, authority, sent),
297
+ bridgedFrame(
298
+ { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "edit", arguments: { changes: body.changes } } },
299
+ body.operation_id,
300
+ ),
301
+ sent,
302
+ );
303
+ if (answer.error !== undefined) return refusedWith(answer.error.message ?? "kit_house_refused");
304
+ const text = answerText(answer.result);
305
+ return answer.result?.isError === true ? refusedWith(text) : acceptedWith(text);
306
+ };
307
+ let outcome;
308
+ try {
309
+ outcome = await pushWorkingCopy(context, frame.params, { binding: { session: bound.session }, submit: edit }, signal);
310
+ } catch (refusal) {
311
+ if (!(refusal instanceof KitRefusal)) throw refusal;
312
+ outcome = { refused: true, text: refusal.message };
313
+ }
314
+ return {
315
+ jsonrpc: "2.0",
316
+ id: frame.id,
317
+ result: { content: [{ type: "text", text: outcome.text }], ...(outcome.refused ? { isError: true } : {}) },
318
+ };
319
+ }
320
+
273
321
  async function openRunBridge(context, bound, authority, start) {
274
322
  const ended = new AbortController();
275
323
  const handed = (act) => endOnEpochReport(ended, act);
@@ -291,8 +339,9 @@ async function openRunBridge(context, bound, authority, start) {
291
339
  ),
292
340
  secret: (interaction, ceremony) => awaitSecretInput(context.session, interaction, ceremony),
293
341
  settle: handed((interaction, outcome) => settleInteraction(context, interaction, outcome)),
294
- forward: handed((frame, signal) =>
295
- localFiles(async (sent) =>
342
+ forward: handed((frame, signal) => frame.method === WORKING_COPY_PUSH_METHOD
343
+ ? sessionPush(context, bound, authority, frame, signal)
344
+ : localFiles(async (sent) =>
296
345
  callHouseMcp(
297
346
  context.session,
298
347
  await sessionAuthority(context.session, bound.session, authority, signal),
@@ -960,7 +1009,16 @@ export async function runResident(options, dependencies = {}) {
960
1009
  const wake = residentWake();
961
1010
  const update = residentUpdate(context, wake);
962
1011
  context.update = update;
963
- const served = await serveResident(session, context.directory, options.signal, (serving) => update.request(serving));
1012
+ const served = await serveResident(
1013
+ session,
1014
+ context.directory,
1015
+ options.signal,
1016
+ (serving) => update.request(serving),
1017
+ (request, signal) =>
1018
+ pushWorkingCopy({ directory: context.directory, session }, request, ownerCaller(session), signal).catch(
1019
+ (error) => ({ refused: true, text: formatCommandError(error) }),
1020
+ ),
1021
+ );
964
1022
  const serve = async (signal) => {
965
1023
  context.busy = true;
966
1024
  const watcher = new AbortController();
@@ -1,8 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
2
+ import { mkdir, readdir, realpath, rename, rm, stat } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { readBindings } from "./binding.js";
4
5
  import {
6
+ KIT_DOOR_BATCH_PATH,
5
7
  KIT_DOOR_DISCOVER_PATH,
8
+ KIT_DOOR_REVISIONS_PATH,
6
9
  KIT_WORKING_COPY_SELECTION_PATH,
7
10
  ROOM_REFERENCE,
8
11
  WORKING_COPIES_HOME,
@@ -13,13 +16,16 @@ import {
13
16
  WORKING_COPY_VERSION,
14
17
  } from "./contract.js";
15
18
  import { runGit } from "./git.js";
16
- import { houseAnswered, KitRefusal } from "./refusal.js";
19
+ import { operationId } from "./operation.js";
20
+ import { formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
17
21
  import { readNote, writeNote, writeText } from "./state.js";
18
- import { bootstrapBundle, discoveryOf, door } from "./sync.js";
19
- import { sidecarPath } from "./sync-library.js";
22
+ import { bootstrapBundle, discoveryOf, door, withSession } from "./sync.js";
23
+ import { sidecarPath, structuredOrReplace } from "./sync-library.js";
20
24
  import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
21
25
 
22
- const COMMITTER = "House Kit <house-kit@localhost>";
26
+ const COMMITTER_NAME = "House Kit";
27
+ const COMMITTER_EMAIL = "house-kit@localhost";
28
+ const COMMITTER = `${COMMITTER_NAME} <${COMMITTER_EMAIL}>`;
23
29
  const GIT_FAILED = "kit_working_copy_git_failed";
24
30
 
25
31
  function rejected(detail = null) {
@@ -149,25 +155,53 @@ function quotedPath(path) {
149
155
  return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
150
156
  }
151
157
 
152
- async function importReceived(repository, scope, read, parent, signal) {
153
- const message = `House ${housePath(scope)} at position ${read.position}`;
154
- const lines = [
155
- Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
158
+ function importCommit(message, parents) {
159
+ return [
156
160
  Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
157
161
  ...importData(message),
158
- ...(parent === null ? [] : [Buffer.from(`from ${parent}\n`)]),
159
- Buffer.from("deleteall\n"),
162
+ ...parents.map((parent, index) => Buffer.from(`${index === 0 ? "from" : "merge"} ${parent}\n`)),
160
163
  ];
161
- for (const file of read.files.filter((entry) => entry.writable)) {
162
- lines.push(Buffer.from(`M 100644 inline ${quotedPath(file.path)}\n`), ...importData(file.content));
164
+ }
165
+
166
+ function importFile(path, content) {
167
+ if (content === null) return [Buffer.from(`D ${quotedPath(path)}\n`)];
168
+ return [Buffer.from(`M 100644 inline ${quotedPath(path)}\n`), ...importData(content)];
169
+ }
170
+
171
+ async function importReceived(repository, { message, parents, files, whole = false, acknowledging = null }, signal) {
172
+ const stream = [];
173
+ const merged = [...parents];
174
+ if (acknowledging !== null && acknowledging.edits.length === 0) merged.push(acknowledging.commit);
175
+ if (acknowledging !== null && acknowledging.edits.length > 0) {
176
+ stream.push(
177
+ Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\nmark :1\n`),
178
+ ...importCommit(`House accepted ${acknowledging.commit}`, [acknowledging.commit]),
179
+ ...acknowledging.edits.flatMap((edit) => importFile(edit.path, edit.content)),
180
+ Buffer.from("\n"),
181
+ );
182
+ merged.push(":1");
163
183
  }
164
- lines.push(Buffer.from("\n"));
184
+ stream.push(
185
+ Buffer.from(`commit ${WORKING_COPY_RECEIVED_REF}\n`),
186
+ ...importCommit(message, merged),
187
+ ...(whole ? [Buffer.from("deleteall\n")] : []),
188
+ ...files.flatMap((file) => importFile(file.path, file.content)),
189
+ Buffer.from("\n"),
190
+ );
165
191
  await runGit(repository, ["fast-import", "--quiet", "--force"], {
166
- input: Buffer.concat(lines),
192
+ input: Buffer.concat(stream),
167
193
  signal,
168
194
  failure: GIT_FAILED,
169
195
  });
170
- const commit = await runGit(repository, ["rev-parse", "--verify", `${WORKING_COPY_RECEIVED_REF}^{commit}`], {
196
+ const received = await commitOf(repository, WORKING_COPY_RECEIVED_REF, signal);
197
+ if (acknowledging === null) return { received, acknowledged: null };
198
+ const acknowledged =
199
+ acknowledging.edits.length === 0 ? acknowledging.commit : await commitOf(repository, `${received}^2`, signal);
200
+ return { received, acknowledged };
201
+ }
202
+
203
+ async function commitOf(repository, name, signal) {
204
+ const commit = await runGit(repository, ["rev-parse", "--verify", `${name}^{commit}`], {
171
205
  capture: true,
172
206
  signal,
173
207
  failure: GIT_FAILED,
@@ -175,6 +209,16 @@ async function importReceived(repository, scope, read, parent, signal) {
175
209
  return commit.toString("utf8").trim();
176
210
  }
177
211
 
212
+ async function remembered(repository, revisions, commit, files, signal) {
213
+ const tree = await treeOf(repository, commit, signal);
214
+ const next = { ...revisions };
215
+ for (const { path, revision } of files) {
216
+ const entry = tree.get(path);
217
+ if (entry !== undefined && revision !== null) next[path] = { ...next[path], [entry.object]: revision };
218
+ }
219
+ return next;
220
+ }
221
+
178
222
  function readOnlyContents(read) {
179
223
  const contents = new Map();
180
224
  for (const file of read.files) {
@@ -268,13 +312,21 @@ function manifest(read, commit) {
268
312
  commit,
269
313
  position: read.position,
270
314
  log_epoch: read.logEpoch,
271
- files: read.files.map(({ path, revision, sha256, writable }) => ({ path, revision, sha256, writable })),
272
315
  read_only: [...readOnlyContents(read).keys()],
273
316
  };
274
317
  }
275
318
 
319
+ function copyPath(directory, copy) {
320
+ return statePath(directory, { kind: copy.scope, ref: copy.room_ref });
321
+ }
322
+
276
323
  async function writeCopy(directory, copy, signal) {
277
- await writeNote(statePath(directory, { kind: copy.scope, ref: copy.room_ref }), copy, signal);
324
+ await writeNote(copyPath(directory, copy), copy, signal);
325
+ }
326
+
327
+ async function readCopy(path) {
328
+ const copy = await readNote(path, null);
329
+ return copy?.version === WORKING_COPY_VERSION ? copy : null;
278
330
  }
279
331
 
280
332
  async function heldCopies(directory) {
@@ -287,10 +339,30 @@ async function heldCopies(directory) {
287
339
  }
288
340
  const held = [];
289
341
  for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
290
- held.push(await readNote(join(rooms, name), null));
342
+ held.push(await readCopy(join(rooms, name)));
291
343
  }
292
- held.push(await readNote(statePath(directory, { kind: "private" }), null));
293
- return held.filter((copy) => copy?.version === WORKING_COPY_VERSION);
344
+ held.push(await readCopy(statePath(directory, { kind: "private" })));
345
+ return held.filter((copy) => copy !== null);
346
+ }
347
+
348
+ const turns = new Map();
349
+
350
+ function exclusively(path, work) {
351
+ const turn = (turns.get(path) ?? Promise.resolve()).then(work);
352
+ const settled = turn.then(
353
+ () => undefined,
354
+ () => undefined,
355
+ );
356
+ turns.set(path, settled);
357
+ void settled.then(() => {
358
+ if (turns.get(path) === settled) turns.delete(path);
359
+ });
360
+ return turn;
361
+ }
362
+
363
+ function received(scope, read, parents) {
364
+ const files = read.files.filter((file) => file.writable);
365
+ return { message: `House ${housePath(scope)} at position ${read.position}`, parents, files, whole: true };
294
366
  }
295
367
 
296
368
  async function materialize(context, scope, signal) {
@@ -300,10 +372,13 @@ async function materialize(context, scope, signal) {
300
372
  if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
301
373
  const staging = join(dirname(repository), `.${randomUUID()}.staging`);
302
374
  await mkdir(staging, { recursive: true });
375
+ const imported = received(scope, read, []);
303
376
  let commit;
377
+ let revisions;
304
378
  try {
305
379
  await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
306
- commit = await importReceived(staging, scope, read, null, signal);
380
+ commit = (await importReceived(staging, imported, signal)).received;
381
+ revisions = await remembered(staging, {}, commit, imported.files, signal);
307
382
  await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
308
383
  signal,
309
384
  failure: GIT_FAILED,
@@ -316,7 +391,6 @@ async function materialize(context, scope, signal) {
316
391
  await rm(staging, { recursive: true, force: true });
317
392
  throw error;
318
393
  }
319
- const received = manifest(read, commit);
320
394
  await writeCopy(context.directory, {
321
395
  version: WORKING_COPY_VERSION,
322
396
  scope: scope.kind,
@@ -324,27 +398,33 @@ async function materialize(context, scope, signal) {
324
398
  room_handle: scope.handle,
325
399
  repository,
326
400
  active: true,
327
- base: received,
328
- received,
401
+ received: manifest(read, commit),
402
+ revisions,
329
403
  });
330
404
  }
331
405
 
406
+ async function receive(context, copy, scope, signal) {
407
+ const read = await readScope(context.session, scope, signal);
408
+ if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
409
+ const imported = received(scope, read, [copy.received.commit]);
410
+ const commit = (await importReceived(copy.repository, imported, signal)).received;
411
+ await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
412
+ await writeIgnoreRules(copy.repository, read, signal);
413
+ return {
414
+ ...copy,
415
+ received: manifest(read, commit),
416
+ revisions: await remembered(copy.repository, copy.revisions, commit, imported.files, signal),
417
+ };
418
+ }
419
+
332
420
  async function refresh(context, copy, scope, signal) {
333
421
  if (!(await present(copy.repository))) {
334
- await rm(statePath(context.directory, { kind: copy.scope, ref: copy.room_ref }), { force: true });
422
+ await rm(copyPath(context.directory, copy), { force: true });
335
423
  await materialize(context, scope, signal);
336
424
  return;
337
425
  }
338
- const read = await readScope(context.session, scope, signal);
339
- if (read.anchor !== copy.room_ref) throw rejected("bootstrap");
340
- const commit = await importReceived(copy.repository, scope, read, copy.received.commit, signal);
341
- await replaceReadOnly(copy.repository, copy.received.read_only, readOnlyContents(read), signal);
342
- await writeIgnoreRules(copy.repository, read, signal);
343
- await writeCopy(
344
- context.directory,
345
- { ...copy, room_handle: scope.handle, active: true, received: manifest(read, commit) },
346
- signal,
347
- );
426
+ const next = await receive(context, copy, scope, signal);
427
+ await writeCopy(context.directory, { ...next, room_handle: scope.handle, active: true }, signal);
348
428
  }
349
429
 
350
430
  function scopeRefused(error) {
@@ -355,21 +435,26 @@ export async function applyWorkingCopies(context, signal) {
355
435
  const selected = (await savedSelection(context.session, signal)).filter(
356
436
  (scope) => scope.kind === "private" || scope.handle !== null,
357
437
  );
358
- const held = await heldCopies(context.directory);
359
- for (const copy of held) {
360
- if (copy.active && !selected.some((scope) => sameScope(copy, scope))) {
361
- await writeCopy(context.directory, { ...copy, active: false }, signal);
362
- }
438
+ for (const held of await heldCopies(context.directory)) {
439
+ if (!held.active || selected.some((scope) => sameScope(held, scope))) continue;
440
+ const path = copyPath(context.directory, held);
441
+ await exclusively(path, async () => {
442
+ const copy = await readCopy(path);
443
+ if (copy !== null) await writeCopy(context.directory, { ...copy, active: false }, signal);
444
+ });
363
445
  }
364
446
  const refused = [];
365
447
  for (const scope of selected) {
366
- const copy = held.find((entry) => sameScope(entry, scope));
448
+ const path = statePath(context.directory, scope);
367
449
  try {
368
- if (copy === undefined) await materialize(context, scope, signal);
369
- else if (!copy.active) await refresh(context, copy, scope, signal);
370
- else if (copy.room_handle !== scope.handle) {
371
- await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
372
- }
450
+ await exclusively(path, async () => {
451
+ const copy = await readCopy(path);
452
+ if (copy === null) await materialize(context, scope, signal);
453
+ else if (!copy.active) await refresh(context, copy, scope, signal);
454
+ else if (copy.room_handle !== scope.handle) {
455
+ await writeCopy(context.directory, { ...copy, room_handle: scope.handle }, signal);
456
+ }
457
+ });
373
458
  } catch (error) {
374
459
  if (!scopeRefused(error)) throw error;
375
460
  refused.push({ path: housePath(scope), code: error.code });
@@ -387,3 +472,387 @@ export async function workingCopyPaths(directory) {
387
472
  }))
388
473
  .sort((left, right) => (left.path < right.path ? -1 : 1));
389
474
  }
475
+
476
+ function scopeOf(copy) {
477
+ return { kind: copy.scope, ref: copy.room_ref, handle: copy.room_handle };
478
+ }
479
+
480
+ function without(copy) {
481
+ const { push, ...settled } = copy;
482
+ return settled;
483
+ }
484
+
485
+ function lines(output) {
486
+ return output.toString("utf8").split("\n").filter((line) => line !== "");
487
+ }
488
+
489
+ function succeeded(git) {
490
+ return git.then(
491
+ () => true,
492
+ () => false,
493
+ );
494
+ }
495
+
496
+ function contains(repository, ancestor, descendant, signal) {
497
+ return succeeded(
498
+ runGit(repository, ["merge-base", "--is-ancestor", ancestor, descendant], { signal, failure: GIT_FAILED }),
499
+ );
500
+ }
501
+
502
+ async function located(cwd, signal) {
503
+ const [common, worktree] = lines(
504
+ await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel"], {
505
+ capture: true,
506
+ signal,
507
+ failure: "kit_working_copy_unresolved",
508
+ }),
509
+ );
510
+ return { repository: await realpath(dirname(common)), worktree };
511
+ }
512
+
513
+ async function copyAt(directory, repository) {
514
+ for (const copy of await heldCopies(directory)) {
515
+ if ((await realpath(copy.repository).catch(() => null)) === repository) return copy;
516
+ }
517
+ throw new KitRefusal("kit_working_copy_not_found", repository);
518
+ }
519
+
520
+ async function treeOf(repository, commit, signal) {
521
+ const listed = await runGit(repository, ["ls-tree", "-r", "-z", commit], {
522
+ capture: true,
523
+ signal,
524
+ failure: GIT_FAILED,
525
+ });
526
+ const tree = new Map();
527
+ for (const entry of listed.toString("utf8").split("\0").filter((line) => line !== "")) {
528
+ const tab = entry.indexOf("\t");
529
+ const [, type, object] = entry.slice(0, tab).split(" ");
530
+ tree.set(entry.slice(tab + 1), { type, object });
531
+ }
532
+ return tree;
533
+ }
534
+
535
+ async function blobText(repository, entry, path, signal) {
536
+ if (entry === undefined) return null;
537
+ if (entry.type !== "blob") throw new KitRefusal("kit_working_copy_not_text", path);
538
+ const bytes = await runGit(repository, ["cat-file", "blob", entry.object], {
539
+ capture: true,
540
+ signal,
541
+ failure: GIT_FAILED,
542
+ });
543
+ try {
544
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
545
+ } catch {
546
+ throw new KitRefusal("kit_working_copy_not_text", path);
547
+ }
548
+ }
549
+
550
+ async function trustedBase(copy, commit, signal) {
551
+ const git = (args) => runGit(copy.repository, args, { capture: true, signal, failure: GIT_FAILED });
552
+ const house = new Set(
553
+ lines(await git(["rev-list", "--first-parent", "--parents", copy.received.commit])).flatMap((line) => {
554
+ const [received, , acknowledged] = line.split(" ");
555
+ return acknowledged === undefined ? [received] : [received, acknowledged];
556
+ }),
557
+ );
558
+ const base = lines(await git(["rev-list", "--topo-order", commit])).find((ancestor) => house.has(ancestor));
559
+ if (base !== undefined) return base;
560
+ throw new KitRefusal(
561
+ "kit_working_copy_base_unknown",
562
+ `${commit} contains no House state; merge or rebase onto ${WORKING_COPY_RECEIVED_REF}`,
563
+ );
564
+ }
565
+
566
+ async function derived(copy, base, commit, signal) {
567
+ const root = housePath(scopeOf(copy));
568
+ const before = await treeOf(copy.repository, base, signal);
569
+ const after = await treeOf(copy.repository, commit, signal);
570
+ const changes = [];
571
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
572
+ const prior = before.get(path);
573
+ const next = after.get(path);
574
+ if (prior?.object === next?.object) continue;
575
+ const target = `${root}/${path}`;
576
+ const revision = copy.revisions[path]?.[prior?.object];
577
+ if (next === undefined) {
578
+ changes.push({ op: "remove", path: target, base: revision });
579
+ continue;
580
+ }
581
+ const content = await blobText(copy.repository, next, path, signal);
582
+ if (prior === undefined) {
583
+ changes.push({ op: "create", path: target, content });
584
+ continue;
585
+ }
586
+ const recorded = { content: await blobText(copy.repository, prior, path, signal), revision };
587
+ changes.push(...structuredOrReplace(root, path, recorded, content));
588
+ }
589
+ return changes;
590
+ }
591
+
592
+ async function revisionText(session, { path, revision }, signal) {
593
+ const page = await door(session, KIT_DOOR_REVISIONS_PATH, { path, revision }, signal);
594
+ const content = page?.revisions?.find((entry) => entry?.revision === revision)?.content;
595
+ if (typeof content !== "string") throw new KitRefusal("kit_working_copy_result_unreadable", `${path} at ${revision}`);
596
+ return content;
597
+ }
598
+
599
+ async function materials(context, copy, entries, signal) {
600
+ const root = housePath(scopeOf(copy));
601
+ const files = [];
602
+ for (const entry of entries) {
603
+ const content = entry.content === undefined ? await revisionText(context.session, entry, signal) : entry.content;
604
+ files.push({ path: entry.path.slice(root.length + 1), revision: entry.revision, content });
605
+ }
606
+ return files;
607
+ }
608
+
609
+ async function recorded(copy, files, message, acknowledging, signal) {
610
+ const imported = await importReceived(
611
+ copy.repository,
612
+ { message, parents: [copy.received.commit], files, acknowledging },
613
+ signal,
614
+ );
615
+ return {
616
+ copy: {
617
+ ...copy,
618
+ received: { ...copy.received, commit: imported.received },
619
+ revisions: await remembered(copy.repository, copy.revisions, imported.received, files, signal),
620
+ },
621
+ acknowledged: imported.acknowledged,
622
+ };
623
+ }
624
+
625
+ function submittedPaths(copy) {
626
+ const root = housePath(scopeOf(copy));
627
+ return new Set(copy.push.changes.map((change) => change.path.slice(root.length + 1)));
628
+ }
629
+
630
+ function short(commit) {
631
+ return commit.slice(0, 12);
632
+ }
633
+
634
+ async function acknowledge(context, copy, signal) {
635
+ const { push } = copy;
636
+ const files = await materials(context, copy, push.written, signal);
637
+ const selected = await treeOf(copy.repository, push.commit, signal);
638
+ const edits = [];
639
+ for (const file of files) {
640
+ if ((await blobText(copy.repository, selected.get(file.path), file.path, signal)) !== file.content) edits.push(file);
641
+ }
642
+ const { copy: next, acknowledged } = await recorded(
643
+ copy,
644
+ files,
645
+ `House ${housePath(scopeOf(copy))} with ${push.commit} accepted`,
646
+ { commit: push.commit, edits },
647
+ signal,
648
+ );
649
+ return { ...next, push: { ...push, acknowledged } };
650
+ }
651
+
652
+ async function integrated(copy, signal) {
653
+ const { commit, acknowledged, worktree, branch } = copy.push;
654
+ const accepted = `House accepted ${short(commit)}`;
655
+ if (acknowledged === commit) return { done: true, text: `${accepted}.` };
656
+ const pending = (reason) => ({
657
+ done: false,
658
+ text: `${accepted}; local integration is pending (${reason}): ${acknowledged} holds House's result.`,
659
+ });
660
+ const git = (args) => runGit(worktree, args, { capture: true, signal, failure: GIT_FAILED });
661
+ const head = await git(["symbolic-ref", "-q", "HEAD"]).then(
662
+ (output) => lines(output)[0],
663
+ () => null,
664
+ );
665
+ if (branch === null || head !== branch) return pending("the branch changed");
666
+ const tip = lines(await git(["rev-parse", "HEAD"]))[0];
667
+ const name = branch.replace(/^refs\/heads\//, "");
668
+ if (await contains(worktree, acknowledged, tip, signal)) return { done: true, text: `${accepted}; ${name} holds it.` };
669
+ if (!(await contains(worktree, commit, tip, signal))) return pending("the branch changed");
670
+ if (lines(await git(["status", "--porcelain", "--untracked-files=no"])).length > 0) {
671
+ return pending("uncommitted changes");
672
+ }
673
+ if (tip === commit) {
674
+ if (!(await succeeded(git(["merge", "--ff-only", "-q", acknowledged])))) return pending("a conflict");
675
+ } else if (
676
+ !(await succeeded(
677
+ git(["-c", `user.name=${COMMITTER_NAME}`, "-c", `user.email=${COMMITTER_EMAIL}`, "rebase", "-q", "--onto", acknowledged, commit]),
678
+ ))
679
+ ) {
680
+ await git(["rebase", "--abort"]).catch(() => undefined);
681
+ return pending("a conflict");
682
+ }
683
+ return { done: true, text: `${accepted}; ${name} is at ${short(lines(await git(["rev-parse", "HEAD"]))[0])}.` };
684
+ }
685
+
686
+ async function finish(context, copy, signal) {
687
+ try {
688
+ let acknowledged = copy;
689
+ if (copy.push.acknowledged === undefined) {
690
+ acknowledged = await acknowledge(context, copy, signal);
691
+ await writeCopy(context.directory, acknowledged, signal);
692
+ }
693
+ const outcome = await integrated(acknowledged, signal);
694
+ if (outcome.done) await writeCopy(context.directory, without(acknowledged), signal);
695
+ return { refused: false, text: outcome.text };
696
+ } catch (error) {
697
+ return {
698
+ refused: false,
699
+ text: `House accepted ${short(copy.push.commit)}; local integration is pending (${formatCommandError(error)}); run house git push again to finish it.`,
700
+ };
701
+ }
702
+ }
703
+
704
+ function sameCaller(left, right) {
705
+ return left.session === right.session && left.owner === right.owner;
706
+ }
707
+
708
+ function definite(error) {
709
+ return (
710
+ error instanceof KitRefusal &&
711
+ error.code !== "kit_command_cancelled" &&
712
+ !houseFailed(error) &&
713
+ !(error.status >= 500)
714
+ );
715
+ }
716
+
717
+ async function orphaned(context, copy, signal) {
718
+ const held = copy.push.caller.session;
719
+ const live = (binding) => binding.session === held && binding.stopped === null && binding.reported === null;
720
+ if (held === undefined || (await readBindings(context.directory)).some(live)) {
721
+ throw new KitRefusal("kit_working_copy_push_pending", "another caller's push awaits its recovery");
722
+ }
723
+ await writeCopy(context.directory, await receive(context, without(copy), scopeOf(copy), signal), signal);
724
+ throw new KitRefusal(
725
+ "kit_working_copy_push_unrecoverable",
726
+ `the Session that pushed ended before its outcome arrived; ${WORKING_COPY_RECEIVED_REF} holds House's current state`,
727
+ );
728
+ }
729
+
730
+ async function expired(context, copy, text, signal) {
731
+ const { push } = copy;
732
+ const root = housePath(scopeOf(copy));
733
+ const confirmed = await receive(context, without(copy), scopeOf(copy), signal);
734
+ const house = await treeOf(copy.repository, confirmed.received.commit, signal);
735
+ const selected = await treeOf(copy.repository, push.commit, signal);
736
+ const paths = [...submittedPaths(copy)];
737
+ if (paths.some((path) => house.get(path)?.object !== selected.get(path)?.object)) {
738
+ await writeCopy(context.directory, confirmed, signal);
739
+ return {
740
+ refused: true,
741
+ text: `${text}\nHouse does not hold ${short(push.commit)}; run house git push again to submit it as a new operation.`,
742
+ };
743
+ }
744
+ const written = [];
745
+ for (const path of paths) {
746
+ const entry = house.get(path);
747
+ written.push({
748
+ path: `${root}/${path}`,
749
+ revision: entry === undefined ? null : confirmed.revisions[path][entry.object],
750
+ content: await blobText(copy.repository, entry, path, signal),
751
+ });
752
+ }
753
+ const accepted = { ...confirmed, push: { ...push, state: "accepted", written } };
754
+ await writeCopy(context.directory, accepted, signal);
755
+ return finish(context, accepted, signal);
756
+ }
757
+
758
+ async function settle(context, copy, caller, signal) {
759
+ const { push } = copy;
760
+ if (!sameCaller(push.caller, caller.binding)) return orphaned(context, copy, signal);
761
+ let answer;
762
+ try {
763
+ answer = await caller.submit({ operation_id: push.operation_id, changes: push.changes }, signal);
764
+ } catch (error) {
765
+ if (!definite(error)) {
766
+ throw new KitRefusal("kit_working_copy_push_uncertain", "run house git push again to recover its outcome");
767
+ }
768
+ const current = error.payload?.error?.current;
769
+ answer = { refused: { code: error.code, text: formatCommandError(error), current: Array.isArray(current) ? current : null } };
770
+ }
771
+ const { refused } = answer;
772
+ if (refused?.code === "operation_expired") return expired(context, copy, refused.text, signal);
773
+ if (refused === null) {
774
+ const submitted = new Set(push.changes.map((change) => change.path));
775
+ const written = answer.written.filter((entry) => submitted.has(entry.path));
776
+ const accepted = { ...copy, push: { ...push, state: "accepted", written } };
777
+ await writeCopy(context.directory, accepted, signal);
778
+ return finish(context, accepted, signal);
779
+ }
780
+ const settled = without(copy);
781
+ await writeCopy(context.directory, settled, signal);
782
+ if (refused.current === null) return { refused: true, text: refused.text };
783
+ try {
784
+ const files = await materials(context, settled, refused.current, signal);
785
+ const message = `House ${housePath(scopeOf(settled))} current where ${short(push.commit)} was refused`;
786
+ await writeCopy(context.directory, (await recorded(settled, files, message, null, signal)).copy, signal);
787
+ return { refused: true, text: refused.text };
788
+ } catch (error) {
789
+ return { refused: true, text: `${refused.text}\n${WORKING_COPY_RECEIVED_REF} is unchanged: ${formatCommandError(error)}` };
790
+ }
791
+ }
792
+
793
+ async function pushed(context, path, at, selector, caller, signal) {
794
+ let copy = await readCopy(path);
795
+ if (copy.push?.state === "submitted") return settle(context, copy, caller, signal);
796
+ const commit = (
797
+ await runGit(at.worktree, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${selector}^{commit}`], {
798
+ capture: true,
799
+ signal,
800
+ failure: "kit_working_copy_commit_unknown",
801
+ })
802
+ )
803
+ .toString("utf8")
804
+ .trim();
805
+ if (copy.push !== undefined) {
806
+ const held = copy.push.acknowledged;
807
+ if (held === undefined || !(await contains(copy.repository, held, commit, signal))) {
808
+ return finish(context, copy, signal);
809
+ }
810
+ copy = without(copy);
811
+ await writeCopy(context.directory, copy, signal);
812
+ }
813
+ if (!copy.active) throw new KitRefusal("kit_working_copy_inactive", housePath(scopeOf(copy)));
814
+ const changes = await derived(copy, await trustedBase(copy, commit, signal), commit, signal);
815
+ if (changes.length === 0) return { refused: false, text: `Nothing to submit: ${short(commit)} matches House.` };
816
+ const branch = await runGit(at.worktree, ["symbolic-ref", "-q", "HEAD"], { capture: true, signal, failure: GIT_FAILED })
817
+ .then((output) => lines(output)[0], () => null);
818
+ const submitted = {
819
+ ...copy,
820
+ push: {
821
+ state: "submitted",
822
+ caller: caller.binding,
823
+ operation_id: operationId(),
824
+ changes,
825
+ commit,
826
+ worktree: at.worktree,
827
+ branch,
828
+ },
829
+ };
830
+ await writeCopy(context.directory, submitted, signal);
831
+ return settle(context, submitted, caller, signal);
832
+ }
833
+
834
+ export async function pushWorkingCopy(context, request, caller, signal) {
835
+ if (typeof request?.cwd !== "string" || typeof request.commit !== "string") {
836
+ throw new KitRefusal("kit_house_arguments_invalid", "cwd and commit");
837
+ }
838
+ const at = await located(request.cwd, signal);
839
+ const path = copyPath(context.directory, await copyAt(context.directory, at.repository));
840
+ return exclusively(path, () => pushed(context, path, at, request.commit, caller, signal));
841
+ }
842
+
843
+ export function ownerCaller(session) {
844
+ return {
845
+ binding: { owner: true },
846
+ async submit(body, signal) {
847
+ return { refused: null, written: (await door(session, KIT_DOOR_BATCH_PATH, body, signal)).contents };
848
+ },
849
+ };
850
+ }
851
+
852
+ export function ownerPush(options, request, dependencies = {}) {
853
+ return withSession(options, dependencies, (session, signal) =>
854
+ session.resident === undefined
855
+ ? pushWorkingCopy({ directory: options.directory, session }, request, ownerCaller(session), signal)
856
+ : session.push(request, signal),
857
+ );
858
+ }