@agentshouse/kit 0.1.0-alpha.31 → 0.1.0-alpha.33

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.31/connect-linux.sh)
15
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/connect-linux.sh)
16
16
  ```
17
17
 
18
18
  The bootstrap first establishes Docker Engine from what it observes on the
@@ -145,6 +145,37 @@ matching directory beneath `/agents/house`, through the resident's lease when
145
145
  the container runs, and refuses a directory outside the workspace before
146
146
  anything reaches House.
147
147
 
148
+ ## Connect a macOS computer
149
+
150
+ On a Mac with Apple silicon or an Intel chip, run the same
151
+ `bash <(curl -fsSL …/connect-linux.sh)` command as
152
+ [a Linux computer](#connect-a-linux-computer). It runs in macOS's own
153
+ `bash` and reads the chip from the hardware, so it selects `arm64` or `amd64`
154
+ correctly even from a terminal running under Rosetta.
155
+
156
+ The bootstrap first establishes Docker Desktop from what it observes. A Docker
157
+ Desktop that already answers is used as it is. Without
158
+ `/Applications/Docker.app` it follows Docker's
159
+ [command-line installation](https://docs.docker.com/desktop/setup/install/mac-install/):
160
+ it downloads `Docker.dmg` for the chip from `desktop.docker.com`, attaches it
161
+ through `sudo hdiutil`, runs its `install --user=<account>` through `sudo`, and
162
+ detaches it. `sudo` asks for the password as it ordinarily does, and the
163
+ installer does not accept Docker's Subscription Service Agreement for you. The
164
+ bootstrap then opens Docker Desktop, including one a restart left stopped, and
165
+ waits up to five minutes for its Linux containers to answer while you accept
166
+ its terms or finish what its window asks. If Docker Desktop is still not ready,
167
+ or the download or the installer fails, the bootstrap names the cause and changes
168
+ nothing further; running it again continues from that state. Docker Desktop
169
+ built for the other chip is refused. The bootstrap reaches `docker` in
170
+ Docker Desktop's app bundle when no terminal has added it to `PATH` yet.
171
+
172
+ Everything after that is the Linux journey above, with `open` as the host
173
+ browser opener. With an HTTPS House, login's container publishes its one
174
+ callback port on the Mac's `127.0.0.1`, so House Login returns from the Mac's
175
+ browser, and no Kit container uses the host network. A loopback House needs
176
+ Docker Desktop's host networking enabled. A `--workspace` root must be a
177
+ directory Docker Desktop shares, such as one under `/Users`.
178
+
148
179
  ## Building the package
149
180
 
150
181
  The repository checkout is the development source. Build the package that can
@@ -584,13 +615,14 @@ Google Sources. After that it acquires only what House names. A
584
615
  subject alone. `runs` takes a resumption or else a waiting Run, readying the
585
616
  agent CLIs, a requested setup and conversation files first. `commands` hands
586
617
  the supervised Session its stops, interrupts and answers and takes a
587
- resumption the same way, without acquiring a Run. An `entries` frame, a change
588
- in a Room the lease admits, delivers the working copies. A pass that took a Run
589
- acquires again once that Run ends, and a pass that found nothing waits for the
590
- next signal. A Run outcome House did not take is reported again every 30
591
- seconds until House answers. The Kit has no idle acquisition cycle, no command
592
- poll and no Session heartbeat: a Session is observed when its process starts
593
- and when its supervisor sees it stop.
618
+ resumption the same way, without acquiring a Run. An `entries` frame carries
619
+ the Change entries of a Room the lease admits, which the resident applies to
620
+ that Room's [working copy](#working-copies) in the order the frames arrive. A
621
+ pass that took a Run acquires again once that Run ends, and a pass that found
622
+ nothing waits for the next signal. A Run outcome House neither took nor finally
623
+ refused is reported again every 30 seconds. The Kit has no idle acquisition
624
+ cycle, no command poll and no Session heartbeat: a Session is observed when its
625
+ process starts and when its supervisor sees it stop.
594
626
 
595
627
  A socket that closes or never opens reconnects after a delay drawn between half
596
628
  and all of one second, doubled for each consecutive failure up to 30 seconds.
@@ -1097,7 +1129,11 @@ Provider continuation remains available reports no terminal state at all and
1097
1129
  leaves the Run active for an owner-originated resumption.
1098
1130
 
1099
1131
  The Kit decides that terminal state once, records it beside its stable operation
1100
- identity before it reports it, and reports it from that record. A Run with a
1132
+ identity before it reports it, and reports it from that record. It decides and
1133
+ records it as soon as the stop is recorded and the final text sealed, before it
1134
+ reports the stopped Session and its continuation facts: a refusal of either of
1135
+ those does not change the terminal state, so the Kit reports that recorded state
1136
+ whatever House answered to them. A Run with a
1101
1137
  supervised session records it in that session's durable local binding; a Run
1102
1138
  whose terminal state was decided without one — a resume command this Kit rejects
1103
1139
  because it retains no Provider continuation for the Run, or an acquired Run it
@@ -1109,11 +1145,23 @@ House answers the committed settlement verbatim and the Agent is never executed
1109
1145
  again. A Run House has already settled answers `worker_run_settled`, which the
1110
1146
  Kit accepts as settled and drops the record for.
1111
1147
 
1112
- Every other refusal leaves that record exactly as it is. The Run stays bound to
1113
- this installation, `kit worker run` names the refusal House answered with and
1114
- exits non-zero, and the same decided body is reported again before anything is
1115
- acquired. Nothing is dropped locally that House did not accept, and a refused
1116
- report is never presented to the owner as a delivered one.
1148
+ The Kit keeps that record until House answers it. The record stays exactly as it
1149
+ is while House is unavailable, never answers or answers in a form the Kit cannot
1150
+ read, declares its refusal retryable, answers 429, 502 or 503 without declaring
1151
+ otherwise, refuses the Kit contract or the Kit's lease, including a lease
1152
+ renewal the report waited on, refuses the operation identity as issued ahead of
1153
+ its clock, or answers `conversation_file_send_pending` because a conversation
1154
+ file the Run sent is still being delivered. A report whose operation identity
1155
+ House no longer holds, answered `operation_expired` or `position_expired`, keeps
1156
+ the same body under a new operation identity. Either way the Run stays bound to
1157
+ this installation, `kit worker run` names the refusal House answered with, if
1158
+ any, and exits non-zero, and the same decided body is reported again before
1159
+ anything is acquired. Every other refusal is House's final answer for that
1160
+ report, such as `worker_run_not_found` or `worker_run_not_acquired`: the Kit
1161
+ writes `kit: run <run> outcome refused: <code>` to standard error once, drops
1162
+ the record, and acquires as if the report had been taken. For the Run it
1163
+ supervised in that pass, `kit worker run` also names that refusal and exits
1164
+ non-zero. A refused report is never presented to the owner as a delivered one.
1117
1165
 
1118
1166
  While a WorkerSession is supervised the Kit keeps one durable local binding
1119
1167
  naming this installation, the WorkerRun, the WorkerSession, the exact tmux
@@ -1131,21 +1179,21 @@ instead of treating the session as lost.
1131
1179
 
1132
1180
  A restarted `kit worker run` reconciles the records it kept before it acquires
1133
1181
  anything new. It reports every retained settlement note first, because those
1134
- Runs have no session left to reconcile, and drops each note House accepts or
1135
- already settled. It next consumes the durable commands House holds for this
1136
- Environment, so a requested owner stop ends the exact tmux session before any
1137
- local channel reopens. It then considers only sessions of its own namespace
1138
- whose binding is complete, and never lists unknown sessions, scans host
1139
- processes, searches another namespace, or adopts an arbitrary process. A session
1140
- that is already gone is confirmed gone, reported as one stopped Session with no
1141
- Provider continuation, and settled as a terminal execution failure. A session
1142
- that is still running is restored only after House
1143
- positively replaces the Session authority for the same Run and Session; the Kit
1144
- then reconnects the typed adapter channel, refreshes the started observation,
1145
- and supervises that session to settlement instead of acquiring another Run. An
1146
- explicit House refusal destroys the stale tmux session and exposes no channel.
1147
- House that cannot be reached leaves the binding and the session exactly as they
1148
- are, invents no stopped, active, or authorized state, and acquires nothing.
1182
+ Runs have no session left to reconcile, and drops each note House accepts,
1183
+ already settled or finally refused. It next consumes the durable commands House
1184
+ holds for this Environment, so a requested owner stop ends the exact tmux
1185
+ session before any local channel reopens. It then considers only sessions of its
1186
+ own namespace whose binding is complete, and never lists unknown sessions, scans
1187
+ host processes, searches another namespace, or adopts an arbitrary process. A
1188
+ session that is already gone is confirmed gone, reported as one stopped Session
1189
+ with no Provider continuation, and settled as a terminal execution failure. A
1190
+ session that is still running is restored only after House positively replaces
1191
+ the Session authority for the same Run and Session; the Kit then reconnects the
1192
+ typed adapter channel, refreshes the started observation, and supervises that
1193
+ session to settlement instead of acquiring another Run. An explicit House
1194
+ refusal destroys the stale tmux session and exposes no channel. House that
1195
+ cannot be reached leaves the binding and the session exactly as they are,
1196
+ invents no stopped, active, or authorized state, and acquires nothing.
1149
1197
 
1150
1198
  A restore of House's database starts a new log epoch. Until this Environment
1151
1199
  has reported in it, House refuses every act that touches a Run, acquisition and
@@ -1373,9 +1421,17 @@ state. The House revision of every file content Kit received, keyed by path,
1373
1421
  Git blob and the House state that received it, stays in Kit home under
1374
1422
  `state/working-copies/`, outside the repository. A local commit is a local tree, never a House revision.
1375
1423
 
1376
- The resident Kit keeps every active copy current. Whenever its control stream
1377
- carries a Room change, and whenever it reads the saved choice, one
1378
- `POST /kit/door/discover` tells it where each
1424
+ The resident Kit keeps every active copy current. An `entries` frame on its
1425
+ control stream names a copied Room, the Room's position and log epoch, and the
1426
+ Change entries that brought it there: each changed path with its revision and
1427
+ text, or its removal. The resident applies the entries under the copy's own
1428
+ homes directly, without reading House, when the frame follows on from the copy:
1429
+ the same socket already carried an earlier frame for that Room, the copy's
1430
+ recorded position has reached that earlier frame's position, the log epoch is
1431
+ the copy's, and every changed path carries its revision and text. A frame the
1432
+ copy already holds changes nothing. Any other frame, and every reading of the
1433
+ saved choice, reads House instead, with the frames that arrive during one
1434
+ reading served by the next: one `POST /kit/door/discover` tells it where each
1379
1435
  copied Room stands, and for a Room that moved on it reads the changes after the
1380
1436
  copy's recorded position through `POST /kit/door/enumerate`. A changed, new or
1381
1437
  removed authored file becomes a commit on `refs/house/received`, its House
@@ -15,7 +15,12 @@ FORWARD=()
15
15
  UBUNTU_RELEASES=(jammy noble resolute)
16
16
  DOCKER_PACKAGES=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin)
17
17
  CONFLICTING_PACKAGES=(docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc)
18
- DOCKER_GROUP=0
18
+ DOCKER_APP=/Applications/Docker.app
19
+ DESKTOP_WAIT_SECONDS=300
20
+ RUNTIME='Docker Engine'
21
+ OPENER=xdg-open
22
+ NETWORK=(--network host)
23
+ PUBLISHED=()
19
24
 
20
25
  refuse() {
21
26
  printf 'kit_bootstrap_refused: %s\n' "$1" >&2
@@ -32,11 +37,11 @@ login_kit() {
32
37
  "${DOCKER_RUN[@]:0:2}" -i "${DOCKER_RUN[@]:2}" login "$@"
33
38
  return
34
39
  fi
35
- command -v xdg-open >/dev/null || refuse 'no host browser opener; rerun with --manual'
36
- "${DOCKER_RUN[@]}" login "$@" --host-browser | while IFS= read -r line; do
40
+ command -v "$OPENER" >/dev/null || refuse 'no host browser opener; rerun with --manual'
41
+ "${DOCKER_RUN[@]:0:2}" ${PUBLISHED[@]+"${PUBLISHED[@]}"} "${DOCKER_RUN[@]:2}" login "$@" --host-browser | while IFS= read -r line; do
37
42
  if [[ "$line" == 'Open '* ]]; then
38
43
  printf 'Opening House Login in the host browser.\n'
39
- xdg-open "${line#Open }" >/dev/null 2>&1 || refuse 'host browser did not open; rerun with --manual'
44
+ "$OPENER" "${line#Open }" >/dev/null 2>&1 || refuse 'host browser did not open; rerun with --manual'
40
45
  else
41
46
  printf '%s\n' "$line"
42
47
  fi
@@ -46,15 +51,7 @@ login_kit() {
46
51
  start_resident() {
47
52
  local network=()
48
53
  [[ "$HOUSE" == https://* ]] || network=(--network host)
49
- docker run -d --name "$NAME" --restart unless-stopped "${network[@]}" --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 >/dev/null
50
- }
51
-
52
- docker() {
53
- if ((DOCKER_GROUP)); then
54
- sudo -u "$(id -un)" docker "$@"
55
- else
56
- command docker "$@"
57
- fi
54
+ docker run -d --name "$NAME" --restart unless-stopped ${network[@]+"${network[@]}"} --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 >/dev/null
58
55
  }
59
56
 
60
57
  elevated() {
@@ -82,6 +79,41 @@ unanswered() {
82
79
  printf '; Docker Engine does not answer: %s' "$(docker_cause)"
83
80
  }
84
81
 
82
+ install_docker_desktop() {
83
+ local mount
84
+ DOWNLOAD=$(mktemp -d) || refuse 'no temporary directory for the Docker Desktop download'
85
+ trap 'rm -rf "$DOWNLOAD"' EXIT
86
+ mount="$DOWNLOAD/volume"
87
+ printf 'Downloading Docker Desktop for %s from docker.com.\n' "$1"
88
+ curl -fsSL -o "$DOWNLOAD/Docker.dmg" "https://desktop.docker.com/mac/main/$ARCH/Docker.dmg" || refuse 'Docker Desktop could not be downloaded'
89
+ printf 'Installing Docker Desktop in /Applications; sudo may ask for your password.\n'
90
+ mkdir "$mount" || refuse 'the Docker Desktop disk image has no mount point'
91
+ elevated hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$DOWNLOAD/Docker.dmg" >/dev/null || refuse 'the Docker Desktop disk image could not be opened'
92
+ if ! elevated "$mount/Docker.app/Contents/MacOS/install" "--user=$(id -un)"; then
93
+ elevated hdiutil detach "$mount" >/dev/null 2>&1 || true
94
+ refuse 'the Docker Desktop installer did not finish'
95
+ fi
96
+ elevated hdiutil detach "$mount" >/dev/null || refuse 'the Docker Desktop disk image could not be closed'
97
+ }
98
+
99
+ establish_docker_desktop() {
100
+ PATH="$PATH:$DOCKER_APP/Contents/Resources/bin"
101
+ docker_answers && return
102
+ if ((FORWARDING)); then
103
+ [[ -d "$DOCKER_APP" ]] || refuse 'House Kit is not installed; run this bootstrap without arguments first'
104
+ refuse "Docker Desktop does not answer: $(docker_cause); start Docker Desktop, then rerun this command"
105
+ fi
106
+ [[ -d "$DOCKER_APP" ]] || install_docker_desktop "$1"
107
+ printf 'Starting Docker Desktop; accept its terms or finish its setup if its window asks.\n'
108
+ open "$DOCKER_APP" || refuse 'Docker Desktop could not be opened'
109
+ local waited=0
110
+ until docker_answers; do
111
+ ((waited < DESKTOP_WAIT_SECONDS)) || refuse "Docker Desktop is not ready: $(docker_cause); finish what its window asks, then rerun this bootstrap"
112
+ sleep 2
113
+ waited=$((waited + 2))
114
+ done
115
+ }
116
+
85
117
  installed_packages() {
86
118
  { dpkg-query -W -f='${db:Status-Status} ${Package}\n' "$@" 2>/dev/null || true; } | awk '$1 == "installed" { print $2 }'
87
119
  }
@@ -132,7 +164,7 @@ establish_docker_engine() {
132
164
  printf 'Adding %s to the docker group; it applies from your next sign-in, so this run reaches Docker Engine through sudo.\n' "$(id -un)"
133
165
  elevated usermod -aG docker "$(id -un)" || refuse "$(id -un) could not be added to the docker group"
134
166
  fi
135
- docker_member || DOCKER_GROUP=1
167
+ docker_member || docker() { sudo -u "$(id -un)" docker "$@"; }
136
168
  fi
137
169
  docker_answers || refuse "Docker Engine does not answer: $(docker_cause)"
138
170
  }
@@ -172,20 +204,39 @@ while (($#)); do
172
204
  done
173
205
 
174
206
  [[ "$IMAGE" =~ ^ghcr\.io/agentshouse/kit@sha256:[0-9a-f]{64}$ ]] || refuse 'this bootstrap has not been published with an immutable image digest'
175
- [[ "$(uname -s)" == Linux ]] || refuse "unsupported host $(uname -s); this bootstrap is for Linux"
176
- case "$(uname -m)" in
177
- x86_64|amd64) ARCH=amd64 ;;
178
- aarch64|arm64) ARCH=arm64 ;;
179
- *) refuse "unsupported Linux architecture $(uname -m)" ;;
207
+ case "$(uname -s)" in
208
+ Linux)
209
+ case "$(uname -m)" in
210
+ x86_64|amd64) ARCH=amd64 ;;
211
+ aarch64|arm64) ARCH=arm64 ;;
212
+ *) refuse "unsupported Linux architecture $(uname -m)" ;;
213
+ esac
214
+ ;;
215
+ Darwin)
216
+ RUNTIME='Docker Desktop'
217
+ OPENER=open
218
+ if [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == 1 ]]; then
219
+ ARCH=arm64
220
+ CHIP='Apple silicon'
221
+ else
222
+ ARCH=amd64
223
+ CHIP='an Intel chip'
224
+ fi
225
+ ;;
226
+ *) refuse "unsupported host $(uname -s); this bootstrap is for Linux and macOS" ;;
180
227
  esac
181
228
  [[ "$WORKSPACE" == /* ]] || refuse 'workspace root must be an absolute path'
182
- establish_docker_engine
229
+ if [[ "$RUNTIME" == 'Docker Desktop' ]]; then
230
+ establish_docker_desktop "$CHIP"
231
+ else
232
+ establish_docker_engine
233
+ fi
183
234
  case "$DOCKER_PLATFORM" in
184
235
  linux/x86_64|linux/amd64) DOCKER_ARCH=amd64 ;;
185
236
  linux/aarch64|linux/arm64) DOCKER_ARCH=arm64 ;;
186
- *) refuse "unsupported Docker Engine platform $DOCKER_PLATFORM" ;;
237
+ *) refuse "unsupported $RUNTIME platform $DOCKER_PLATFORM" ;;
187
238
  esac
188
- [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match Docker Engine linux/$DOCKER_ARCH"
239
+ [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match $RUNTIME linux/$DOCKER_ARCH"
189
240
  if ((FORWARDING)); then
190
241
  docker container inspect "$NAME" >/dev/null 2>&1 || refuse 'House Kit is not installed; run this bootstrap without arguments first'
191
242
  WORKSPACE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Source}}{{end}}{{end}}' "$NAME")
@@ -197,7 +248,7 @@ for path in "$KIT_HOME" "$WORKSPACE"; do
197
248
  [[ ! -L "$path" ]] || refuse "$path is a symbolic link"
198
249
  mkdir -p -m 700 "$path" || refuse "$path could not be created"
199
250
  [[ -d "$path" ]] || refuse "$path is not a directory"
200
- [[ "$(stat -c %u "$path")" == "$(id -u)" ]] || refuse "$path is not owned by this User"
251
+ [[ -O "$path" ]] || refuse "$path is not owned by this User"
201
252
  chmod 700 "$path" || refuse "$path could not be owner-protected"
202
253
  done
203
254
  KIT_HOME=$(cd "$KIT_HOME" && pwd -P)
@@ -219,13 +270,19 @@ if ((FORWARDING == 0)); then
219
270
  if [[ ! -f "$KIT_HOME/kit.json" ]]; then
220
271
  printf '{"availability":"intermittent","execution_authority":"allow","migrators":[],"instances":[],"clients":[],"host":null,"skills":%s}\n' "$SKILLS" > "$KIT_HOME/kit.json"
221
272
  elif grep -Eq '"skills"[[:space:]]*:' "$KIT_HOME/kit.json"; then
222
- sed -E -i "s/(\"skills\"[[:space:]]*:[[:space:]]*)(true|false)/\\1$SKILLS/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
273
+ sed -E -i.old "s/(\"skills\"[[:space:]]*:[[:space:]]*)(true|false)/\\1$SKILLS/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
223
274
  else
224
- sed -E -i "1s/^([[:space:]]*)\\{/\\1{\"skills\":$SKILLS,/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
275
+ sed -E -i.old "1s/^([[:space:]]*)\\{/\\1{\"skills\":$SKILLS,/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
225
276
  fi
277
+ rm -f "$KIT_HOME/kit.json.old"
226
278
  grep -Eq "\"skills\"[[:space:]]*:[[:space:]]*$SKILLS" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
227
279
  fi
228
- 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")
280
+ if [[ "$RUNTIME" == 'Docker Desktop' && "$HOUSE" == https://* ]]; then
281
+ NETWORK=()
282
+ LOGIN_PORT=$((49152 + RANDOM % 16384))
283
+ PUBLISHED=(--publish "127.0.0.1:$LOGIN_PORT:$LOGIN_PORT" --env "HOUSE_KIT_LOGIN_PORT=$LOGIN_PORT")
284
+ fi
285
+ DOCKER_RUN=(docker run --rm ${NETWORK[@]+"${NETWORK[@]}"} --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")
229
286
 
230
287
  if docker container inspect "$NAME" >/dev/null 2>&1; then
231
288
  INSTALLED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$NAME")
@@ -240,7 +297,7 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
240
297
  [[ -f "$KIT_HOME/credential" && -f "$KIT_HOME/environment" && -f "$KIT_HOME/house" ]] || refuse 'the installed Kit has no enrolled authority'
241
298
  if ((FORWARDING)); then
242
299
  if [[ "$FORWARDED" == kit && "${FORWARD[0]:-}" == login ]]; then
243
- login_kit "${FORWARD[@]:1}"
300
+ login_kit ${FORWARD[@]+"${FORWARD[@]:1}"}
244
301
  exit 0
245
302
  fi
246
303
  PLACED=()
@@ -259,9 +316,9 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
259
316
  stty -echo
260
317
  fi
261
318
  if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
262
- docker exec -i "${TERMINAL[@]}" "${PLACED[@]}" "$NAME" "$FORWARDED" "${FORWARD[@]}"
319
+ docker exec -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} "$NAME" "$FORWARDED" ${FORWARD[@]+"${FORWARD[@]}"}
263
320
  else
264
- "${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${PLACED[@]}" "${ENTRY[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
321
+ "${DOCKER_RUN[@]:0:2}" -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} ${ENTRY[@]+"${ENTRY[@]}"} "${DOCKER_RUN[@]:2}" ${FORWARD[@]+"${FORWARD[@]}"}
265
322
  fi
266
323
  exit
267
324
  fi
@@ -294,7 +351,7 @@ else
294
351
  LOGIN=()
295
352
  if ((HOUSE_SELECTED)); then LOGIN+=(--house "$HOUSE"); fi
296
353
  if ((MANUAL)); then LOGIN+=(--manual); fi
297
- login_kit "${LOGIN[@]}"
354
+ login_kit ${LOGIN[@]+"${LOGIN[@]}"}
298
355
  fi
299
356
 
300
357
  "${DOCKER_RUN[@]}" apply
package/bin/kit.js CHANGED
@@ -119,6 +119,7 @@ async function runLogin(args, directory) {
119
119
  directory,
120
120
  hostBrowser: values["--host-browser"] === true,
121
121
  hostname: process.env.HOUSE_KIT_HOSTNAME,
122
+ loginPort: process.env.HOUSE_KIT_LOGIN_PORT,
122
123
  ...(values["--environment"] === undefined ? {} : { environment: values["--environment"] }),
123
124
  });
124
125
  }
@@ -478,8 +479,10 @@ async function runWorkerRun(args, directory) {
478
479
  `Run ${run.run} stays bound to this installation until House accepts that report` +
479
480
  `${run.refused === null ? "" : ` (${run.refused.code})`}.`,
480
481
  );
482
+ } else if (run.refused !== null) {
483
+ write(`House refused that report (${run.refused.code}); this installation no longer holds Run ${run.run}.`);
481
484
  }
482
- if (!run.settled || reported?.outcome === "failed") process.exitCode = 1;
485
+ if (!run.settled || run.refused !== null || reported?.outcome === "failed") process.exitCode = 1;
483
486
  }
484
487
 
485
488
  function syncCancellation() {
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.31",
3
+ "version": "0.1.0-alpha.33",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.31/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/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.31/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.33/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.31",
3
+ "version": "0.1.0-alpha.33",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,6 +40,7 @@ async function held(session, signal, heard, dependencies) {
40
40
  perMessageDeflate: false,
41
41
  });
42
42
  let finished = false;
43
+ const carried = new Map();
43
44
  const finish = (reason = null, refusal = null) => {
44
45
  if (finished) return;
45
46
  finished = true;
@@ -63,7 +64,8 @@ async function held(session, signal, heard, dependencies) {
63
64
  if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
64
65
  heard.available(frame.subject);
65
66
  } else if (frame?.type === "entries") {
66
- heard.changed();
67
+ heard.changed(frame, carried.get(frame.authority) ?? null);
68
+ carried.set(frame.authority, frame.position);
67
69
  } else if (VIEW_FRAMES.includes(frame?.type)) {
68
70
  heard.viewed?.(frame);
69
71
  }
package/src/login.js CHANGED
@@ -216,11 +216,12 @@ export async function login(options, dependencies = {}) {
216
216
  return { environment: exchanged.environment };
217
217
  }
218
218
 
219
- export async function loopback() {
219
+ export async function loopback(published) {
220
220
  const server = createServer();
221
221
  await new Promise((resolve, reject) => {
222
222
  server.once("error", reject);
223
- server.listen(0, "127.0.0.1", resolve);
223
+ if (published === undefined) server.listen(0, "127.0.0.1", resolve);
224
+ else server.listen(Number(published), "0.0.0.0", resolve);
224
225
  });
225
226
  const address = server.address();
226
227
  const code = new Promise((resolve, reject) => {
@@ -274,7 +275,7 @@ export async function connectHouse(options, dependencies = {}) {
274
275
  refuseDrained(options.directory);
275
276
  await prepareLaunchBase(launchBase(dependencies));
276
277
  if (options.presentation === "manual") return login(options, dependencies);
277
- const callback = await loopback();
278
+ const callback = await loopback(options.loginPort);
278
279
  try {
279
280
  return await login(
280
281
  { ...options, presentation: "loopback", loopbackUri: callback.uri },
package/src/tasks.js CHANGED
@@ -24,6 +24,23 @@ export function coalescedTask(signal, fail, work) {
24
24
  return { trigger, idle: () => task === null, settled: () => task };
25
25
  }
26
26
 
27
+ export function orderedTask(signal, fail, work) {
28
+ let task = null;
29
+ const push = (...args) => {
30
+ const next = (task ?? Promise.resolve())
31
+ .then(() => (signal.aborted ? undefined : work(...args)))
32
+ .catch((error) => {
33
+ if (signal.aborted) return;
34
+ fail(error);
35
+ });
36
+ task = next;
37
+ void next.then(() => {
38
+ if (task === next) task = null;
39
+ });
40
+ };
41
+ return { push, idle: () => task === null, settled: () => task };
42
+ }
43
+
27
44
  export function residentWake() {
28
45
  let pending = false;
29
46
  let release = null;
package/src/worker.js CHANGED
@@ -22,12 +22,13 @@ import { inheritedEnvironment } from "./command.js";
22
22
  import { sourceEntries } from "./google/entries.js";
23
23
  import { watchKitWork } from "./kit-work-stream.js";
24
24
  import { finalPlan } from "./plan.js";
25
- import { coalescedTask, residentWake } from "./tasks.js";
25
+ import { coalescedTask, orderedTask, residentWake } from "./tasks.js";
26
26
  import {
27
27
  applyWorkingCopies,
28
28
  deliverWorkingCopies,
29
29
  ownerCaller,
30
30
  pushWorkingCopy,
31
+ receiveWorkingCopyEntries,
31
32
  recordIndexChange,
32
33
  uploadedPaths,
33
34
  workingCopyPaths,
@@ -48,7 +49,7 @@ import {
48
49
  WORKING_COPY_PUSH_METHOD,
49
50
  WORKING_COPY_UPLOAD_COMMAND,
50
51
  } from "./contract.js";
51
- import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
52
+ import { EpochReportRequired, formatCommandError, houseAnswered, houseFailed, KitRefusal } from "./refusal.js";
52
53
  import {
53
54
  acceptAssistantText,
54
55
  acquireCommands,
@@ -227,7 +228,21 @@ async function completeOwnerStop(context, bound, requestedAt) {
227
228
  return { destroyed, confirmed: true, failure };
228
229
  }
229
230
 
230
- async function settleReported(context, bound) {
231
+ const REPORT_LATER = ["conversation_file_send_pending", "environment_control_lease_rejected", "operation_id_invalid"];
232
+
233
+ const REPORT_REISSUED = ["operation_expired", "position_expired"];
234
+
235
+ function reportLater(refusal) {
236
+ const retryable = refusal.payload?.error?.retryable;
237
+ return (
238
+ !houseAnswered(refusal) ||
239
+ retryable === true ||
240
+ REPORT_LATER.includes(refusal.code) ||
241
+ ([429, 502, 503].includes(refusal.status) && retryable === undefined)
242
+ );
243
+ }
244
+
245
+ async function settleReported(context, bound, retain) {
231
246
  try {
232
247
  await reportRunOutcome(context.session, bound.run, bound.reported, bound.plan);
233
248
  return { settled: true, failure: null };
@@ -235,14 +250,22 @@ async function settleReported(context, bound) {
235
250
  if (undecided(context, refusal)) return { settled: false, failure: null };
236
251
  if (!(refusal instanceof KitRefusal)) throw refusal;
237
252
  if (refusal.code === "worker_run_settled") return { settled: true, failure: null };
238
- return { settled: false, failure: { code: refusal.code, detail: refusal.message } };
253
+ const failure = { code: refusal.code, detail: refusal.message };
254
+ if (REPORT_REISSUED.includes(refusal.code)) {
255
+ bound.reported = { ...bound.reported, operation: operationId() };
256
+ await retain(bound);
257
+ return { settled: false, failure };
258
+ }
259
+ if (reportLater(refusal)) return { settled: false, failure };
260
+ reported(`run ${bound.run} outcome refused: ${refusal.code}`);
261
+ return { settled: true, failure };
239
262
  }
240
263
  }
241
264
 
242
265
  async function reportTerminalState(context, bound, report) {
243
266
  const settling = { ...bound, reported: { operation: operationId(), ...report } };
244
267
  await writeBinding(context.directory, settling);
245
- const settled = await settleReported(context, settling);
268
+ const settled = await settleReported(context, settling, (held) => writeBinding(context.directory, held));
246
269
  if (settled.settled) await removeBinding(context.directory, settling.tmux);
247
270
  return { ...settled, reported: settling.reported };
248
271
  }
@@ -482,22 +505,35 @@ function stoppedReport(bound) {
482
505
 
483
506
  async function settleStopped(context, bound) {
484
507
  const { stopped } = bound;
485
- const unresolved = (refused) => ({ failure: stopped.failure, refused, settled: false });
508
+ const unresolved = (refused) => ({ failure: stopped.failure, reported: bound.reported, refused, settled: false });
486
509
  if (bound.sealed !== null) {
487
510
  const refused = await dispatchSeal(context, bound);
488
511
  if (refused !== null) return unresolved(refused);
489
512
  }
490
- let failure = stopped.failure;
491
- try {
492
- await observeSessionStop(context.session, bound.session, stopped.textAccepted, bound.plan);
493
- } catch (refusal) {
494
- if (undecided(context, refusal)) return unresolved(null);
495
- if (!(refusal instanceof KitRefusal) || refusal.code !== "conversation_final_text_pending") throw refusal;
496
- failure ??= { code: refusal.code, detail: refusal.message };
497
- await observeSessionStop(context.session, bound.session, false, bound.plan);
498
- }
499
513
  const report = stoppedReport(bound);
514
+ if (report !== null && bound.reported === null) {
515
+ bound.reported = { operation: operationId(), ...report };
516
+ await writeBinding(context.directory, bound);
517
+ }
500
518
  if (report !== null) await removeContinuation(context.directory, bound.run);
519
+ let failure = stopped.failure;
520
+ const observe = async (textAccepted) => {
521
+ try {
522
+ await observeSessionStop(context.session, bound.session, textAccepted, bound.plan);
523
+ return null;
524
+ } catch (refusal) {
525
+ if (undecided(context, refusal)) return undefined;
526
+ if (!(refusal instanceof KitRefusal)) throw refusal;
527
+ return refusal;
528
+ }
529
+ };
530
+ let observed = await observe(stopped.textAccepted);
531
+ if (observed?.code === "conversation_final_text_pending") {
532
+ failure ??= { code: observed.code, detail: observed.message };
533
+ observed = await observe(false);
534
+ }
535
+ if (observed === undefined) return unresolved(null);
536
+ if (observed !== null && report === null) throw observed;
501
537
  try {
502
538
  await reportContinuation(context.session, bound.session, {
503
539
  providerContinuation: report === null,
@@ -505,17 +541,21 @@ async function settleStopped(context, bound) {
505
541
  });
506
542
  } catch (refusal) {
507
543
  if (undecided(context, refusal)) return unresolved(null);
508
- if (!(refusal instanceof KitRefusal) || refusal.code !== "worker_run_settled") throw refusal;
509
- await removeContinuation(context.directory, bound.run);
510
- await removeBinding(context.directory, bound.tmux);
511
- return { failure, refused: null, settled: true };
544
+ if (!(refusal instanceof KitRefusal)) throw refusal;
545
+ if (refusal.code === "worker_run_settled") {
546
+ await removeContinuation(context.directory, bound.run);
547
+ await removeBinding(context.directory, bound.tmux);
548
+ return { failure, refused: null, settled: true };
549
+ }
550
+ if (report === null) throw refusal;
512
551
  }
513
552
  if (report === null) {
514
553
  await removeBinding(context.directory, bound.tmux);
515
554
  return { failure, refused: null, settled: true };
516
555
  }
517
- const settled = await reportTerminalState(context, bound, report);
518
- return { failure, reported: settled.reported, refused: settled.failure, settled: settled.settled };
556
+ const settled = await settleReported(context, bound, (held) => writeBinding(context.directory, held));
557
+ if (settled.settled) await removeBinding(context.directory, bound.tmux);
558
+ return { failure, reported: bound.reported, refused: settled.failure, settled: settled.settled };
519
559
  }
520
560
 
521
561
  function assignmentRoute(context, assignment) {
@@ -601,7 +641,7 @@ async function settleRefused(context, command, failure) {
601
641
  reported: { operation: operationId(), ...failedReport(failure) },
602
642
  };
603
643
  await writeSettlement(context.directory, settling);
604
- const settled = await settleReported(context, settling);
644
+ const settled = await settleReported(context, settling, (held) => writeSettlement(context.directory, held));
605
645
  if (settled.settled) await removeSettlement(context.directory, settling.run);
606
646
  return { run: settling.run, session: settling.session, tmux: null, assistant: [],
607
647
  failure, reported: settling.reported, refused: settled.failure,
@@ -697,7 +737,7 @@ async function reconcile(context) {
697
737
  const stopped = [];
698
738
  const unresolved = [];
699
739
  for (const settlement of await readSettlements(context.directory)) {
700
- const settled = await settleReported(context, settlement);
740
+ const settled = await settleReported(context, settlement, (held) => writeSettlement(context.directory, held));
701
741
  if (settled.settled) await removeSettlement(context.directory, settlement.run);
702
742
  else unresolved.push(unresolvedRun(settlement, settled.failure));
703
743
  }
@@ -706,17 +746,17 @@ async function reconcile(context) {
706
746
  const commands = (await pendingCommands(context))?.stops ?? [];
707
747
  const carried = [];
708
748
  for (const binding of bindings) {
709
- if (binding.reported !== null) {
710
- const settled = await settleReported(context, binding);
711
- if (settled.settled) await removeBinding(context.directory, binding.tmux);
712
- else unresolved.push(unresolvedRun(binding, settled.failure));
713
- continue;
714
- }
715
749
  if (binding.stopped !== null) {
716
750
  const settled = await settleStopped(context, binding);
717
751
  if (!settled.settled) unresolved.push(unresolvedRun(binding, settled.refused));
718
752
  continue;
719
753
  }
754
+ if (binding.reported !== null) {
755
+ const settled = await settleReported(context, binding, (held) => writeBinding(context.directory, held));
756
+ if (settled.settled) await removeBinding(context.directory, binding.tmux);
757
+ else unresolved.push(unresolvedRun(binding, settled.failure));
758
+ continue;
759
+ }
720
760
  const command = commands.find((pending) => pending.run === binding.run) ?? null;
721
761
  const requestedAt = binding.ownerStop ?? command?.requestedAt ?? null;
722
762
  if (requestedAt === null) {
@@ -1105,12 +1145,12 @@ export async function runResident(options, dependencies = {}) {
1105
1145
  context.startLogins(needsLogin);
1106
1146
  });
1107
1147
  const unavailable = (error) => reported(`working copies unavailable: ${error?.message ?? error}`);
1108
- const changed = () => {
1148
+ const redeliver = () => {
1109
1149
  if (!update.requested) deliveries.trigger();
1110
1150
  };
1111
1151
  const behind = (outcome) => {
1112
1152
  if (!outcome.behind) return;
1113
- controlSleep(interval, signal).then(changed, () => {});
1153
+ controlSleep(interval, signal).then(redeliver, () => {});
1114
1154
  };
1115
1155
  const copies = coalescedTask(signal, unavailable, async () =>
1116
1156
  behind(await reportedWorkingCopies(applyWorkingCopies, context, signal)),
@@ -1118,6 +1158,12 @@ export async function runResident(options, dependencies = {}) {
1118
1158
  const deliveries = coalescedTask(signal, unavailable, async () =>
1119
1159
  behind(await reportedWorkingCopies(deliverWorkingCopies, context, signal)),
1120
1160
  );
1161
+ const entries = orderedTask(signal, unavailable, async (frame, since) => {
1162
+ if (!(await receiveWorkingCopyEntries(context, frame, since, signal))) redeliver();
1163
+ });
1164
+ const changed = (frame, since) => {
1165
+ if (!update.requested) entries.push(frame, since);
1166
+ };
1121
1167
  sources = sourceEntries({
1122
1168
  session: context.session,
1123
1169
  directory: context.directory,
@@ -1210,6 +1256,7 @@ export async function runResident(options, dependencies = {}) {
1210
1256
  agentRefresh.idle() &&
1211
1257
  copies.idle() &&
1212
1258
  deliveries.idle() &&
1259
+ entries.idle() &&
1213
1260
  sources.idle())
1214
1261
  ) {
1215
1262
  return;
@@ -1223,6 +1270,7 @@ export async function runResident(options, dependencies = {}) {
1223
1270
  await agentRefresh.settled();
1224
1271
  await copies.settled();
1225
1272
  await deliveries.settled();
1273
+ await entries.settled();
1226
1274
  await sources.settled();
1227
1275
  for (const running of logins.values()) running.controller.abort();
1228
1276
  await Promise.all([...logins.values()].map((running) => running.task));
@@ -561,6 +561,55 @@ export async function deliverWorkingCopies(context, signal) {
561
561
  : deliveredOutcome(delivered.refused, delivered.behind);
562
562
  }
563
563
 
564
+ function streamChange(entry) {
565
+ if (entry?.kind === "removed") return { path: entry.path, removed: true };
566
+ return { path: entry?.path, revision: entry?.revision, content: entry?.text, provenance: entry?.provenance };
567
+ }
568
+
569
+ function followsOn(copy, frame, since, changes) {
570
+ const { received } = copy;
571
+ return (
572
+ since !== null &&
573
+ received.log_epoch === frame.log_epoch &&
574
+ BigInt(received.position) >= BigInt(since) &&
575
+ !unreadableChanges(changes)
576
+ );
577
+ }
578
+
579
+ async function carriedEntries(context, path, frame, since, signal) {
580
+ const copy = await readCopy(path);
581
+ if (!copy?.active) return true;
582
+ const { received } = copy;
583
+ if (received.log_epoch === frame.log_epoch && BigInt(frame.position) <= BigInt(received.position)) return true;
584
+ const homes = copy.scope === "private" ? WORKING_COPY_PRIVATE_HOMES : WORKING_COPY_ROOM_HOMES;
585
+ const changes = frame.entries
586
+ .map(streamChange)
587
+ .filter((change) => typeof change.path !== "string" || homes.some((home) => covers(change.path, home)));
588
+ if (!followsOn(copy, frame, since, changes)) return false;
589
+ const view = { changes, position: frame.position, log_epoch: frame.log_epoch };
590
+ const authority = { operations: { write: received.declared.write }, protected_paths: received.declared.protected };
591
+ await writeCopy(context.directory, await incorporated(copy, view, authority, signal), signal);
592
+ return true;
593
+ }
594
+
595
+ export async function receiveWorkingCopyEntries(context, frame, since, signal) {
596
+ if (
597
+ typeof frame.authority !== "string" ||
598
+ typeof frame.log_epoch !== "string" ||
599
+ !/^[0-9]+$/.test(frame.position) ||
600
+ !Array.isArray(frame.entries) ||
601
+ (since !== null && !/^[0-9]+$/.test(since))
602
+ ) {
603
+ return false;
604
+ }
605
+ const copy = (await heldCopies(context.directory)).find(
606
+ (held) => held.active && held.room_ref === frame.authority,
607
+ );
608
+ if (copy === undefined) return true;
609
+ const path = copyPath(context.directory, copy);
610
+ return exclusively(path, () => carriedEntries(context, path, frame, since, signal));
611
+ }
612
+
564
613
  export async function applyWorkingCopies(context, signal) {
565
614
  const selected = (await savedSelection(context.session, signal)).filter(
566
615
  (scope) => scope.kind === "private" || scope.handle !== null,
@@ -913,6 +962,14 @@ export function uploadedPaths(changes) {
913
962
  return [...pushedPaths(changes)].sort();
914
963
  }
915
964
 
965
+ function unreadableChanges(changes) {
966
+ return changes.some(
967
+ (change) =>
968
+ typeof change?.path !== "string" ||
969
+ (change.removed !== true && (typeof change.content !== "string" || typeof change.revision !== "string")),
970
+ );
971
+ }
972
+
916
973
  async function enumerated(context, copy, since, signal) {
917
974
  const answer = await door(
918
975
  context.session,
@@ -925,11 +982,7 @@ async function enumerated(context, copy, since, signal) {
925
982
  !Array.isArray(view?.changes) ||
926
983
  typeof view.position !== "string" ||
927
984
  typeof view.log_epoch !== "string" ||
928
- view.changes.some(
929
- (change) =>
930
- typeof change?.path !== "string" ||
931
- (change.removed !== true && (typeof change.content !== "string" || typeof change.revision !== "string")),
932
- )
985
+ unreadableChanges(view.changes)
933
986
  ) {
934
987
  throw rejected("enumerate");
935
988
  }