@agentshouse/kit 0.1.0-alpha.11 → 0.1.0-alpha.13

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.11/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.13/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -20,8 +20,31 @@ and starts the container with Docker's `unless-stopped` lifecycle. Pass
20
20
  `--workspace /absolute/path` for one other workspace root or `--manual` for
21
21
  the existing URL and one-time-code login route. `--house <origin>` selects
22
22
  another House and is retained in Kit home. A repeated invocation reuses the
23
- same container, credential and Environment; a different installed image is
24
- left for the explicit update journey.
23
+ same container, credential and Environment.
24
+
25
+ Running a newer release's bootstrap the same way is the only update. When the
26
+ installed container runs another image, the bootstrap pulls this release's
27
+ image and asks the running resident Kit to drain with `kit resident drain`. The
28
+ resident stops acquiring Runs, resumptions, setup and agent CLI work, while the
29
+ Sessions it supervises keep their renewal, owner stops, answers and reports.
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,
33
+ the resident stops renewing, releases its exact lease and stays quiescent, and
34
+ the bootstrap replaces the container with one on this release's image over the
35
+ same Kit home and workspace. The credential, Environment, Kit-home state and
36
+ everything under the workspace are kept; anything installed elsewhere in the old
37
+ container is not. A stopped container is replaced without a drain.
38
+
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
42
+ every running Session locally and never stops one to make progress. A Session
43
+ that ends keeps its exact binding, stop observation and outcome in Kit home,
44
+ and the drain answers ready once no supervised process runs. The updated Kit
45
+ waits for the old lease to expire, acquires the next one and reports each held
46
+ Session before it acquires any work, restoring no process and inventing no
47
+ outcome.
25
48
 
26
49
  The versioned `bootstrap.json` is the machine-readable command source for this
27
50
  release. It is exported from the npm package as `@agentshouse/kit/bootstrap`
@@ -981,7 +1004,12 @@ content, no prompt, and no resolved secret, and Session authority is always
981
1004
  reissued by House rather than recovered from disk. It is evidence for
982
1005
  reconciliation, never permission to resume or recreate work, and nothing keeps
983
1006
  it once House has answered for the Run: a settled terminal state, a confirmed
984
- owner stop, and a retained Provider continuation each drop it.
1007
+ owner stop, and a retained Provider continuation each drop it. When the
1008
+ supervised process ends, the binding first records what the Kit observed: the
1009
+ sealed final text, whether the turn completed, the capacity or provider refusal,
1010
+ the execution failure and whether a Provider continuation is retained. The Kit
1011
+ then reports that stop, and a restarted Kit reports the same recorded stop
1012
+ instead of treating the session as lost.
985
1013
 
986
1014
  A restarted `kit worker run` reconciles the records it kept before it acquires
987
1015
  anything new. It reports every retained settlement note first, because those
@@ -1141,7 +1169,16 @@ cancels its request there, and the lease stays held when the command ends. The
1141
1169
  resident serves one such command at a time and refuses another with
1142
1170
  `environment_control_lease_active`, so two commands still never work at once.
1143
1171
  A resident whose lease House refuses answers the command with that refusal; the
1144
- command never acquires a lease of its own while a resident serves it.
1172
+ command never acquires a lease of its own while a resident serves it. While an
1173
+ update drains, `state/update-drain` refuses every such command, `kit login` and
1174
+ `kit worker run` with `kit_update_draining` before it enrolls, acquires or
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.
1145
1182
 
1146
1183
  Interrupting the command cancels the running connector without advancing the
1147
1184
  checkpoint.
@@ -1468,6 +1505,7 @@ $HOUSE_KIT_HOME/
1468
1505
  state/attachments/<upload identity>.json
1469
1506
  state/conversation-originals/<attachment identity>.json
1470
1507
  state/sessions/<tmux session>.json
1508
+ state/update-drain
1471
1509
  state/continuations/<run identity>.json
1472
1510
  state/<instance>/credentials/google.json
1473
1511
  state/<instance>/checkpoints/<service>.json
@@ -37,6 +37,10 @@ login_kit() {
37
37
  done
38
38
  }
39
39
 
