@todos-dev/cli 0.1.1 → 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 +407 -439
  2. package/package.json +10 -10
package/dist/index.js CHANGED
@@ -119,19 +119,23 @@ async function withRetry(fn, shouldRetry, label, delays = DEFAULT_RETRY_DELAYS_M
119
119
  function workspacesRoot() {
120
120
  return process.env.TDS_WORKSPACES_DIR ?? (0, import_node_path2.join)((0, import_node_os2.homedir)(), ".tds", "workspaces");
121
121
  }
122
- function spawnGit(args2, cwd) {
122
+ function gitEnv(config2) {
123
+ const entries = Object.entries({ "credential.helper": "", ...config2 });
124
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_COUNT: String(entries.length) };
125
+ entries.forEach(([key, value], i) => {
126
+ env[`GIT_CONFIG_KEY_${i}`] = key;
127
+ env[`GIT_CONFIG_VALUE_${i}`] = value;
128
+ });
129
+ return env;
130
+ }
131
+ function authConfig(token) {
132
+ return { [`url.https://x-access-token:${token}@github.com/.insteadOf`]: "https://github.com/" };
133
+ }
134
+ function spawnGit(args2, cwd, config2 = {}) {
123
135
  return new Promise((resolve, reject) => {
124
136
  const child = (0, import_node_child_process.spawn)("git", args2, {
125
137
  cwd,
126
- env: {
127
- ...process.env,
128
- GIT_TERMINAL_PROMPT: "0",
129
- // Override any system credential helper (e.g. gh auth git-credential) so
130
- // git uses the token embedded in the URL directly and never calls an external helper.
131
- GIT_CONFIG_COUNT: "1",
132
- GIT_CONFIG_KEY_0: "credential.helper",
133
- GIT_CONFIG_VALUE_0: ""
134
- },
138
+ env: gitEnv(config2),
135
139
  stdio: ["ignore", "pipe", "pipe"]
136
140
  });
137
141
  let stdout = "";
@@ -154,9 +158,9 @@ function isTransientGitError(err) {
154
158
  const msg = err instanceof Error ? err.message : String(err);
155
159
  return /SSL_ERROR_SYSCALL|SSL_connect|gnutls_handshake|[Cc]ould not resolve host|Temporary failure in name resolution|Connection (timed out|reset|refused)|Operation timed out|Failed to connect|[Rr]ecv failure|[Ss]end failure|early EOF|RPC failed|unexpected disconnect|remote end hung up|The requested URL returned error: 5\d\d/.test(msg);
156
160
  }
157
- function runGit(args2, cwd) {
158
- if (!RETRYABLE_GIT_OPS.has(args2[0])) return spawnGit(args2, cwd);
159
- return withRetry(() => spawnGit(args2, cwd), isTransientGitError, `[workspace] git ${args2[0]}`);
161
+ function runGit(args2, cwd, config2) {
162
+ if (!RETRYABLE_GIT_OPS.has(args2[0])) return spawnGit(args2, cwd, config2);
163
+ return withRetry(() => spawnGit(args2, cwd, config2), isTransientGitError, `[workspace] git ${args2[0]}`);
160
164
  }
161
165
  async function pruneIgnoredFromIndex(cwd) {
162
166
  try {
@@ -172,31 +176,18 @@ async function pruneIgnoredFromIndex(cwd) {
172
176
  function redactToken(text3) {
173
177
  return text3.replace(/x-access-token:[^@]+@/g, "x-access-token:***@");
174
178
  }
175
- function authUrl(repoFullName, token) {
176
- return `https://x-access-token:${token}@github.com/${repoFullName}.git`;
177
- }
178
179
  function cleanUrl(repoFullName) {
179
180
  return `https://github.com/${repoFullName}.git`;
180
181
  }
181
- async function pushBranch(cwd, repoFullName, token, branch) {
182
- await runGit(["remote", "set-url", "origin", authUrl(repoFullName, token)], cwd);
183
- try {
184
- await runGit(["push", "--force-with-lease", "-u", "origin", branch], cwd);
185
- } finally {
186
- await runGit(["remote", "set-url", "origin", cleanUrl(repoFullName)], cwd).catch(() => {
187
- });
188
- }
182
+ async function pushBranch(cwd, token, branch) {
183
+ await runGit(["push", "--force-with-lease", "-u", "origin", branch], cwd, authConfig(token));
189
184
  }
190
- async function fetchRef(baseDir, repoFullName, token, ref) {
191
- await runGit(["remote", "set-url", "origin", authUrl(repoFullName, token)], baseDir);
185
+ async function fetchRef(baseDir, token, ref) {
192
186
  try {
193
- await runGit(["fetch", "origin", ref], baseDir);
187
+ await runGit(["fetch", "origin", ref], baseDir, authConfig(token));
194
188
  return true;
195
189
  } catch {
196
190
  return false;
197
- } finally {
198
- await runGit(["remote", "set-url", "origin", cleanUrl(repoFullName)], baseDir).catch(() => {
199
- });
200
191
  }
201
192
  }
202
193
  var projectLocks = /* @__PURE__ */ new Map();
@@ -214,22 +205,15 @@ async function ensureBaseRepo(baseDir, repoFullName, defaultBranch, token) {
214
205
  () => {
215
206
  (0, import_node_fs2.rmSync)(baseDir, { recursive: true, force: true });
216
207
  (0, import_node_fs2.mkdirSync)(baseDir, { recursive: true });
217
- return spawnGit(["clone", "--branch", defaultBranch, authUrl(repoFullName, token), baseDir]);
208
+ return spawnGit(["clone", "--branch", defaultBranch, cleanUrl(repoFullName), baseDir], void 0, authConfig(token));
218
209
  },
219
210
  isTransientGitError,
220
211
  "[workspace] git clone"
221
212
  );
222
- await runGit(["remote", "set-url", "origin", cleanUrl(repoFullName)], baseDir);
223
213
  return;
224
214
  }
225
215
  console.log(`[workspace] Fetching ${repoFullName} (branch: ${defaultBranch})\u2026`);
226
- await runGit(["remote", "set-url", "origin", authUrl(repoFullName, token)], baseDir);
227
- try {
228
- await runGit(["fetch", "origin", defaultBranch], baseDir);
229
- } finally {
230
- await runGit(["remote", "set-url", "origin", cleanUrl(repoFullName)], baseDir).catch(() => {
231
- });
232
- }
216
+ await runGit(["fetch", "origin", defaultBranch], baseDir, authConfig(token));
233
217
  }
234
218
  async function prepareConversationWorkspace(projectId, conversationId, repoFullName, defaultBranch, token, opts = {}) {
235
219
  return withProjectLock(projectId, async () => {
@@ -242,7 +226,7 @@ async function prepareConversationWorkspace(projectId, conversationId, repoFullN
242
226
  const worktreeExists = (0, import_node_fs2.existsSync)((0, import_node_path2.join)(worktreeDir, ".git"));
243
227
  let convBranchFetched = false;
244
228
  if (restoringToCommit || !worktreeExists) {
245
- convBranchFetched = await fetchRef(baseDir, repoFullName, token, branchName);
229
+ convBranchFetched = await fetchRef(baseDir, token, branchName);
246
230
  }
247
231
  const assertReachable = async (cwd) => {
248
232
  if (!restoringToCommit) return;
@@ -377,12 +361,17 @@ var MAX_TIMEOUT_MS = 12e4;
377
361
  var IDLE_MS = 800;
378
362
  var OUTPUT_CAP = 6e4;
379
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.";
380
- function makeClientShellTool(tunnel, build, runners = []) {
381
- 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).";
382
371
  return {
383
372
  name: "client_shell",
384
373
  label: "Client Shell",
385
- 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,
386
375
  parameters: {
387
376
  type: "object",
388
377
  properties: {
@@ -400,11 +389,19 @@ function makeClientShellTool(tunnel, build, runners = []) {
400
389
  if (!tunnel || !tunnel.shellAvailable()) {
401
390
  return text("Reverse shell is unavailable: the sync tunnel sidecar is not running on this machine.", { closed: true, truncated: false });
402
391
  }
403
- const picked = resolveMachine(runners, p.machine);
392
+ const picked = resolveMachine(runnersRef.current, p.machine);
404
393
  if ("error" in picked) return text(picked.error, { closed: true, truncated: false });
405
- const machineId = picked.machineId;
394
+ const runner = picked.runner;
395
+ const machineId = runner.machineId;
406
396
  const t = tunnel;
407
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
+ }
408
405
  return new Promise((resolve) => {
409
406
  let settled = false;
410
407
  let closed = false;
@@ -419,7 +416,7 @@ function makeClientShellTool(tunnel, build, runners = []) {
419
416
  clearTimeout(maxTimer);
420
417
  signal?.removeEventListener("abort", onAbort);
421
418
  unsub();
422
- resolve(res ?? shellResult(t.drainShell(build, machineId), closed));
419
+ resolve(res ?? shellResult(t, build, runner, closed));
423
420
  };
424
421
  const onAbort = () => finish();
425
422
  const armIdle = () => {
@@ -439,11 +436,6 @@ function makeClientShellTool(tunnel, build, runners = []) {
439
436
  finish();
440
437
  return;
441
438
  }
442
- if (payload === "" && t.shellClosed(build, machineId)) {
443
- closed = true;
444
- finish();
445
- return;
446
- }
447
439
  if (payload !== "" && !t.shellSendInput(build, machineId, payload)) {
448
440
  finish(text("Reverse shell is unavailable: failed to write to the sidecar.", { closed: true, truncated: false }));
449
441
  } else {
@@ -459,9 +451,9 @@ function resolveMachine(runners, requested) {
459
451
  const q = requested.trim().toLowerCase();
460
452
  const hit = runners.find((r) => r.machineId.toLowerCase() === q || r.name.toLowerCase() === q);
461
453
  if (!hit) return { error: `Unknown runner "${requested}". Available: ${runners.map((r) => r.name).join(", ")}.` };
462
- return { machineId: hit.machineId };
454
+ return { runner: hit };
463
455
  }
464
- if (runners.length === 1) return { machineId: runners[0].machineId };
456
+ if (runners.length === 1) return { runner: runners[0] };
465
457
  return { error: `Several runners are attached \u2014 pass "machine" to pick one: ${runners.map((r) => r.name).join(", ")}.` };
466
458
  }
467
459
  function shellPayload(p) {
@@ -470,11 +462,20 @@ function shellPayload(p) {
470
462
  function clampTimeout(ms) {
471
463
  return Math.min(Math.max(Number(ms) || DEFAULT_TIMEOUT_MS, 1e3), MAX_TIMEOUT_MS);
472
464
  }
473
- 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);
474
468
  const truncated = out.length > OUTPUT_CAP;
475
469
  if (truncated) out = out.slice(-OUTPUT_CAP);
476
- if (closed && out.trim() === "") return text(NO_RUNNER_MSG, { closed: true, truncated: false });
477
- 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 });
478
479
  }
479
480
  function text(s, details) {
480
481
  return { content: [{ type: "text", text: s }], details };
@@ -514,19 +515,28 @@ function makeListTodosTool(serverUrl, token, step) {
514
515
  return {
515
516
  name: "list_todos",
516
517
  label: "List Todos",
517
- 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).`,
518
519
  parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
519
520
  async execute() {
520
521
  if (!projectId) return text2("No project context for this turn \u2014 todos are unavailable.");
521
- 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
+ ]);
522
526
  if (!reply.ok) return text2(errorText(reply, "Failed to list todos"));
523
527
  const todos = reply.body ?? [];
524
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
+ ` : "";
525
533
  const lines = todos.map((t) => {
526
534
  const mine = t.createdBy === step.agent.agentId ? " (yours)" : "";
527
- 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}`;
528
538
  });
529
- let out = lines.join("\n");
539
+ let out = header + lines.join("\n");
530
540
  if (out.length > LIST_CAP) out = out.slice(0, LIST_CAP) + "\n\u2026(truncated)";
531
541
  return text2(out);
532
542
  }
@@ -566,14 +576,14 @@ function makeUpdateTodoTool(serverUrl, token, step) {
566
576
  return {
567
577
  name: "update_todo",
568
578
  label: "Update Todo",
569
- 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.`,
570
580
  parameters: {
571
581
  type: "object",
572
582
  properties: {
573
583
  id: { type: "string", description: "The id of the todo to update (from list_todos)." },
574
584
  title: { type: "string", description: "New title (optional)." },
575
585
  spec: { type: "string", description: "New spec/description (optional)." },
576
- 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).` }
577
587
  },
578
588
  required: ["id"],
579
589
  additionalProperties: false
@@ -689,14 +699,12 @@ async function materializeSession(opts) {
689
699
  return {
690
700
  sessionManager: opts.sdk.SessionManager.open(mode.sessionFilePath, sessionsDir(), opts.cwd),
691
701
  isContinue: true,
692
- sessionFilePath: mode.sessionFilePath,
693
702
  sessionKeyForSave: null
694
703
  };
695
704
  }
696
705
  return {
697
- sessionManager: opts.sdk.SessionManager.create(opts.cwd, sessionsDir(), { id: opts.createSessionId }),
706
+ sessionManager: opts.sdk.SessionManager.create(opts.cwd, sessionsDir(), { id: opts.sessionKey }),
698
707
  isContinue: false,
699
- sessionFilePath: null,
700
708
  sessionKeyForSave: opts.sessionKey
701
709
  };
702
710
  }
@@ -866,7 +874,6 @@ function ensureSkillGc(agentDir) {
866
874
  }
867
875
 
868
876
  // src/lib/streaming.ts
869
- var LOCAL_EXECUTED_TOOLS = /* @__PURE__ */ new Set(["read", "write", "edit", "bash", "grep", "find", "ls", "web_fetch", "client_shell", "list_todos", "create_todo", "update_todo"]);
870
877
  function formatToolInput(toolName, args2) {
871
878
  if (!args2) return "";
872
879
  switch (toolName) {
@@ -891,22 +898,32 @@ function formatToolInput(toolName, args2) {
891
898
  }
892
899
  }
893
900
  }
901
+ async function postRaw(serverUrl, path, body, token) {
902
+ const headers = { "Content-Type": "application/json" };
903
+ if (token) headers["Authorization"] = `Bearer ${token}`;
904
+ const res = await fetch(`${serverUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
905
+ if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
906
+ return await res.json();
907
+ }
894
908
  async function post(serverUrl, path, body, token) {
895
909
  try {
896
- const headers = { "Content-Type": "application/json" };
897
- if (token) headers["Authorization"] = `Bearer ${token}`;
898
- const res = await fetch(`${serverUrl}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
899
- if (!res.ok) {
900
- console.error(`[machine] POST ${path} \u2192 HTTP ${res.status}`);
901
- return {};
902
- }
903
- return await res.json();
910
+ return await postRaw(serverUrl, path, body, token);
904
911
  } catch (err) {
905
912
  console.error(`[machine] POST ${path} failed:`, err.message);
906
913
  return {};
907
914
  }
908
915
  }
909
- function startHeartbeat(serverUrl, stepId, agentId, onSignal, token) {
916
+ async function postCritical(serverUrl, path, body, token) {
917
+ return withRetry(
918
+ () => postRaw(serverUrl, path, body, token),
919
+ (err) => {
920
+ const status = err.httpStatus;
921
+ return status === void 0 || status >= 500;
922
+ },
923
+ `[machine] POST ${path}`
924
+ );
925
+ }
926
+ function startHeartbeat(serverUrl, stepId, agentId, onSignal, token, onRunners) {
910
927
  let active = true;
911
928
  let inFlight = false;
912
929
  const tick = async () => {
@@ -915,6 +932,7 @@ function startHeartbeat(serverUrl, stepId, agentId, onSignal, token) {
915
932
  try {
916
933
  const res = await post(serverUrl, `/api/machine/heartbeat/${stepId}`, { agentId }, token);
917
934
  if (active && res.stop) onSignal();
935
+ if (active && Array.isArray(res.runners)) onRunners?.(res.runners);
918
936
  } finally {
919
937
  inFlight = false;
920
938
  }
@@ -926,7 +944,14 @@ function startHeartbeat(serverUrl, stepId, agentId, onSignal, token) {
926
944
  clearInterval(id);
927
945
  };
928
946
  }
929
- function attachStreamingSession(session, label, postToken) {
947
+ var POST_PACING_MS = 50;
948
+ function mergeKind(body) {
949
+ if (typeof body.text !== "string") return null;
950
+ if (body.type === void 0) return "text";
951
+ if (body.type === "thinking") return "thinking";
952
+ return null;
953
+ }
954
+ function attachStreamingSession(session, label, postToken, onStop) {
930
955
  const abort = new AbortController();
931
956
  abort.signal.addEventListener("abort", () => {
932
957
  try {
@@ -934,43 +959,37 @@ function attachStreamingSession(session, label, postToken) {
934
959
  } catch {
935
960
  }
936
961
  });
937
- let textBuffer = "";
938
- let thinkingBuffer = "";
939
962
  let fullText = "";
940
- let assistantStarted = false;
941
963
  let lastModelError;
942
- const startAssistant = () => {
943
- assistantStarted = true;
944
- textBuffer = "";
945
- thinkingBuffer = "";
946
- };
947
- const ensureAssistant = () => {
948
- if (!assistantStarted) startAssistant();
949
- };
950
- const emitToolStart = (toolName, detail) => {
951
- ensureAssistant();
952
- postToken({ type: "tool_start", toolName, detail });
964
+ const queue = [];
965
+ let pump = Promise.resolve();
966
+ let pumping = false;
967
+ const runPump = async () => {
968
+ try {
969
+ while (queue.length) {
970
+ const res = await postToken(queue.shift());
971
+ if (res.stop) onStop();
972
+ if (queue.length) await new Promise((r) => setTimeout(r, POST_PACING_MS));
973
+ }
974
+ } finally {
975
+ pumping = false;
976
+ }
953
977
  };
954
- const flush = () => {
955
- if (textBuffer) {
956
- const c = textBuffer;
957
- textBuffer = "";
958
- postToken({ text: c });
978
+ const push = (body) => {
979
+ const kind = mergeKind(body);
980
+ const last = queue[queue.length - 1];
981
+ if (kind && last && mergeKind(last) === kind) {
982
+ last.text = last.text + body.text;
983
+ return;
959
984
  }
960
- if (thinkingBuffer) {
961
- const c = thinkingBuffer;
962
- thinkingBuffer = "";
963
- postToken({ type: "thinking", text: c });
985
+ queue.push(body);
986
+ if (!pumping) {
987
+ pumping = true;
988
+ pump = runPump();
964
989
  }
965
990
  };
966
- const flushTimer = setInterval(flush, 50);
967
991
  const unsub = session.subscribe((event) => {
968
992
  const ev = event;
969
- if (ev.type === "message_start") {
970
- const message = ev.message;
971
- if (message?.role === "assistant") startAssistant();
972
- return;
973
- }
974
993
  if (ev.type === "message_end") {
975
994
  const message = ev.message;
976
995
  const stopReason = message?.stopReason;
@@ -989,33 +1008,23 @@ function attachStreamingSession(session, label, postToken) {
989
1008
  if (!ev.success) console.error(`[${label}] auto_retry_end failed finalError=${ev.finalError}`);
990
1009
  return;
991
1010
  }
992
- if (ev.type === "tool_execution_start") {
993
- const toolName = String(ev.toolName ?? "tool");
994
- const args2 = ev.args;
995
- emitToolStart(toolName, formatToolInput(toolName, args2));
996
- return;
997
- }
998
1011
  if (ev.type === "message_update") {
999
1012
  const ame = ev.assistantMessageEvent;
1000
1013
  if (ame?.type === "text_delta") {
1001
1014
  const delta = String(ame.delta ?? "");
1002
1015
  if (delta) {
1003
- ensureAssistant();
1004
- textBuffer += delta;
1005
1016
  fullText += delta;
1017
+ push({ text: delta });
1006
1018
  }
1007
1019
  } else if (ame?.type === "thinking_delta") {
1008
1020
  const delta = String(ame.delta ?? "");
1009
- if (delta) {
1010
- ensureAssistant();
1011
- thinkingBuffer += delta;
1012
- }
1021
+ if (delta) push({ type: "thinking", text: delta });
1013
1022
  } else if (ame?.type === "toolcall_end") {
1014
1023
  const toolCall = ame.toolCall;
1015
1024
  const toolName = String(toolCall?.name ?? "");
1016
- if (toolName && !LOCAL_EXECUTED_TOOLS.has(toolName)) {
1025
+ if (toolName) {
1017
1026
  const args2 = toolCall?.arguments;
1018
- emitToolStart(toolName, formatToolInput(toolName, args2));
1027
+ push({ type: "tool_start", toolName, detail: formatToolInput(toolName, args2) });
1019
1028
  }
1020
1029
  }
1021
1030
  }
@@ -1024,7 +1033,6 @@ function attachStreamingSession(session, label, postToken) {
1024
1033
  const dispose = () => {
1025
1034
  if (disposed) return;
1026
1035
  disposed = true;
1027
- clearInterval(flushTimer);
1028
1036
  unsub();
1029
1037
  try {
1030
1038
  session.dispose();
@@ -1033,16 +1041,17 @@ function attachStreamingSession(session, label, postToken) {
1033
1041
  };
1034
1042
  return {
1035
1043
  abort,
1036
- flush,
1037
1044
  getFullText: () => fullText,
1038
1045
  getLastModelError: () => lastModelError,
1046
+ idle: () => pump,
1039
1047
  dispose
1040
1048
  };
1041
1049
  }
1042
1050
 
1043
1051
  // src/machine.ts
1044
- function makeDone(serverUrl, stepId, agentId, conversationId, token) {
1045
- return (payload) => post(serverUrl, `/api/machine/done/${stepId}`, { agentId, conversationId, ...payload }, token);
1052
+ function makeDone(step, serverUrl, token) {
1053
+ const { stepId, conversationId, mode, agent: { agentId } } = step;
1054
+ return (payload) => postCritical(serverUrl, `/api/machine/done/${stepId}`, { agentId, conversationId, mode, ...payload }, token).catch((err) => console.error(`[step] ${stepId} done report failed:`, err.message));
1046
1055
  }
1047
1056
  async function computeDiffHash(cwd, defaultBranch, stepId) {
1048
1057
  try {
@@ -1105,7 +1114,7 @@ async function prepareStepWorkspace(step) {
1105
1114
  workspace.repoFullName,
1106
1115
  workspace.defaultBranch,
1107
1116
  workspace.installationToken,
1108
- // 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.
1109
1118
  step.restore ? { restoreTo: step.restore.commitSha ?? `origin/${workspace.defaultBranch}` } : {}
1110
1119
  );
1111
1120
  }
@@ -1166,18 +1175,22 @@ async function executeStep(step, serverUrl, token, tunnel) {
1166
1175
  console.log(`[step] ${stepId} start (conv ${conversationId})`);
1167
1176
  let interrupted = false;
1168
1177
  let abortRef = null;
1169
- const stopHeartbeat = startHeartbeat(serverUrl, stepId, agentId, () => {
1178
+ const stopTurn = () => {
1170
1179
  interrupted = true;
1171
1180
  abortRef?.abort();
1172
- }, token);
1173
- const done2 = makeDone(serverUrl, stepId, agentId, conversationId, token);
1181
+ };
1182
+ const runnersRef = { current: step.runners ?? [] };
1183
+ const stopHeartbeat = startHeartbeat(serverUrl, stepId, agentId, stopTurn, token, (r) => {
1184
+ runnersRef.current = r;
1185
+ });
1186
+ const done = makeDone(step, serverUrl, token);
1174
1187
  try {
1175
1188
  const runtime = await getPiRuntime();
1176
1189
  const { sdk, authStorage, modelRegistry, agentDir } = runtime;
1177
1190
  ensureSkillGc(agentDir);
1178
1191
  const model = resolveModel(modelRegistry, step.agent);
1179
1192
  if (!model) {
1180
- await done2({ error: "No authenticated model available on this machine. Run `tds provider add` (or `tds provider login <name>`) on the machine." });
1193
+ await done({ error: "No authenticated model available on this machine. Run `tds provider add` (or `tds provider login <name>`) on the machine." });
1181
1194
  return;
1182
1195
  }
1183
1196
  console.log(`[step] ${stepId} using model ${model.provider}/${model.id}`);
@@ -1185,29 +1198,20 @@ async function executeStep(step, serverUrl, token, tunnel) {
1185
1198
  try {
1186
1199
  cwd = await prepareStepWorkspace(step) ?? agentDir;
1187
1200
  } catch (err) {
1188
- await done2({ error: `Workspace preparation failed: ${err.message}` });
1201
+ await done({ error: `Workspace preparation failed: ${err.message}` });
1189
1202
  return;
1190
1203
  }
1191
- if (step.restore) {
1192
- try {
1193
- await restoreSessionToCheckpoint(step);
1194
- } catch (err) {
1195
- await done2({ error: `Session rewind failed: ${err.message}` });
1196
- return;
1197
- }
1198
- }
1199
1204
  const ephemeral = step.mode === "ephemeral_turn";
1200
- const m = ephemeral ? { sessionManager: sdk.SessionManager.inMemory(cwd), isContinue: false, sessionFilePath: null, sessionKeyForSave: null } : await materializeSession({
1205
+ const m = ephemeral ? { sessionManager: sdk.SessionManager.inMemory(cwd), isContinue: false, sessionKeyForSave: null } : await materializeSession({
1201
1206
  sdk,
1202
1207
  sessionKey: conversationId,
1203
1208
  cwd,
1204
- createSessionId: conversationId,
1205
1209
  restoreUrl: step.sessionRestoreUrl
1206
1210
  });
1207
1211
  console.log(m.isContinue ? `[step] ${stepId} continue session ${conversationId}` : `[step] ${stepId} new session ${conversationId}`);
1208
1212
  const skills = await materializeSkills(step.skills, agentDir);
1209
1213
  const extraCustomTools = [
1210
- makeClientShellTool(tunnel, conversationId, step.runners),
1214
+ makeClientShellTool(tunnel, conversationId, runnersRef),
1211
1215
  ...makeTodoTools(serverUrl, token, step)
1212
1216
  ];
1213
1217
  const session = await createSession(sdk, {
@@ -1226,62 +1230,14 @@ async function executeStep(step, serverUrl, token, tunnel) {
1226
1230
  if (m.sessionKeyForSave && session.sessionFile) {
1227
1231
  saveSessionPath(m.sessionKeyForSave, session.sessionFile);
1228
1232
  }
1229
- let flushChain = Promise.resolve();
1230
- let mergeableTextTail = null;
1231
- let mergeableThinkingTail = null;
1232
- const enqueueTokenPost = (body) => {
1233
- const isPlainText = typeof body.text === "string" && body.type === void 0;
1234
- const isThinking = body.type === "thinking" && typeof body.text === "string";
1235
- if (isPlainText) {
1236
- mergeableThinkingTail = null;
1237
- if (mergeableTextTail) {
1238
- mergeableTextTail.text += body.text;
1239
- return;
1240
- }
1241
- const tail = { text: body.text };
1242
- mergeableTextTail = tail;
1243
- flushChain = flushChain.then(async () => {
1244
- mergeableTextTail = null;
1245
- const res = await post(serverUrl, `/api/machine/token/${stepId}`, { agentId, text: tail.text }, token);
1246
- if (res.stop && !interrupted) {
1247
- interrupted = true;
1248
- abortRef?.abort();
1249
- }
1250
- });
1251
- return;
1252
- }
1253
- if (isThinking) {
1254
- mergeableTextTail = null;
1255
- if (mergeableThinkingTail) {
1256
- mergeableThinkingTail.text += body.text;
1257
- return;
1258
- }
1259
- const tail = { text: body.text };
1260
- mergeableThinkingTail = tail;
1261
- flushChain = flushChain.then(async () => {
1262
- mergeableThinkingTail = null;
1263
- const res = await post(serverUrl, `/api/machine/token/${stepId}`, { agentId, type: "thinking", text: tail.text }, token);
1264
- if (res.stop && !interrupted) {
1265
- interrupted = true;
1266
- abortRef?.abort();
1267
- }
1268
- });
1269
- return;
1270
- }
1271
- mergeableTextTail = null;
1272
- mergeableThinkingTail = null;
1273
- flushChain = flushChain.then(async () => {
1274
- const res = await post(serverUrl, `/api/machine/token/${stepId}`, { agentId, ...body }, token);
1275
- if (res.stop && !interrupted) {
1276
- interrupted = true;
1277
- abortRef?.abort();
1278
- }
1279
- });
1280
- };
1281
- const stream = attachStreamingSession(session, `step:${stepId}`, enqueueTokenPost);
1233
+ const stream = attachStreamingSession(
1234
+ session,
1235
+ `step:${stepId}`,
1236
+ (body) => post(serverUrl, `/api/machine/token/${stepId}`, { agentId, ...body }, token),
1237
+ stopTurn
1238
+ );
1282
1239
  abortRef = stream.abort;
1283
1240
  if (interrupted) stream.abort.abort();
1284
- const fullPrompt = step.prompt;
1285
1241
  let commitSha;
1286
1242
  let sessionLeafEntryId;
1287
1243
  let diffHash;
@@ -1297,10 +1253,10 @@ async function executeStep(step, serverUrl, token, tunnel) {
1297
1253
  } catch (err) {
1298
1254
  console.warn(`[step] ${stepId} checkpoint commit failed:`, err.message);
1299
1255
  }
1300
- const { repoFullName, installationToken, defaultBranch } = step.workspace;
1256
+ const { installationToken, defaultBranch } = step.workspace;
1301
1257
  const branchName = `tds/conv-${conversationId}`;
1302
1258
  try {
1303
- await pushBranch(cwd, repoFullName, installationToken, branchName);
1259
+ await pushBranch(cwd, installationToken, branchName);
1304
1260
  console.log(`[step] ${stepId} pushed ${branchName}`);
1305
1261
  } catch (err) {
1306
1262
  console.error(`[step] ${stepId} push failed:`, err.message);
@@ -1314,31 +1270,28 @@ async function executeStep(step, serverUrl, token, tunnel) {
1314
1270
  const donePayload = () => ({ text: stream.getFullText(), modelId: step.agent.modelId || model.id, commitSha, sessionLeafEntryId, diffHash });
1315
1271
  const images = await downloadStepImages(step);
1316
1272
  try {
1317
- await session.prompt(fullPrompt, images.length ? { images } : void 0);
1318
- stream.dispose();
1319
- stream.flush();
1320
- await flushChain;
1273
+ await session.prompt(step.prompt, images.length ? { images } : void 0);
1274
+ await stream.idle();
1321
1275
  const modelError = !stream.getFullText() && stream.getLastModelError();
1322
1276
  if (!ephemeral) await backupSession(conversationId, session.sessionFile, step.sessionBackupUrl);
1323
1277
  if (modelError) {
1324
- await done2({ error: modelError });
1278
+ await done({ error: modelError });
1325
1279
  } else {
1326
1280
  await captureCheckpoint();
1327
- await done2(donePayload());
1281
+ await done(donePayload());
1328
1282
  }
1329
1283
  } catch (err) {
1330
- stream.dispose();
1331
- await flushChain.catch(() => {
1284
+ await stream.idle().catch(() => {
1332
1285
  });
1333
1286
  if (!ephemeral) await backupSession(conversationId, session.sessionFile, step.sessionBackupUrl);
1334
1287
  if (interrupted) {
1335
1288
  console.log(`[step] ${stepId} interrupted \u2014 saving partial content`);
1336
1289
  await captureCheckpoint();
1337
- await done2(donePayload());
1290
+ await done(donePayload());
1338
1291
  } else {
1339
1292
  const msg = err instanceof Error ? err.message : String(err);
1340
1293
  console.error(`[step] ${stepId} failed:`, msg);
1341
- await done2({ error: msg });
1294
+ await done({ error: msg });
1342
1295
  }
1343
1296
  } finally {
1344
1297
  stream.dispose();
@@ -1348,15 +1301,15 @@ async function executeStep(step, serverUrl, token, tunnel) {
1348
1301
  }
1349
1302
  }
1350
1303
  async function executeRestore(step, serverUrl, token) {
1351
- const { stepId, conversationId, agent: { agentId } } = step;
1304
+ const { stepId, conversationId } = step;
1352
1305
  console.log(`[restore] ${stepId} start (conv ${conversationId})`);
1353
- const done2 = makeDone(serverUrl, stepId, agentId, conversationId, token);
1306
+ const done = makeDone(step, serverUrl, token);
1354
1307
  let diffHash;
1355
1308
  const rewindCode = async () => {
1356
1309
  const cwd = await prepareStepWorkspace(step);
1357
1310
  if (!cwd || !step.workspace) return;
1358
- const { repoFullName, defaultBranch, installationToken } = step.workspace;
1359
- await pushBranch(cwd, repoFullName, installationToken, `tds/conv-${conversationId}`);
1311
+ const { defaultBranch, installationToken } = step.workspace;
1312
+ await pushBranch(cwd, installationToken, `tds/conv-${conversationId}`);
1360
1313
  diffHash = await computeDiffHash(cwd, defaultBranch, stepId);
1361
1314
  };
1362
1315
  const rewindSession = async () => {
@@ -1368,10 +1321,10 @@ async function executeRestore(step, serverUrl, token) {
1368
1321
  if (failed) {
1369
1322
  const msg = failed.reason instanceof Error ? failed.reason.message : String(failed.reason);
1370
1323
  console.error(`[restore] ${stepId} failed:`, msg);
1371
- await done2({ error: msg });
1324
+ await done({ error: msg });
1372
1325
  return;
1373
1326
  }
1374
- await done2({ diffHash });
1327
+ await done({ diffHash });
1375
1328
  console.log(`[restore] ${stepId} done`);
1376
1329
  }
1377
1330
 
@@ -16067,13 +16020,8 @@ var STEP_DEF = {
16067
16020
  plan_review: { track: "plan", sink: "review" },
16068
16021
  implement_review: { track: "implement", sink: "review" }
16069
16022
  };
16070
- var STEP_PHASE = {
16071
- plan: STEP_DEF.plan.phase,
16072
- plan_revision: STEP_DEF.plan_revision.phase,
16073
- implement: STEP_DEF.implement.phase,
16074
- implement_revision: STEP_DEF.implement_revision.phase,
16075
- merge: STEP_DEF.merge.phase
16076
- };
16023
+ var isReviewStep = (k) => STEP_DEF[k].sink === "review";
16024
+ var REVIEW_STEP_KINDS = Object.keys(STEP_DEF).filter(isReviewStep);
16077
16025
  var CreateTeamRequestSchema = external_exports.object({
16078
16026
  name: external_exports.string().trim().min(1)
16079
16027
  });
@@ -16083,7 +16031,8 @@ var UpdateTeamRequestSchema = external_exports.object({
16083
16031
  });
16084
16032
  var UpdateProjectRequestSchema = external_exports.object({
16085
16033
  name: external_exports.string().min(1).optional(),
16086
- description: external_exports.string().optional()
16034
+ description: external_exports.string().optional(),
16035
+ defaultBranch: external_exports.string().min(1).optional()
16087
16036
  });
16088
16037
  var UpdateUserProfileRequestSchema = external_exports.object({
16089
16038
  name: external_exports.string().trim().optional(),
@@ -16124,7 +16073,10 @@ var CreateTodoRequestSchema = external_exports.object({
16124
16073
  // Spec is optional: a title-only todo is allowed (the UI flags the missing spec).
16125
16074
  spec: external_exports.string().trim().default(""),
16126
16075
  tagIds: external_exports.array(external_exports.string()).optional(),
16127
- orderIndex: external_exports.number().optional()
16076
+ orderIndex: external_exports.number().optional(),
16077
+ // Birth phase — idle states only (a build is still a separate, explicit action). Defaults to
16078
+ // 'todo'; 'idea' lets a duplicate of a parked idea land straight in the idea library.
16079
+ phase: external_exports.enum(["todo", "idea"]).optional()
16128
16080
  });
16129
16081
  var AgentEntrySchema = external_exports.object({
16130
16082
  agentId: external_exports.string(),
@@ -16239,6 +16191,12 @@ var PollClient = class {
16239
16191
  roles = [];
16240
16192
  tunnel;
16241
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;
16242
16200
  // Only a builder claims and runs build steps; a runner-only machine still reports presence.
16243
16201
  get canClaim() {
16244
16202
  return this.roles.includes("builder");
@@ -16347,6 +16305,8 @@ ${node.sshHostKey}` : null };
16347
16305
  async reportPresence() {
16348
16306
  try {
16349
16307
  const body = { load: this.runningTasks.size };
16308
+ const mountSync = this.getMountSync?.();
16309
+ if (mountSync) body.mountSync = mountSync;
16350
16310
  const { node, key } = this.tunnelSnapshot();
16351
16311
  if (key !== this.lastSentTunnelKey) {
16352
16312
  body.sshHostKey = node?.sshHostKey ?? null;
@@ -16369,8 +16329,9 @@ ${node.sshHostKey}` : null };
16369
16329
  if (Array.isArray(data?.roles)) {
16370
16330
  this.setRoles(data.roles.filter((r) => r === "builder" || r === "runner"));
16371
16331
  }
16332
+ if (Array.isArray(data?.mounts)) this.onMounts?.(data.mounts);
16333
+ this.lastSentTunnelKey = key;
16372
16334
  }
16373
- this.lastSentTunnelKey = key;
16374
16335
  } catch {
16375
16336
  }
16376
16337
  }
@@ -16397,9 +16358,7 @@ ${node.sshHostKey}` : null };
16397
16358
  try {
16398
16359
  while (this.running && this.canClaim) {
16399
16360
  try {
16400
- if (this.maxConcurrentTasks == null) await this.reportPresence();
16401
- const maxConcurrentTasks = this.maxConcurrentTasks;
16402
- if (maxConcurrentTasks == null) throw new Error("Missing server maxConcurrentTasks");
16361
+ const maxConcurrentTasks = this.maxConcurrentTasks ?? 1;
16403
16362
  if (this.runningTasks.size >= maxConcurrentTasks) {
16404
16363
  await Promise.race([...this.runningTasks.values(), sleep(2e3, this.abortController.signal)]);
16405
16364
  continue;
@@ -16454,13 +16413,10 @@ function sleep(ms, signal) {
16454
16413
  }
16455
16414
 
16456
16415
  // src/SyncManager.ts
16457
- var import_node_fs6 = require("node:fs");
16416
+ var import_node_child_process2 = require("node:child_process");
16458
16417
  var import_node_os4 = require("node:os");
16459
16418
  var import_node_path6 = require("node:path");
16460
16419
 
16461
- // src/lib/mirror.ts
16462
- var import_node_child_process2 = require("node:child_process");
16463
-
16464
16420
  // src/lib/bin.ts
16465
16421
  var import_node_fs5 = require("node:fs");
16466
16422
  var import_node_path5 = require("node:path");
@@ -16500,6 +16456,7 @@ function resolveBin(name) {
16500
16456
  dir = parent;
16501
16457
  }
16502
16458
  }
16459
+ console.warn(`[bin] ${name} binary not found (no @todos-dev/cli-${process.platform}-${process.arch} package, no vendor/ tree near ${__dirname}) \u2014 falling back to PATH. Run \`pnpm vendor:bins\` (dev) or reinstall the CLI.`);
16503
16460
  return name;
16504
16461
  }
16505
16462
  var TUNNEL_BIN = resolveBin("tds-tunnel");
@@ -16525,144 +16482,127 @@ function createNdjsonParser(onEvent) {
16525
16482
  };
16526
16483
  }
16527
16484
 
16528
- // src/lib/mirror.ts
16529
- function spawnMirror(opts) {
16530
- const proc = (0, import_node_child_process2.spawn)(TUNNEL_BIN, [
16531
- "connect",
16532
- "--server",
16533
- opts.serverUrl,
16534
- "--token",
16535
- opts.token,
16536
- "--conversation",
16537
- opts.buildId,
16538
- "--dest",
16539
- opts.dest,
16540
- "--default-branch",
16541
- opts.defaultBranch,
16542
- "--machine",
16543
- opts.machineId,
16544
- ...opts.allowExec ? ["--allow-exec"] : []
16545
- ], { stdio: ["ignore", "pipe", "inherit"] });
16546
- if (opts.onEvent) {
16547
- const parse3 = createNdjsonParser(opts.onEvent);
16548
- proc.stdout?.on("data", (d) => parse3(String(d)));
16549
- }
16550
- const exited = new Promise((resolve) => {
16551
- proc.on("exit", (code) => resolve(code ?? 0));
16552
- proc.on("error", (err) => {
16553
- opts.onEvent?.({ event: "error", message: err.message });
16554
- resolve(1);
16555
- });
16556
- });
16557
- return {
16558
- exited,
16559
- stop: async () => {
16560
- if (proc.exitCode === null) proc.kill();
16561
- await exited;
16562
- }
16563
- };
16564
- }
16565
-
16566
16485
  // src/SyncManager.ts
16567
- var RECONCILE_INTERVAL_MS = 8e3;
16486
+ var RESTART_MIN_MS = 1e3;
16487
+ var RESTART_MAX_MS = 6e4;
16568
16488
  function expandHome(p) {
16569
16489
  if (p === "~") return (0, import_node_os4.homedir)();
16570
16490
  if (p.startsWith("~/") || p.startsWith("~\\")) return (0, import_node_path6.join)((0, import_node_os4.homedir)(), p.slice(2));
16571
16491
  return p;
16572
16492
  }
16573
16493
  var SyncManager = class {
16574
- // keyed by mountId
16494
+ // by mountId
16575
16495
  constructor(serverUrl, token, machineId) {
16576
16496
  this.serverUrl = serverUrl;
16577
16497
  this.token = token;
16578
16498
  this.machineId = machineId;
16579
16499
  }
16580
- timer = null;
16581
- running = false;
16582
- 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();
16583
16506
  start() {
16584
- if (this.running) return;
16585
- this.running = true;
16507
+ this.stopped = false;
16586
16508
  console.log(`[machine] runner: managing worktree mirrors (machineId=${this.machineId})`);
16587
- const tick = () => {
16588
- void this.reconcile();
16589
- };
16590
- tick();
16591
- this.timer = setInterval(tick, RECONCILE_INTERVAL_MS);
16509
+ this.spawnSidecar();
16592
16510
  }
16593
16511
  stop() {
16594
- this.running = false;
16595
- if (this.timer) {
16596
- clearInterval(this.timer);
16597
- this.timer = null;
16512
+ this.stopped = true;
16513
+ if (this.restartTimer) {
16514
+ clearTimeout(this.restartTimer);
16515
+ this.restartTimer = null;
16598
16516
  }
16599
- for (const mountId of [...this.tunnels.keys()]) this.teardown(mountId);
16517
+ this.proc?.kill();
16518
+ this.proc = null;
16600
16519
  }
16601
- // Default dir for a mount with no explicit destDir, under the runner's home. e.g. ~/.tds/runner/owner/repo/119
16602
- defaultDest(m) {
16603
- 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
+ });
16604
16547
  }
16605
- async fetchMounts() {
16548
+ spawnSidecar() {
16606
16549
  try {
16607
- const res = await fetch(`${this.serverUrl}/api/machine/mounts`, {
16608
- method: "POST",
16609
- headers: { Authorization: `Bearer ${this.token}` },
16610
- signal: AbortSignal.timeout(8e3)
16611
- });
16612
- if (!res.ok) return null;
16613
- 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
+ );
16614
16556
  } catch {
16615
- return null;
16616
- }
16617
- }
16618
- async reconcile() {
16619
- if (!this.running) return;
16620
- const resolved = await this.fetchMounts();
16621
- if (resolved === null) return;
16622
- const desired = new Map(resolved.map((r) => [r.mountId, r]));
16623
- for (const [mountId, t] of [...this.tunnels]) {
16624
- const want = desired.get(mountId);
16625
- if (!want || want.buildId !== t.buildId || want.allowExec !== t.allowExec) this.teardown(mountId);
16626
- }
16627
- for (const r of resolved) {
16628
- if (!this.tunnels.has(r.mountId)) this.startTunnel(r);
16629
- }
16630
- }
16631
- startTunnel(r) {
16632
- const dest = r.destDir ? expandHome(r.destDir) : this.defaultDest(r);
16633
- try {
16634
- (0, import_node_fs6.mkdirSync)(dest, { recursive: true });
16635
- } catch (e) {
16636
- 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");
16637
16558
  return;
16638
16559
  }
16639
- console.log(`[machine] runner: mirroring #${r.seqNum} (build ${r.buildId.slice(0, 8)}) \u2192 ${dest}${r.allowExec ? " [exec]" : ""}`);
16640
- const handle = spawnMirror({
16641
- serverUrl: this.serverUrl,
16642
- token: this.token,
16643
- buildId: r.buildId,
16644
- dest,
16645
- defaultBranch: r.defaultBranch,
16646
- machineId: this.machineId,
16647
- allowExec: r.allowExec,
16648
- onEvent: (e) => {
16649
- if (e.event === "error") console.warn(`[machine] runner: mount ${r.mountId}: ${e.message ?? "error"}`);
16650
- else if (e.event === "synced" && (e.changed || e.deleted)) {
16651
- console.log(`[machine] runner: #${r.seqNum} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
16652
- }
16653
- }
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();
16654
16567
  });
16655
- this.tunnels.set(r.mountId, { buildId: r.buildId, allowExec: r.allowExec, handle });
16656
- void handle.exited.then(() => {
16657
- const cur = this.tunnels.get(r.mountId);
16658
- 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", () => {
16659
16570
  });
16660
16571
  }
16661
- teardown(mountId) {
16662
- const t = this.tunnels.get(mountId);
16663
- if (!t) return;
16664
- this.tunnels.delete(mountId);
16665
- 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
+ }
16666
16606
  }
16667
16607
  };
16668
16608
 
@@ -16776,6 +16716,9 @@ async function syncModelsToServer(serverUrl, token, runtime) {
16776
16716
  // src/lib/tunnel.ts
16777
16717
  var import_node_child_process3 = require("node:child_process");
16778
16718
  var SHELL_BUFFER_CAP = 256 * 1024;
16719
+ var RESTART_MIN_MS2 = 1e3;
16720
+ var RESTART_MAX_MS2 = 6e4;
16721
+ var EMPTY_BUFFER = Buffer.alloc(0);
16779
16722
  var TunnelServer = class _TunnelServer {
16780
16723
  constructor(serverUrl, token) {
16781
16724
  this.serverUrl = serverUrl;
@@ -16784,14 +16727,13 @@ var TunnelServer = class _TunnelServer {
16784
16727
  proc = null;
16785
16728
  node = null;
16786
16729
  onChange = null;
16787
- // Reverse-shell subscribers, keyed by (build, machine) — a build mirrored onto several runners has
16788
- // one shell per machine. The agent's client_shell tool subscribes while it drives one; sidecar
16789
- // shell-output/shell-closed events fan out to them.
16790
- shellListeners = /* @__PURE__ */ new Map();
16791
- // Buffered PTY output per (build, machine), drained by the tool. Recorded even with no live
16792
- // subscriber so output a backgrounded command emits between tool calls survives until the next drain.
16793
- shellBuffers = /* @__PURE__ */ new Map();
16794
- closedShells = /* @__PURE__ */ new Set();
16730
+ stopped = false;
16731
+ restartTimer = null;
16732
+ restartDelayMs = RESTART_MIN_MS2;
16733
+ // Reverse shells, keyed by (build, machine) — a build mirrored onto several runners has one shell
16734
+ // per machine. Output is buffered even with no live subscriber so output a backgrounded command
16735
+ // emits between tool calls survives until the next drain.
16736
+ shells = /* @__PURE__ */ new Map();
16795
16737
  // Set the single handler fired whenever the tunnel node appears/changes/clears, so presence can
16796
16738
  // be pushed immediately instead of waiting for the next heartbeat tick. Fires now if already ready.
16797
16739
  setNodeChangeHandler(cb) {
@@ -16799,6 +16741,10 @@ var TunnelServer = class _TunnelServer {
16799
16741
  if (this.node) cb();
16800
16742
  }
16801
16743
  start() {
16744
+ this.stopped = false;
16745
+ this.spawnSidecar();
16746
+ }
16747
+ spawnSidecar() {
16802
16748
  try {
16803
16749
  this.proc = (0, import_node_child_process3.spawn)(
16804
16750
  TUNNEL_BIN,
@@ -16819,11 +16765,22 @@ var TunnelServer = class _TunnelServer {
16819
16765
  this.node = null;
16820
16766
  this.closeAllShells();
16821
16767
  this.onChange?.();
16768
+ this.scheduleRestart();
16822
16769
  });
16823
16770
  this.proc.stdout?.on("data", (d) => this.onStdout(String(d)));
16824
16771
  this.proc.stdin?.on("error", () => {
16825
16772
  });
16826
16773
  }
16774
+ scheduleRestart() {
16775
+ if (this.stopped || this.restartTimer) return;
16776
+ const delay2 = this.restartDelayMs;
16777
+ this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS2);
16778
+ console.warn(`[tunnel] sidecar exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
16779
+ this.restartTimer = setTimeout(() => {
16780
+ this.restartTimer = null;
16781
+ this.spawnSidecar();
16782
+ }, delay2);
16783
+ }
16827
16784
  // Reverse-shell map key: a build mirrored onto several runners has one shell per machine.
16828
16785
  static shellKey(build, machine) {
16829
16786
  return `${build}\0${machine}`;
@@ -16833,19 +16790,26 @@ var TunnelServer = class _TunnelServer {
16833
16790
  onStdout = createNdjsonParser((evt) => {
16834
16791
  if (evt.event === "ready" && evt.tailnetAddr && evt.sshHostKey) {
16835
16792
  this.node = { tailnetAddr: evt.tailnetAddr, sshHostKey: evt.sshHostKey };
16793
+ this.restartDelayMs = RESTART_MIN_MS2;
16836
16794
  console.log(`[tunnel] ready (tailnet ${evt.tailnetAddr})`);
16837
16795
  this.onChange?.();
16838
16796
  } else if ((evt.event === "shell-opened" || evt.event === "shell-output" || evt.event === "shell-closed") && evt.build) {
16839
- const key = _TunnelServer.shellKey(evt.build, evt.machine ?? "");
16797
+ const s = this.shellState(_TunnelServer.shellKey(evt.build, evt.machine ?? ""));
16840
16798
  if (evt.event === "shell-opened") {
16841
- this.closedShells.delete(key);
16799
+ s.connected = true;
16800
+ s.closed = false;
16801
+ s.gen++;
16842
16802
  } else if (evt.event === "shell-output") {
16843
- this.appendShellBuffer(key, Buffer.from(evt.data ?? "", "base64"));
16844
- for (const l of this.shellListeners.get(key) ?? []) l.onActivity();
16803
+ s.connected = true;
16804
+ s.closed = false;
16805
+ let next = Buffer.concat([s.buffer, Buffer.from(evt.data ?? "", "base64")]);
16806
+ if (next.length > SHELL_BUFFER_CAP) next = next.subarray(next.length - SHELL_BUFFER_CAP);
16807
+ s.buffer = next;
16808
+ for (const l of s.listeners) l.onActivity();
16845
16809
  } else {
16846
- this.closedShells.add(key);
16847
- const subs = [...this.shellListeners.get(key) ?? []];
16848
- for (const l of subs) l.onClosed();
16810
+ s.connected = false;
16811
+ s.closed = true;
16812
+ for (const l of [...s.listeners]) l.onClosed();
16849
16813
  }
16850
16814
  } else if (evt.event === "error") {
16851
16815
  console.warn(`[tunnel] ${evt.message ?? "error"}`);
@@ -16872,52 +16836,66 @@ var TunnelServer = class _TunnelServer {
16872
16836
  shellSendInput(build, machine, data) {
16873
16837
  return this.writeControl({ cmd: "shell-input", build, machine, data: Buffer.from(data, "utf8").toString("base64") });
16874
16838
  }
16875
- shellClosed(build, machine) {
16876
- return this.closedShells.has(_TunnelServer.shellKey(build, machine));
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;
16877
16853
  }
16878
16854
  subscribeShell(build, machine, l) {
16879
- const key = _TunnelServer.shellKey(build, machine);
16880
- let set2 = this.shellListeners.get(key);
16881
- if (!set2) {
16882
- set2 = /* @__PURE__ */ new Set();
16883
- this.shellListeners.set(key, set2);
16884
- }
16885
- set2.add(l);
16855
+ const s = this.shellState(_TunnelServer.shellKey(build, machine));
16856
+ s.listeners.add(l);
16886
16857
  return () => {
16887
- const s = this.shellListeners.get(key);
16888
- if (!s) return;
16889
- s.delete(l);
16890
- if (s.size === 0) this.shellListeners.delete(key);
16858
+ s.listeners.delete(l);
16891
16859
  };
16892
16860
  }
16893
16861
  // Return and clear a (build, machine) shell's buffered PTY output (UTF-8). The tool calls this
16894
- // after a quiet period to get everything the shell printed since the last drain.
16862
+ // after a quiet period to get everything the shell printed since the last drain; the closed
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).
16895
16865
  drainShell(build, machine) {
16896
- const key = _TunnelServer.shellKey(build, machine);
16897
- const buf = this.shellBuffers.get(key);
16898
- this.closedShells.delete(key);
16899
- if (!buf || buf.length === 0) return "";
16900
- this.shellBuffers.delete(key);
16901
- return buf.toString("utf8");
16902
- }
16903
- appendShellBuffer(key, chunk) {
16904
- this.closedShells.delete(key);
16905
- const prev = this.shellBuffers.get(key);
16906
- let next = prev ? Buffer.concat([prev, chunk]) : chunk;
16907
- if (next.length > SHELL_BUFFER_CAP) next = next.subarray(next.length - SHELL_BUFFER_CAP);
16908
- this.shellBuffers.set(key, next);
16909
- }
16866
+ const s = this.shells.get(_TunnelServer.shellKey(build, machine));
16867
+ if (!s) return "";
16868
+ const out = s.buffer.toString("utf8");
16869
+ s.buffer = EMPTY_BUFFER;
16870
+ s.closed = false;
16871
+ return out;
16872
+ }
16873
+ shellState(key) {
16874
+ let s = this.shells.get(key);
16875
+ if (!s) {
16876
+ s = { buffer: EMPTY_BUFFER, closed: false, gen: 0, reportedGen: 0, listeners: /* @__PURE__ */ new Set() };
16877
+ this.shells.set(key, s);
16878
+ }
16879
+ return s;
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.
16910
16883
  closeAllShells() {
16911
- const all = [...this.shellListeners.values()].flatMap((s) => [...s]);
16912
- this.shellListeners.clear();
16913
- for (const l of all) l.onClosed();
16914
- this.closedShells.clear();
16915
- this.shellBuffers.clear();
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
+ }
16916
16889
  }
16917
16890
  current() {
16918
16891
  return this.node;
16919
16892
  }
16920
16893
  stop() {
16894
+ this.stopped = true;
16895
+ if (this.restartTimer) {
16896
+ clearTimeout(this.restartTimer);
16897
+ this.restartTimer = null;
16898
+ }
16921
16899
  this.proc?.kill();
16922
16900
  this.proc = null;
16923
16901
  this.node = null;
@@ -16927,7 +16905,7 @@ var TunnelServer = class _TunnelServer {
16927
16905
 
16928
16906
  // src/lib/flagParser.ts
16929
16907
  var REPEATABLE = /* @__PURE__ */ new Set(["model"]);
16930
- function parseFlags(args2) {
16908
+ function parseFlags(args2, shortFlags = {}) {
16931
16909
  const flags = {};
16932
16910
  const repeated = {};
16933
16911
  const bools = {};
@@ -16935,12 +16913,9 @@ function parseFlags(args2) {
16935
16913
  for (let i = 0; i < args2.length; i++) {
16936
16914
  const a = args2[i];
16937
16915
  if (!a.startsWith("--")) {
16938
- if (a === "-y") {
16939
- bools["yes"] = true;
16940
- continue;
16941
- }
16942
- if (a === "-f") {
16943
- bools["foreground"] = true;
16916
+ const short = a.startsWith("-") ? shortFlags[a.slice(1)] : void 0;
16917
+ if (short) {
16918
+ bools[short] = true;
16944
16919
  continue;
16945
16920
  }
16946
16921
  positionals.push(a);
@@ -16979,7 +16954,7 @@ function assign(key, value, flags, repeated) {
16979
16954
 
16980
16955
  // src/commands/start.ts
16981
16956
  async function startCommand(args2) {
16982
- const { flags, bools } = parseFlags(args2);
16957
+ const { flags, bools } = parseFlags(args2, { f: "foreground" });
16983
16958
  if (bools.supervisor) return runSupervisor(args2);
16984
16959
  if (bools.foreground) {
16985
16960
  const { config: config2, serverUrl } = await ensureEnrolled(flags);
@@ -17103,16 +17078,24 @@ async function runWorker(config2, serverUrl) {
17103
17078
  tunnel = void 0;
17104
17079
  client.setTunnel(void 0);
17105
17080
  };
17081
+ let lastMounts = [];
17082
+ client.onMounts = (mounts) => {
17083
+ lastMounts = mounts;
17084
+ syncManager?.setMounts(mounts);
17085
+ };
17106
17086
  const enableRunner = () => {
17107
17087
  if (syncManager) return;
17108
17088
  syncManager = new SyncManager(serverUrl, cfg.token, cfg.machineId);
17109
17089
  syncManager.start();
17090
+ syncManager.setMounts(lastMounts);
17091
+ client.getMountSync = () => syncManager?.status() ?? null;
17110
17092
  };
17111
17093
  const disableRunner = () => {
17112
17094
  if (!syncManager) return;
17113
17095
  console.log("[machine] Disabling runner: stopping worktree mirrors.");
17114
17096
  syncManager.stop();
17115
17097
  syncManager = void 0;
17098
+ client.getMountSync = void 0;
17116
17099
  };
17117
17100
  async function applyRoles(roles) {
17118
17101
  if (roles.includes("builder")) {
@@ -17182,13 +17165,13 @@ async function restartCommand(args2) {
17182
17165
  var import_child_process4 = require("child_process");
17183
17166
  var import_fs5 = require("fs");
17184
17167
  async function logsCommand(args2) {
17185
- const { bools } = parseFlags(args2);
17168
+ const { bools } = parseFlags(args2, { f: "follow" });
17186
17169
  const p = logPath();
17187
17170
  if (!(0, import_fs5.existsSync)(p)) {
17188
17171
  console.log(`[tds] No log file yet (${p}). Run 'tds start' first.`);
17189
17172
  return;
17190
17173
  }
17191
- const follow = bools.foreground || bools.follow;
17174
+ const follow = bools.follow;
17192
17175
  console.error(`[tds] ${p}`);
17193
17176
  const tailArgs = follow ? ["-f", p] : ["-n", "200", p];
17194
17177
  const child = (0, import_child_process4.spawn)("tail", tailArgs, { stdio: ["ignore", "inherit", "inherit"] });
@@ -17245,7 +17228,7 @@ function renderTabBar(tabs, active) {
17245
17228
  return i === active ? import_yoctocolors_cjs.default.inverse(import_yoctocolors_cjs.default.cyan(label)) : import_yoctocolors_cjs.default.dim(label);
17246
17229
  }).join(" ");
17247
17230
  }
17248
- var tabbedSearchPrompt = (0, import_core8.createPrompt)((config2, done2) => {
17231
+ var tabbedSearchPrompt = (0, import_core8.createPrompt)((config2, done) => {
17249
17232
  const { tabs } = config2;
17250
17233
  const pageSize = config2.pageSize ?? 8;
17251
17234
  const theme = (0, import_core8.makeTheme)({});
@@ -17266,7 +17249,7 @@ var tabbedSearchPrompt = (0, import_core8.createPrompt)((config2, done2) => {
17266
17249
  if ((0, import_core8.isEnterKey)(key)) {
17267
17250
  if (!selected) return;
17268
17251
  setStatus("done");
17269
- done2(selected.value);
17252
+ done(selected.value);
17270
17253
  return;
17271
17254
  }
17272
17255
  if (key.name === "left" || key.name === "right") {
@@ -17450,16 +17433,16 @@ function findPreset(presets, id) {
17450
17433
  }
17451
17434
 
17452
17435
  // src/lib/modelsJson.ts
17453
- var import_node_fs7 = require("node:fs");
17436
+ var import_node_fs6 = require("node:fs");
17454
17437
  var import_node_path7 = require("node:path");
17455
17438
  function modelsJsonPath(agentDir) {
17456
17439
  return (0, import_node_path7.join)(agentDir, "models.json");
17457
17440
  }
17458
17441
  function readModelsJson(agentDir) {
17459
17442
  const p = modelsJsonPath(agentDir);
17460
- if (!(0, import_node_fs7.existsSync)(p)) return { providers: {} };
17443
+ if (!(0, import_node_fs6.existsSync)(p)) return { providers: {} };
17461
17444
  try {
17462
- 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"));
17463
17446
  return { providers: parsed.providers ?? {} };
17464
17447
  } catch (e) {
17465
17448
  throw new Error(`Failed to parse ${p}: ${e.message}`);
@@ -17467,8 +17450,8 @@ function readModelsJson(agentDir) {
17467
17450
  }
17468
17451
  function writeModelsJson(agentDir, config2) {
17469
17452
  const p = modelsJsonPath(agentDir);
17470
- (0, import_node_fs7.mkdirSync)((0, import_node_path7.dirname)(p), { recursive: true });
17471
- (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 });
17472
17455
  }
17473
17456
  function upsertProvider(config2, name, entry) {
17474
17457
  return { providers: { ...config2.providers, [name]: entry } };
@@ -17556,7 +17539,7 @@ async function menu() {
17556
17539
  }
17557
17540
  }
17558
17541
  async function addProvider(args2) {
17559
- const parsed = parseFlags(args2);
17542
+ const parsed = parseFlags(args2, { y: "yes" });
17560
17543
  const yes = parsed.bools["yes"] === true;
17561
17544
  const runtime = await getPiRuntime();
17562
17545
  const presets = buildPresets(runtime.builtinProviderIds, runtime.displayNameFor);
@@ -17703,7 +17686,7 @@ async function listProviders(args2) {
17703
17686
  }
17704
17687
  }
17705
17688
  async function removeProvider2(args2) {
17706
- const { positionals, bools } = parseFlags(args2);
17689
+ const { positionals, bools } = parseFlags(args2, { y: "yes" });
17707
17690
  const yes = bools["yes"] === true;
17708
17691
  const runtime = await getPiRuntime();
17709
17692
  const { authStorage } = runtime;
@@ -17808,6 +17791,8 @@ async function logoutCommand() {
17808
17791
  // src/index.ts
17809
17792
  var COMMANDS = {
17810
17793
  start: {
17794
+ run: startCommand,
17795
+ longLived: true,
17811
17796
  summary: "Bring this machine online in the background (enroll if needed)",
17812
17797
  help: `Usage: tds start [options]
17813
17798
 
@@ -17831,6 +17816,7 @@ Options:
17831
17816
  --server <url> Override the server URL`
17832
17817
  },
17833
17818
  stop: {
17819
+ run: () => stopCommand(),
17834
17820
  summary: "Stop the background machine",
17835
17821
  help: `Usage: tds stop
17836
17822
 
@@ -17838,6 +17824,8 @@ Stop the background daemon started by 'tds start'. Sends SIGTERM so in-flight ta
17838
17824
  shut down cleanly; SIGKILLs only if it doesn't exit in time.`
17839
17825
  },
17840
17826
  restart: {
17827
+ run: restartCommand,
17828
+ longLived: true,
17841
17829
  summary: "Restart the background machine",
17842
17830
  help: `Usage: tds restart [options]
17843
17831
 
@@ -17845,6 +17833,7 @@ Stop the background daemon (if running) and start it again in the background. Ta
17845
17833
  same options as 'tds start'.`
17846
17834
  },
17847
17835
  logs: {
17836
+ run: logsCommand,
17848
17837
  summary: "Show the background machine's logs",
17849
17838
  help: `Usage: tds logs [-f]
17850
17839
 
@@ -17854,6 +17843,7 @@ Options:
17854
17843
  -f, --follow Follow the log as it grows (like 'tail -f')`
17855
17844
  },
17856
17845
  logout: {
17846
+ run: () => logoutCommand(),
17857
17847
  summary: "Forget this machine's local registration",
17858
17848
  help: `Usage: tds logout
17859
17849
 
@@ -17862,12 +17852,14 @@ Stop the background daemon (if running), then clear ~/.tds/machine.json so the n
17862
17852
  (an admin does that in the web UI).`
17863
17853
  },
17864
17854
  status: {
17855
+ run: () => statusCommand(),
17865
17856
  summary: "Show this machine's registration and status",
17866
17857
  help: `Usage: tds status
17867
17858
 
17868
17859
  Show this machine's registration and current status.`
17869
17860
  },
17870
17861
  provider: {
17862
+ run: providerCommand,
17871
17863
  summary: "Manage AI providers and models",
17872
17864
  help: `Usage: tds provider <add|list|remove|login> [options]
17873
17865
 
@@ -17896,38 +17888,14 @@ if (COMMANDS[cmd] && (rest.includes("--help") || rest.includes("-h"))) {
17896
17888
  console.log(COMMANDS[cmd].help);
17897
17889
  process.exit(0);
17898
17890
  }
17899
- switch (cmd) {
17900
- // One-shot commands: exit explicitly when done (fetch keep-alive sockets block natural exit).
17901
- case "logout":
17902
- logoutCommand().then(done).catch(fatal);
17903
- break;
17904
- case "status":
17905
- statusCommand().then(done).catch(fatal);
17906
- break;
17907
- case "provider":
17908
- providerCommand(rest).then(done).catch(fatal);
17909
- break;
17910
- case "stop":
17911
- stopCommand().then(done).catch(fatal);
17912
- break;
17913
- case "logs":
17914
- logsCommand(rest).then(done).catch(fatal);
17915
- break;
17916
- // start/restart self-detach (exit after launching) or run long-lived (--foreground/--supervisor).
17917
- case "start":
17918
- startCommand(rest).catch(fatal);
17919
- break;
17920
- case "restart":
17921
- restartCommand(rest).catch(fatal);
17922
- break;
17923
- default:
17924
- console.error(`[tds] Unknown command: ${cmd}`);
17925
- printHelp();
17926
- process.exit(1);
17927
- }
17928
- function done() {
17929
- process.exit(0);
17891
+ var command = COMMANDS[cmd];
17892
+ if (!command) {
17893
+ console.error(`[tds] Unknown command: ${cmd}`);
17894
+ printHelp();
17895
+ process.exit(1);
17930
17896
  }
17897
+ if (command.longLived) command.run(rest).catch(fatal);
17898
+ else command.run(rest).then(() => process.exit(0)).catch(fatal);
17931
17899
  function fatal(err) {
17932
17900
  console.error("[tds] Fatal:", err.message);
17933
17901
  process.exit(1);