@runuai/host 0.9.13 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/agent-cli.ts CHANGED
@@ -168,6 +168,22 @@ function parseFlags(args) {
168
168
  return { flags, rest };
169
169
  }
170
170
 
171
+ // A punchlist item's text is prose, and prose written in THIS repo contains
172
+ // --id and --text. parseFlags is global and greedy, so routing todo text
173
+ // through it silently rewrote the request: "todo edit aaaaaa the --id flag"
174
+ // resolved to { ref: "flag", text: "aaaaaa the" } and edited a different item,
175
+ // and a valueless --text became the literal string "true" — an item
176
+ // overwritten with garbage, or worse, the wrong item overwritten at all. Todo
177
+ // text is therefore read from the raw argv tail and never flag-parsed. The
178
+ // --id <ref> form survives only in leading position, where it cannot be
179
+ // mistaken for prose.
180
+ function todoRef(args) {
181
+ return args[0] === "--id"
182
+ ? { ref: args[1], tail: args.slice(2) }
183
+ : { ref: args[0] && !args[0].startsWith("--") ? args[0] : "", tail: args.slice(1) };
184
+ }
185
+ function todoText(args) { return args.join(" ").trim(); }
186
+
171
187
  async function api(method, path, body) {
172
188
  const res = await fetch(API_URL + path, {
173
189
  method,
@@ -292,14 +308,30 @@ async function main() {
292
308
  break;
293
309
  }
294
310
  case "todo add": {
295
- const text = pos.join(" ") || flags.text || "";
311
+ const text = todoText(rest);
296
312
  if (!text) { console.error("uai: todo add needs text"); process.exit(1); }
297
313
  const t = (await api("POST", "/api/agent/todos", { op: "add", text })).todo;
298
314
  out("added #" + t.shortId + ": " + t.text);
299
315
  break;
300
316
  }
317
+ case "todo edit": {
318
+ const { ref, tail } = todoRef(rest);
319
+ const text = todoText(tail);
320
+ if (!ref) { console.error("uai: todo edit needs an item id (see 'uai todo list')"); process.exit(1); }
321
+ if (!text) { console.error("uai: todo edit needs replacement text"); process.exit(1); }
322
+ const t = (await api("POST", "/api/agent/todos", { op: "edit", ref, text })).todo;
323
+ out("edited #" + t.shortId + ": " + t.text);
324
+ break;
325
+ }
326
+ case "todo remove": {
327
+ const { ref } = todoRef(rest);
328
+ if (!ref) { console.error("uai: todo remove needs an item id (see 'uai todo list')"); process.exit(1); }
329
+ await api("POST", "/api/agent/todos", { op: "remove", ref });
330
+ out("removed #" + String(ref).replace(/^#/, ""));
331
+ break;
332
+ }
301
333
  case "todo claim": case "todo done": case "todo reopen": case "todo unclaim": {
302
- const ref = pos[0] || flags.id;
334
+ const { ref } = todoRef(rest);
303
335
  if (!ref) { console.error("uai: todo " + action + " needs an item id (see 'uai todo list')"); process.exit(1); }
304
336
  const t = (await api("POST", "/api/agent/todos", { op: action, ref })).todo;
305
337
  out(action + " #" + t.shortId + ": " + t.text);
@@ -359,6 +391,8 @@ async function main() {
359
391
  " uai memory delete <id>",
360
392
  " uai todo list",
361
393
  " uai todo add <text>",
394
+ " uai todo edit <#id> <text>",
395
+ " uai todo remove <#id>",
362
396
  " uai todo claim|done|reopen|unclaim <#id>",
363
397
  " uai preview add <name> <containerPort>",
364
398
  " uai project create --name <n> --repo <git-url> [--prompt <p>]",
@@ -3083,7 +3083,9 @@ export function buildSystemPreamble(
3083
3083
  "You are one agent in a uai task chat channel, shared with the human",
3084
3084
  "and the other agents. To hand work to or ask another agent, mention",
3085
3085
  "it by id at the start of a line — e.g. `@codex please review the",
3086
- "diff`. uai routes that message into that agent's input.",
3086
+ "diff`. uai buffers the handoff and routes your complete turn into that",
3087
+ "agent's input only after your current turn ends. The mention does NOT",
3088
+ "wake the peer while you continue using tools in the same turn.",
3087
3089
  ];
3088
3090
  const collaborationBrief = isSecretary
3089
3091
  ? [
@@ -3107,8 +3109,9 @@ export function buildSystemPreamble(
3107
3109
  "Collaborate with your peers — divide up the work, review each other's",
3108
3110
  "changes, share concrete ideas, and debate approach decisions by",
3109
3111
  "@-mentioning them. That is how the team gets things done, and you",
3110
- "should do it freely whenever it moves the work forward. But mentioning",
3111
- "a peer WAKES it and costs a turn, so make each one count: a message to",
3112
+ "should do it freely whenever it moves the work forward. But a peer",
3113
+ "@mention WAKES it after your turn ends and costs a turn, so make each",
3114
+ "one count: a message to",
3112
3115
  "a peer should ADVANCE the work — a real proposal, a question you need",
3113
3116
  "answered, a hand-off, or a review with specific findings. Do NOT",
3114
3117
  "@-mention a peer just to greet, thank, agree, acknowledge, or say",
@@ -3267,7 +3270,7 @@ export function buildSystemPreamble(
3267
3270
  ? ", `memory search <query>`"
3268
3271
  : "") +
3269
3272
  (agent.permissions?.includes("todo.write")
3270
- ? ", `todo list|add|claim|done`"
3273
+ ? ", `todo list|add|edit|remove|claim|done`"
3271
3274
  : "") +
3272
3275
  (agent.permissions?.includes("previews.write")
3273
3276
  ? ", `preview add <name> <port>`"
@@ -3327,7 +3330,9 @@ export function buildSystemPreamble(
3327
3330
  "**`todo claim <#id>` BEFORE you start** an item so two agents",
3328
3331
  "don't do the same work — and `todo done <#id>` when it's",
3329
3332
  "complete. Check `todo list` before picking up work. The human",
3330
- "watches the same list in the task UI.",
3333
+ "watches the same list in the task UI. Use `todo edit <#id>",
3334
+ "<text>` to correct an item and `todo remove <#id>` to delete",
3335
+ "one; both refuse an item another agent is actively claiming.",
3331
3336
  ]
3332
3337
  : []),
3333
3338
  ...(agent.permissions?.includes("tasks.history")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -167,6 +167,22 @@ fi
167
167
  reject_unsafe_host_directory "$task_workspace" "the task workspace"
168
168
  mkdir -p "$task_workspace"
169
169
 
170
+ # Git resolves symlinked ancestors when it writes a linked worktree's absolute
171
+ # `.git` pointer. Keep the configured (lexical) task paths as Uai's stable host
172
+ # paths, but also remember their physical aliases for validation and bind-mount
173
+ # destinations. The task root itself was rejected above if it was a symlink;
174
+ # `pwd -P` therefore resolves only trusted ancestors such as a migrated
175
+ # UAI_WORKSPACE_ROOT (`~/.uai-host-app/workspace -> ~/.uai`).
176
+ step "WORKTREE_FAILED" "resolve task workspace paths"
177
+ if ! task_dir_physical=$(cd "$task_dir" && pwd -P) \
178
+ || [ -z "$task_dir_physical" ]; then
179
+ emit_err "WORKTREE_FAILED" \
180
+ "Uai could not resolve the task workspace's physical host path" \
181
+ "resolve task workspace paths"
182
+ fi
183
+ task_workspace_physical="$task_dir_physical/workspace"
184
+ task_uai_dir_physical="$task_dir_physical/.uai"
185
+
170
186
  # -----------------------------------------------------------------------------
171
187
  # 3. Bare mirrors + worktrees, one per selected project.
172
188
  # -----------------------------------------------------------------------------
@@ -486,6 +502,7 @@ while IFS= read -r project_obj; do
486
502
  # that old bind mount. The new cache path has never entered a container.
487
503
  mirror_dir="$projects_root/$project_id/host-cache.git"
488
504
  task_repo_dir="$task_uai_dir/repos/$project_id.git"
505
+ task_repo_dir_physical="$task_uai_dir_physical/repos/$project_id.git"
489
506
  worktree_target="$task_workspace/$project_slug"
490
507
  reject_unsafe_host_directory "$worktree_target" \
491
508
  "the project worktree for $project_slug"
@@ -513,14 +530,18 @@ while IFS= read -r project_obj; do
513
530
  gitdir_lines=$(awk 'END { print NR }' "$worktree_target/.git")
514
531
  gitdir_value=$(sed -n 's/^gitdir: //p' "$worktree_target/.git")
515
532
  case "$gitdir_value" in
516
- "$task_repo_dir"/worktrees/*) ;;
533
+ "$task_repo_dir"/worktrees/*)
534
+ gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
535
+ ;;
536
+ "$task_repo_dir_physical"/worktrees/*)
537
+ gitdir_leaf=${gitdir_value#"$task_repo_dir_physical"/worktrees/}
538
+ ;;
517
539
  *)
518
540
  emit_err "WORKTREE_FAILED" \
519
541
  "This task uses Uai's legacy shared Git layout. Its container has been removed so it can no longer write the shared mirror. Preserve any uncommitted changes from $worktree_target, then recreate the task to migrate safely." \
520
542
  "legacy task repository requires recreation ($project_id)"
521
543
  ;;
522
544
  esac
523
- gitdir_leaf=${gitdir_value#"$task_repo_dir"/worktrees/}
524
545
  case "$gitdir_leaf" in
525
546
  ''|.|..|*/*)
526
547
  emit_err "WORKTREE_FAILED" \
@@ -1018,11 +1039,24 @@ fi
1018
1039
  # repository is mounted; the host cache never enters a container. Legacy
1019
1040
  # worktrees fail closed before Compose rendering and must be recreated.
1020
1041
  printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
1042
+ if [ "$task_workspace_physical" != "$task_workspace" ]; then
1043
+ # Git canonicalizes a symlinked workspace root in both directions: the
1044
+ # worktree's `.git` points at the physical repository, and the repository's
1045
+ # worktree metadata points back at the physical workspace. Mount the same
1046
+ # already-approved workspace at that alias without exposing its `.uai`
1047
+ # sibling or any broader host directory.
1048
+ printf ' - "%s:%s"\n' \
1049
+ "$task_workspace" "$task_workspace_physical"
1050
+ fi
1021
1051
  while IFS= read -r vol_obj; do
1022
1052
  [ -n "$vol_obj" ] || continue
1023
1053
  vol_pid=$(jq -r '.id' <<<"$vol_obj")
1024
1054
  printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
1025
1055
  "$task_uai_dir" "$vol_pid" "$task_uai_dir" "$vol_pid"
1056
+ if [ "$task_uai_dir_physical" != "$task_uai_dir" ]; then
1057
+ printf ' - "%s/repos/%s.git:%s/repos/%s.git"\n' \
1058
+ "$task_uai_dir" "$vol_pid" "$task_uai_dir_physical" "$vol_pid"
1059
+ fi
1026
1060
  done < <(jq -c '.[]' <<<"$projects_json")
1027
1061
  printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
1028
1062
  # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
@@ -1290,7 +1324,9 @@ while IFS= read -r safe_project; do
1290
1324
  for safe_worktree in \
1291
1325
  "/workspace/$safe_project_slug" \
1292
1326
  "$task_workspace/$safe_project_slug" \
1293
- "$task_uai_dir/repos/$safe_project_id.git"; do
1327
+ "$task_uai_dir/repos/$safe_project_id.git" \
1328
+ "$task_workspace_physical/$safe_project_slug" \
1329
+ "$task_uai_dir_physical/repos/$safe_project_id.git"; do
1294
1330
  if ! printf '%s\n' "$container_safe_dirs" \
1295
1331
  | grep -Fqx -- "$safe_worktree"; then
1296
1332
  if ! docker exec -u node "${managed_exec_env[@]}" "$app_container" \
@@ -1305,6 +1341,26 @@ while IFS= read -r safe_project; do
1305
1341
  done
1306
1342
  done < <(jq -c '.[]' <<<"$projects_json")
1307
1343
 
1344
+ # A running container is not sufficient proof that its linked worktrees are
1345
+ # usable. In particular, Git may have canonicalized a symlinked host workspace
1346
+ # root while Compose mounted only its lexical spelling. Verify the actual path
1347
+ # agents use before credentials, dependency installation, or status=running.
1348
+ step "CONTAINER_INIT_FAILED" "verify task Git worktrees"
1349
+ while IFS= read -r verify_project; do
1350
+ [ -n "$verify_project" ] || continue
1351
+ verify_project_slug=$(jq -r '.slug' <<<"$verify_project")
1352
+ verify_is_worktree=""
1353
+ if ! verify_is_worktree=$(docker exec -u node "${managed_exec_env[@]}" \
1354
+ "$app_container" "${managed_clean_env[@]}" /usr/bin/git \
1355
+ -C "/workspace/$verify_project_slug" rev-parse \
1356
+ --is-inside-work-tree 2>/dev/null) \
1357
+ || [ "$verify_is_worktree" != "true" ]; then
1358
+ emit_err "CONTAINER_INIT_FAILED" \
1359
+ "Uai could not open $verify_project_slug as a Git worktree inside the task container. Retry the task after checking the host workspace path." \
1360
+ "verify task Git worktree ($verify_project_slug)"
1361
+ fi
1362
+ done < <(jq -c '.[]' <<<"$projects_json")
1363
+
1308
1364
  # A failed/retried start can preserve a container that still holds a previous
1309
1365
  # account. Erase that Uai-managed state first for both connected and
1310
1366
  # disconnected starts; otherwise a no-credential resume could briefly reuse a
package/src/index.ts CHANGED
@@ -171,26 +171,123 @@ export const hostCommands: HostCommands = {
171
171
  .split(/\r?\n/)
172
172
  .some((state) => state.trim() === "running")
173
173
  ) {
174
- // waitForChannelClose crossed the teardown fence above. A completed
175
- // close-only teardown may have left this running task tombstoned, so
176
- // the idempotent-success transition must reopen delivery just like a
177
- // fresh successful taskUp does.
178
- orchestrator.allowChannel(input.task.id);
179
- return {
180
- ok: true,
181
- value: {
182
- composeProject: existingTask.composeProject,
183
- worktreePath: existingTask.worktreePath,
184
- codeServerPort: existingTask.codeServerPort ?? undefined,
185
- previewPorts: parsePreviewPortRuntimes(
186
- existingTask.previewPorts,
187
- ),
188
- },
189
- };
174
+ // Docker being up is not enough to prove a lost-response retry is
175
+ // healthy. The worktree's `.git` file contains an absolute pointer
176
+ // into the task-private repository; after a host workspace-root
177
+ // migration the old container can keep running while every agent
178
+ // sees `fatal: not a git repository`. Probe from INSIDE the live
179
+ // container, which is the filesystem view the agents actually use.
180
+ // Scratchpads have no projects, so this remains an O(1) fast path.
181
+ const appContainer = `${existingTask.composeProject}-app-1`;
182
+ let projectsHealthy = true;
183
+ for (const project of input.projects) {
184
+ const gitHealth = await dockerCli(
185
+ [
186
+ "exec",
187
+ "-u",
188
+ "node",
189
+ // docker exec inherits the Compose/project environment. Clear
190
+ // loader and Git controls before the first executable, then
191
+ // give Git a minimal environment of its own. Keep this aligned
192
+ // with task-up.sh's managed container operations.
193
+ "-e",
194
+ "HOME=/home/node",
195
+ "-e",
196
+ "PATH=/usr/bin:/bin",
197
+ "-e",
198
+ "LD_PRELOAD=",
199
+ "-e",
200
+ "LD_LIBRARY_PATH=",
201
+ "-e",
202
+ "DYLD_INSERT_LIBRARIES=",
203
+ "-e",
204
+ "DYLD_LIBRARY_PATH=",
205
+ "-e",
206
+ "BASH_ENV=",
207
+ "-e",
208
+ "ENV=",
209
+ "-e",
210
+ "GIT_CONFIG=",
211
+ "-e",
212
+ "GIT_CONFIG_GLOBAL=",
213
+ "-e",
214
+ "GIT_CONFIG_SYSTEM=",
215
+ "-e",
216
+ "GIT_CONFIG_NOSYSTEM=",
217
+ "-e",
218
+ "GIT_CONFIG_COUNT=0",
219
+ "-e",
220
+ "GIT_EXEC_PATH=",
221
+ "-e",
222
+ "GIT_SSH=",
223
+ "-e",
224
+ "GIT_SSH_COMMAND=",
225
+ "-e",
226
+ "GIT_ASKPASS=",
227
+ "-e",
228
+ "SSH_ASKPASS=",
229
+ "-w",
230
+ `/workspace/${project.slug}`,
231
+ appContainer,
232
+ "/usr/bin/env",
233
+ "-i",
234
+ "HOME=/home/node",
235
+ "PATH=/usr/bin:/bin",
236
+ "/usr/bin/git",
237
+ "rev-parse",
238
+ "--is-inside-work-tree",
239
+ ],
240
+ { timeoutMs: 10_000 },
241
+ );
242
+ // A timeout/spawn error is not proof that the repository is
243
+ // broken. Preserve the running task and let the caller retry once
244
+ // Docker can answer, just as the runtime-state probe above does.
245
+ if (gitHealth.status === null) {
246
+ return {
247
+ ok: false,
248
+ code: HostErrorCode.HostUnavailable,
249
+ message:
250
+ `could not verify Git state for task ${input.task.id} ` +
251
+ `project ${project.id}: ` +
252
+ (gitHealth.stderr.trim().slice(0, 200) ||
253
+ "docker did not answer"),
254
+ retryable: true,
255
+ };
256
+ }
257
+ if (
258
+ gitHealth.status !== 0 ||
259
+ gitHealth.stdout.trim() !== "true"
260
+ ) {
261
+ projectsHealthy = false;
262
+ console.warn(
263
+ `[host-agent] task ${input.task.id}: project ${project.id} Git health check failed; rebuilding the running task (${gitHealth.stderr.trim().slice(0, 200) || `git exited ${String(gitHealth.status)}`})`,
264
+ );
265
+ break;
266
+ }
267
+ }
268
+ if (projectsHealthy) {
269
+ // waitForChannelClose crossed the teardown fence above. A completed
270
+ // close-only teardown may have left this running task tombstoned,
271
+ // so the idempotent-success transition must reopen delivery just
272
+ // like a fresh successful taskUp does.
273
+ orchestrator.allowChannel(input.task.id);
274
+ return {
275
+ ok: true,
276
+ value: {
277
+ composeProject: existingTask.composeProject,
278
+ worktreePath: existingTask.worktreePath,
279
+ codeServerPort: existingTask.codeServerPort ?? undefined,
280
+ previewPorts: parsePreviewPortRuntimes(
281
+ existingTask.previewPorts,
282
+ ),
283
+ },
284
+ };
285
+ }
190
286
  }
191
- // Docker positively proved the mirrored app is absent/stopped. Drain
192
- // and invalidate any stale in-memory channel before taskUp recreates
193
- // the same stable compose/container names.
287
+ // Docker positively proved the mirrored app is absent/stopped, or the
288
+ // live container proved one of its project repositories is broken.
289
+ // Drain and invalidate any stale in-memory channel before taskUp
290
+ // recreates the same stable compose/container names and repairs Git.
194
291
  await orchestrator.closeChannel(input.task.id);
195
292
  }
196
293
  recordTaskStarting(input.task.id);