@agentshouse/kit 0.1.0-alpha.12 → 0.1.0-alpha.14

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.12/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -27,16 +27,18 @@ installed container runs another image, the bootstrap pulls this release's
27
27
  image and asks the running resident Kit to drain with `kit resident drain`. The
28
28
  resident stops acquiring Runs, resumptions, setup and agent CLI work, while the
29
29
  Sessions it supervises keep their renewal, owner stops, answers and reports.
30
- While any Agent Session is still running or unreported, the bootstrap names it
31
- and leaves the container as it is; rerun it once they end. Once nothing remains,
30
+ While any Agent Session is still running or unreported, including one whose Run
31
+ was being acquired when the drain began, the bootstrap names it and leaves the
32
+ container as it is; rerun it once they end. Once nothing remains,
32
33
  the resident stops renewing, releases its exact lease and stays quiescent, and
33
34
  the bootstrap replaces the container with one on this release's image over the
34
35
  same Kit home and workspace. The credential, Environment, Kit-home state and
35
36
  everything under the workspace are kept; anything installed elsewhere in the old
36
37
  container is not. A stopped container is replaced without a drain.
37
38
 
38
- If House refuses the installed Kit with `kit_upgrade_required`, the resident
39
- calls House no more, neither to release nor to report. It keeps supervising
39
+ If House refuses the installed Kit with `kit_upgrade_required`, including in
40
+ its answer to a command the resident serves, the resident calls House no more,
41
+ neither to release nor to report. It keeps supervising
40
42
  every running Session locally and never stops one to make progress. A Session
41
43
  that ends keeps its exact binding, stop observation and outcome in Kit home,
42
44
  and the drain answers ready once no supervised process runs. The updated Kit
@@ -1170,8 +1172,13 @@ A resident whose lease House refuses answers the command with that refusal; the
1170
1172
  command never acquires a lease of its own while a resident serves it. While an
1171
1173
  update drains, `state/update-drain` refuses every such command, `kit login` and
1172
1174
  `kit worker run` with `kit_update_draining` before it enrolls, acquires or
1173
- delegates anything, and the drain request ends the delegation of a command
1174
- already served; the bootstrap removes the file when it replaces the container.
1175
+ delegates anything. A command already served keeps the resident's lease until
1176
+ it ends, while the resident refuses its acquisition of new work and its
1177
+ Telegram updates with `kit_update_draining`, which ends a served
1178
+ `kit telegram serve` once each of its activities reports the deliveries and
1179
+ files it already acquired; until it ends, the drain answers that it waits for
1180
+ that command and the bootstrap leaves the container as it is. The bootstrap
1181
+ removes the file when it replaces the container.
1175
1182
 
1176
1183
  Interrupting the command cancels the running connector without advancing the
1177
1184
  checkpoint.
@@ -1210,9 +1217,52 @@ interrupted run is never resumed: the next run opens a new manifest and House
1210
1217
  answers with what is still needed. The command reports the Publication
1211
1218
  reference, the paths delivered, the items refused and the ending.
1212
1219
 
1220
+ ## Working copies
1221
+
1222
+ The Environment owner chooses in House which Rooms this Environment keeps
1223
+ locally, and whether it keeps `/private`. An Environment whose owner never chose
1224
+ copies nothing, and a Room joined later stays uncopied until the owner selects
1225
+ it. The resident Kit reads the saved choice through
1226
+ `POST /kit/working-copy/selection` when it starts, whenever its control stream
1227
+ connects or reconnects, and whenever House points it there after a save;
1228
+ `kit worker run` reads it once before its pass.
1229
+
1230
+ Each selected Room becomes one ordinary Git repository at
1231
+ `/agents/house/working-copies/rooms/<room_ref>`, and `/private` one at
1232
+ `/agents/house/working-copies/private`. The Room reference names the location,
1233
+ so a Room handle change moves nothing. Every Agent launched afterwards receives
1234
+ the House path and local repository of each active copy in its context, one
1235
+ line each, such as `/rooms/work: /agents/house/working-copies/rooms/r_1x.q`.
1236
+
1237
+ A Room repository holds the Room's complete readable content: `ROOM.md`,
1238
+ `library/`, `capture/` with its provenance sidecars, and `collaboration/`. The
1239
+ `/private` repository holds its Library, collaboration, each Room's private
1240
+ `AGENTS.md`, the `agents/` cards and `house/`. Attached settings and attachment
1241
+ bytes are not copied. The branch `main` starts at the commit House served, which
1242
+ tracks exactly the files House declares writable under the Environment's
1243
+ current grant. Every other file is read-only House content: the generated
1244
+ `.gitignore` names and explains it, so Git never tracks it, and the matching
1245
+ `.ignore` keeps it visible to ripgrep and other native search. Neither file
1246
+ grants anything, and House refuses a change to a read-only path however it is
1247
+ committed.
1248
+
1249
+ Local edits, staging and commits change nothing in House. `refs/house/received`
1250
+ records the House content last received. The per-file manifest, with each
1251
+ file's House revision, digest and writability, and the commit it corresponds to
1252
+ stay in Kit home under `state/working-copies/`, outside the repository. A local
1253
+ commit is a local tree, never a House revision.
1254
+
1255
+ Deselecting a Room, or losing read access to it, marks its copy inactive:
1256
+ launches omit it and nothing local is deleted. Selecting it again reads the Room
1257
+ again, advances `refs/house/received`, and replaces only the read-only content
1258
+ Kit wrote, never the branch, the index or a tracked file. A Room the owner may
1259
+ read without sync is refused as `operation_denied` and stays uncopied, while its
1260
+ ordinary remote reads are unaffected. Kit names each refused copy on its
1261
+ standard error.
1262
+
1213
1263
  ## Explicit synchronization
1214
1264
 