40
+ start_resident() {
41
+ 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
+
40
44
  if [[ "${1:-}" == kit ]]; then
41
45
  FORWARDING=1
42
46
  FORWARD=("${@:2}")
@@ -114,7 +118,8 @@ fi
114
118
  DOCKER_RUN=(docker run --rm --network host --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
115
119
 
116
120
  if docker container inspect "$NAME" >/dev/null 2>&1; then
117
- [[ "$(docker inspect --format '{{.Config.Image}}' "$NAME")" == "$IMAGE" ]] || refuse 'an installed Kit uses a different image; update it explicitly'
121
+ INSTALLED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$NAME")
122
+ ((FORWARDING == 0)) || [[ "$INSTALLED_IMAGE" == "$IMAGE" ]] || refuse 'an installed Kit uses a different image; rerun this bootstrap without arguments to update it'
118
123
  [[ "$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$NAME")" == unless-stopped ]] || refuse 'the installed Kit has a different lifecycle'
119
124
  [[ "$(docker inspect --format '{{len .Mounts}}' "$NAME")" == 2 ]] || refuse 'the installed Kit has unexpected mounts'
120
125
  [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$KIT_HOME" ]] || refuse 'the installed Kit uses another Kit home'
@@ -142,6 +147,17 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
142
147
  fi
143
148
  exit
144
149
  fi
150
+ if [[ "$INSTALLED_IMAGE" != "$IMAGE" ]]; then
151
+ docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
152
+ if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
153
+ docker exec "$NAME" kit resident drain
154
+ fi
155
+ docker rm -f "$NAME" >/dev/null
156
+ rm -f "$KIT_HOME/state/update-drain"
157
+ start_resident
158
+ printf 'House Kit updated for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
159
+ exit 0
160
+ fi
145
161
  "${DOCKER_RUN[@]}" credential check --house "$HOUSE"
146
162
  if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
147
163
  printf 'House Kit is already running for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
@@ -169,5 +185,5 @@ else
169
185
  fi
170
186
 
171
187
  "${DOCKER_RUN[@]}" apply --house "$HOUSE"
172
- 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
188
+ start_resident
173
189
  printf 'House Kit connected for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
package/bin/kit.js CHANGED
@@ -9,7 +9,7 @@ import { setupEnvironment } from "../src/setup.js";
9
9
  import { signInConnector } from "../src/connector-login.js";
10
10
  import { attachSession } from "../src/attach.js";
11
11
  import { runResident, runWorkers } from "../src/worker.js";
12
- import { openSession, releaseControl } from "../src/house.js";
12
+ import { openSession, releaseControl, requestDrain } from "../src/house.js";
13
13
  import { GOOGLE_OAUTH_CLIENT } from "../src/instance/google.js";
14
14
  import { reportHost } from "../src/host.js";
15
15
  import { signInAgent, updateAgents } from "../src/agents.js";
@@ -167,6 +167,23 @@ async function runResidentCommand(args, directory) {
167
167
  }
168
168
  }
169
169
 
170
+ async function runResidentDrain(args, directory) {
171
+ declared(args, [], 0, { exact: true });
172
+ const answer = await requestDrain(directory);
173
+ if (answer.ready === true) {
174
+ write("House Kit is ready for its update.");
175
+ return;
176
+ }
177
+ write(
178
+ answer.sessions.length > 0
179
+ ? `House Kit is draining for its update and waits for Agent Sessions ${answer.sessions.join(", ")}.`
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}).`,
183
+ );
184
+ process.exitCode = 1;
185
+ }
186
+
170
187
  async function runSetup(args, directory) {
171
188
  const { values } = declared(args, ["--house", "--config"], 0, { exact: true });
172
189
  const house = await selectedHouse(values, directory);
@@ -965,7 +982,10 @@ async function main() {
965
982
  const directory = process.env.HOUSE_KIT_HOME ?? join(homedir(), ".house-kit");
966
983
  if (command === "login") return runLogin(args, directory);
967
984
  if (command === "credential" && args[0] === "check") return runCredentialCheck(args.slice(1), directory);
968
- if (command === "resident") return runResidentCommand(args, directory);
985
+ if (command === "resident") {
986
+ if (args[0] === "drain") return runResidentDrain(args.slice(1), directory);
987
+ return runResidentCommand(args, directory);
988
+ }
969
989
  if (command === "setup") return runSetup(args, directory);
970
990
  if (command === "apply") return runApply(args, directory);
971
991
  if (command === "worker") {
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.13",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.11/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.13/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.11/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.13/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.11",
3
+ "version": "0.1.0-alpha.13",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/binding.js CHANGED
@@ -15,6 +15,18 @@ function bindingPath(directory, tmux) {
15
15
  return join(bindingDirectory(directory), `${tmux}.json`);
16
16
  }
17
17
 
18
+ function stoppedNote(stopped) {
19
+ if (stopped === null || stopped === undefined) return null;
20
+ return {
21
+ turn_completed: stopped.turnCompleted,
22
+ capacity_exhausted: stopped.capacityExhausted,
23
+ provider_refused: stopped.providerRefused,
24
+ continuable: stopped.continuable,
25
+ failure: stopped.failure,
26
+ text_accepted: stopped.textAccepted,
27
+ };
28
+ }
29
+
18
30
  export async function writeBinding(directory, binding) {
19
31
  const reported = binding.reported ?? null;
20
32
  await writeNote(bindingPath(directory, binding.tmux), {
@@ -29,6 +41,7 @@ export async function writeBinding(directory, binding) {
29
41
  sealed: binding.sealed === null || binding.sealed === undefined
30
42
  ? null
31
43
  : { operation: binding.sealed.operation, blocks: binding.sealed.blocks },
44
+ stopped: stoppedNote(binding.stopped),
32
45
  reported: reported === null ? null : reportedNote(reported),
33
46
  });
34
47
  }
@@ -45,6 +58,31 @@ function retainedSeal(stored) {
45
58
  return { operation: stored.operation, blocks: stored.blocks };
46
59
  }
47
60
 
61
+ function retainedFailure(stored) {
62
+ if (stored === null) return null;
63
+ if (typeof stored !== "object" || Array.isArray(stored)) return undefined;
64
+ if (typeof stored.code !== "string" || stored.code.length === 0) return undefined;
65
+ if (stored.detail !== null && typeof stored.detail !== "string") return undefined;
66
+ return { code: stored.code, detail: stored.detail };
67
+ }
68
+
69
+ function retainedStop(stored) {
70
+ if (stored === null || stored === undefined) return null;
71
+ if (typeof stored !== "object" || Array.isArray(stored)) return undefined;
72
+ const flags = ["turn_completed", "capacity_exhausted", "provider_refused", "continuable", "text_accepted"];
73
+ if (flags.some((flag) => typeof stored[flag] !== "boolean")) return undefined;
74
+ const failure = retainedFailure(stored.failure);
75
+ if (failure === undefined) return undefined;
76
+ return {
77
+ turnCompleted: stored.turn_completed,
78
+ capacityExhausted: stored.capacity_exhausted,
79
+ providerRefused: stored.provider_refused,
80
+ continuable: stored.continuable,
81
+ failure,
82
+ textAccepted: stored.text_accepted,
83
+ };
84
+ }
85
+
48
86
  function completeBinding(directory, stored) {
49
87
  if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return null;
50
88
  const named = [stored.run, stored.session, stored.tmux, stored.socket, stored.nonce];
@@ -54,6 +92,8 @@ function completeBinding(directory, stored) {
54
92
  if (stored.owner_stop !== null && typeof stored.owner_stop !== "string") return null;
55
93
  const sealed = retainedSeal(stored.sealed);
56
94
  if (sealed === undefined) return null;
95
+ const stopped = retainedStop(stored.stopped);
96
+ if (stopped === undefined) return null;
57
97
  const reported = retainedReport(stored.reported);
58
98
  if (reported === undefined) return null;
59
99
  return {
@@ -65,6 +105,7 @@ function completeBinding(directory, stored) {
65
105
  conversation: stored.conversation === true,
66
106
  ownerStop: stored.owner_stop,
67
107
  sealed,
108
+ stopped,
68
109
  reported,
69
110
  };
70
111
  }
package/src/house.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { chmod, mkdir, readFile, rm } from "node:fs/promises";
2
3
  import { connect, createServer } from "node:net";
3
4
  import { dirname, join } from "node:path";
@@ -39,6 +40,8 @@ import { houseRefusal, KitRefusal, throwHouseAnswer } from "./refusal.js";
39
40
  const LEASE = /^ahl_[A-Za-z0-9_-]+$/;
40
41
  const RESIDENT = "Resident";
41
42
  const LEASE_ACTIVE = "environment_control_lease_active";
43
+ const UPGRADE_REQUIRED = "kit_upgrade_required";
44
+ const UPDATE_DRAINING = "kit_update_draining";
42
45
  const ASSISTANT_DRAFT_STATES = ["streamed", "unsupported"];
43
46
  const ITEM_OUTCOMES = ["accepted", "duplicate", "rejected"];
44
47
 
@@ -53,6 +56,10 @@ async function readCredential(directory) {
53
56
  return stored;
54
57
  }
55
58
 
59
+ function latchRefusal(answer, dependencies) {
60
+ if (answer?.error?.code === UPGRADE_REQUIRED) dependencies.contractRefused?.();
61
+ }
62
+
56
63
  async function houseAnswer(house, path, init, dependencies, signal) {
57
64
  const fetcher = dependencies.fetch ?? fetch;
58
65
  let response;
@@ -70,6 +77,7 @@ async function houseAnswer(house, path, init, dependencies, signal) {
70
77
  } catch {
71
78
  answer = null;
72
79
  }
80
+ latchRefusal(answer, dependencies);
73
81
  throwHouseAnswer(response.status, answer, path);
74
82
  if (answer === null) throw new KitRefusal("kit_house_unavailable", path);
75
83
  return answer;
@@ -113,16 +121,31 @@ function residentSocket(directory) {
113
121
  return join(directory, "run", "resident.sock");
114
122
  }
115
123
 
124
+ export function updateDrain(directory) {
125
+ return join(directory, "state", "update-drain");
126
+ }
127
+
128
+ export function refuseDrained(directory) {
129
+ if (existsSync(updateDrain(directory))) throw new KitRefusal(UPDATE_DRAINING);
130
+ }
131
+
116
132
  function residentAnswer(status, code) {
117
133
  const body = Buffer.from(JSON.stringify({ error: { code } })).toString("base64");
118
134
  return { status, type: "application/json", body };
119
135
  }
120
136
 
121
- 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) {
122
142
  const path = typeof request.path === "string" ? request.path : "";
123
143
  if (!path.startsWith("/kit/") || path.startsWith("/kit/lease")) {
124
144
  return residentAnswer(403, "kit_resident_route_refused");
125
145
  }
146
+ if (draining && newWork(path)) {
147
+ return residentAnswer(409, UPDATE_DRAINING);
148
+ }
126
149
  let authorization;
127
150
  try {
128
151
  authorization = await heldControl(session, signal);
@@ -137,6 +160,7 @@ async function residentRelay(session, request, signal) {
137
160
  signal,
138
161
  });
139
162
  const body = Buffer.from(await response.arrayBuffer());
163
+ if (response.status === 409) latchRefusal(JSON.parse(body.toString("utf8")), session.dependencies);
140
164
  return {
141
165
  status: response.status,
142
166
  type: response.headers.get("content-type"),
@@ -147,63 +171,105 @@ async function residentRelay(session, request, signal) {
147
171
  }
148
172
  }
149
173
 
150
- export async function serveResident(session, directory, signal) {
174
+ export async function serveResident(session, directory, signal, drain) {
151
175
  const socket = residentSocket(directory);
152
176
  await mkdir(dirname(socket), { recursive: true, mode: 0o700 });
153
177
  await rm(socket, { force: true });
178
+ const connections = new Set();
154
179
  let holder = null;
155
- const server = createServer((connection) => {
156
- connection.on("error", () => connection.destroy());
157
- if (holder !== null) {
158
- writeFrame(connection, { refused: LEASE_ACTIVE });
180
+ let draining = false;
181
+ function answerDrain(connection) {
182
+ draining = true;
183
+ drain(holder !== null).then(
184
+ (answer) => {
185
+ if (!connection.destroyed) writeFrame(connection, answer);
186
+ connection.end();
187
+ },
188
+ () => connection.destroy(),
189
+ );
190
+ return false;
191
+ }
192
+ function hold(connection) {
193
+ if (holder !== null || draining || existsSync(updateDrain(directory))) {
194
+ writeFrame(connection, { refused: draining || holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
159
195
  connection.end();
160
- return;
196
+ return false;
161
197
  }
162
198
  holder = connection;
199
+ renewControl(session, signal).then(
200
+ () => writeFrame(connection, { held: true }),
201
+ (error) => {
202
+ writeFrame(connection, { refused: error.code ?? "kit_lease_rejected" });
203
+ connection.end();
204
+ },
205
+ );
206
+ return true;
207
+ }
208
+ const server = createServer((connection) => {
209
+ connections.add(connection);
210
+ connection.on("error", () => connection.destroy());
163
211
  const relayed = new Map();
212
+ let held = null;
164
213
  connection.on("close", () => {
214
+ connections.delete(connection);
165
215
  if (holder === connection) holder = null;
166
216
  for (const cancelled of relayed.values()) cancelled.abort();
167
217
  });
168
- renewControl(session, signal).then(
169
- () => {
170
- writeFrame(connection, { held: true });
171
- readFrames(connection, {
172
- onFrame: (request) => {
173
- if (request.cancel === true) {
174
- relayed.get(request.id)?.abort();
175
- return;
176
- }
177
- const cancelled = new AbortController();
178
- relayed.set(request.id, cancelled);
179
- void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal])).then(
180
- (answer) => {
181
- relayed.delete(request.id);
182
- if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
183
- },
184
- );
218
+ readFrames(connection, {
219
+ onFrame: (request) => {
220
+ if (held === null) {
221
+ held = request.drain === true ? answerDrain(connection) : hold(connection);
222
+ return;
223
+ }
224
+ if (!held) return;
225
+ if (request.cancel === true) {
226
+ relayed.get(request.id)?.abort();
227
+ return;
228
+ }
229
+ const cancelled = new AbortController();
230
+ relayed.set(request.id, cancelled);
231
+ void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal]), draining).then(
232
+ (answer) => {
233
+ relayed.delete(request.id);
234
+ if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
185
235
  },
186
- onRefusal: () => connection.destroy(),
187
- });
188
- },
189
- (error) => {
190
- writeFrame(connection, { refused: error.code ?? "kit_lease_rejected" });
191
- connection.end();
236
+ );
192
237
  },
193
- );
238
+ onRefusal: () => connection.destroy(),
239
+ });
194
240
  });
195
241
  await new Promise((resolve, reject) => {
196
242
  server.once("error", reject);
197
243
  server.listen(socket, resolve);
198
244
  });
199
245
  await chmod(socket, 0o600);
200
- return async () => {
201
- holder?.destroy();
202
- await new Promise((resolve) => server.close(resolve));
203
- await rm(socket, { force: true });
246
+ return {
247
+ serving: () => holder !== null,
248
+ async close() {
249
+ for (const connection of connections) connection.destroy();
250
+ await new Promise((resolve) => server.close(resolve));
251
+ await rm(socket, { force: true });
252
+ },
204
253
  };
205
254
  }
206
255
 
256
+ export function requestDrain(directory) {
257
+ const connection = connect(residentSocket(directory));
258
+ return new Promise((resolve, reject) => {
259
+ const unavailable = () => reject(new KitRefusal("kit_resident_unavailable"));
260
+ connection.on("error", unavailable);
261
+ connection.on("close", unavailable);
262
+ readFrames(connection, {
263
+ onFrame: (answer) => {
264
+ resolve(answer);
265
+ connection.end();
266
+ },
267
+ onRefusal: () => connection.destroy(),
268
+ });
269
+ writeFrame(connection, { drain: true });
270
+ });
271
+ }
272
+
207
273
  async function residentDelegation(directory, fetcher) {
208
274
  const connection = connect(residentSocket(directory));
209
275
  const pending = new Map();
@@ -226,6 +292,7 @@ async function residentDelegation(directory, fetcher) {
226
292
  onFrame: (frame) => (frame.id === undefined ? greet(frame) : settle(frame.id, frame)),
227
293
  onRefusal: () => connection.destroy(),
228
294
  });
295
+ writeFrame(connection, { hold: true });
229
296
  const held = await greeting;
230
297
  if (held === null) return null;
231
298
  if (held.held !== true) {
@@ -276,6 +343,7 @@ async function residentDelegation(directory, fetcher) {
276
343
  }
277
344
 
278
345
  export async function openSession(options, dependencies = {}) {
346
+ if (options.resident !== true) refuseDrained(options.directory);
279
347
  const session = {
280
348
  house: options.house,
281
349
  dependencies,
@@ -285,7 +353,7 @@ export async function openSession(options, dependencies = {}) {
285
353
  signal: options.signal,
286
354
  };
287
355
  const resident =
288
- options.acquire === true
356
+ options.acquire === true || options.resident === true
289
357
  ? null
290
358
  : await residentDelegation(options.directory, dependencies.fetch ?? fetch);
291
359
  if (resident !== null) {
@@ -293,24 +361,30 @@ export async function openSession(options, dependencies = {}) {
293
361
  session.dependencies = { ...dependencies, fetch: resident.fetch };
294
362
  return session;
295
363
  }
364
+ session.dependencies = {
365
+ ...dependencies,
366
+ contractRefused: () => {
367
+ session.refused = true;
368
+ },
369
+ };
296
370
  const credential = await readCredential(options.directory);
297
- Object.assign(
298
- session,
299
- heldLease(
300
- await houseRequest(
301
- session.house,
302
- `Bearer ${credential}`,
303
- "/kit/lease",
304
- {},
305
- session.dependencies,
306
- session.signal,
307
- ),
308
- ),
309
- );
371
+ const answer = await houseRequest(
372
+ session.house,
373
+ `Bearer ${credential}`,
374
+ "/kit/lease",
375
+ {},
376
+ session.dependencies,
377
+ session.signal,
378
+ ).catch((error) => {
379
+ if (options.resident === true && session.refused) return null;
380
+ throw error;
381
+ });
382
+ if (answer !== null) Object.assign(session, heldLease(answer));
310
383
  return session;
311
384
  }
312
385
 
313
386
  async function renewControl(session, signal) {
387
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
314
388
  Object.assign(
315
389
  session,
316
390
  heldLease(
@@ -328,13 +402,14 @@ async function renewControl(session, signal) {
328
402
 
329
403
  export async function heldControl(session, signal = session.signal) {
330
404
  if (session.resident !== undefined) return RESIDENT;
405
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
331
406
  if (session.expiresAt - session.now().getTime() <= LEASE_RENEWAL_MARGIN_MS) {
332
407
  await renewControl(session, signal);
333
408
  }
334
409
  return `Bearer ${session.lease}`;
335
410
  }
336
411
 
337
- function controlSleep(milliseconds, signal) {
412
+ export function controlSleep(milliseconds, signal) {
338
413
  return new Promise((resolve, reject) => {
339
414
  if (signal.aborted) {
340
415
  reject(new KitRefusal("kit_command_cancelled"));
@@ -382,6 +457,7 @@ export async function releaseControl(session, options = {}) {
382
457
  if (session.lease === null) return;
383
458
  const held = session.lease;
384
459
  try {
460
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
385
461
  await houseRequest(
386
462
  session.house,
387
463
  `Bearer ${held}`,
@@ -430,7 +506,7 @@ export async function maintainControl(session, operation, signal, { release = tr
430
506
  throw new KitRefusal("kit_lease_rejected");
431
507
  }
432
508
  } catch (error) {
433
- if (stopped) return;
509
+ if (stopped || session.refused) return;
434
510
  rejectFailure(error);
435
511
  maintenance.abort();
436
512
  return;
@@ -49,6 +49,7 @@ async function connected(session, signal, available, dependencies) {
49
49
  });
50
50
  socket.on("unexpected-response", (_request, response) => {
51
51
  response.resume();
52
+ if (response.statusCode === 409) session.dependencies.contractRefused?.();
52
53
  const refusal =
53
54
  response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
54
55
  socket.terminate();
package/src/login.js CHANGED
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline/promises";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import { KIT_CREDENTIAL } from "./contract.js";
10
10
  import { launchBase, prepareLaunchBase } from "./directory.js";
11
- import { houseRequest } from "./house.js";
11
+ import { houseRequest, refuseDrained } from "./house.js";
12
12
  import { KitRefusal } from "./refusal.js";
13
13
  import { writeText } from "./state.js";
14
14
 
@@ -260,6 +260,7 @@ export async function openBrowser(url) {
260
260
  }
261
261
 
262
262
  export async function connectHouse(options, dependencies = {}) {
263
+ refuseDrained(options.directory);
263
264
  await prepareLaunchBase(launchBase(dependencies));
264
265
  if (options.presentation === "manual") return login(options, dependencies);
265
266
  const callback = await loopback();
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
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { setTimeout as delay } from "node:timers/promises";
4
5
  import { fileURLToPath } from "node:url";
@@ -44,6 +45,7 @@ import {
44
45
  rejectResumption,
45
46
  callHouseMcp,
46
47
  commitRun,
48
+ controlSleep,
47
49
  maintainControl,
48
50
  mutatingCall,
49
51
  observeSessionStart,
@@ -56,14 +58,17 @@ import {
56
58
  toolMutations,
57
59
  reportRunOutcome,
58
60
  reportSessionHeartbeat,
61
+ releaseControl,
59
62
  reportSessions,
60
63
  serveResident,
61
64
  sessionAuthority,
62
65
  streamAssistantDraft,
66
+ updateDrain,
63
67
  } from "./house.js";
64
68
  import { operationId } from "./operation.js";
65
69
  import { runSetup, signInWantedAgent } from "./setup.js";
66
70
  import { readSettlements, removeSettlement, writeSettlement } from "./settlement.js";
71
+ import { writeText } from "./state.js";
67
72
  import { failedReport, terminalReport } from "./terminal.js";
68
73
  import {
69
74
  createSession,
@@ -75,47 +80,31 @@ import {
75
80
  } from "./tmux.js";
76
81
 
77
82
  const LAUNCHER_MAIN = fileURLToPath(new URL("launcher-main.js", import.meta.url));
83
+ const RESIDENT_INTERVAL_MS = 30_000;
78
84
 
79
85
  async function dispatchSeal(context, bound) {
80
86
  try {
81
- const accepted = await acceptAssistantText(context.session, bound.session, bound.sealed);
82
- await resolveSeal(context, bound);
83
- return { accepted, failure: null, unresolved: false, refused: null };
87
+ await acceptAssistantText(context.session, bound.session, bound.sealed);
88
+ bound.stopped.textAccepted = true;
84
89
  } catch (refusal) {
85
90
  if (!(refusal instanceof KitRefusal)) throw refusal;
86
91
  const named = { code: refusal.code, detail: refusal.message };
87
- if (undecided(refusal)) return { accepted: null, failure: null, unresolved: true, refused: named };
88
- await resolveSeal(context, bound);
89
- return { accepted: null, failure: named, unresolved: false, refused: null };
92
+ if (undecided(context, refusal)) return named;
93
+ bound.stopped.failure ??= named;
90
94
  }
91
- }
92
-
93
- async function resolveSeal(context, bound) {
94
95
  bound.sealed = null;
95
96
  await writeBinding(context.directory, bound);
96
- }
97
-
98
- async function sealAssistantText(context, bound, said) {
99
- const blocks = assistantBlocks(said.join("\n\n"));
100
- if (blocks === null) return { accepted: null, failure: null, unresolved: false, refused: null };
101
- bound.sealed = { operation: operationId(), blocks };
102
- await writeBinding(context.directory, bound);
103
- return dispatchSeal(context, bound);
104
- }
105
-
106
- async function recoverAssistantSeal(context, binding) {
107
- if (binding.sealed === null) return { unresolved: false, refused: null };
108
- const sealed = await dispatchSeal(context, binding);
109
- return { unresolved: sealed.unresolved, refused: sealed.refused };
97
+ return null;
110
98
  }
111
99
 
112
100
  function unresolvedRun(bound, refused = null) {
113
101
  return { run: bound.run, session: bound.session, refused };
114
102
  }
115
103
 
116
- function undecided(refusal) {
104
+ function undecided(context, refusal) {
117
105
  return (
118
106
  houseFailed(refusal) ||
107
+ context.session.refused === true ||
119
108
  (refusal instanceof KitRefusal &&
120
109
  ["kit_command_cancelled", "kit_session_token_rejected"].includes(refusal.code))
121
110
  );
@@ -125,7 +114,9 @@ async function pendingCommands(context, kinds = ["stop"]) {
125
114
  try {
126
115
  return await acquireCommands(context.session, WORKER_COMMAND_BATCH_MAX, kinds);
127
116
  } catch (refusal) {
128
- if (houseFailed(refusal)) return { stops: [], answers: [], interrupts: [] };
117
+ if (houseFailed(refusal) || context.session.refused === true) {
118
+ return { stops: [], answers: [], interrupts: [] };
119
+ }
129
120
  throw refusal;
130
121
  }
131
122
  }
@@ -186,6 +177,7 @@ async function supervise(context, bound, bridge) {
186
177
  while (live) {
187
178
  await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
188
179
  if (!live) return null;
180
+ await context.update?.announce();
189
181
  if (Date.now() >= commanded) {
190
182
  commanded = Date.now() + WORKER_COMMAND_POLL_MS;
191
183
  const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
@@ -227,7 +219,7 @@ async function completeOwnerStop(context, bound, requestedAt) {
227
219
  try {
228
220
  await observeSessionStop(context.session, bound.session);
229
221
  } catch (refusal) {
230
- if (houseFailed(refusal)) return { destroyed, confirmed: false, failure: null };
222
+ if (undecided(context, refusal)) return { destroyed, confirmed: false, failure: null };
231
223
  if (!(refusal instanceof KitRefusal)) throw refusal;
232
224
  failure = { code: refusal.code, detail: refusal.message };
233
225
  }
@@ -241,7 +233,7 @@ async function settleReported(context, bound) {
241
233
  await reportRunOutcome(context.session, bound.run, bound.reported);
242
234
  return { settled: true, failure: null };
243
235
  } catch (refusal) {
244
- if (undecided(refusal)) return { settled: false, failure: null };
236
+ if (undecided(context, refusal)) return { settled: false, failure: null };
245
237
  if (!(refusal instanceof KitRefusal)) throw refusal;
246
238
  if (refusal.code === "worker_run_settled") return { settled: true, failure: null };
247
239
  return { settled: false, failure: { code: refusal.code, detail: refusal.message } };
@@ -375,50 +367,65 @@ async function superviseBound(context, bound, opened, start) {
375
367
  held !== null &&
376
368
  stopped?.providerContinuation === true &&
377
369
  stopped.providerSessionId !== null;
378
- const turnObserved = stopped?.stopOutcome === "completed";
379
- const sealed = turnObserved
380
- ? await sealAssistantText(context, bound, outcome.assistant)
381
- : { accepted: null, failure: null, unresolved: false, refused: null };
382
- if (sealed.unresolved) return { ...outcome, refused: sealed.refused, settled: false };
383
- if (sealed.failure !== null) outcome.failure ??= sealed.failure;
384
- const execution = outcome.failure;
370
+ if (continuable) {
371
+ await writeContinuation(context.directory, { ...held, providerSessionId: stopped.providerSessionId });
372
+ }
373
+ const turnCompleted = stopped?.stopOutcome === "completed";
374
+ const blocks = turnCompleted ? assistantBlocks(outcome.assistant.join("\n\n")) : null;
375
+ bound.sealed = blocks === null ? null : { operation: operationId(), blocks };
376
+ bound.stopped = {
377
+ turnCompleted,
378
+ capacityExhausted,
379
+ providerRefused,
380
+ continuable,
381
+ failure: outcome.failure,
382
+ textAccepted: false,
383
+ };
384
+ await writeBinding(context.directory, bound);
385
+ return { ...outcome, ...(await settleStopped(context, bound)) };
386
+ }
387
+
388
+ function stoppedReport(bound) {
389
+ if (bound.stopped === null || bound.stopped === undefined) return null;
390
+ return terminalReport({ ...bound.stopped, conversation: bound.conversation === true });
391
+ }
392
+
393
+ async function settleStopped(context, bound) {
394
+ const { stopped } = bound;
395
+ const unresolved = (refused) => ({ failure: stopped.failure, refused, settled: false });
396
+ if (bound.sealed !== null) {
397
+ const refused = await dispatchSeal(context, bound);
398
+ if (refused !== null) return unresolved(refused);
399
+ }
400
+ let failure = stopped.failure;
385
401
  try {
386
- await observeSessionStop(context.session, bound.session, sealed.accepted !== null);
402
+ await observeSessionStop(context.session, bound.session, stopped.textAccepted);
387
403
  } catch (refusal) {
404
+ if (undecided(context, refusal)) return unresolved(null);
388
405
  if (!(refusal instanceof KitRefusal) || refusal.code !== "conversation_final_text_pending") throw refusal;
389
- outcome.failure ??= { code: refusal.code, detail: refusal.message };
406
+ failure ??= { code: refusal.code, detail: refusal.message };
390
407
  await observeSessionStop(context.session, bound.session);
391
408
  }
392
- const report = terminalReport({
393
- conversation: bound.conversation === true,
394
- turnCompleted: turnObserved,
395
- capacityExhausted,
396
- providerRefused,
397
- failure: execution,
398
- continuable,
399
- });
400
- const retained = report === null;
401
- if (retained) {
402
- await writeContinuation(context.directory, {
403
- ...held,
404
- providerSessionId: stopped.providerSessionId,
409
+ const report = stoppedReport(bound);
410
+ if (report !== null) await removeContinuation(context.directory, bound.run);
411
+ try {
412
+ await reportContinuation(context.session, bound.session, {
413
+ providerContinuation: report === null,
414
+ providerCapacityExhausted: stopped.capacityExhausted,
405
415
  });
406
- } else await removeContinuation(context.directory, bound.run);
407
- await reportContinuation(context.session, bound.session, {
408
- providerContinuation: retained,
409
- providerCapacityExhausted: capacityExhausted,
410
- });
411
- if (retained) {
416
+ } catch (refusal) {
417
+ if (undecided(context, refusal)) return unresolved(null);
418
+ if (!(refusal instanceof KitRefusal) || refusal.code !== "worker_run_settled") throw refusal;
419
+ await removeContinuation(context.directory, bound.run);
420
+ await removeBinding(context.directory, bound.tmux);
421
+ return { failure, refused: null, settled: true };
422
+ }
423
+ if (report === null) {
412
424
  await removeBinding(context.directory, bound.tmux);
413
- return outcome;
425
+ return { failure, refused: null, settled: true };
414
426
  }
415
427
  const settled = await reportTerminalState(context, bound, report);
416
- return {
417
- ...outcome,
418
- reported: settled.reported,
419
- refused: settled.failure,
420
- settled: settled.settled,
421
- };
428
+ return { failure, reported: settled.reported, refused: settled.failure, settled: settled.settled };
422
429
  }
423
430
 
424
431
  function assignmentRoute(context, assignment) {
@@ -506,7 +513,7 @@ async function superviseAssignment(context, assignment, resumed = false) {
506
513
  detail: refusal instanceof KitRefusal ? refusal.message : null,
507
514
  };
508
515
  if (resumed) return refuseContinuation(context, assignment, failure);
509
- if (undecided(refusal)) throw refusal;
516
+ if (undecided(context, refusal)) throw refusal;
510
517
  return refuseAssignment(context, assignment, failure);
511
518
  }
512
519
  const { bound, opened } = prepared;
@@ -555,7 +562,7 @@ async function settleAbsentSession(context, binding) {
555
562
  providerCapacityExhausted: false,
556
563
  });
557
564
  } catch (refusal) {
558
- if (undecided(refusal)) return { settled: false, failure: null };
565
+ if (undecided(context, refusal)) return { settled: false, failure: null };
559
566
  if (!(refusal instanceof KitRefusal)) throw refusal;
560
567
  }
561
568
  await removeContinuation(context.directory, binding.run);
@@ -571,7 +578,7 @@ async function confirmRestorable(context, binding) {
571
578
  try {
572
579
  authority = await replaceSessionAuthority(context.session, binding.session);
573
580
  } catch (refusal) {
574
- if (undecided(refusal)) return { authority: null, unresolved: true };
581
+ if (undecided(context, refusal)) return { authority: null, unresolved: true };
575
582
  if (!(refusal instanceof KitRefusal)) throw refusal;
576
583
  await destroySession(context.runtime, binding.tmux);
577
584
  await removeBinding(context.directory, binding.tmux);
@@ -593,17 +600,17 @@ async function reconcile(context) {
593
600
  const commands = (await pendingCommands(context)).stops;
594
601
  const carried = [];
595
602
  for (const binding of bindings) {
596
- const resealed = await recoverAssistantSeal(context, binding);
597
- if (resealed.unresolved) {
598
- unresolved.push(unresolvedRun(binding, resealed.refused));
599
- continue;
600
- }
601
603
  if (binding.reported !== null) {
602
604
  const settled = await settleReported(context, binding);
603
605
  if (settled.settled) await removeBinding(context.directory, binding.tmux);
604
606
  else unresolved.push(unresolvedRun(binding, settled.failure));
605
607
  continue;
606
608
  }
609
+ if (binding.stopped !== null) {
610
+ const settled = await settleStopped(context, binding);
611
+ if (!settled.settled) unresolved.push(unresolvedRun(binding, settled.refused));
612
+ continue;
613
+ }
607
614
  const command = commands.find((pending) => pending.run === binding.run) ?? null;
608
615
  const requestedAt = binding.ownerStop ?? command?.requestedAt ?? null;
609
616
  if (requestedAt === null) {
@@ -643,7 +650,7 @@ async function reportHeldSessions(context) {
643
650
  const continued = (await readContinuationRuns(context.directory)).map((run) => ({ run, reported: null }));
644
651
  const held = new Map();
645
652
  for (const entry of [...settlements, ...bindings, ...continued]) {
646
- held.set(entry.run, held.get(entry.run) ?? entry.reported);
653
+ held.set(entry.run, held.get(entry.run) ?? entry.reported ?? stoppedReport(entry));
647
654
  }
648
655
  const reported = await reportSessions(
649
656
  context.session,
@@ -670,17 +677,23 @@ async function afterEpochReport(context, pass) {
670
677
  return pass();
671
678
  }
672
679
 
680
+ function unlessDraining(context, act) {
681
+ return context.update?.requested ? null : act();
682
+ }
683
+
673
684
  async function workerPass(context, dependencies, baseConfiguration) {
674
685
  const { session, signal } = context;
675
686
  const reconciled = await reconcile(context);
676
- if (reconciled.run !== null || reconciled.unresolved.length > 0) return reconciled;
687
+ if (reconciled.run !== null || reconciled.unresolved.length > 0 || context.update?.requested) {
688
+ return reconciled;
689
+ }
677
690
  context.busy = false;
678
691
  const needsLogin = await queueAgentRefresh(context, dependencies, signal, baseConfiguration);
679
692
  context.startLogins?.(needsLogin);
680
693
  context.busy = true;
681
694
  const { configuration } = context;
682
- context.inbound = await reconcileInbound(context.directory, session);
683
- const setup = await acquireSetup(session, signal);
695
+ context.inbound = await unlessDraining(context, () => reconcileInbound(context.directory, session));
696
+ const setup = await unlessDraining(context, () => acquireSetup(session, signal));
684
697
  if (setup !== null) {
685
698
  context.setup = await runSetup(
686
699
  {
@@ -697,19 +710,21 @@ async function workerPass(context, dependencies, baseConfiguration) {
697
710
  const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
698
711
  context.startLogins?.(needsLogin);
699
712
  }
700
- const resumption = await acquireOneResumption(session);
713
+ const resumption = await unlessDraining(context, () => acquireOneResumption(session));
701
714
  if (resumption !== null) return { ...reconciled, run: await superviseResumption(context, resumption) };
702
- const assignment = await acquireOneRun(session);
715
+ const assignment = await unlessDraining(context, () => acquireOneRun(session));
703
716
  if (assignment === null) return reconciled;
704
717
  return { ...reconciled, run: await superviseAssignment(context, assignment) };
705
718
  }
706
719
 
707
- async function workerContext(options, dependencies) {
720
+ async function workerContext(options, dependencies, resident = false) {
708
721
  const configuration = await readConfiguration(options.configuration);
709
722
  const runtime = workerRuntime(options.directory, dependencies.tmux ?? "tmux");
710
723
  const launcher = dependencies.launcher ?? [process.execPath, LAUNCHER_MAIN];
711
724
  const tmux = await readyTmux(runtime);
712
- const session = await openSession({ ...options, acquire: true }, dependencies);
725
+ const session = resident
726
+ ? await residentSession(options, dependencies)
727
+ : await openSession({ ...options, acquire: true }, dependencies);
713
728
  const context = {
714
729
  configuration,
715
730
  runtime,
@@ -827,14 +842,83 @@ function residentWake() {
827
842
  };
828
843
  }
829
844
 
845
+ function residentUpdate(context, wake) {
846
+ let requested = existsSync(updateDrain(context.directory));
847
+ let ready = null;
848
+ const waiting = [];
849
+ return {
850
+ get requested() {
851
+ return requested;
852
+ },
853
+ async request(serving) {
854
+ if (!requested) {
855
+ requested = true;
856
+ await writeText(updateDrain(context.directory), "");
857
+ }
858
+ wake.notify();
859
+ const sessions = await heldSessions(context);
860
+ if (ready !== null) return ready;
861
+ if (serving) return { ready: false, sessions, serving };
862
+ if (sessions.length > 0) return { ready: false, sessions };
863
+ return new Promise((resolve) => waiting.push(resolve));
864
+ },
865
+ answer(answer) {
866
+ for (const resolve of waiting.splice(0)) resolve(answer);
867
+ },
868
+ async announce() {
869
+ if (waiting.length === 0) return;
870
+ const sessions = await heldSessions(context);
871
+ if (sessions.length > 0) this.answer({ ready: false, sessions });
872
+ },
873
+ quiesce() {
874
+ ready = { ready: true, sessions: [] };
875
+ this.answer(ready);
876
+ },
877
+ };
878
+ }
879
+
880
+ async function heldSessions(context) {
881
+ const bindings = await readBindings(context.directory);
882
+ if (!context.session.refused) {
883
+ return [...bindings, ...(await readSettlements(context.directory))].map((held) => held.run);
884
+ }
885
+ const live = [];
886
+ for (const binding of bindings) {
887
+ if (binding.stopped === null && (await sessionPresent(context.runtime, binding.tmux))) {
888
+ live.push(binding.run);
889
+ }
890
+ }
891
+ return live;
892
+ }
893
+
894
+ async function residentSession(options, dependencies) {
895
+ for (;;) {
896
+ try {
897
+ return await openSession({ ...options, resident: true }, dependencies);
898
+ } catch (refusal) {
899
+ if (refusal?.code !== "environment_control_lease_active") throw refusal;
900
+ await controlSleep(dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS, options.signal);
901
+ }
902
+ }
903
+ }
904
+
830
905
  export async function runResident(options, dependencies = {}) {
831
- const { context } = await workerContext(options, dependencies);
832
- await maintainControl(context.session, async (signal) => {
833
- const closeResident = await serveResident(context.session, context.directory, signal);
906
+ const { context } = await workerContext(options, dependencies, true);
907
+ const { session } = context;
908
+ const interval = dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS;
909
+ const wake = residentWake();
910
+ const update = residentUpdate(context, wake);
911
+ context.update = update;
912
+ const served = await serveResident(session, context.directory, options.signal, (serving) => update.request(serving));
913
+ const serve = async (signal) => {
834
914
  context.busy = true;
835
- const wake = residentWake();
836
915
  const watcher = new AbortController();
837
916
  let failure = null;
917
+ const fail = (error) => {
918
+ if (signal.aborted || session.refused) return;
919
+ failure = error;
920
+ wake.notify();
921
+ };
838
922
  const logins = new Map();
839
923
  const attemptedLogins = new Map();
840
924
  context.cancelLogins = (wanted) => {
@@ -853,6 +937,7 @@ export async function runResident(options, dependencies = {}) {
853
937
  }
854
938
  };
855
939
  context.startLogins = (agents) => {
940
+ if (update.requested) return;
856
941
  for (const agent of agents) {
857
942
  const { kind, release } = agent;
858
943
  if (logins.has(kind) || attemptedLogins.get(kind) === release) continue;
@@ -866,8 +951,7 @@ export async function runResident(options, dependencies = {}) {
866
951
  })()
867
952
  .catch((error) => {
868
953
  if (loginSignal.aborted) return;
869
- failure = error;
870
- wake.notify();
954
+ fail(error);
871
955
  })
872
956
  .finally(() => logins.delete(kind));
873
957
  logins.set(kind, { controller, task });
@@ -879,6 +963,7 @@ export async function runResident(options, dependencies = {}) {
879
963
  let pointerPending = false;
880
964
  let pointerTask = null;
881
965
  const pointer = () => {
966
+ if (update.requested) return;
882
967
  pointerPending = true;
883
968
  if (pointerTask !== null) return;
884
969
  pointerTask = (async () => {
@@ -890,8 +975,7 @@ export async function runResident(options, dependencies = {}) {
890
975
  })()
891
976
  .catch((error) => {
892
977
  if (signal.aborted) return;
893
- failure = error;
894
- wake.notify();
978
+ fail(error);
895
979
  })
896
980
  .finally(() => {
897
981
  pointerTask = null;
@@ -904,16 +988,22 @@ export async function runResident(options, dependencies = {}) {
904
988
  AbortSignal.any([signal, watcher.signal]),
905
989
  pointer,
906
990
  dependencies,
907
- ).catch((error) => {
908
- failure = error;
909
- wake.notify();
910
- });
991
+ ).catch(fail);
911
992
  try {
912
993
  while (!signal.aborted) {
913
994
  if (failure !== null) throw failure;
914
- const configuration = await readConfiguration(options.configuration);
915
- await controlledPass(context, dependencies, configuration, signal);
916
- await wake.wait(dependencies.residentIntervalMs ?? 30_000, signal);
995
+ if (!session.refused) {
996
+ const configuration = await readConfiguration(options.configuration);
997
+ await controlledPass(context, dependencies, configuration, signal).catch((error) => {
998
+ if (!session.refused) throw error;
999
+ });
1000
+ }
1001
+ if (session.refused || update.requested) {
1002
+ const sessions = await heldSessions(context);
1003
+ if (sessions.length > 0) update.answer({ ready: false, sessions });
1004
+ else if (session.refused || (!served.serving() && logins.size === 0 && pointerTask === null)) return;
1005
+ }
1006
+ await wake.wait(interval, signal);
917
1007
  }
918
1008
  } finally {
919
1009
  watcher.abort();
@@ -922,7 +1012,31 @@ export async function runResident(options, dependencies = {}) {
922
1012
  for (const running of logins.values()) running.controller.abort();
923
1013
  await Promise.all([...logins.values()].map((running) => running.task));
924
1014
  await context.agentRefresh;
925
- await closeResident();
926
1015
  }
927
- }, options.signal);
1016
+ };
1017
+ try {
1018
+ for (;;) {
1019
+ try {
1020
+ await maintainControl(session, serve, options.signal, { release: false });
1021
+ } catch (error) {
1022
+ await releaseControl(session);
1023
+ throw error;
1024
+ }
1025
+ if (session.refused) break;
1026
+ try {
1027
+ await releaseControl(session, { required: true });
1028
+ break;
1029
+ } catch (refusal) {
1030
+ if (!(refusal instanceof KitRefusal)) throw refusal;
1031
+ if (session.refused) break;
1032
+ update.answer({ ready: false, sessions: [], refused: refusal.code });
1033
+ }
1034
+ await wake.wait(interval, options.signal);
1035
+ }
1036
+ update.quiesce();
1037
+ while (!options.signal.aborted) await wake.wait(interval, options.signal);
1038
+ throw new KitRefusal("kit_command_cancelled");
1039
+ } finally {
1040
+ await served.close();
1041
+ }
928
1042
  }