@agentshouse/kit 0.1.0-alpha.27 → 0.1.0-alpha.28

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
@@ -12,7 +12,7 @@ The Skill set Kit installs is Matt Pocock's
12
12
  On Linux `amd64` or `arm64`, run the published, versioned bootstrap once:
13
13
 
14
14
  ```sh
15
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.27/connect-linux.sh)
15
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/connect-linux.sh)
16
16
  ```
17
17
 
18
18
  The bootstrap first establishes Docker Engine from what it observes on the
@@ -446,7 +446,9 @@ kit setup
446
446
  ```
447
447
 
448
448
  `kit worker run` performs the same step at the start of a pass, before it
449
- hands out any work, so a managed Environment needs no terminal. The walk first
449
+ hands out any work, so a managed Environment needs no terminal; the resident
450
+ Kit performs it each time its control stream connects and on each `runs`
451
+ signal, which House also sends for every setup request. The walk first
450
452
  prepares the absolute `/agents/house` base, then visits every declared agent
451
453
  CLI and then every connector instance, in configuration order:
452
454
 
@@ -557,6 +559,39 @@ send one JSON line
557
559
  with `action: "authorization"` and `mode: "status"` or `mode: "login"`; the
558
560
  command returns exactly one JSON object such as `{ "authorized": true }`.
559
561
 
562
+ ## The Kit control stream
563
+
564
+ The resident Kit holds one `/kit/stream` WebSocket for its Environment,
565
+ authenticated with its Environment control lease and the Kit contract header,
566
+ for as long as it holds that lease; `kit worker run` holds one for the length
567
+ of its pass to receive its owner's commands. The Kit sends nothing on it and
568
+ accepts every frame House sends.
569
+
570
+ Each time the socket connects, the resident acquires everything a signal could
571
+ have named, because a signal House sent while the socket was down is lost: its
572
+ Runs and resumptions with their setup and conversation files, the owner's
573
+ pending commands, the agent CLI list, the saved working copy choice and its
574
+ Google Sources. After that it acquires only what House names. A
575
+ `work_available` frame for `agents`, `working_copy` or `sources` acquires that
576
+ subject alone. `runs` takes a resumption or else a waiting Run, readying the
577
+ agent CLIs, a requested setup and conversation files first. `commands` hands
578
+ the supervised Session its stops, interrupts and answers and takes a
579
+ resumption the same way, without acquiring a Run. An `entries` frame, a change
580
+ in a Room the lease admits, delivers the working copies. A pass that took a Run
581
+ acquires again once that Run ends, and a pass that found nothing waits for the
582
+ next signal. A Run outcome House did not take is reported again every 30
583
+ seconds until House answers. The Kit has no idle acquisition cycle, no command
584
+ poll and no Session heartbeat: a Session is observed when its process starts
585
+ and when its supervisor sees it stop.
586
+
587
+ A socket that closes or never opens reconnects after a delay drawn between half
588
+ and all of one second, doubled for each consecutive failure up to 30 seconds.
589
+ An `authority_changed` close reconnects after a delay drawn between 0 and 2
590
+ seconds, with no error backoff. A `replaced` close, which means a newer
591
+ connection holds the Environment, ends the resident with `kit_stream_replaced`,
592
+ and an upgrade House refuses with `kit_upgrade_required` stops every House call
593
+ as [above](#connect-a-linux-computer) describes; neither is retried.
594
+
560
595
  ## The Worker runtime
561
596
 
562
597
  ```sh
@@ -980,8 +1015,9 @@ A save that fails leaves one note in `state/conversation-originals/` naming the
980
1015
  attachment, its Run, the local file and the descriptor it declared. Each
981
1016
  later `kit worker run` retries it from that file without materializing or
982
1017
  acknowledging anything again, and reports `source_unavailable` when
983
- the file is gone or changed rather than saving different bytes. While a
984
- WorkerSession stays supervised the same retry runs at the heartbeat interval. The note leaves once
1018
+ the file is gone or changed rather than saving different bytes. When a
1019
+ supervised WorkerSession ends, the same retry runs once more before its Run is
1020
+ reported. The note leaves once
985
1021
  House records the save, once House answers that the Run is over, or when House
986
1022
  authorizes that Run's cache cleanup; House accepts no save for a Run that has
987
1023
  settled, so an unsaved original keeps its failed outcome from then on. The owner
@@ -1117,7 +1153,10 @@ written into the durable binding. Reconciliation must obtain current House
1117
1153
  authority before the live process can make further calls.
1118
1154
 
1119
1155
  An owner stop requested through House reaches this Kit as a durable command,