1215
- Beside the reconciling Working Copy, Kit has one explicit synchronization mode.
1265
+ Beside [working copies](#working-copies), Kit has one explicit synchronization mode.
1216
1266
  It selects prefixes over the User root, materializes them into a named local
1217
1267
  directory, enumerates whole content and removals after the recorded positions,
1218
1268
  and submits a resolved exact-base change set the caller prepared. Kit does no
@@ -1495,6 +1545,8 @@ $HOUSE_KIT_HOME/
1495
1545
  state/repositories/<repository identity>.json
1496
1546
  state/sync/<replica identity>.json
1497
1547
  state/sync/<replica identity>.pending.json
1548
+ state/working-copies/rooms/<room ref>.json
1549
+ state/working-copies/private.json
1498
1550
  state/attachments/<upload identity>.json
1499
1551
  state/conversation-originals/<attachment identity>.json
1500
1552
  state/sessions/<tmux session>.json
package/bin/kit.js CHANGED
@@ -177,7 +177,9 @@ async function runResidentDrain(args, directory) {
177
177
  write(
178
178
  answer.sessions.length > 0
179
179
  ? `House Kit is draining for its update and waits for Agent Sessions ${answer.sessions.join(", ")}.`
180
- : `House Kit is draining for its update; House refused its lease release (${answer.refused}).`,
180
+ : answer.serving === true
181
+ ? "House Kit is draining for its update and waits for the Kit command it serves."
182
+ : `House Kit is draining for its update; House refused its lease release (${answer.refused}).`,
181
183
  );
182
184
  process.exitCode = 1;
183
185
  }
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.12/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/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.12/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.14/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.12",
3
+ "version": "0.1.0-alpha.14",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contract.js CHANGED
@@ -291,6 +291,15 @@ export const KIT_DOOR_BATCH_PATH = "/kit/door/batch";
291
291
  export const EXPLICIT_SYNC_VERSION = "explicit-sync/2";
292
292
  export const KIT_DOOR_UNMOUNT_PATH = "/kit/door/unmount";
293
293
 
294
+ export const KIT_WORKING_COPY_SELECTION_PATH = "/kit/working-copy/selection";
295
+ export const WORKING_COPY_VERSION = "working-copy-state/1";
296
+ export const WORKING_COPIES_HOME = "working-copies";
297
+ export const WORKING_COPY_ROOM_HOMES = ["capture", "library", "collaboration"];
298
+ export const WORKING_COPY_PRIVATE_HOMES = ["library", "collaboration", "rooms", "agents", "house"];
299
+ export const WORKING_COPY_RECEIVED_REF = "refs/house/received";
300
+ export const WORKING_COPY_BRANCH = "main";
301
+ export const ROOM_REFERENCE = /^r_[0-9a-z]+\.[0-9a-z]$/;
302
+
294
303
  export const MIGRATOR_CONTRACT = "kit-migrator/1";
295
304
  export const MIGRATOR_CATEGORY = "migrator";
296
305
  export const MIGRATOR_MANIFEST = "migrator.json";
package/src/git.js ADDED
@@ -0,0 +1,62 @@
1
+ import { spawn } from "node:child_process";
2
+ import { KitRefusal } from "./refusal.js";
3
+
4
+ function cancelledOr(signal, error, code) {
5
+ return signal?.aborted === true || error?.name === "AbortError"
6
+ ? new KitRefusal("kit_command_cancelled")
7
+ : new KitRefusal(code);
8
+ }
9
+
10
+ function stdio(capture, input) {
11
+ if (input !== null) return ["pipe", capture ? "pipe" : "inherit", "inherit"];
12
+ return capture ? ["ignore", "pipe", "inherit"] : "inherit";
13
+ }
14
+
15
+ export function runGit(cwd, args, options = {}) {
16
+ const { dependencies = {}, capture = false, input = null, signal, failure } = options;
17
+ if (signal?.aborted === true) return Promise.reject(new KitRefusal("kit_command_cancelled"));
18
+ const launcher = dependencies.spawn ?? spawn;
19
+ return new Promise((resolve, reject) => {
20
+ let child;
21
+ try {
22
+ child = launcher("git", args, {
23
+ cwd,
24
+ env: process.env,
25
+ stdio: stdio(capture, input),
26
+ signal,
27
+ killSignal: "SIGKILL",
28
+ });
29
+ } catch (error) {
30
+ reject(cancelledOr(signal, error, "kit_git_unavailable"));
31
+ return;
32
+ }
33
+ const output = [];
34
+ let settled = false;
35
+ const finish = (operation) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ operation();
39
+ };
40
+ child.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk)));
41
+ child.once("error", (error) =>
42
+ finish(() => reject(cancelledOr(signal, error, "kit_git_unavailable"))),
43
+ );
44
+ child.once("close", (status) => {
45
+ finish(() => {
46
+ if (signal?.aborted === true) {
47
+ reject(new KitRefusal("kit_command_cancelled"));
48
+ return;
49
+ }
50
+ if (status !== 0) {
51
+ reject(new KitRefusal(failure));
52
+ return;
53
+ }
54
+ resolve(Buffer.concat(output));
55
+ });
56
+ });
57
+ if (input !== null) {
58
+ child.stdin.on("error", () => undefined);
59
+ child.stdin.end(input);
60
+ }
61
+ });
62
+ }
package/src/house.js CHANGED
@@ -56,6 +56,10 @@ async function readCredential(directory) {
56
56
  return stored;
57
57
  }
58
58
 
