@todos-dev/cli 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist/index.js +208 -171
  2. package/package.json +7 -7
package/dist/index.js CHANGED
@@ -361,12 +361,17 @@ var MAX_TIMEOUT_MS = 12e4;
361
361
  var IDLE_MS = 800;
362
362
  var OUTPUT_CAP = 6e4;
363
363
  var NO_RUNNER_MSG = "No exec-enabled runner is connected for this build. Add a sync machine for this todo (with command execution enabled) in the web app, and make sure it is online (`tds start`) with Runner enabled. Until then commands cannot run on a runner.";
364
- function makeClientShellTool(tunnel, build, runners = []) {
365
- const runnerList = runners.length ? `Available runner machines: ${runners.map((r) => r.name).join(", ")}.` : "No exec-enabled runner machines are currently attached to this todo.";
364
+ var RECONNECT_NOTE = "[shell reconnected \u2014 the previous shell session was lost: cwd, env vars and background processes were reset]";
365
+ function disconnectedMsg(runner) {
366
+ const cause = runner.online === false ? "its machine is offline (no presence heartbeat \u2014 `tds start` may have stopped, or the machine is asleep/disconnected)" : "the sync shell channel to it is down (the sync client may be reconnecting)";
367
+ return `Runner "${runner.name}" is attached, but ${cause}. Wait a moment and retry; if this persists, ask the user to check the runner machine.`;
368
+ }
369
+ function makeClientShellTool(tunnel, build, runnersRef) {
370
+ const runnerList = runnersRef.current.length ? `Available runner machines: ${runnersRef.current.map((r) => r.name).join(", ")}.` : "No exec-enabled runner machines are currently attached to this todo (the set can change mid-conversation).";
366
371
  return {
367
372
  name: "client_shell",
368
373
  label: "Client Shell",
369
- description: "Run a command in a persistent interactive shell on a RUNNER MACHINE \u2014 a separate host the build's worktree is mirrored onto. Use this (not bash) to run tests, start dev servers, or configure an environment that only exists on the runner. The shell is stateful: cwd, environment variables, and background processes persist across calls. Send a command with `input`; call again with empty input to read more output from a long-running command; set `interrupt` to send Ctrl-C. When several runners are attached, pass `machine` to pick one. " + runnerList,
374
+ description: "Run a command in a persistent interactive shell on a RUNNER MACHINE \u2014 a separate host the build's worktree is mirrored onto. Use this (not bash) to run tests, start dev servers, or configure an environment that only exists on the runner. The shell is stateful: cwd, environment variables, and background processes persist across calls \u2014 but only while the runner's sync connection holds. If sync drops, the shell (and anything running in it) dies; a reconnect opens a fresh shell and the next result says so. Send a command with `input`; call again with empty input to read more output from a long-running command; set `interrupt` to send Ctrl-C. When several runners are attached, pass `machine` to pick one. " + runnerList,
370
375
  parameters: {
371
376
  type: "object",
372
377
  properties: {
@@ -384,11 +389,19 @@ function makeClientShellTool(tunnel, build, runners = []) {
384
389
  if (!tunnel || !tunnel.shellAvailable()) {
385
390
  return text("Reverse shell is unavailable: the sync tunnel sidecar is not running on this machine.", { closed: true, truncated: false });
386
391
  }
387
- const picked = resolveMachine(runners, p.machine);
392
+ const picked = resolveMachine(runnersRef.current, p.machine);
388
393
  if ("error" in picked) return text(picked.error, { closed: true, truncated: false });
389
- const machineId = picked.machineId;
394
+ const runner = picked.runner;
395
+ const machineId = runner.machineId;
390
396
  const t = tunnel;
391
397
  const payload = shellPayload(p);
398
+ if (t.shellConnected(build, machineId) === false) {
399
+ const out = t.drainShell(build, machineId);
400
+ const msg = disconnectedMsg(runner);
401
+ return text(out.trim() ? `${out}
402
+ [shell disconnected]
403
+ ${msg}` : msg, { closed: true, truncated: false });
404
+ }
392
405
  return new Promise((resolve) => {
393
406
  let settled = false;
394
407
  let closed = false;
@@ -403,7 +416,7 @@ function makeClientShellTool(tunnel, build, runners = []) {
403
416
  clearTimeout(maxTimer);
404
417
  signal?.removeEventListener("abort", onAbort);
405
418
  unsub();
406
- resolve(res ?? shellResult(t.drainShell(build, machineId), closed));
419
+ resolve(res ?? shellResult(t, build, runner, closed));
407
420
  };
408
421
  const onAbort = () => finish();
409
422
  const armIdle = () => {
@@ -423,11 +436,6 @@ function makeClientShellTool(tunnel, build, runners = []) {
423
436
  finish();
424
437
  return;
425
438
  }
426
- if (payload === "" && t.shellClosed(build, machineId)) {
427
- closed = true;
428
- finish();
429
- return;
430
- }
431
439
  if (payload !== "" && !t.shellSendInput(build, machineId, payload)) {
432
440
  finish(text("Reverse shell is unavailable: failed to write to the sidecar.", { closed: true, truncated: false }));
433
441
  } else {
@@ -443,9 +451,9 @@ function resolveMachine(runners, requested) {
443
451
  const q = requested.trim().toLowerCase();
444
452
  const hit = runners.find((r) => r.machineId.toLowerCase() === q || r.name.toLowerCase() === q);
445
453
  if (!hit) return { error: `Unknown runner "${requested}". Available: ${runners.map((r) => r.name).join(", ")}.` };
446
- return { machineId: hit.machineId };
454
+ return { runner: hit };
447
455
  }
448
- if (runners.length === 1) return { machineId: runners[0].machineId };
456
+ if (runners.length === 1) return { runner: runners[0] };
449
457
  return { error: `Several runners are attached \u2014 pass "machine" to pick one: ${runners.map((r) => r.name).join(", ")}.` };
450
458
  }
451
459
  function shellPayload(p) {
@@ -454,11 +462,20 @@ function shellPayload(p) {
454
462
  function clampTimeout(ms) {
455
463
  return Math.min(Math.max(Number(ms) || DEFAULT_TIMEOUT_MS, 1e3), MAX_TIMEOUT_MS);
456
464
  }
457
- function shellResult(out, closed) {
465
+ function shellResult(t, build, runner, closed) {
466
+ const reconnected = t.consumeShellReconnect(build, runner.machineId);
467
+ let out = t.drainShell(build, runner.machineId);
458
468
  const truncated = out.length > OUTPUT_CAP;
459
469
  if (truncated) out = out.slice(-OUTPUT_CAP);
460
- if (closed && out.trim() === "") return text(NO_RUNNER_MSG, { closed: true, truncated: false });
461
- return text((out || "(no output)") + (closed ? "\n[shell session closed]" : ""), { closed, truncated });
470
+ if (closed && out.trim() === "" && !reconnected) {
471
+ return text(disconnectedMsg(runner), { closed: true, truncated: false });
472
+ }
473
+ const parts = [];
474
+ if (reconnected) parts.push(RECONNECT_NOTE);
475
+ if (truncated) parts.push(`[output truncated to the last ${OUTPUT_CAP} characters]`);
476
+ parts.push(out || "(no output)");
477
+ if (closed) parts.push("[shell session closed]");
478
+ return text(parts.join("\n"), { closed, truncated });
462
479
  }
463
480
  function text(s, details) {
464
481
  return { content: [{ type: "text", text: s }], details };
@@ -498,19 +515,28 @@ function makeListTodosTool(serverUrl, token, step) {
498
515
  return {
499
516
  name: "list_todos",
500
517
  label: "List Todos",
501
- description: 'List the todos in this project so you can see existing/related work and find a todo to update. Returns each todo as "#<seq> [<phase>] <title> (id: <id>)"; todos you created are marked (yours).',
518
+ description: `List the todos in this project so you can see existing/related work and find a todo to update. The first line lists the project's tags with their ids (usable as update_todo's tagIds). Returns each todo as "#<seq> [<phase>] <title> (id: <id>) [tags: \u2026]"; todos you created are marked (yours).`,
502
519
  parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
503
520
  async execute() {
504
521
  if (!projectId) return text2("No project context for this turn \u2014 todos are unavailable.");
505
- const reply = await request(serverUrl, "GET", `/api/projects/${projectId}/todos`, token);
522
+ const [reply, tagsReply] = await Promise.all([
523
+ request(serverUrl, "GET", `/api/projects/${projectId}/todos`, token),
524
+ request(serverUrl, "GET", `/api/projects/${projectId}/tags`, token)
525
+ ]);
506
526
  if (!reply.ok) return text2(errorText(reply, "Failed to list todos"));
507
527
  const todos = reply.body ?? [];
508
528
  if (!todos.length) return text2("No todos in this project yet.");
529
+ const tags = tagsReply.ok ? tagsReply.body ?? [] : [];
530
+ const tagName = new Map(tags.map((t) => [t.id, t.name]));
531
+ const header = tags.length ? `Project tags: ${tags.map((t) => `${t.name} (id: ${t.id})`).join(", ")}
532
+ ` : "";
509
533
  const lines = todos.map((t) => {
510
534
  const mine = t.createdBy === step.agent.agentId ? " (yours)" : "";
511
- return `#${t.seqNum} [${t.phase}] ${t.title} (id: ${t.id})${mine}`;
535
+ const names = (t.tagIds ?? []).map((id) => tagName.get(id)).filter(Boolean);
536
+ const tagsStr = names.length ? ` [tags: ${names.join(", ")}]` : "";
537
+ return `#${t.seqNum} [${t.phase}] ${t.title} (id: ${t.id})${tagsStr}${mine}`;
512
538
  });
513
- let out = lines.join("\n");
539
+ let out = header + lines.join("\n");
514
540
  if (out.length > LIST_CAP) out = out.slice(0, LIST_CAP) + "\n\u2026(truncated)";
515
541
  return text2(out);
516
542
  }
@@ -550,14 +576,14 @@ function makeUpdateTodoTool(serverUrl, token, step) {
550
576
  return {
551
577
  name: "update_todo",
552
578
  label: "Update Todo",
553
- description: `Update a todo YOU created (only your own \u2014 found via list_todos, marked "(yours)"). You can change its title, spec, or tags. Use this to refine a follow-up todo as you learn more. You cannot edit todos created by others or the user, change a todo's phase, or start a build.`,
579
+ description: `Update a todo YOU created (only your own \u2014 found via list_todos, marked "(yours)"). You can change its title, spec, or tags (tag ids come from the "Project tags" line of list_todos). Use this to refine a follow-up todo as you learn more. You cannot edit todos created by others or the user, change a todo's phase, or start a build.`,
554
580
  parameters: {
555
581
  type: "object",
556
582
  properties: {
557
583
  id: { type: "string", description: "The id of the todo to update (from list_todos)." },
558
584
  title: { type: "string", description: "New title (optional)." },
559
585
  spec: { type: "string", description: "New spec/description (optional)." },
560
- tagIds: { type: "array", items: { type: "string" }, description: "Replacement tag id list (optional)." }
586
+ tagIds: { type: "array", items: { type: "string" }, description: `Replacement tag id list; ids come from list_todos's "Project tags" line (optional).` }
561
587
  },
562
588
  required: ["id"],
563
589
  additionalProperties: false
@@ -897,7 +923,7 @@ async function postCritical(serverUrl, path, body, token) {
897
923
  `[machine] POST ${path}`
898
924
  );
899
925
  }
900
- function startHeartbeat(serverUrl, stepId, agentId, onSignal, token) {
926
+ function startHeartbeat(serverUrl, stepId, agentId, onSignal, token, onRunners) {
901
927
  let active = true;
902
928
  let inFlight = false;
903
929
  const tick = async () => {
@@ -906,6 +932,7 @@ function startHeartbeat(serverUrl, stepId, agentId, onSignal, token) {
906
932
  try {
907
933
  const res = await post(serverUrl, `/api/machine/heartbeat/${stepId}`, { agentId }, token);
908
934
  if (active && res.stop) onSignal();
935
+ if (active && Array.isArray(res.runners)) onRunners?.(res.runners);
909
936
  } finally {
910
937
  inFlight = false;
911
938
  }
@@ -1087,7 +1114,7 @@ async function prepareStepWorkspace(step) {
1087
1114
  workspace.repoFullName,
1088
1115
  workspace.defaultBranch,
1089
1116
  workspace.installationToken,
1090
- // Lazy/pure rewind: land the worktree on the checkpoint before any prompt or mirror reads it.
1117
+ // Pure restore step: land the worktree on the checkpoint before the mirror/PR reads it.
1091
1118
  step.restore ? { restoreTo: step.restore.commitSha ?? `origin/${workspace.defaultBranch}` } : {}
1092
1119
  );
1093
1120
  }
@@ -1152,7 +1179,10 @@ async function executeStep(step, serverUrl, token, tunnel) {
1152
1179
  interrupted = true;
1153
1180
  abortRef?.abort();
1154
1181
  };
1155
- const stopHeartbeat = startHeartbeat(serverUrl, stepId, agentId, stopTurn, token);
1182
+ const runnersRef = { current: step.runners ?? [] };
1183
+ const stopHeartbeat = startHeartbeat(serverUrl, stepId, agentId, stopTurn, token, (r) => {
1184
+ runnersRef.current = r;
1185
+ });
1156
1186
  const done = makeDone(step, serverUrl, token);
1157
1187
  try {
1158
1188
  const runtime = await getPiRuntime();
@@ -1171,14 +1201,6 @@ async function executeStep(step, serverUrl, token, tunnel) {
1171
1201
  await done({ error: `Workspace preparation failed: ${err.message}` });
1172
1202
  return;
1173
1203
  }
1174
- if (step.restore) {
1175
- try {
1176
- await restoreSessionToCheckpoint(step);
1177
- } catch (err) {
1178
- await done({ error: `Session rewind failed: ${err.message}` });
1179
- return;
1180
- }
1181
- }
1182
1204
  const ephemeral = step.mode === "ephemeral_turn";
1183
1205
  const m = ephemeral ? { sessionManager: sdk.SessionManager.inMemory(cwd), isContinue: false, sessionKeyForSave: null } : await materializeSession({
1184
1206
  sdk,
@@ -1189,7 +1211,7 @@ async function executeStep(step, serverUrl, token, tunnel) {
1189
1211
  console.log(m.isContinue ? `[step] ${stepId} continue session ${conversationId}` : `[step] ${stepId} new session ${conversationId}`);
1190
1212
  const skills = await materializeSkills(step.skills, agentDir);
1191
1213
  const extraCustomTools = [
1192
- makeClientShellTool(tunnel, conversationId, step.runners),
1214
+ makeClientShellTool(tunnel, conversationId, runnersRef),
1193
1215
  ...makeTodoTools(serverUrl, token, step)
1194
1216
  ];
1195
1217
  const session = await createSession(sdk, {
@@ -16169,6 +16191,12 @@ var PollClient = class {
16169
16191
  roles = [];
16170
16192
  tunnel;
16171
16193
  onRolesChanged;
16194
+ // Per-mount mirror health provider (set while the runner role is active). Rides the presence
16195
+ // heartbeat so the server can tell a dead mirror from a healthy one; null omits the field.
16196
+ getMountSync;
16197
+ // Desired mounts pushed back on the presence response (runner machines only) — presence is the
16198
+ // runner's single poll; the SyncManager forwards the set to the mirror daemon.
16199
+ onMounts;
16172
16200
  // Only a builder claims and runs build steps; a runner-only machine still reports presence.
16173
16201
  get canClaim() {
16174
16202
  return this.roles.includes("builder");
@@ -16277,6 +16305,8 @@ ${node.sshHostKey}` : null };
16277
16305
  async reportPresence() {
16278
16306
  try {
16279
16307
  const body = { load: this.runningTasks.size };
16308
+ const mountSync = this.getMountSync?.();
16309
+ if (mountSync) body.mountSync = mountSync;
16280
16310
  const { node, key } = this.tunnelSnapshot();
16281
16311
  if (key !== this.lastSentTunnelKey) {
16282
16312
  body.sshHostKey = node?.sshHostKey ?? null;
@@ -16299,6 +16329,7 @@ ${node.sshHostKey}` : null };
16299
16329
  if (Array.isArray(data?.roles)) {
16300
16330
  this.setRoles(data.roles.filter((r) => r === "builder" || r === "runner"));
16301
16331
  }
16332
+ if (Array.isArray(data?.mounts)) this.onMounts?.(data.mounts);
16302
16333
  this.lastSentTunnelKey = key;
16303
16334
  }
16304
16335
  } catch {
@@ -16382,13 +16413,10 @@ function sleep(ms, signal) {
16382
16413
  }
16383
16414
 
16384
16415
  // src/SyncManager.ts
16385
- var import_node_fs6 = require("node:fs");
16416
+ var import_node_child_process2 = require("node:child_process");
16386
16417
  var import_node_os4 = require("node:os");
16387
16418
  var import_node_path6 = require("node:path");
16388
16419
 
16389
- // src/lib/mirror.ts
16390
- var import_node_child_process2 = require("node:child_process");
16391
-
16392
16420
  // src/lib/bin.ts
16393
16421
  var import_node_fs5 = require("node:fs");
16394
16422
  var import_node_path5 = require("node:path");
@@ -16454,145 +16482,127 @@ function createNdjsonParser(onEvent) {
16454
16482
  };
16455
16483
  }
16456
16484
 
16457
- // src/lib/mirror.ts
16458
- function spawnMirror(opts) {
16459
- const proc = (0, import_node_child_process2.spawn)(TUNNEL_BIN, [
16460
- "connect",
16461
- "--server",
16462
- opts.serverUrl,
16463
- "--token",
16464
- opts.token,
16465
- "--build",
16466
- opts.buildId,
16467
- "--dest",
16468
- opts.dest,
16469
- "--default-branch",
16470
- opts.defaultBranch,
16471
- "--machine",
16472
- opts.machineId,
16473
- ...opts.allowExec ? ["--allow-exec"] : []
16474
- ], { stdio: ["ignore", "pipe", "inherit"] });
16475
- if (opts.onEvent) {
16476
- const parse3 = createNdjsonParser(opts.onEvent);
16477
- proc.stdout?.on("data", (d) => parse3(String(d)));
16478
- }
16479
- const exited = new Promise((resolve) => {
16480
- proc.on("exit", (code) => resolve(code ?? 0));
16481
- proc.on("error", (err) => {
16482
- opts.onEvent?.({ event: "error", message: err.message });
16483
- resolve(1);
16484
- });
16485
- });
16486
- return {
16487
- exited,
16488
- stop: async () => {
16489
- if (proc.exitCode === null) proc.kill();
16490
- await exited;
16491
- }
16492
- };
16493
- }
16494
-
16495
16485
  // src/SyncManager.ts
16496
- var RECONCILE_INTERVAL_MS = 8e3;
16486
+ var RESTART_MIN_MS = 1e3;
16487
+ var RESTART_MAX_MS = 6e4;
16497
16488
  function expandHome(p) {
16498
16489
  if (p === "~") return (0, import_node_os4.homedir)();
16499
16490
  if (p.startsWith("~/") || p.startsWith("~\\")) return (0, import_node_path6.join)((0, import_node_os4.homedir)(), p.slice(2));
16500
16491
  return p;
16501
16492
  }
16502
16493
  var SyncManager = class {
16503
- // keyed by mountId
16494
+ // by mountId
16504
16495
  constructor(serverUrl, token, machineId) {
16505
16496
  this.serverUrl = serverUrl;
16506
16497
  this.token = token;
16507
16498
  this.machineId = machineId;
16508
16499
  }
16509
- timer = null;
16510
- running = false;
16511
- tunnels = /* @__PURE__ */ new Map();
16500
+ proc = null;
16501
+ stopped = false;
16502
+ restartTimer = null;
16503
+ restartDelayMs = RESTART_MIN_MS;
16504
+ desired = [];
16505
+ health = /* @__PURE__ */ new Map();
16512
16506
  start() {
16513
- if (this.running) return;
16514
- this.running = true;
16507
+ this.stopped = false;
16515
16508
  console.log(`[machine] runner: managing worktree mirrors (machineId=${this.machineId})`);
16516
- const tick = () => {
16517
- void this.reconcile();
16518
- };
16519
- tick();
16520
- this.timer = setInterval(tick, RECONCILE_INTERVAL_MS);
16509
+ this.spawnSidecar();
16521
16510
  }
16522
16511
  stop() {
16523
- this.running = false;
16524
- if (this.timer) {
16525
- clearInterval(this.timer);
16526
- this.timer = null;
16512
+ this.stopped = true;
16513
+ if (this.restartTimer) {
16514
+ clearTimeout(this.restartTimer);
16515
+ this.restartTimer = null;
16527
16516
  }
16528
- for (const mountId of [...this.tunnels.keys()]) this.teardown(mountId);
16517
+ this.proc?.kill();
16518
+ this.proc = null;
16529
16519
  }
16530
- // Default dir for a mount with no explicit destDir, under the runner's home. e.g. ~/.tds/runner/owner/repo/119
16531
- defaultDest(m) {
16532
- return (0, import_node_path6.join)((0, import_node_os4.homedir)(), defaultMirrorSubpath(m.repoFullName, m.seqNum));
16520
+ // Desired mounts, straight off the presence response. Declarative: resolve dests, remember the
16521
+ // set (a respawned daemon gets it replayed on its 'up' event), and forward it.
16522
+ setMounts(resolved) {
16523
+ this.desired = resolved.map((r) => ({
16524
+ mountId: r.mountId,
16525
+ buildId: r.buildId,
16526
+ dest: r.destDir ? expandHome(r.destDir) : (0, import_node_path6.join)((0, import_node_os4.homedir)(), defaultMirrorSubpath(r.repoFullName, r.seqNum)),
16527
+ defaultBranch: r.defaultBranch,
16528
+ allowExec: r.allowExec
16529
+ }));
16530
+ const keep = new Set(this.desired.map((m) => m.mountId));
16531
+ for (const id of [...this.health.keys()]) if (!keep.has(id)) this.health.delete(id);
16532
+ this.sendMounts();
16533
+ }
16534
+ // Per-mount mirror health for the presence heartbeat: age of the last completed cycle (computed
16535
+ // here so no other machine ever compares clocks) + last error. The web mounts panel and sync
16536
+ // indicator read this — presence alone can't tell a dead mirror from a healthy one.
16537
+ status() {
16538
+ const now = Date.now();
16539
+ return this.desired.map((m) => {
16540
+ const h = this.health.get(m.mountId);
16541
+ return {
16542
+ mountId: m.mountId,
16543
+ ageMs: h?.syncedAt ? now - h.syncedAt : null,
16544
+ ...h?.lastError ? { error: h.lastError } : {}
16545
+ };
16546
+ });
16533
16547
  }
16534
- async fetchMounts() {
16548
+ spawnSidecar() {
16535
16549
  try {
16536
- const res = await fetch(`${this.serverUrl}/api/machine/mounts`, {
16537
- method: "POST",
16538
- headers: { Authorization: `Bearer ${this.token}` },
16539
- signal: AbortSignal.timeout(8e3)
16540
- });
16541
- if (!res.ok) return null;
16542
- return await res.json();
16550
+ this.proc = (0, import_node_child_process2.spawn)(
16551
+ TUNNEL_BIN,
16552
+ ["mirror", "--server", this.serverUrl, "--token", this.token, "--machine", this.machineId],
16553
+ // stdin is piped: the desired mount set goes down as NDJSON control messages.
16554
+ { stdio: ["pipe", "pipe", "inherit"] }
16555
+ );
16543
16556
  } catch {
16544
- return null;
16545
- }
16546
- }
16547
- async reconcile() {
16548
- if (!this.running) return;
16549
- const resolved = await this.fetchMounts();
16550
- if (resolved === null) return;
16551
- if (!this.running) return;
16552
- const desired = new Map(resolved.map((r) => [r.mountId, r]));
16553
- for (const [mountId, t] of [...this.tunnels]) {
16554
- const want = desired.get(mountId);
16555
- if (!want || want.buildId !== t.buildId || want.allowExec !== t.allowExec) this.teardown(mountId);
16556
- }
16557
- for (const r of resolved) {
16558
- if (!this.tunnels.has(r.mountId)) this.startTunnel(r);
16559
- }
16560
- }
16561
- startTunnel(r) {
16562
- const dest = r.destDir ? expandHome(r.destDir) : this.defaultDest(r);
16563
- try {
16564
- (0, import_node_fs6.mkdirSync)(dest, { recursive: true });
16565
- } catch (e) {
16566
- console.warn(`[machine] runner: cannot create ${dest}: ${e.message} \u2014 skipping mount ${r.mountId}`);
16557
+ console.warn("[machine] runner: tds-tunnel not available \u2014 mirroring disabled");
16567
16558
  return;
16568
16559
  }
16569
- console.log(`[machine] runner: mirroring #${r.seqNum} (build ${r.buildId.slice(0, 8)}) \u2192 ${dest}${r.allowExec ? " [exec]" : ""}`);
16570
- const handle = spawnMirror({
16571
- serverUrl: this.serverUrl,
16572
- token: this.token,
16573
- buildId: r.buildId,
16574
- dest,
16575
- defaultBranch: r.defaultBranch,
16576
- machineId: this.machineId,
16577
- allowExec: r.allowExec,
16578
- onEvent: (e) => {
16579
- if (e.event === "error") console.warn(`[machine] runner: mount ${r.mountId}: ${e.message ?? "error"}`);
16580
- else if (e.event === "synced" && (e.changed || e.deleted)) {
16581
- console.log(`[machine] runner: #${r.seqNum} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
16582
- }
16583
- }
16560
+ this.proc.on("error", () => {
16561
+ console.warn("[machine] runner: mirror daemon failed to start \u2014 mirroring disabled");
16562
+ this.proc = null;
16563
+ });
16564
+ this.proc.on("exit", () => {
16565
+ this.proc = null;
16566
+ this.scheduleRestart();
16584
16567
  });
16585
- this.tunnels.set(r.mountId, { buildId: r.buildId, allowExec: r.allowExec, handle });
16586
- void handle.exited.then(() => {
16587
- const cur = this.tunnels.get(r.mountId);
16588
- if (cur && cur.handle === handle) this.tunnels.delete(r.mountId);
16568
+ this.proc.stdout?.on("data", (d) => this.onStdout(String(d)));
16569
+ this.proc.stdin?.on("error", () => {
16589
16570
  });
16590
16571
  }
16591
- teardown(mountId) {
16592
- const t = this.tunnels.get(mountId);
16593
- if (!t) return;
16594
- this.tunnels.delete(mountId);
16595
- void t.handle.stop();
16572
+ scheduleRestart() {
16573
+ if (this.stopped || this.restartTimer) return;
16574
+ const delay2 = this.restartDelayMs;
16575
+ this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS);
16576
+ console.warn(`[machine] runner: mirror daemon exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
16577
+ this.restartTimer = setTimeout(() => {
16578
+ this.restartTimer = null;
16579
+ this.spawnSidecar();
16580
+ }, delay2);
16581
+ }
16582
+ onStdout = createNdjsonParser((e) => {
16583
+ if (e.event === "up") {
16584
+ this.restartDelayMs = RESTART_MIN_MS;
16585
+ this.sendMounts();
16586
+ } else if (e.event === "synced" && e.mount) {
16587
+ this.health.set(e.mount, { syncedAt: Date.now(), lastError: null });
16588
+ if (e.changed || e.deleted) console.log(`[machine] runner: mount ${e.mount} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
16589
+ } else if (e.event === "error") {
16590
+ if (e.mount) {
16591
+ const h = this.health.get(e.mount);
16592
+ this.health.set(e.mount, { syncedAt: h?.syncedAt ?? 0, lastError: e.message ?? "error" });
16593
+ console.warn(`[machine] runner: mount ${e.mount}: ${e.message ?? "error"}`);
16594
+ } else {
16595
+ console.warn(`[machine] runner: ${e.message ?? "error"}`);
16596
+ }
16597
+ }
16598
+ });
16599
+ sendMounts() {
16600
+ const stdin = this.proc?.stdin;
16601
+ if (!stdin || stdin.destroyed) return;
16602
+ try {
16603
+ stdin.write(JSON.stringify({ cmd: "mounts", mounts: this.desired }) + "\n");
16604
+ } catch {
16605
+ }
16596
16606
  }
16597
16607
  };
16598
16608
 
@@ -16706,8 +16716,8 @@ async function syncModelsToServer(serverUrl, token, runtime) {
16706
16716
  // src/lib/tunnel.ts
16707
16717
  var import_node_child_process3 = require("node:child_process");
16708
16718
  var SHELL_BUFFER_CAP = 256 * 1024;
16709
- var RESTART_MIN_MS = 1e3;
16710
- var RESTART_MAX_MS = 6e4;
16719
+ var RESTART_MIN_MS2 = 1e3;
16720
+ var RESTART_MAX_MS2 = 6e4;
16711
16721
  var EMPTY_BUFFER = Buffer.alloc(0);
16712
16722
  var TunnelServer = class _TunnelServer {
16713
16723
  constructor(serverUrl, token) {
@@ -16719,7 +16729,7 @@ var TunnelServer = class _TunnelServer {
16719
16729
  onChange = null;
16720
16730
  stopped = false;
16721
16731
  restartTimer = null;
16722
- restartDelayMs = RESTART_MIN_MS;
16732
+ restartDelayMs = RESTART_MIN_MS2;
16723
16733
  // Reverse shells, keyed by (build, machine) — a build mirrored onto several runners has one shell
16724
16734
  // per machine. Output is buffered even with no live subscriber so output a backgrounded command
16725
16735
  // emits between tool calls survives until the next drain.
@@ -16764,7 +16774,7 @@ var TunnelServer = class _TunnelServer {
16764
16774
  scheduleRestart() {
16765
16775
  if (this.stopped || this.restartTimer) return;
16766
16776
  const delay2 = this.restartDelayMs;
16767
- this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS);
16777
+ this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS2);
16768
16778
  console.warn(`[tunnel] sidecar exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
16769
16779
  this.restartTimer = setTimeout(() => {
16770
16780
  this.restartTimer = null;
@@ -16780,20 +16790,24 @@ var TunnelServer = class _TunnelServer {
16780
16790
  onStdout = createNdjsonParser((evt) => {
16781
16791
  if (evt.event === "ready" && evt.tailnetAddr && evt.sshHostKey) {
16782
16792
  this.node = { tailnetAddr: evt.tailnetAddr, sshHostKey: evt.sshHostKey };
16783
- this.restartDelayMs = RESTART_MIN_MS;
16793
+ this.restartDelayMs = RESTART_MIN_MS2;
16784
16794
  console.log(`[tunnel] ready (tailnet ${evt.tailnetAddr})`);
16785
16795
  this.onChange?.();
16786
16796
  } else if ((evt.event === "shell-opened" || evt.event === "shell-output" || evt.event === "shell-closed") && evt.build) {
16787
16797
  const s = this.shellState(_TunnelServer.shellKey(evt.build, evt.machine ?? ""));
16788
16798
  if (evt.event === "shell-opened") {
16799
+ s.connected = true;
16789
16800
  s.closed = false;
16801
+ s.gen++;
16790
16802
  } else if (evt.event === "shell-output") {
16803
+ s.connected = true;
16791
16804
  s.closed = false;
16792
16805
  let next = Buffer.concat([s.buffer, Buffer.from(evt.data ?? "", "base64")]);
16793
16806
  if (next.length > SHELL_BUFFER_CAP) next = next.subarray(next.length - SHELL_BUFFER_CAP);
16794
16807
  s.buffer = next;
16795
16808
  for (const l of s.listeners) l.onActivity();
16796
16809
  } else {
16810
+ s.connected = false;
16797
16811
  s.closed = true;
16798
16812
  for (const l of [...s.listeners]) l.onClosed();
16799
16813
  }
@@ -16822,8 +16836,20 @@ var TunnelServer = class _TunnelServer {
16822
16836
  shellSendInput(build, machine, data) {
16823
16837
  return this.writeControl({ cmd: "shell-input", build, machine, data: Buffer.from(data, "utf8").toString("base64") });
16824
16838
  }
16825
- shellClosed(build, machine) {
16826
- return this.shells.get(_TunnelServer.shellKey(build, machine))?.closed ?? false;
16839
+ // Whether the client's shell channel is currently registered sidecar-side. undefined = no event
16840
+ // seen yet for this key (a client may still be connected — input discovers it); false = it was
16841
+ // there and dropped (or input bounced), so the tool fails fast instead of timing out.
16842
+ shellConnected(build, machine) {
16843
+ return this.shells.get(_TunnelServer.shellKey(build, machine))?.connected;
16844
+ }
16845
+ // True once per PTY replacement: the shell reconnected (new session, prior state lost) since the
16846
+ // tool last consumed this flag. The first-ever open is not a "reconnect".
16847
+ consumeShellReconnect(build, machine) {
16848
+ const s = this.shells.get(_TunnelServer.shellKey(build, machine));
16849
+ if (!s) return false;
16850
+ const reconnected = s.reportedGen >= 1 && s.gen > s.reportedGen;
16851
+ s.reportedGen = s.gen;
16852
+ return reconnected;
16827
16853
  }
16828
16854
  subscribeShell(build, machine, l) {
16829
16855
  const s = this.shellState(_TunnelServer.shellKey(build, machine));
@@ -16834,29 +16860,32 @@ var TunnelServer = class _TunnelServer {
16834
16860
  }
16835
16861
  // Return and clear a (build, machine) shell's buffered PTY output (UTF-8). The tool calls this
16836
16862
  // after a quiet period to get everything the shell printed since the last drain; the closed
16837
- // marker is cleared too so the next session for this key starts fresh.
16863
+ // marker is cleared too so the next session for this key starts fresh. The entry itself is kept —
16864
+ // connected/gen are cross-call memory (tiny, bounded by builds this daemon has served).
16838
16865
  drainShell(build, machine) {
16839
- const key = _TunnelServer.shellKey(build, machine);
16840
- const s = this.shells.get(key);
16866
+ const s = this.shells.get(_TunnelServer.shellKey(build, machine));
16841
16867
  if (!s) return "";
16842
16868
  const out = s.buffer.toString("utf8");
16843
16869
  s.buffer = EMPTY_BUFFER;
16844
16870
  s.closed = false;
16845
- if (s.listeners.size === 0) this.shells.delete(key);
16846
16871
  return out;
16847
16872
  }
16848
16873
  shellState(key) {
16849
16874
  let s = this.shells.get(key);
16850
16875
  if (!s) {
16851
- s = { buffer: EMPTY_BUFFER, closed: false, listeners: /* @__PURE__ */ new Set() };
16876
+ s = { buffer: EMPTY_BUFFER, closed: false, gen: 0, reportedGen: 0, listeners: /* @__PURE__ */ new Set() };
16852
16877
  this.shells.set(key, s);
16853
16878
  }
16854
16879
  return s;
16855
16880
  }
16881
+ // Sidecar death takes every client connection with it. Entries survive (not cleared) so the
16882
+ // shells' reconnect generations still tick when clients re-register against the respawned sidecar.
16856
16883
  closeAllShells() {
16857
- const states = [...this.shells.values()];
16858
- this.shells.clear();
16859
- for (const s of states) for (const l of [...s.listeners]) l.onClosed();
16884
+ for (const s of this.shells.values()) {
16885
+ s.connected = false;
16886
+ s.closed = true;
16887
+ for (const l of [...s.listeners]) l.onClosed();
16888
+ }
16860
16889
  }
16861
16890
  current() {
16862
16891
  return this.node;
@@ -17049,16 +17078,24 @@ async function runWorker(config2, serverUrl) {
17049
17078
  tunnel = void 0;
17050
17079
  client.setTunnel(void 0);
17051
17080
  };
17081
+ let lastMounts = [];
17082
+ client.onMounts = (mounts) => {
17083
+ lastMounts = mounts;
17084
+ syncManager?.setMounts(mounts);
17085
+ };
17052
17086
  const enableRunner = () => {
17053
17087
  if (syncManager) return;
17054
17088
  syncManager = new SyncManager(serverUrl, cfg.token, cfg.machineId);
17055
17089
  syncManager.start();
17090
+ syncManager.setMounts(lastMounts);
17091
+ client.getMountSync = () => syncManager?.status() ?? null;
17056
17092
  };
17057
17093
  const disableRunner = () => {
17058
17094
  if (!syncManager) return;
17059
17095
  console.log("[machine] Disabling runner: stopping worktree mirrors.");
17060
17096
  syncManager.stop();
17061
17097
  syncManager = void 0;
17098
+ client.getMountSync = void 0;
17062
17099
  };
17063
17100
  async function applyRoles(roles) {
17064
17101
  if (roles.includes("builder")) {
@@ -17396,16 +17433,16 @@ function findPreset(presets, id) {
17396
17433
  }
17397
17434
 
17398
17435
  // src/lib/modelsJson.ts
17399
- var import_node_fs7 = require("node:fs");
17436
+ var import_node_fs6 = require("node:fs");
17400
17437
  var import_node_path7 = require("node:path");
17401
17438
  function modelsJsonPath(agentDir) {
17402
17439
  return (0, import_node_path7.join)(agentDir, "models.json");
17403
17440
  }
17404
17441
  function readModelsJson(agentDir) {
17405
17442
  const p = modelsJsonPath(agentDir);
17406
- if (!(0, import_node_fs7.existsSync)(p)) return { providers: {} };
17443
+ if (!(0, import_node_fs6.existsSync)(p)) return { providers: {} };
17407
17444
  try {
17408
- const parsed = JSON.parse((0, import_node_fs7.readFileSync)(p, "utf-8"));
17445
+ const parsed = JSON.parse((0, import_node_fs6.readFileSync)(p, "utf-8"));
17409
17446
  return { providers: parsed.providers ?? {} };
17410
17447
  } catch (e) {
17411
17448
  throw new Error(`Failed to parse ${p}: ${e.message}`);
@@ -17413,8 +17450,8 @@ function readModelsJson(agentDir) {
17413
17450
  }
17414
17451
  function writeModelsJson(agentDir, config2) {
17415
17452
  const p = modelsJsonPath(agentDir);
17416
- (0, import_node_fs7.mkdirSync)((0, import_node_path7.dirname)(p), { recursive: true });
17417
- (0, import_node_fs7.writeFileSync)(p, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
17453
+ (0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(p), { recursive: true });
17454
+ (0, import_node_fs6.writeFileSync)(p, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
17418
17455
  }
17419
17456
  function upsertProvider(config2, name, entry) {
17420
17457
  return { providers: { ...config2.providers, [name]: entry } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@todos-dev/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "bin": {
5
5
  "tds": "dist/index.js"
6
6
  },
@@ -27,12 +27,12 @@
27
27
  "@tds/types": "0.1.0"
28
28
  },
29
29
  "optionalDependencies": {
30
- "@todos-dev/cli-darwin-arm64": "0.1.2",
31
- "@todos-dev/cli-darwin-x64": "0.1.2",
32
- "@todos-dev/cli-linux-x64": "0.1.2",
33
- "@todos-dev/cli-linux-arm64": "0.1.2",
34
- "@todos-dev/cli-win32-x64": "0.1.2",
35
- "@todos-dev/cli-win32-arm64": "0.1.2"
30
+ "@todos-dev/cli-darwin-arm64": "0.1.3",
31
+ "@todos-dev/cli-darwin-x64": "0.1.3",
32
+ "@todos-dev/cli-linux-x64": "0.1.3",
33
+ "@todos-dev/cli-linux-arm64": "0.1.3",
34
+ "@todos-dev/cli-win32-x64": "0.1.3",
35
+ "@todos-dev/cli-win32-arm64": "0.1.3"
36
36
  },
37
37
  "scripts": {
38
38
  "dev": "tsx watch src/index.ts",