1120
- both at startup and while a Session is supervised. The Kit destroys the exact
1156
+ both at startup and while a Session is supervised: House signals `commands` on
1157
+ the Kit control stream and the supervisor acquires the pending stops,
1158
+ interrupts and answers at once, so a stop reaches the process within a second.
1159
+ The Kit destroys the exact
1121
1160
  tmux session, confirms that it is gone, and reports the stopped Session. It
1122
1161
  reports no continuation for a Run stopped by its owner and never reopens the
1123
1162
  local channel for that Session.
@@ -1278,8 +1317,8 @@ The Environment owner chooses in House which Rooms this Environment keeps
1278
1317
  locally, and whether it keeps `/private`. An Environment whose owner never chose
1279
1318
  copies nothing, and a Room joined later stays uncopied until the owner selects
1280
1319
  it. The resident Kit reads the saved choice through
1281
- `POST /kit/working-copy/selection` when it starts, whenever its control stream
1282
- connects or reconnects, and whenever House points it there after a save;
1320
+ `POST /kit/working-copy/selection` whenever its control stream connects or
1321
+ reconnects and whenever House points it there after a save;
1283
1322
  `kit worker run` reads it once before its pass.
1284
1323
 
1285
1324
  Each selected Room becomes one ordinary Git repository at
@@ -1308,8 +1347,9 @@ state. The House revision of every file content Kit received, keyed by path,
1308
1347
  Git blob and the House state that received it, stays in Kit home under
1309
1348
  `state/working-copies/`, outside the repository. A local commit is a local tree, never a House revision.
1310
1349
 
1311
- The resident Kit keeps every active copy current. On each pass, and whenever
1312
- it reads the saved choice, one `POST /kit/door/discover` tells it where each
1350
+ The resident Kit keeps every active copy current. Whenever its control stream
1351
+ carries a Room change, and whenever it reads the saved choice, one
1352
+ `POST /kit/door/discover` tells it where each
1313
1353
  copied Room stands, and for a Room that moved on it reads the changes after the
1314
1354
  copy's recorded position through `POST /kit/door/enumerate`. A changed, new or
1315
1355
  removed authored file becomes a commit on `refs/house/received`, its House
@@ -1328,7 +1368,8 @@ or protected paths House declares for the Room change, Kit reads the Room again
1328
1368
  as a reselection does. A position older than House
1329
1369
  retains, or from before House's database was restored, makes Kit read the Room
1330
1370
  again as a reselection does; while House's replica has not reached the position
1331
- the copy waits for the next pass. A Room House no longer answers under its
1371
+ the copy waits, and the resident delivers again 30 seconds later. A Room House
1372
+ no longer answers under its
1332
1373
  recorded handle makes Kit read the saved choice again, which follows a handle
1333
1374
  change or marks the copy inactive, and a Room that lost sync is named on
1334
1375
  standard error as `operation_denied` and keeps what it holds. `kit worker run`
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.27",
3
+ "version": "0.1.0-alpha.28",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.27/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/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.27/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/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.27",
3
+ "version": "0.1.0-alpha.28",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contract.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CLAUDE_PASTED_CODE, CODEX_DEVICE_CODE, GROK_DEVICE_CODE } from "./agent-logins.js";
2
2
 
3
- export const KIT_CONTRACT_VERSION = "1";
3
+ export const KIT_CONTRACT_VERSION = "2";
4
4
  export const KIT_CAPTURE_BATCH_MAX = 100;
5
5
  export const COMMAND_INPUT_MAX_BYTES = 4 * 1024;
6
6
  export const AUTHORIZATION_OUTPUT_MAX_BYTES = 4 * 1024;
@@ -21,10 +21,11 @@ export const COMMAND_ADAPTER_SESSION_TIMEOUT_MS = 30_000;
21
21
  export const WORKER_EXIT_GRACE_MS = 5_000;
22
22
  export const WORKER_EXIT_POLL_MS = 100;
23
23
  export const WORKER_SUPERVISION_POLL_MS = 2_000;
24
- export const WORKER_COMMAND_POLL_MS = 2_000;
25
- export const WORKER_HEARTBEAT_MS = 30_000;
26
24
  export const WORKER_COMMAND_BATCH_MAX = 10;
27
25
  export const WORKER_RUN_TOKEN_MARGIN_MS = 60_000;
26
+ export const KIT_STREAM_RECONNECT_MS = 1_000;
27
+ export const KIT_STREAM_RECONNECT_MAX_MS = 30_000;
28
+ export const KIT_STREAM_REJOIN_MS = 2_000;
28
29
  export const TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES = 10 * 1024 * 1024;