59
+ function latchRefusal(answer, dependencies) {
60
+ if (answer?.error?.code === UPGRADE_REQUIRED) dependencies.contractRefused?.();
61
+ }
62
+
59
63
  async function houseAnswer(house, path, init, dependencies, signal) {
60
64
  const fetcher = dependencies.fetch ?? fetch;
61
65
  let response;
@@ -73,7 +77,7 @@ async function houseAnswer(house, path, init, dependencies, signal) {
73
77
  } catch {
74
78
  answer = null;
75
79
  }
76
- if (answer?.error?.code === UPGRADE_REQUIRED) dependencies.contractRefused?.();
80
+ latchRefusal(answer, dependencies);
77
81
  throwHouseAnswer(response.status, answer, path);
78
82
  if (answer === null) throw new KitRefusal("kit_house_unavailable", path);
79
83
  return answer;
@@ -130,11 +134,18 @@ function residentAnswer(status, code) {
130
134
  return { status, type: "application/json", body };
131
135
  }
132
136
 
133
- async function residentRelay(session, request, signal) {
137
+ function newWork(path) {
138
+ return path === "/kit/telegram/updates" || (path.endsWith("/acquire") && path !== "/kit/commands/acquire");
139
+ }
140
+
141
+ async function residentRelay(session, request, signal, draining) {
134
142
  const path = typeof request.path === "string" ? request.path : "";
135
143
  if (!path.startsWith("/kit/") || path.startsWith("/kit/lease")) {
136
144
  return residentAnswer(403, "kit_resident_route_refused");
137
145
  }
146
+ if (draining && newWork(path)) {
147
+ return residentAnswer(409, UPDATE_DRAINING);
148
+ }
138
149
  let authorization;
139
150
  try {
140
151
  authorization = await heldControl(session, signal);
@@ -149,6 +160,7 @@ async function residentRelay(session, request, signal) {
149
160
  signal,
150
161
  });
151
162
  const body = Buffer.from(await response.arrayBuffer());
163
+ if (response.status === 409) latchRefusal(JSON.parse(body.toString("utf8")), session.dependencies);
152
164
  return {
153
165
  status: response.status,
154
166
  type: response.headers.get("content-type"),
@@ -168,9 +180,7 @@ export async function serveResident(session, directory, signal, drain) {
168
180
  let draining = false;
169
181
  function answerDrain(connection) {
170
182
  draining = true;
171
- const answered = drain();
172
- holder?.destroy();
173
- answered.then(
183
+ drain(holder !== null).then(
174
184
  (answer) => {
175
185
  if (!connection.destroyed) writeFrame(connection, answer);
176
186
  connection.end();
@@ -181,7 +191,7 @@ export async function serveResident(session, directory, signal, drain) {
181
191
  }
182
192
  function hold(connection) {
183
193
  if (holder !== null || draining || existsSync(updateDrain(directory))) {
184
- writeFrame(connection, { refused: holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
194
+ writeFrame(connection, { refused: draining || holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
185
195
  connection.end();
186
196
  return false;
187
197
  }
@@ -218,7 +228,7 @@ export async function serveResident(session, directory, signal, drain) {
218
228
  }
219
229
  const cancelled = new AbortController();
220
230
  relayed.set(request.id, cancelled);
221
- void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal])).then(
231
+ void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal]), draining).then(
222
232
  (answer) => {
223
233
  relayed.delete(request.id);
224
234
  if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
@@ -5,6 +5,7 @@ import { heldControl } from "./house.js";
5
5
  import { KitRefusal } from "./refusal.js";
6
6
 
7
7
  const PROTOCOL = "house.kit.stream.1";
8
+ const WATCHED_SUBJECTS = ["agents", "working_copy"];
8
9
 
9
10
  function streamAddress(house) {
10
11
  const url = new URL("/kit/stream", house);
@@ -37,7 +38,7 @@ async function connected(session, signal, available, dependencies) {
37
38
  finish();
38
39
  };
39
40
  signal.addEventListener("abort", stop, { once: true });
40
- socket.on("open", available);
41
+ socket.on("open", () => available(null));
41
42
  socket.on("message", (data) => {
42
43
  let frame;
43
44
  try {
@@ -45,7 +46,9 @@ async function connected(session, signal, available, dependencies) {
45
46
  } catch {
46
47
  return;
47
48
  }
48
- if (frame?.type === "work_available" && frame.subject === "agents") available();
49
+ if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
50
+ available(frame.subject);
51
+ }
49
52
  });
50
53
  socket.on("unexpected-response", (_request, response) => {
51
54
  response.resume();
@@ -64,7 +67,7 @@ async function connected(session, signal, available, dependencies) {
64
67
  });
65
68
  }
66
69
 
67
- export async function watchKitAgentWork(session, signal, available, dependencies = {}) {
70
+ export async function watchKitWork(session, signal, available, dependencies = {}) {
68
71
  let retryMs = 500;
69
72
  while (!signal.aborted) {
70
73
  const began = Date.now();
package/src/launcher.js CHANGED
@@ -129,6 +129,12 @@ async function configureSession(acp, contract, sessionId, adapter) {
129
129
  }
130
130
  }
131
131
 
132
+ function workingCopyContext(copies) {
133
+ if (!Array.isArray(copies) || copies.length === 0) return [];
134
+ const lines = copies.map((copy) => `${copy.path}: ${copy.repository}`);
135
+ return [{ type: "text", text: ["Local Git working copies of House paths:", ...lines].join("\n") }];
136
+ }
137
+
132
138
  async function runTurn(channel, start, bridge, turn) {
133
139
  const contract = compiledContract(start.adapter?.kind);
134
140
  const adapter = startCommandAdapter(
@@ -193,6 +199,7 @@ async function runTurn(channel, start, bridge, turn) {
193
199
  prompt: [
194
200
  { type: "text", text: JSON.stringify(request) },
195
201
  { type: "text", text: instructions },
202
+ ...workingCopyContext(start.working_copies),
196
203
  ],
197
204
  });
198
205
  await new Promise((resolve) => setImmediate(resolve));
package/src/repository.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { spawn } from "node:child_process";
3
2
  import { constants } from "node:fs";
4
3
  import { open, readlink, realpath } from "node:fs/promises";
5
4
  import { join } from "node:path";
5
+ import { runGit } from "./git.js";
6
6
  import { deliverPublicationChunk, maintainControl, openPublicationManifest, openSession } from "./house.js";
7
7
  import { KitRefusal } from "./refusal.js";
8
8
  import { readRepositoryState, writeRepositoryState } from "./state.js";
@@ -258,69 +258,11 @@ async function* chunkArchive(checkout, entries, signal) {
258
258
  yield Buffer.alloc(BLOCK_BYTES * 2);
259
259
  }
260
260
 
261
- function runGit(checkout, args, dependencies, capture, signal) {
262
- active(signal);
263
- const launcher = dependencies.spawn ?? spawn;
264
- return new Promise((resolve, reject) => {
265
- let child;
266
- try {
267
- child = launcher("git", args, {
268
- cwd: checkout,
269
- env: process.env,
270
- stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit",
271
- signal,
272
- killSignal: "SIGKILL",
273
- });
274
- } catch (error) {
275
- reject(
276
- signal?.aborted === true || error?.name === "AbortError"
277
- ? new KitRefusal("kit_command_cancelled")
278
- : new KitRefusal("kit_git_unavailable"),
279
- );
280
- return;
281
- }
282
- const output = [];
283
- let settled = false;
284
- const finish = (operation) => {
285
- if (settled) return;
286
- settled = true;
287
- operation();
288
- };
289
- child.stdout?.on("data", (chunk) => output.push(Buffer.from(chunk)));
290
- child.once("error", (error) =>
291
- finish(() =>
292
- reject(
293
- signal?.aborted === true || error?.name === "AbortError"
294
- ? new KitRefusal("kit_command_cancelled")
295
- : new KitRefusal("kit_git_unavailable"),
296
- ),
297
- ),
298
- );
299
- child.once("close", (status) => {
300
- finish(() => {
301
- if (signal?.aborted === true) {
302
- reject(new KitRefusal("kit_command_cancelled"));
303
- return;
304
- }
305
- if (status !== 0) {
306
- reject(
307
- new KitRefusal(args[0] === "pull" ? "kit_git_pull_failed" : "kit_git_selection_failed"),
308
- );
309
- return;
310
- }
311
- resolve(Buffer.concat(output));
312
- });
313
- });
314
- });
315
- }
316
-
317
261
  async function selectedPaths(checkout, dependencies, signal) {
318
262
  const output = await runGit(
319
263
  checkout,
320
264
  ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
321
- dependencies,
322
- true,
323
- signal,
265
+ { dependencies, capture: true, signal, failure: "kit_git_selection_failed" },
324
266
  );
325
267
  const paths = [];
326
268
  let offset = 0;
@@ -469,7 +411,7 @@ export async function captureRepository(options, dependencies = {}) {
469
411
  session,
470
412
  async (signal) => {
471
413
  const state = stateOf(await readRepositoryState(options.directory, key), identity);
472
- await runGit(identity.checkout, ["pull"], dependencies, false, signal);
414
+ await runGit(identity.checkout, ["pull"], { dependencies, signal, failure: "kit_git_pull_failed" });
473
415
  const now = dependencies.now ?? (() => new Date());
474
416
  let sourceRef = state.source_ref;
475
417
  let publication = null;
package/src/sync.js CHANGED
@@ -46,7 +46,7 @@ async function withSession(options, dependencies, operation) {
46
46
  return maintainControl(session, (signal) => operation(session, signal), options.signal);
47
47
  }
48
48
 
49
- async function door(session, path, body, signal) {
49
+ export async function door(session, path, body, signal) {
50
50
  return houseRequest(
51
51
  session.house,
52
52
  await heldControl(session, signal),
@@ -83,7 +83,7 @@ function changePosition(value, declaration) {
83
83
  return { position: value.position, log_epoch: value.log_epoch };
84
84
  }
85
85
 
86
- function discoveryOf(value) {
86
+ export function discoveryOf(value) {
87
87
  if (typeof value?.md_model_version !== "string" || !Array.isArray(value.authorities)) {
88
88
  throw new KitRefusal("kit_sync_rejected", "discover");
89
89
  }
@@ -93,7 +93,7 @@ function discoveryOf(value) {
93
93
  return value;
94
94
  }
95
95
 
96
- async function bootstrapBundle(session, prefixes, signal) {
96
+ export async function bootstrapBundle(session, prefixes, signal) {
97
97
  const answer = await door(session, KIT_DOOR_BOOTSTRAP_PATH, { prefixes }, signal);
98
98
  if (typeof answer?.transfer?.url === "string") {
99
99
  const granted = await houseGetJson(answer.transfer.url, session.dependencies, signal);
package/src/telegram.js CHANGED
@@ -863,20 +863,21 @@ async function onePass(gateway, activity, work) {
863
863
  try {
864
864
  await work(gateway);
865
865
  } catch (error) {
866
- if (cancelled(error) || !(error instanceof KitRefusal)) throw error;
866
+ if (cancelled(error) || !(error instanceof KitRefusal) || error.code === "kit_update_draining") throw error;
867
867
  gateway.summary.failures.push({ activity, item: null, code: error.code });
868
868
  }
869
869
  }
870
870
 
871
871
  async function paceActivity(gateway, stopping, activity, work) {
872
872
  try {
873
- for (let index = 0; index < gateway.passes; index += 1) {
873
+ for (let index = 0; index < gateway.passes && !gateway.draining; index += 1) {
874
874
  if (gateway.signal?.aborted === true) throw new KitRefusal("kit_command_cancelled");
875
875
  await onePass(gateway, activity, work);
876
876
  if (index + 1 < gateway.passes) await gateway.wait(TELEGRAM_GATEWAY_POLL_MS, gateway.signal);
877
877
  }
878
878
  } catch (error) {
879
- stopping.abort();
879
+ if (error.code === "kit_update_draining") gateway.draining = true;
880
+ else stopping.abort();
880
881
  throw error;
881
882
  }
882
883
  }
@@ -964,6 +965,7 @@ function gatewayContext(session, options, signal, summary = emptySummary()) {
964
965
  gate: rateGate(() => session.now().getTime()),
965
966
  wait: session.dependencies.wait ?? pause,
966
967
  passes: options.passes ?? Number.POSITIVE_INFINITY,
968
+ draining: false,
967
969
  summary,
968
970
  };
969
971
  }
package/src/worker.js CHANGED
@@ -17,7 +17,8 @@ import { openAssistantDrafts } from "./draft.js";
17
17
  import { localReply, localRequest, reconcileInbound } from "./inbound.js";
18
18
  import { localFiles } from "./local-files.js";
19
19
  import { inheritedEnvironment } from "./command.js";
20
- import { watchKitAgentWork } from "./kit-work-stream.js";
20
+ import { watchKitWork } from "./kit-work-stream.js";
21
+ import { applyWorkingCopies, workingCopyPaths } from "./working-copy.js";
21
22
  import { readConfiguration } from "./configuration.js";
22
23
  import { launchBase, readyLaunchDirectory } from "./directory.js";
23
24
  import {
@@ -177,6 +178,7 @@ async function supervise(context, bound, bridge) {
177
178
  while (live) {
178
179
  await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
179
180
  if (!live) return null;
181
+ await context.update?.announce();
180
182
  if (Date.now() >= commanded) {
181
183
  commanded = Date.now() + WORKER_COMMAND_POLL_MS;
182
184
  const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
@@ -435,6 +437,11 @@ function assignmentRoute(context, assignment) {
435
437
  return assignment.route;
436
438
  }
437
439
 
440
+ async function appliedWorkingCopies(context) {
441
+ await context.workingCopiesApplied?.();
442
+ return workingCopyPaths(context.directory);
443
+ }
444
+
438
445
  async function prepareAssignment(context, assignment, resumed) {
439
446
  const route = assignmentRoute(context, assignment);
440
447
  const command = await installedAgentCommand(context.directory, route.adapter.kind);
@@ -462,6 +469,7 @@ async function prepareAssignment(context, assignment, resumed) {
462
469
  const authority = { token: assignment.session.token, expiresAt: assignment.session.expiresAt };
463
470
  const opened = await openRunBridge(context, bound, authority, {
464
471
  working_directory: assignment.workingDirectory,
472
+ working_copies: await appliedWorkingCopies(context),
465
473
  execution_authority: context.configuration.executionAuthority,
466
474
  adapter: {
467
475
  kind: route.adapter.kind,
@@ -793,6 +801,7 @@ export async function runWorkers(options, dependencies = {}) {
793
801
  const { context, tmux } = await workerContext(options, dependencies);
794
802
  const { session, runtime } = context;
795
803
  const supervised = await maintainControl(session, async (signal) => {
804
+ await applySavedWorkingCopies(context, signal);
796
805
  return controlledPass(context, dependencies, context.configuration, signal);
797
806
  }, session.signal);
798
807
  return {
@@ -809,6 +818,41 @@ export async function runWorkers(options, dependencies = {}) {
809
818
  };
810
819
  }
811
820
 
821
+ function reported(line) {
822
+ process.stderr.write(`kit: ${line}\n`);
823
+ }
824
+
825
+ async function applySavedWorkingCopies(context, signal) {
826
+ const applied = await applyWorkingCopies(context, signal);
827
+ for (const refused of applied.refused) {
828
+ reported(`working copy ${refused.path} refused: ${refused.code}`);
829
+ }
830
+ }
831
+
832
+ function coalescedTask(signal, fail, work) {
833
+ let pending = false;
834
+ let task = null;
835
+ const trigger = () => {
836
+ pending = true;
837
+ if (task !== null) return;
838
+ task = (async () => {
839
+ while (pending && !signal.aborted) {
840
+ pending = false;
841
+ await work();
842
+ }
843
+ })()
844
+ .catch((error) => {
845
+ if (signal.aborted) return;
846
+ fail(error);
847
+ })
848
+ .finally(() => {
849
+ task = null;
850
+ if (pending && !signal.aborted) trigger();
851
+ });
852
+ };
853
+ return { trigger, idle: () => task === null, settled: () => task };
854
+ }
855
+
812
856
  function residentWake() {
813
857
  let pending = false;
814
858
  let release = null;
@@ -849,20 +893,26 @@ function residentUpdate(context, wake) {
849
893
  get requested() {
850
894
  return requested;
851
895
  },
852
- async request() {
896
+ async request(serving) {
853
897
  if (!requested) {
854
898
  requested = true;
855
899
  await writeText(updateDrain(context.directory), "");
856
900
  }
857
901
  wake.notify();
858
- if (ready !== null) return ready;
859
902
  const sessions = await heldSessions(context);
903
+ if (ready !== null) return ready;
904
+ if (serving) return { ready: false, sessions, serving };
860
905
  if (sessions.length > 0) return { ready: false, sessions };
861
906
  return new Promise((resolve) => waiting.push(resolve));
862
907
  },
863
908
  answer(answer) {
864
909
  for (const resolve of waiting.splice(0)) resolve(answer);
865
910
  },
911
+ async announce() {
912
+ if (waiting.length === 0) return;
913
+ const sessions = await heldSessions(context);
914
+ if (sessions.length > 0) this.answer({ ready: false, sessions });
915
+ },
866
916
  quiesce() {
867
917
  ready = { ready: true, sessions: [] };
868
918
  this.answer(ready);
@@ -902,7 +952,7 @@ export async function runResident(options, dependencies = {}) {
902
952
  const wake = residentWake();
903
953
  const update = residentUpdate(context, wake);
904
954
  context.update = update;
905
- const served = await serveResident(session, context.directory, options.signal, () => update.request());
955
+ const served = await serveResident(session, context.directory, options.signal, (serving) => update.request(serving));
906
956
  const serve = async (signal) => {
907
957
  context.busy = true;
908
958
  const watcher = new AbortController();
@@ -953,33 +1003,30 @@ export async function runResident(options, dependencies = {}) {
953
1003
  context.waitAgentLogin = async (kind) => {
954
1004
  await logins.get(kind)?.task;
955
1005
  };
956
- let pointerPending = false;
957
- let pointerTask = null;
1006
+ const agentRefresh = coalescedTask(signal, fail, async () => {
1007
+ const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
1008
+ context.startLogins(needsLogin);
1009
+ });
1010
+ const copies = coalescedTask(
1011
+ signal,
1012
+ (error) => reported(`working copies unavailable: ${error?.message ?? error}`),
1013
+ () => applySavedWorkingCopies(context, signal),
1014
+ );
958
1015
  const pointer = () => {
1016
+ if (!update.requested) agentRefresh.trigger();
1017
+ };
1018
+ const available = (subject) => {
959
1019
  if (update.requested) return;
960
- pointerPending = true;
961
- if (pointerTask !== null) return;
962
- pointerTask = (async () => {
963
- while (pointerPending && !signal.aborted) {
964
- pointerPending = false;
965
- const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
966
- context.startLogins(needsLogin);
967
- }
968
- })()
969
- .catch((error) => {
970
- if (signal.aborted) return;
971
- fail(error);
972
- })
973
- .finally(() => {
974
- pointerTask = null;
975
- if (pointerPending && !signal.aborted) pointer();
976
- });
1020
+ if (subject !== "working_copy") agentRefresh.trigger();
1021
+ if (subject !== "agents") copies.trigger();
977
1022
  };
978
1023
  context.onIdle = pointer;
979
- const watching = (dependencies.watchKitAgentWork ?? watchKitAgentWork)(
1024
+ context.workingCopiesApplied = copies.settled;
1025
+ if (!update.requested) copies.trigger();
1026
+ const watching = (dependencies.watchKitWork ?? watchKitWork)(
980
1027
  context.session,
981
1028
  AbortSignal.any([signal, watcher.signal]),
982
- pointer,
1029
+ available,
983
1030
  dependencies,
984
1031
  ).catch(fail);
985
1032
  try {
@@ -994,14 +1041,20 @@ export async function runResident(options, dependencies = {}) {
994
1041
  if (session.refused || update.requested) {
995
1042
  const sessions = await heldSessions(context);
996
1043
  if (sessions.length > 0) update.answer({ ready: false, sessions });
997
- else if (session.refused || (!served.serving() && logins.size === 0 && pointerTask === null)) return;
1044
+ else if (
1045
+ session.refused ||
1046
+ (!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle())
1047
+ ) {
1048
+ return;
1049
+ }
998
1050
  }
999
1051
  await wake.wait(interval, signal);
1000
1052
  }
1001
1053
  } finally {
1002
1054
  watcher.abort();
1003
1055
  await watching;
1004
- if (pointerTask !== null) await pointerTask;
1056
+ await agentRefresh.settled();
1057
+ await copies.settled();
1005
1058
  for (const running of logins.values()) running.controller.abort();
1006
1059
  await Promise.all([...logins.values()].map((running) => running.task));
1007
1060
  await context.agentRefresh;
@@ -0,0 +1,389 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ KIT_DOOR_DISCOVER_PATH,
6
+ KIT_WORKING_COPY_SELECTION_PATH,
7
+ ROOM_REFERENCE,
8
+ WORKING_COPIES_HOME,
9
+ WORKING_COPY_BRANCH,
10
+ WORKING_COPY_PRIVATE_HOMES,
11
+ WORKING_COPY_RECEIVED_REF,
12
+ WORKING_COPY_ROOM_HOMES,
13
+ WORKING_COPY_VERSION,
14
+ } from "./contract.js";
15
+ import { runGit } from "./git.js";
16
+ import { houseAnswered, KitRefusal } from "./refusal.js";
17
+ import { readNote, writeNote, writeText } from "./state.js";
18
+ import { bootstrapBundle, discoveryOf, door } from "./sync.js";
19
+ import { sidecarPath } from "./sync-library.js";
20
+ import { locate, removeReplicaFile, writeReplicaFile } from "./sync-state.js";
21
+
22
+ const COMMITTER = "House Kit <house-kit@localhost>";
23
+ const GIT_FAILED = "kit_working_copy_git_failed";
24
+
25
+ function rejected(detail = null) {
26
+ return new KitRefusal("kit_working_copy_rejected", detail);
27
+ }
28
+
29
+ function stateHome(directory) {
30
+ return join(directory, "state", "working-copies");
31
+ }
32
+
33
+ function statePath(directory, scope) {
34
+ if (scope.kind === "private") return join(stateHome(directory), "private.json");
35
+ if (!ROOM_REFERENCE.test(scope.ref)) throw new KitRefusal("kit_state_name_invalid", scope.ref);
36
+ return join(stateHome(directory), "rooms", `${scope.ref}.json`);
37
+ }
38
+
39
+ function repositoryPath(base, scope) {
40
+ const root = join(base, WORKING_COPIES_HOME);
41
+ return scope.kind === "private" ? join(root, "private") : join(root, "rooms", scope.ref);
42
+ }
43
+
44
+ function housePath(scope) {
45
+ return scope.kind === "private" ? "/private" : `/rooms/${scope.handle}`;
46
+ }
47
+
48
+ function sameScope(copy, scope) {
49
+ return copy.scope === scope.kind && (scope.kind === "private" || copy.room_ref === scope.ref);
50
+ }
51
+
52
+ function selectedScope(room) {
53
+ if (
54
+ typeof room?.room_ref !== "string" ||
55
+ !ROOM_REFERENCE.test(room.room_ref) ||
56
+ (room.room_handle !== null && (typeof room.room_handle !== "string" || room.room_handle === ""))
57
+ ) {
58
+ throw rejected("selection");
59
+ }
60
+ return { kind: "room", ref: room.room_ref, handle: room.room_handle };
61
+ }
62
+
63
+ async function savedSelection(session, signal) {
64
+ const answer = await door(session, KIT_WORKING_COPY_SELECTION_PATH, {}, signal);
65
+ const copy = answer?.working_copy;
66
+ if (copy === null) return [];
67
+ if (!Array.isArray(copy?.rooms) || typeof copy.private !== "boolean") throw rejected("selection");
68
+ return [
69
+ ...copy.rooms.map(selectedScope),
70
+ ...(copy.private ? [{ kind: "private", ref: null, handle: null }] : []),
71
+ ];
72
+ }
73
+
74
+ function selectedPrefixes(scope) {
75
+ const homes = scope.kind === "private" ? WORKING_COPY_PRIVATE_HOMES : WORKING_COPY_ROOM_HOMES;
76
+ return homes.map((home) => `${housePath(scope)}/${home}`);
77
+ }
78
+
79
+ function receivedFiles(view) {
80
+ const manifest = view?.manifest;
81
+ if (
82
+ !Array.isArray(manifest?.files) ||
83
+ !Array.isArray(view.contents) ||
84
+ typeof manifest.anchor !== "string" ||
85
+ typeof manifest.position !== "string" ||
86
+ typeof manifest.log_epoch !== "string"
87
+ ) {
88
+ throw rejected("bootstrap");
89
+ }
90
+ const contents = new Map();
91
+ for (const entry of view.contents) {
92
+ if (typeof entry?.path !== "string" || typeof entry.content !== "string") throw rejected("bootstrap");
93
+ contents.set(entry.path, entry.content);
94
+ }
95
+ return manifest.files.map((file) => {
96
+ const content = contents.get(file?.path);
97
+ if (
98
+ content === undefined ||
99
+ typeof file.revision !== "string" ||
100
+ typeof file.sha256 !== "string" ||
101
+ typeof file.writable !== "boolean"
102
+ ) {
103
+ throw rejected("bootstrap");
104
+ }
105
+ return {
106
+ path: file.path,
107
+ revision: file.revision,
108
+ sha256: file.sha256,
109
+ writable: file.writable,
110
+ content,
111
+ provenance: typeof file.provenance === "string" ? file.provenance : null,
112
+ };
113
+ });
114
+ }
115
+
116
+ async function readScope(session, scope, signal) {
117
+ const prefixes = selectedPrefixes(scope);
118
+ const discovery = discoveryOf(await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
119
+ const bundle = await bootstrapBundle(session, prefixes, signal);
120
+ if (bundle.scopes.length === 0) throw new KitRefusal("kit_working_copy_unavailable", housePath(scope));
121
+ if (bundle.scopes.length !== 1) throw rejected("bootstrap");
122
+ const [view] = bundle.scopes;
123
+ const files = receivedFiles(view);
124
+ const authority = discovery.authorities.find(
125
+ (entry) => entry?.room_ref === view.manifest.anchor,
126
+ );
127
+ if (
128
+ authority === undefined ||
129
+ !Array.isArray(authority.protected_paths) ||
130
+ (scope.kind === "room" && view.manifest.anchor !== scope.ref)
131
+ ) {
132
+ throw rejected("discover");
133
+ }
134
+ return {
135
+ anchor: view.manifest.anchor,
136
+ position: view.manifest.position,
137
+ logEpoch: view.manifest.log_epoch,
138
+ protectedPaths: authority.protected_paths.filter((path) => typeof path === "string"),
139
+ files,
140
+ };
141
+ }
142
+
143
+ function importData(text) {
144
+ const bytes = Buffer.from(text, "utf8");
145
+ return [Buffer.from(`data ${bytes.length}\n`), bytes, Buffer.from("\n")];
146
+ }
147
+
148
+ function quotedPath(path) {
149
+ return `"${path.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\n")}"`;
150
+ }
151
+
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`),
156
+ Buffer.from(`committer ${COMMITTER} ${Math.floor(Date.now() / 1000)} +0000\n`),
157
+ ...importData(message),
158
+ ...(parent === null ? [] : [Buffer.from(`from ${parent}\n`)]),
159
+ Buffer.from("deleteall\n"),
160
+ ];
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));
163
+ }
164
+ lines.push(Buffer.from("\n"));
165
+ await runGit(repository, ["fast-import", "--quiet", "--force"], {
166
+ input: Buffer.concat(lines),
167
+ signal,
168
+ failure: GIT_FAILED,
169
+ });
170
+ const commit = await runGit(repository, ["rev-parse", "--verify", `${WORKING_COPY_RECEIVED_REF}^{commit}`], {
171
+ capture: true,
172
+ signal,
173
+ failure: GIT_FAILED,
174
+ });
175
+ return commit.toString("utf8").trim();
176
+ }
177
+
178
+ function readOnlyContents(read) {
179
+ const contents = new Map();
180
+ for (const file of read.files) {
181
+ if (!file.writable) contents.set(file.path, file.content);
182
+ if (file.provenance !== null) contents.set(sidecarPath(file.path), file.provenance);
183
+ }
184
+ return contents;
185
+ }
186
+
187
+ async function present(path) {
188
+ try {
189
+ await stat(path);
190
+ return true;
191
+ } catch (error) {
192
+ if (error.code === "ENOENT") return false;
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ async function trackedPaths(repository, signal) {
198
+ const listed = await runGit(repository, ["ls-files", "-z"], { capture: true, signal, failure: GIT_FAILED });
199
+ return new Set(listed.toString("utf8").split("\0").filter((path) => path !== ""));
200
+ }
201
+
202
+ async function replaceReadOnly(repository, previous, next, signal) {
203
+ const tracked = await trackedPaths(repository, signal);
204
+ const held = new Set(previous);
205
+ for (const [path, content] of next) {
206
+ if (tracked.has(path)) continue;
207
+ const target = locate(repository, "", path);
208
+ if (!held.has(path) && (await present(target))) continue;
209
+ await writeReplicaFile(target, content, signal);
210
+ }
211
+ for (const path of held) {
212
+ if (next.has(path) || tracked.has(path)) continue;
213
+ await removeReplicaFile(locate(repository, "", path));
214
+ }
215
+ }
216
+
217
+ function covers(path, prefix) {
218
+ return path === prefix || path.startsWith(`${prefix}/`);
219
+ }
220
+
221
+ function ignorePattern(path) {
222
+ return `/${path.replace(/[\\*?[]/g, "\\$&").replace(/ +$/, (spaces) => spaces.replace(/ /g, "\\ "))}`;
223
+ }
224
+
225
+ function ignoreGroups(read) {
226
+ const writable = read.files.filter((file) => file.writable).map((file) => file.path);
227
+ const readOnly = read.files.filter((file) => !file.writable).map((file) => file.path);
228
+ const whole = read.protectedPaths.filter((path) => !writable.some((file) => covers(file, path)));
229
+ const protectedPath = (path) => read.protectedPaths.some((prefix) => covers(path, prefix));
230
+ const house = [
231
+ ...whole,
232
+ ...readOnly.filter((path) => protectedPath(path) && !whole.some((prefix) => covers(path, prefix))),
233
+ ];
234
+ const granted = readOnly.filter((path) => !protectedPath(path));
235
+ return { house: house.map(ignorePattern), granted: granted.map(ignorePattern) };
236
+ }
237
+
238
+ async function writeIgnoreRules(repository, read, signal) {
239
+ const { house, granted } = ignoreGroups(read);
240
+ const gitignore = [
241
+ "# House Kit writes this file and .ignore. The paths below are read-only House",
242
+ "# content: Git does not track them, and House refuses a change to them even",
243
+ "# when this file is edited or a path is force-added.",
244
+ "/.gitignore",
245
+ "/.ignore",
246
+ "_provenance",
247
+ "",
248
+ "# House maintains or protects these paths.",
249
+ ...house,
250
+ "",
251
+ "# Your current House grant does not allow writing these paths.",
252
+ ...granted,
253
+ "",
254
+ ];
255
+ const ignore = [
256
+ "# House Kit writes this file so native search still reads the read-only House",
257
+ "# content that .gitignore keeps out of Git.",
258
+ "!_provenance",
259
+ ...[...house, ...granted].map((pattern) => `!${pattern}`),
260
+ "",
261
+ ];
262
+ await writeText(join(repository, ".gitignore"), gitignore.join("\n"), signal, 0o644);
263
+ await writeText(join(repository, ".ignore"), ignore.join("\n"), signal, 0o644);
264
+ }
265
+
266
+ function manifest(read, commit) {
267
+ return {
268
+ commit,
269
+ position: read.position,
270
+ log_epoch: read.logEpoch,
271
+ files: read.files.map(({ path, revision, sha256, writable }) => ({ path, revision, sha256, writable })),
272
+ read_only: [...readOnlyContents(read).keys()],
273
+ };
274
+ }
275
+
276
+ async function writeCopy(directory, copy, signal) {
277
+ await writeNote(statePath(directory, { kind: copy.scope, ref: copy.room_ref }), copy, signal);
278
+ }
279
+
280
+ async function heldCopies(directory) {
281
+ const rooms = join(stateHome(directory), "rooms");
282
+ let names = [];
283
+ try {
284
+ names = await readdir(rooms);
285
+ } catch (error) {
286
+ if (error.code !== "ENOENT") throw new KitRefusal("kit_state_unreadable", rooms);
287
+ }
288
+ const held = [];
289
+ for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) {
290
+ held.push(await readNote(join(rooms, name), null));
291
+ }
292
+ held.push(await readNote(statePath(directory, { kind: "private" }), null));
293
+ return held.filter((copy) => copy?.version === WORKING_COPY_VERSION);
294
+ }
295
+
296
+ async function materialize(context, scope, signal) {
297
+ const read = await readScope(context.session, scope, signal);
298
+ const identity = { kind: scope.kind, ref: read.anchor };
299
+ const repository = repositoryPath(context.launchBase, identity);
300
+ if (await present(repository)) throw new KitRefusal("kit_working_copy_unmanaged", repository);
301
+ const staging = join(dirname(repository), `.${randomUUID()}.staging`);
302
+ await mkdir(staging, { recursive: true });
303
+ let commit;
304
+ try {
305
+ await runGit(staging, ["init", "-q", "-b", WORKING_COPY_BRANCH], { signal, failure: GIT_FAILED });
306
+ commit = await importReceived(staging, scope, read, null, signal);
307
+ await runGit(staging, ["update-ref", `refs/heads/${WORKING_COPY_BRANCH}`, commit], {
308
+ signal,
309
+ failure: GIT_FAILED,
310
+ });
311
+ await runGit(staging, ["read-tree", "-m", "-u", "HEAD"], { signal, failure: GIT_FAILED });
312
+ await replaceReadOnly(staging, [], readOnlyContents(read), signal);
313
+ await writeIgnoreRules(staging, read, signal);
314
+ await rename(staging, repository);
315
+ } catch (error) {
316
+ await rm(staging, { recursive: true, force: true });
317
+ throw error;
318
+ }
319
+ const received = manifest(read, commit);
320
+ await writeCopy(context.directory, {
321
+ version: WORKING_COPY_VERSION,
322
+ scope: scope.kind,
323
+ room_ref: read.anchor,
324
+ room_handle: scope.handle,
325
+ repository,
326
+ active: true,
327
+ base: received,
328
+ received,
329
+ });
330
+ }
331
+
332
+ async function refresh(context, copy, scope, signal) {
333
+ if (!(await present(copy.repository))) {
334
+ await rm(statePath(context.directory, { kind: copy.scope, ref: copy.room_ref }), { force: true });
335
+ await materialize(context, scope, signal);
336
+ return;
337
+ }
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
+ );
348
+ }
349
+
350
+ function scopeRefused(error) {
351
+ return error instanceof KitRefusal && (houseAnswered(error) || error.code.startsWith("kit_working_copy_"));
352
+ }
353
+
354
+ export async function applyWorkingCopies(context, signal) {
355
+ const selected = (await savedSelection(context.session, signal)).filter(
356
+ (scope) => scope.kind === "private" || scope.handle !== null,
357
+ );
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
+ }
363
+ }
364
+ const refused = [];
365
+ for (const scope of selected) {
366
+ const copy = held.find((entry) => sameScope(entry, scope));
367
+ 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
+ }
373
+ } catch (error) {
374
+ if (!scopeRefused(error)) throw error;
375
+ refused.push({ path: housePath(scope), code: error.code });
376
+ }
377
+ }
378
+ return { refused };
379
+ }
380
+
381
+ export async function workingCopyPaths(directory) {
382
+ return (await heldCopies(directory))
383
+ .filter((copy) => copy.active)
384
+ .map((copy) => ({
385
+ path: housePath({ kind: copy.scope, handle: copy.room_handle }),
386
+ repository: copy.repository,
387
+ }))
388
+ .sort((left, right) => (left.path < right.path ? -1 : 1));
389
+ }