29
30
  export const CONVERSATION_FILE_TRANSFER_TIMEOUT_MS = 30_000;
30
31
  export const WORKER_INHERITED_ENVIRONMENT = ["PATH", "HOME"];
package/src/house.js CHANGED
@@ -1117,17 +1117,6 @@ function acquiredInterrupt(value, declaration) {
1117
1117
  };
1118
1118
  }
1119
1119
 
1120
- export async function reportSessionHeartbeat(session, workerSession) {
1121
- await houseRequest(
1122
- session.house,
1123
- await heldControl(session),
1124
- `/kit/sessions/${encodeURIComponent(workerSession)}/heartbeat`,
1125
- {},
1126
- session.dependencies,
1127
- session.signal,
1128
- );
1129
- }
1130
-
1131
1120
  function answeredValue(value, declaration) {
1132
1121
  if (typeof value === "boolean") return value;
1133
1122
  if (typeof value === "number") {
@@ -1,11 +1,17 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
  import WebSocket from "ws";
3
- import { KIT_CONTRACT_VERSION } from "./contract.js";
3
+ import {
4
+ KIT_CONTRACT_VERSION,
5
+ KIT_STREAM_RECONNECT_MAX_MS,
6
+ KIT_STREAM_RECONNECT_MS,
7
+ KIT_STREAM_REJOIN_MS,
8
+ } from "./contract.js";
4
9
  import { heldControl } from "./house.js";
5
10
  import { KitRefusal } from "./refusal.js";
6
11
 
7
12
  const PROTOCOL = "house.kit.stream.1";
8
- const WATCHED_SUBJECTS = ["agents", "working_copy", "sources"];
13
+ const WORK_SUBJECTS = ["runs", "commands", "agents", "working_copy", "sources"];
14
+ const FINAL_REFUSALS = ["kit_upgrade_required", "kit_stream_replaced"];
9
15
 
10
16
  function streamAddress(house) {
11
17
  const url = new URL("/kit/stream", house);
@@ -15,72 +21,88 @@ function streamAddress(house) {
15
21
  return url;
16
22
  }
17
23
 
18
- async function connected(session, signal, available, dependencies) {
24
+ function frameOf(data) {
25
+ try {
26
+ return JSON.parse(data.toString());
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async function held(session, signal, heard, dependencies) {
19
33
  const authorization = await heldControl(session, signal);
20
34
  return new Promise((resolve, reject) => {
21
35
  const Socket = dependencies.workSocket ?? WebSocket;
22
36
  const socket = new Socket(streamAddress(session.house), PROTOCOL, {
23
37
  headers: { authorization, "x-house-kit-version": KIT_CONTRACT_VERSION },
24
38
  handshakeTimeout: 10_000,
25
- maxPayload: 4096,
26
39
  perMessageDeflate: false,
27
40
  });
28
41
  let finished = false;
29
- const finish = (error = null) => {
42
+ const finish = (reason = null, refusal = null) => {
30
43
  if (finished) return;
31
44
  finished = true;
32
45
  signal.removeEventListener("abort", stop);
33
- if (error === null) resolve();
34
- else reject(error);
46
+ if (refusal === null) resolve(reason);
47
+ else reject(refusal);
35
48
  };
36
49
  const stop = () => {
37
50
  socket.terminate();
38
51
  finish();
39
52
  };
40
53
  signal.addEventListener("abort", stop, { once: true });
41
- socket.on("open", () => available(null));
54
+ socket.on("open", () => heard.available(null));
42
55
  socket.on("message", (data) => {
43
- let frame;
44
- try {
45
- frame = JSON.parse(data.toString());
46
- } catch {
47
- return;
48
- }
49
- if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
50
- available(frame.subject);
56
+ const frame = frameOf(data);
57
+ if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
58
+ heard.available(frame.subject);
59
+ } else if (frame?.type === "entries") {
60
+ heard.changed();
51
61
  }
52
62
  });
53
63
  socket.on("unexpected-response", (_request, response) => {
54
64
  response.resume();
55
65
  if (response.statusCode === 409) session.dependencies.contractRefused?.();
56
- const refusal =
57
- response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
66
+ finish(null, response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null);
58
67
  socket.terminate();
59
- finish(refusal);
60
68
  });
61
69
  socket.on("error", () => {
62
70
  socket.terminate();
63
71
  finish();
64
72
  });
65
- socket.on("close", () => finish());
73
+ socket.on("close", (_code, reason) => {
74
+ const closed = reason?.toString() ?? "";
75
+ finish(closed, closed === "replaced" ? new KitRefusal("kit_stream_replaced") : null);
76
+ });
66
77
  if (signal.aborted) stop();
67
78
  });
68
79
  }
69
80
 
70
- export async function watchKitWork(session, signal, available, dependencies = {}) {
71
- let retryMs = 500;
81
+ export async function watchKitWork(session, signal, heard, dependencies = {}) {
82
+ const random = dependencies.random ?? Math.random;
83
+ const sleep = dependencies.streamSleep ?? ((milliseconds) => delay(milliseconds, undefined, { signal }));
84
+ let failures = 0;
72
85
  while (!signal.aborted) {
73
86
  const began = Date.now();
87
+ let reason = null;
74
88
  try {
75
- await connected(session, signal, available, dependencies);
89
+ reason = await held(session, signal, heard, dependencies);
76
90
  } catch (error) {
77
- if (error instanceof KitRefusal && error.code === "kit_upgrade_required") throw error;
78
- if (signal.aborted) return;
91
+ if (error instanceof KitRefusal && FINAL_REFUSALS.includes(error.code)) throw error;
79
92
  }
80
93
  if (signal.aborted) return;
81
- retryMs = Date.now() - began >= 30_000 ? 1000 : Math.min(retryMs * 2, 30_000);
94
+ let wait;
95
+ if (reason === "authority_changed") {
96
+ failures = 0;
97
+ wait = random() * KIT_STREAM_REJOIN_MS;
98
+ } else {
99
+ if (Date.now() - began >= KIT_STREAM_RECONNECT_MAX_MS) failures = 0;
100
+ const ceiling = Math.min(KIT_STREAM_RECONNECT_MAX_MS, KIT_STREAM_RECONNECT_MS * 2 ** failures);
101
+ failures += 1;
102
+ wait = ceiling / 2 + (random() * ceiling) / 2;
103
+ }
82
104
  try {
83
- await delay(dependencies.workReconnectMs ?? retryMs, undefined, { signal });
105
+ await sleep(wait);
84
106
  } catch (error) {
85
107
  if (!signal.aborted) throw error;
86
108
  }
package/src/tasks.js CHANGED
@@ -33,25 +33,28 @@ export function residentWake() {
33
33
  release?.();
34
34
  },
35
35
  async wait(milliseconds, signal) {
36
- if (pending) {
37
- pending = false;
38
- return;
39
- }
40
- const pause = new AbortController();
41
- try {
42
- await Promise.race([
43
- new Promise((resolve) => {
44
- release = resolve;
45
- }),
46
- delay(milliseconds, undefined, { signal: AbortSignal.any([signal, pause.signal]) }),
47
- ]);
48
- } catch (error) {
49
- if (!signal.aborted) throw error;
50
- } finally {
51
- release = null;
52
- pending = false;
53
- pause.abort();
36
+ if (!pending && !signal.aborted) {
37
+ const pause = new AbortController();
38
+ const paused = AbortSignal.any([signal, pause.signal]);
39
+ try {
40
+ await Promise.race([
41
+ new Promise((resolve) => {
42
+ release = resolve;
43
+ }),
44
+ Number.isFinite(milliseconds)
45
+ ? delay(milliseconds, undefined, { signal: paused })
46
+ : new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
47
+ ]);
48
+ } catch (error) {
49
+ if (!signal.aborted) throw error;
50
+ } finally {
51
+ release = null;
52
+ pause.abort();
53
+ }
54
54
  }
55
+ const woken = pending;
56
+ pending = false;
57
+ return woken;
55
58
  },
56
59
  };
57
60
  }
package/src/worker.js CHANGED
@@ -40,10 +40,8 @@ import {
40
40
  MCP_REQUEST_BODY_MAX_BYTES,
41
41
  PROVIDER_CONTINUATION_UNAVAILABLE,
42
42
  WORKER_COMMAND_BATCH_MAX,
43
- WORKER_COMMAND_POLL_MS,
44
43
  WORKER_EXIT_GRACE_MS,
45
44
  WORKER_EXIT_POLL_MS,
46
- WORKER_HEARTBEAT_MS,
47
45
  WORKER_INHERITED_ENVIRONMENT,
48
46
  WORKER_SUPERVISION_POLL_MS,
49
47
  WORKING_COPY_INDEX_METHOD,
@@ -76,7 +74,6 @@ import {
76
74
  reportInteractionOutcome,
77
75
  toolMutations,
78
76
  reportRunOutcome,
79
- reportSessionHeartbeat,
80
77
  releaseControl,
81
78
  reportSessions,
82
79
  serveResident,
@@ -101,6 +98,7 @@ import {
101
98
 
102
99
  const LAUNCHER_MAIN = fileURLToPath(new URL("launcher-main.js", import.meta.url));
103
100
  const RESIDENT_INTERVAL_MS = 30_000;
101
+ const PASS_REACHES = ["commands", "runs"];
104
102
 
105
103
  async function dispatchSeal(context, bound) {
106
104
  try {
@@ -134,9 +132,7 @@ async function pendingCommands(context, kinds = ["stop"]) {
134
132
  try {
135
133
  return await acquireCommands(context.session, WORKER_COMMAND_BATCH_MAX, kinds);
136
134
  } catch (refusal) {
137
- if (houseFailed(refusal) || context.session.refused === true) {
138
- return { stops: [], answers: [], interrupts: [] };
139
- }
135
+ if (houseFailed(refusal) || context.session.refused === true) return null;
140
136
  throw refusal;
141
137
  }
142
138
  }
@@ -157,14 +153,6 @@ async function settleInterrupt(context, interrupt, outcome) {
157
153
  }
158
154
  }
159
155
 
160
- async function reportHeartbeat(context, bound) {
161
- try {
162
- await reportSessionHeartbeat(context.session, bound.session);
163
- } catch (refusal) {
164
- if (!(refusal instanceof KitRefusal)) throw refusal;
165
- }
166
- }
167
-
168
156
  function retryFiles(context) {
169
157
  if (context.retrying !== undefined) return;
170
158
  context.retrying = retryConversationFiles(context)
@@ -187,40 +175,32 @@ async function settleFiles(context) {
187
175
  }
188
176
 
189
177
  async function supervise(context, bound, bridge) {
190
- const terminal = bridge.departed;
191
- let live = true;
192
- terminal.then(() => {
193
- live = false;
194
- });
195
- let commanded = Date.now() + WORKER_COMMAND_POLL_MS;
196
- let pulsed = Date.now() + WORKER_HEARTBEAT_MS;
197
- while (live) {
198
- await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
199
- if (!live) return null;
178
+ const departed = new AbortController();
179
+ bridge.departed.then(() => departed.abort());
180
+ const waiting = AbortSignal.any([context.signal, departed.signal]);
181
+ let owed = false;
182
+ for (;;) {
183
+ const commanded = (await context.commands.wait(WORKER_SUPERVISION_POLL_MS, waiting)) || owed;
184
+ if (departed.signal.aborted) return null;
185
+ if (context.signal.aborted) throw new KitRefusal("kit_command_cancelled");
200
186
  await context.update?.announce();
201
- if (Date.now() >= commanded) {
202
- commanded = Date.now() + WORKER_COMMAND_POLL_MS;
187
+ if (commanded) {
203
188
  const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
204
- for (const answer of polled.answers) {
189
+ owed = polled === null && context.session.refused !== true;
190
+ for (const answer of polled?.answers ?? []) {
205
191
  if (answer.session === bound.session) bridge.answered(answer);
206
192
  }
207
- for (const interrupt of polled.interrupts) {
193
+ for (const interrupt of polled?.interrupts ?? []) {
208
194
  if (interrupt.session !== bound.session || interrupt.run !== bound.run) continue;
209
195
  if (!bridge.interrupt(interrupt)) {
210
196
  await settleInterrupt(context, interrupt.interrupt, "nothing_to_interrupt");
211
197
  }
212
198
  }
213
- const stop = polled.stops.find((command) => command.run === bound.run);
199
+ const stop = polled?.stops.find((command) => command.run === bound.run);
214
200
  if (stop !== undefined) return stop;
215
201
  }
216
- if (Date.now() >= pulsed) {
217
- pulsed = Date.now() + WORKER_HEARTBEAT_MS;
218
- retryFiles(context);
219
- await reportHeartbeat(context, bound);
220
- }
221
202
  if (!(await sessionPresent(context.runtime, bound.tmux))) return null;
222
203
  }
223
- return null;
224
204
  }
225
205
 
226
206
  async function grantExitGrace(runtime, handle) {
@@ -432,6 +412,7 @@ async function superviseBound(context, bound, opened, start) {
432
412
  } finally {
433
413
  try {
434
414
  try {
415
+ retryFiles(context);
435
416
  await settleFiles(context);
436
417
  for (const interaction of bridge.unsettledInteractions()) {
437
418
  await settleInteraction(context, interaction, "expired");
@@ -727,7 +708,7 @@ async function reconcile(context) {
727
708
  }
728
709
  const bindings = await readBindings(context.directory);
729
710
  if (bindings.length === 0) return { run: null, stopped, unresolved };
730
- const commands = (await pendingCommands(context)).stops;
711
+ const commands = (await pendingCommands(context))?.stops ?? [];
731
712
  const carried = [];
732
713
  for (const binding of bindings) {
733
714
  if (binding.reported !== null) {
@@ -811,7 +792,7 @@ function unlessDraining(context, act) {
811
792
  return context.update?.requested ? null : act();
812
793
  }
813
794
 
814
- async function workerPass(context, dependencies, baseConfiguration) {
795
+ async function workerPass(context, dependencies, baseConfiguration, reach) {
815
796
  const { session, signal } = context;
816
797
  const reconciled = await reconcile(context);
817
798
  if (reconciled.run !== null || reconciled.unresolved.length > 0 || context.update?.requested) {
@@ -842,6 +823,7 @@ async function workerPass(context, dependencies, baseConfiguration) {
842
823
  }
843
824
  const resumption = await unlessDraining(context, () => acquireOneResumption(session));
844
825
  if (resumption !== null) return { ...reconciled, run: await superviseResumption(context, resumption) };
826
+ if (reach !== "runs") return reconciled;
845
827
  const assignment = await unlessDraining(context, () => acquireOneRun(session));
846
828
  if (assignment === null) return reconciled;
847
829
  return { ...reconciled, run: await superviseAssignment(context, assignment) };
@@ -865,6 +847,7 @@ async function workerContext(options, dependencies, resident = false) {
865
847
  directory: options.directory,
866
848
  home: dependencies.home ?? homedir(),
867
849
  files: { originals: [], failures: [] },
850
+ commands: residentWake(),
868
851
  epochReport: null,
869
852
  agentRefresh: Promise.resolve(),
870
853
  configurationPath: options.configuration,
@@ -909,7 +892,7 @@ function queueAgentRefresh(context, dependencies, signal, configuration, pointer
909
892
  return refreshed;
910
893
  }
911
894
 
912
- async function controlledPass(context, dependencies, configuration, signal) {
895
+ async function controlledPass(context, dependencies, configuration, signal, reach = "runs") {
913
896
  context.signal = signal;
914
897
  context.session.signal = signal;
915
898
  await reconcileSkillSet({
@@ -919,7 +902,7 @@ async function controlledPass(context, dependencies, configuration, signal) {
919
902
  });
920
903
  context.busy = true;
921
904
  try {
922
- return await afterEpochReport(context, () => workerPass(context, dependencies, configuration));
905
+ return await afterEpochReport(context, () => workerPass(context, dependencies, configuration, reach));
923
906
  } finally {
924
907
  context.busy = false;
925
908
  if (context.deferredRemoval) context.onIdle?.();
@@ -930,8 +913,23 @@ export async function runWorkers(options, dependencies = {}) {
930
913
  const { context, tmux } = await workerContext(options, dependencies);
931
914
  const { session, runtime } = context;
932
915
  const supervised = await maintainControl(session, async (signal) => {
933
- await reportedWorkingCopies(applyWorkingCopies, context, signal);
934
- return controlledPass(context, dependencies, context.configuration, signal);
916
+ const watcher = new AbortController();
917
+ const commanded = (subject) => {
918
+ if (subject === null || subject === "commands") context.commands.notify();
919
+ };
920
+ const watching = (dependencies.watchKitWork ?? watchKitWork)(
921
+ session,
922
+ AbortSignal.any([signal, watcher.signal]),
923
+ { available: commanded, changed: () => {} },
924
+ dependencies,
925
+ ).catch((error) => reported(`control stream ended: ${error?.code ?? error?.message ?? error}`));
926
+ try {
927
+ await reportedWorkingCopies(applyWorkingCopies, context, signal);
928
+ return await controlledPass(context, dependencies, context.configuration, signal);
929
+ } finally {
930
+ watcher.abort();
931
+ await watching;
932
+ }
935
933
  }, session.signal);
936
934
  return {
937
935
  tmux,
@@ -952,9 +950,11 @@ function reported(line) {
952
950
  }
953
951
 
954
952
  async function reportedWorkingCopies(act, context, signal) {
955
- for (const refused of (await act(context, signal)).refused) {
953
+ const outcome = await act(context, signal);
954
+ for (const refused of outcome.refused) {
956
955
  reported(`working copy ${refused.path} refused: ${refused.code}`);
957
956
  }
957
+ return outcome;
958
958
  }
959
959
 
960
960
  function residentUpdate(context, wake) {
@@ -1092,11 +1092,18 @@ export async function runResident(options, dependencies = {}) {
1092
1092
  context.startLogins(needsLogin);
1093
1093
  });
1094
1094
  const unavailable = (error) => reported(`working copies unavailable: ${error?.message ?? error}`);
1095
- const copies = coalescedTask(signal, unavailable, () =>
1096
- reportedWorkingCopies(applyWorkingCopies, context, signal),
1095
+ const changed = () => {
1096
+ if (!update.requested) deliveries.trigger();
1097
+ };
1098
+ const behind = (outcome) => {
1099
+ if (!outcome.behind) return;
1100
+ controlSleep(interval, signal).then(changed, () => {});
1101
+ };
1102
+ const copies = coalescedTask(signal, unavailable, async () =>
1103
+ behind(await reportedWorkingCopies(applyWorkingCopies, context, signal)),
1097
1104
  );
1098
- const deliveries = coalescedTask(signal, unavailable, () =>
1099
- reportedWorkingCopies(deliverWorkingCopies, context, signal),
1105
+ const deliveries = coalescedTask(signal, unavailable, async () =>
1106
+ behind(await reportedWorkingCopies(deliverWorkingCopies, context, signal)),
1100
1107
  );
1101
1108
  sources = sourceEntries({
1102
1109
  session: context.session,
@@ -1111,7 +1118,38 @@ export async function runResident(options, dependencies = {}) {
1111
1118
  const pointer = () => {
1112
1119
  if (!update.requested) agentRefresh.trigger();
1113
1120
  };
1114
- const available = (subject = null) => {
1121
+ let passOwed = null;
1122
+ let deferred = null;
1123
+ let retrying = null;
1124
+ const higher = (reach, than) =>
1125
+ PASS_REACHES.indexOf(reach) > PASS_REACHES.indexOf(than) ? reach : than;
1126
+ const owe = (reach) => {
1127
+ passOwed = higher(reach, passOwed);
1128
+ wake.notify();
1129
+ };
1130
+ const defer = (reach) => {
1131
+ deferred = higher(reach, deferred);
1132
+ if (retrying !== null) return;
1133
+ retrying = new AbortController();
1134
+ controlSleep(interval, AbortSignal.any([signal, retrying.signal])).then(() => {
1135
+ retrying = null;
1136
+ const owed = deferred;
1137
+ deferred = null;
1138
+ owe(owed);
1139
+ }, () => {});
1140
+ };
1141
+ const settle = (reach) => {
1142
+ retrying?.abort();
1143
+ retrying = null;
1144
+ if (higher(deferred, reach) !== reach) owe(deferred);
1145
+ deferred = null;
1146
+ };
1147
+ const available = (subject) => {
1148
+ if (subject === null || subject === "runs") owe("runs");
1149
+ if (subject === null || subject === "commands") {
1150
+ context.commands.notify();
1151
+ owe("commands");
1152
+ }
1115
1153
  if (update.requested) return;
1116
1154
  if (subject === null || subject === "agents") agentRefresh.trigger();
1117
1155
  if (subject === null || subject === "working_copy") copies.trigger();
@@ -1119,23 +1157,31 @@ export async function runResident(options, dependencies = {}) {
1119
1157
  };
1120
1158
  context.onIdle = pointer;
1121
1159
  context.workingCopiesApplied = copies.settled;
1122
- if (!update.requested) copies.trigger();
1123
1160
  const watching = (dependencies.watchKitWork ?? watchKitWork)(
1124
1161
  context.session,
1125
1162
  AbortSignal.any([signal, watcher.signal]),
1126
- available,
1163
+ { available, changed },
1127
1164
  dependencies,
1128
1165
  ).catch(fail);
1129
1166
  try {
1130
1167
  while (!signal.aborted) {
1131
1168
  if (failure !== null) throw failure;
1132
- if (!session.refused) {
1169
+ if (passOwed !== null && !session.refused) {
1170
+ const reach = passOwed;
1171
+ passOwed = null;
1133
1172
  const configuration = await readConfiguration(options.configuration);
1134
- await controlledPass(context, dependencies, configuration, signal).catch((error) => {
1135
- if (!session.refused) throw error;
1136
- });
1173
+ const passed = await controlledPass(context, dependencies, configuration, signal, reach).catch(
1174
+ (error) => {
1175
+ if (!session.refused) throw error;
1176
+ return null;
1177
+ },
1178
+ );
1179
+ if ((passed?.run ?? null) !== null) owe(reach);
1180
+ if ((passed?.unresolved.length ?? 0) > 0) defer(reach);
1181
+ else if (passed !== null) settle(reach);
1137
1182
  }
1138
- if (session.refused || update.requested) {
1183
+ const draining = session.refused || update.requested;
1184
+ if (draining) {
1139
1185
  const sessions = await heldSessions(context);
1140
1186
  if (sessions.length > 0) update.answer({ ready: false, sessions });
1141
1187
  else if (
@@ -1150,8 +1196,7 @@ export async function runResident(options, dependencies = {}) {
1150
1196
  return;
1151
1197
  }
1152
1198
  }
1153
- await wake.wait(interval, signal);
1154
- if (!update.requested && !session.refused) deliveries.trigger();
1199
+ if (session.refused || passOwed === null) await wake.wait(draining ? interval : Infinity, signal);
1155
1200
  }
1156
1201
  } finally {
1157
1202
  watcher.abort();
@@ -1185,7 +1230,7 @@ export async function runResident(options, dependencies = {}) {
1185
1230
  await wake.wait(interval, options.signal);
1186
1231
  }
1187
1232
  update.quiesce();
1188
- while (!options.signal.aborted) await wake.wait(interval, options.signal);
1233
+ while (!options.signal.aborted) await wake.wait(Infinity, options.signal);
1189
1234
  throw new KitRefusal("kit_command_cancelled");
1190
1235
  } finally {
1191
1236
  await served.close();
@@ -497,7 +497,7 @@ async function deliver(context, path, authority, signal) {
497
497
  let copy = await readCopy(path);
498
498
  if (copy?.active && JSON.stringify(copy.received.declared) !== JSON.stringify(declaration(authority))) {
499
499
  await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
500
- return;
500
+ return false;
501
501
  }
502
502
  while (
503
503
  copy?.active &&
@@ -508,24 +508,26 @@ async function deliver(context, path, authority, signal) {
508
508
  try {
509
509
  view = await enumerated(context, copy, since, signal);
510
510
  } catch (error) {
511
- if (error.code === "replica_behind") return;
511
+ if (error.code === "replica_behind") return true;
512
512
  if (error.code !== "position_expired") throw error;
513
513
  await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
514
- return;
514
+ return false;
515
515
  }
516
- if (view.position === since.position) return;
516
+ if (view.position === since.position) return false;
517
517
  copy = await incorporated(copy, view, authority, signal);
518
518
  await writeCopy(context.directory, copy, signal);
519
519
  }
520
+ return false;
520
521
  }
521
522
 
522
523
  async function delivering(context, signal) {
523
524
  const copies = (await heldCopies(context.directory)).filter((copy) => copy.active);
524
- if (copies.length === 0) return { refused: [], lost: false };
525
+ if (copies.length === 0) return { refused: [], lost: false, behind: false };
525
526
  const prefixes = copies.flatMap((copy) => selectedPrefixes(scopeOf(copy)));
526
527
  const discovery = discoveryOf(await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
527
528
  const refused = [];
528
529
  let lost = false;
530
+ let behind = false;
529
531
  for (const copy of copies) {
530
532
  const at = housePath(scopeOf(copy));
531
533
  const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
@@ -539,18 +541,24 @@ async function delivering(context, signal) {
539
541
  }
540
542
  const path = copyPath(context.directory, copy);
541
543
  try {
542
- await exclusively(path, () => deliver(context, path, authority, signal));
544
+ if (await exclusively(path, () => deliver(context, path, authority, signal))) behind = true;
543
545
  } catch (error) {
544
546
  if (!scopeRefused(error)) throw error;
545
547
  refused.push({ path: at, code: error.code });
546
548
  }
547
549
  }
548
- return { refused, lost };
550
+ return { refused, lost, behind };
551
+ }
552
+
553
+ function deliveredOutcome(refused, behind) {
554
+ return behind ? { refused, behind } : { refused };
549
555
  }
550
556
 
551
557
  export async function deliverWorkingCopies(context, signal) {
552
558
  const delivered = await delivering(context, signal);
553
- return delivered.lost ? applyWorkingCopies(context, signal) : { refused: delivered.refused };
559
+ return delivered.lost
560
+ ? applyWorkingCopies(context, signal)
561
+ : deliveredOutcome(delivered.refused, delivered.behind);
554
562
  }
555
563
 
556
564
  export async function applyWorkingCopies(context, signal) {
@@ -582,7 +590,8 @@ export async function applyWorkingCopies(context, signal) {
582
590
  refused.push({ path: housePath(scope), code: error.code });
583
591
  }
584
592
  }
585
- return { refused: [...refused, ...(await delivering(context, signal)).refused] };
593
+ const delivered = await delivering(context, signal);
594
+ return deliveredOutcome([...refused, ...delivered.refused], delivered.behind);
586
595
  }
587
596
 
588
597
  export async function workingCopyPaths(directory) {