@todos-dev/cli 0.1.2 → 0.1.4

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 +351 -250
  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, {
@@ -1345,6 +1367,18 @@ function clearConfig() {
1345
1367
  }
1346
1368
  }
1347
1369
 
1370
+ // src/lib/log.ts
1371
+ var installed = false;
1372
+ function installTimestampedLogging() {
1373
+ if (installed) return;
1374
+ installed = true;
1375
+ const stamp = () => `[${(/* @__PURE__ */ new Date()).toISOString()}]`;
1376
+ for (const method of ["log", "info", "warn", "error", "debug"]) {
1377
+ const orig = console[method].bind(console);
1378
+ console[method] = (...args2) => orig(stamp(), ...args2);
1379
+ }
1380
+ }
1381
+
1348
1382
  // src/lib/daemon.ts
1349
1383
  var LOG_CAP = 10 * 1024 * 1024;
1350
1384
  var EXIT_TERMINAL = 78;
@@ -1409,6 +1443,7 @@ function spawnSupervisor(userArgs, logFd) {
1409
1443
  var MODE_FLAGS = /* @__PURE__ */ new Set(["--supervisor", "--foreground", "-f"]);
1410
1444
  var userFlagsOnly = (args2) => args2.filter((a) => !MODE_FLAGS.has(a));
1411
1445
  async function runSupervisor(args2) {
1446
+ installTimestampedLogging();
1412
1447
  const userArgs = userFlagsOnly(args2);
1413
1448
  const MAX_BACKOFF = 3e4;
1414
1449
  const STABLE_MS = 6e4;
@@ -14151,14 +14186,14 @@ var safeDecodeAsync2 = /* @__PURE__ */ _safeDecodeAsync(ZodRealError);
14151
14186
  var _installedGroups = /* @__PURE__ */ new WeakMap();
14152
14187
  function _installLazyMethods(inst, group, methods) {
14153
14188
  const proto = Object.getPrototypeOf(inst);
14154
- let installed = _installedGroups.get(proto);
14155
- if (!installed) {
14156
- installed = /* @__PURE__ */ new Set();
14157
- _installedGroups.set(proto, installed);
14189
+ let installed2 = _installedGroups.get(proto);
14190
+ if (!installed2) {
14191
+ installed2 = /* @__PURE__ */ new Set();
14192
+ _installedGroups.set(proto, installed2);
14158
14193
  }
14159
- if (installed.has(group))
14194
+ if (installed2.has(group))
14160
14195
  return;
14161
- installed.add(group);
14196
+ installed2.add(group);
14162
14197
  for (const key in methods) {
14163
14198
  const fn = methods[key];
14164
14199
  Object.defineProperty(proto, key, {
@@ -16161,6 +16196,7 @@ var PollClient = class {
16161
16196
  runningTasks = /* @__PURE__ */ new Map();
16162
16197
  presenceTimer = null;
16163
16198
  gcTimer = null;
16199
+ pokeTimer = null;
16164
16200
  lastSentTunnelKey;
16165
16201
  revoked = false;
16166
16202
  maxConcurrentTasks = null;
@@ -16169,6 +16205,12 @@ var PollClient = class {
16169
16205
  roles = [];
16170
16206
  tunnel;
16171
16207
  onRolesChanged;
16208
+ // Per-mount mirror health provider (set while the runner role is active). Rides the presence
16209
+ // heartbeat so the server can tell a dead mirror from a healthy one; null omits the field.
16210
+ getMountSync;
16211
+ // Desired mounts pushed back on the presence response (runner machines only) — presence is the
16212
+ // runner's single poll; the SyncManager forwards the set to the mirror daemon.
16213
+ onMounts;
16172
16214
  // Only a builder claims and runs build steps; a runner-only machine still reports presence.
16173
16215
  get canClaim() {
16174
16216
  return this.roles.includes("builder");
@@ -16195,6 +16237,16 @@ var PollClient = class {
16195
16237
  tunnel?.setNodeChangeHandler(() => void this.reportPresence());
16196
16238
  void this.reportPresence();
16197
16239
  }
16240
+ // Out-of-cycle beat for mirror-health transitions, so the web flips within ~a second
16241
+ // instead of waiting out the ≤20s presence tail. Debounced: a burst of transitions (daemon
16242
+ // restart syncing every mount at once) collapses into one POST carrying the final state.
16243
+ pokePresence() {
16244
+ if (this.pokeTimer) return;
16245
+ this.pokeTimer = setTimeout(() => {
16246
+ this.pokeTimer = null;
16247
+ void this.reportPresence();
16248
+ }, 250);
16249
+ }
16198
16250
  maybeStartClaim() {
16199
16251
  if (this.running && this.canClaim && !this.loopActive) {
16200
16252
  this.loopActive = true;
@@ -16229,6 +16281,10 @@ var PollClient = class {
16229
16281
  clearInterval(this.gcTimer);
16230
16282
  this.gcTimer = null;
16231
16283
  }
16284
+ if (this.pokeTimer) {
16285
+ clearTimeout(this.pokeTimer);
16286
+ this.pokeTimer = null;
16287
+ }
16232
16288
  }
16233
16289
  // The machine was removed from its team (or its token revoked): every /api/machine/* call now
16234
16290
  // 401/403s. Fail fast instead of spinning forever — stop and exit so the operator notices.
@@ -16277,6 +16333,8 @@ ${node.sshHostKey}` : null };
16277
16333
  async reportPresence() {
16278
16334
  try {
16279
16335
  const body = { load: this.runningTasks.size };
16336
+ const mountSync = this.getMountSync?.();
16337
+ if (mountSync) body.mountSync = mountSync;
16280
16338
  const { node, key } = this.tunnelSnapshot();
16281
16339
  if (key !== this.lastSentTunnelKey) {
16282
16340
  body.sshHostKey = node?.sshHostKey ?? null;
@@ -16299,6 +16357,7 @@ ${node.sshHostKey}` : null };
16299
16357
  if (Array.isArray(data?.roles)) {
16300
16358
  this.setRoles(data.roles.filter((r) => r === "builder" || r === "runner"));
16301
16359
  }
16360
+ if (Array.isArray(data?.mounts)) this.onMounts?.(data.mounts);
16302
16361
  this.lastSentTunnelKey = key;
16303
16362
  }
16304
16363
  } catch {
@@ -16382,11 +16441,10 @@ function sleep(ms, signal) {
16382
16441
  }
16383
16442
 
16384
16443
  // src/SyncManager.ts
16385
- var import_node_fs6 = require("node:fs");
16386
16444
  var import_node_os4 = require("node:os");
16387
16445
  var import_node_path6 = require("node:path");
16388
16446
 
16389
- // src/lib/mirror.ts
16447
+ // src/lib/sidecar.ts
16390
16448
  var import_node_child_process2 = require("node:child_process");
16391
16449
 
16392
16450
  // src/lib/bin.ts
@@ -16454,145 +16512,174 @@ function createNdjsonParser(onEvent) {
16454
16512
  };
16455
16513
  }
16456
16514
 
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;
16515
+ // src/lib/sidecar.ts
16516
+ var RESTART_MIN_MS = 1e3;
16517
+ var RESTART_MAX_MS = 6e4;
16518
+ var Sidecar = class {
16519
+ constructor(args2, label, onEvent, onExit) {
16520
+ this.args = args2;
16521
+ this.label = label;
16522
+ this.onExit = onExit;
16523
+ this.parse = createNdjsonParser(onEvent);
16524
+ }
16525
+ proc = null;
16526
+ stopped = false;
16527
+ restartTimer = null;
16528
+ restartDelayMs = RESTART_MIN_MS;
16529
+ parse;
16530
+ start() {
16531
+ this.stopped = false;
16532
+ this.spawn();
16533
+ }
16534
+ stop() {
16535
+ this.stopped = true;
16536
+ if (this.restartTimer) {
16537
+ clearTimeout(this.restartTimer);
16538
+ this.restartTimer = null;
16491
16539
  }
16492
- };
16493
- }
16540
+ this.proc?.kill();
16541
+ this.proc = null;
16542
+ }
16543
+ // Owner saw the sidecar's healthy signal — future crashes retry quickly again.
16544
+ resetBackoff() {
16545
+ this.restartDelayMs = RESTART_MIN_MS;
16546
+ }
16547
+ // Whether control messages can be sent at all (process up with a writable stdin).
16548
+ alive() {
16549
+ return Boolean(this.proc && !this.proc.killed && this.proc.stdin && !this.proc.stdin.destroyed);
16550
+ }
16551
+ // Write one NDJSON control message to the sidecar's stdin; false when it can't be delivered
16552
+ // (the exit/respawn path is the recovery, so callers just skip).
16553
+ write(msg) {
16554
+ const stdin = this.proc?.stdin;
16555
+ if (!stdin || stdin.destroyed) return false;
16556
+ try {
16557
+ stdin.write(JSON.stringify(msg) + "\n");
16558
+ return true;
16559
+ } catch {
16560
+ return false;
16561
+ }
16562
+ }
16563
+ spawn() {
16564
+ try {
16565
+ this.proc = (0, import_node_child_process2.spawn)(TUNNEL_BIN, this.args, { stdio: ["pipe", "pipe", "inherit"] });
16566
+ } catch {
16567
+ console.warn(`[${this.label}] tds-tunnel not available \u2014 disabled`);
16568
+ return;
16569
+ }
16570
+ this.proc.on("error", () => {
16571
+ console.warn(`[${this.label}] sidecar failed to start \u2014 disabled`);
16572
+ this.proc = null;
16573
+ });
16574
+ this.proc.on("exit", () => {
16575
+ this.proc = null;
16576
+ this.onExit?.();
16577
+ this.scheduleRestart();
16578
+ });
16579
+ this.proc.stdout?.on("data", (d) => this.parse(String(d)));
16580
+ this.proc.stdin?.on("error", () => {
16581
+ });
16582
+ }
16583
+ scheduleRestart() {
16584
+ if (this.stopped || this.restartTimer) return;
16585
+ const delay2 = this.restartDelayMs;
16586
+ this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS);
16587
+ console.warn(`[${this.label}] sidecar exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
16588
+ this.restartTimer = setTimeout(() => {
16589
+ this.restartTimer = null;
16590
+ this.spawn();
16591
+ }, delay2);
16592
+ }
16593
+ };
16494
16594
 
16495
16595
  // src/SyncManager.ts
16496
- var RECONCILE_INTERVAL_MS = 8e3;
16497
16596
  function expandHome(p) {
16498
16597
  if (p === "~") return (0, import_node_os4.homedir)();
16499
16598
  if (p.startsWith("~/") || p.startsWith("~\\")) return (0, import_node_path6.join)((0, import_node_os4.homedir)(), p.slice(2));
16500
16599
  return p;
16501
16600
  }
16601
+ var WORKTREE_REBUILD_PATIENCE_MS = 18e4;
16502
16602
  var SyncManager = class {
16503
- // keyed by mountId
16504
16603
  constructor(serverUrl, token, machineId) {
16505
- this.serverUrl = serverUrl;
16506
- this.token = token;
16507
16604
  this.machineId = machineId;
16605
+ this.sidecar = new Sidecar(
16606
+ ["mirror", "--server", serverUrl, "--token", token, "--machine", machineId],
16607
+ "runner",
16608
+ (e) => this.onEvent(e)
16609
+ );
16508
16610
  }
16509
- timer = null;
16510
- running = false;
16511
- tunnels = /* @__PURE__ */ new Map();
16611
+ sidecar;
16612
+ desired = [];
16613
+ health = /* @__PURE__ */ new Map();
16614
+ // by mountId
16615
+ // Fired on coarse health transitions (first cycle, error set/cleared) so the presence beat
16616
+ // carrying the change goes out immediately instead of on the next 20s tick.
16617
+ onHealthChanged;
16512
16618
  start() {
16513
- if (this.running) return;
16514
- this.running = true;
16515
16619
  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);
16620
+ this.sidecar.start();
16521
16621
  }
16522
16622
  stop() {
16523
- this.running = false;
16524
- if (this.timer) {
16525
- clearInterval(this.timer);
16526
- this.timer = null;
16527
- }
16528
- for (const mountId of [...this.tunnels.keys()]) this.teardown(mountId);
16529
- }
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));
16533
- }
16534
- async fetchMounts() {
16535
- 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();
16543
- } catch {
16544
- return null;
16545
- }
16623
+ this.sidecar.stop();
16546
16624
  }
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}`);
16567
- return;
16568
- }
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,
16625
+ // Desired mounts, straight off the presence response. Declarative: resolve dests, remember the
16626
+ // set (a respawned daemon gets it replayed on its 'up' event), and forward it.
16627
+ setMounts(resolved) {
16628
+ this.desired = resolved.map((r) => ({
16629
+ mountId: r.mountId,
16573
16630
  buildId: r.buildId,
16574
- dest,
16631
+ dest: r.destDir ? expandHome(r.destDir) : (0, import_node_path6.join)((0, import_node_os4.homedir)(), defaultMirrorSubpath(r.repoFullName, r.seqNum)),
16575
16632
  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
- }
16584
- });
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);
16633
+ allowExec: r.allowExec
16634
+ }));
16635
+ const keep = new Set(this.desired.map((m) => m.mountId));
16636
+ for (const id of [...this.health.keys()]) if (!keep.has(id)) this.health.delete(id);
16637
+ this.sendMounts();
16638
+ }
16639
+ // Per-mount mirror health for the presence heartbeat: age of the last completed cycle (computed
16640
+ // here so no other machine ever compares clocks) + last error. The web mounts panel and sync
16641
+ // indicator read this — presence alone can't tell a dead mirror from a healthy one.
16642
+ status() {
16643
+ const now = Date.now();
16644
+ return this.desired.map((m) => {
16645
+ const h = this.health.get(m.mountId);
16646
+ return {
16647
+ mountId: m.mountId,
16648
+ ageMs: h?.syncedAt ? now - h.syncedAt : null,
16649
+ ...h?.lastError ? { error: h.lastError } : {}
16650
+ };
16589
16651
  });
16590
16652
  }
16591
- teardown(mountId) {
16592
- const t = this.tunnels.get(mountId);
16593
- if (!t) return;
16594
- this.tunnels.delete(mountId);
16595
- void t.handle.stop();
16653
+ onEvent(e) {
16654
+ if (e.event === "up") {
16655
+ this.sidecar.resetBackoff();
16656
+ this.sendMounts();
16657
+ } else if (e.event === "synced" && e.mount) {
16658
+ const prev = this.health.get(e.mount);
16659
+ this.health.set(e.mount, { syncedAt: Date.now(), lastError: null });
16660
+ if (!prev?.syncedAt || prev.lastError) this.onHealthChanged?.();
16661
+ if (e.changed || e.deleted) console.log(`[machine] runner: mount ${e.mount} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
16662
+ } else if (e.event === "worktree-missing" && e.mount) {
16663
+ const prev = this.health.get(e.mount);
16664
+ const missingSince = prev?.missingSince ?? Date.now();
16665
+ const lastError = Date.now() - missingSince > WORKTREE_REBUILD_PATIENCE_MS ? e.message ?? "worktree missing" : null;
16666
+ this.health.set(e.mount, { syncedAt: 0, lastError, missingSince });
16667
+ if (!!prev?.syncedAt || (prev?.lastError ?? null) !== lastError) this.onHealthChanged?.();
16668
+ console.log(`[machine] runner: mount ${e.mount}: worktree missing \u2014 rebuild requested`);
16669
+ } else if (e.event === "error") {
16670
+ if (e.mount) {
16671
+ const prev = this.health.get(e.mount);
16672
+ const message = e.message ?? "error";
16673
+ this.health.set(e.mount, { syncedAt: prev?.syncedAt ?? 0, lastError: message });
16674
+ if (prev?.lastError !== message) this.onHealthChanged?.();
16675
+ console.warn(`[machine] runner: mount ${e.mount}: ${message}`);
16676
+ } else {
16677
+ console.warn(`[machine] runner: ${e.message ?? "error"}`);
16678
+ }
16679
+ }
16680
+ }
16681
+ sendMounts() {
16682
+ this.sidecar.write({ cmd: "mounts", mounts: this.desired });
16596
16683
  }
16597
16684
  };
16598
16685
 
@@ -16704,26 +16791,30 @@ async function syncModelsToServer(serverUrl, token, runtime) {
16704
16791
  }
16705
16792
 
16706
16793
  // src/lib/tunnel.ts
16707
- var import_node_child_process3 = require("node:child_process");
16708
16794
  var SHELL_BUFFER_CAP = 256 * 1024;
16709
- var RESTART_MIN_MS = 1e3;
16710
- var RESTART_MAX_MS = 6e4;
16711
16795
  var EMPTY_BUFFER = Buffer.alloc(0);
16712
16796
  var TunnelServer = class _TunnelServer {
16713
- constructor(serverUrl, token) {
16714
- this.serverUrl = serverUrl;
16715
- this.token = token;
16716
- }
16717
- proc = null;
16718
16797
  node = null;
16719
16798
  onChange = null;
16720
- stopped = false;
16721
- restartTimer = null;
16722
- restartDelayMs = RESTART_MIN_MS;
16799
+ // Process lifecycle (spawn / respawn-with-backoff / stdin control) is the shared Sidecar's job;
16800
+ // this class keeps only the domain state that dies with the process: node + shells.
16801
+ sidecar;
16723
16802
  // Reverse shells, keyed by (build, machine) — a build mirrored onto several runners has one shell
16724
16803
  // per machine. Output is buffered even with no live subscriber so output a backgrounded command
16725
16804
  // emits between tool calls survives until the next drain.
16726
16805
  shells = /* @__PURE__ */ new Map();
16806
+ constructor(serverUrl, token) {
16807
+ this.sidecar = new Sidecar(
16808
+ ["serve", "--server", serverUrl, "--token", token],
16809
+ "tunnel",
16810
+ (evt) => this.onEvent(evt),
16811
+ () => {
16812
+ this.node = null;
16813
+ this.closeAllShells();
16814
+ this.onChange?.();
16815
+ }
16816
+ );
16817
+ }
16727
16818
  // Set the single handler fired whenever the tunnel node appears/changes/clears, so presence can
16728
16819
  // be pushed immediately instead of waiting for the next heartbeat tick. Fires now if already ready.
16729
16820
  setNodeChangeHandler(cb) {
@@ -16731,99 +16822,65 @@ var TunnelServer = class _TunnelServer {
16731
16822
  if (this.node) cb();
16732
16823
  }
16733
16824
  start() {
16734
- this.stopped = false;
16735
- this.spawnSidecar();
16736
- }
16737
- spawnSidecar() {
16738
- try {
16739
- this.proc = (0, import_node_child_process3.spawn)(
16740
- TUNNEL_BIN,
16741
- ["serve", "--server", this.serverUrl, "--token", this.token],
16742
- // stdin is piped (not ignored) so we can write reverse-shell control messages to the sidecar.
16743
- { stdio: ["pipe", "pipe", "inherit"] }
16744
- );
16745
- } catch {
16746
- console.warn("[tunnel] sidecar not available \u2014 runner mirroring disabled");
16747
- return;
16748
- }
16749
- this.proc.on("error", () => {
16750
- console.warn("[tunnel] sidecar failed to start \u2014 runner mirroring disabled");
16751
- this.proc = null;
16752
- });
16753
- this.proc.on("exit", () => {
16754
- this.proc = null;
16755
- this.node = null;
16756
- this.closeAllShells();
16757
- this.onChange?.();
16758
- this.scheduleRestart();
16759
- });
16760
- this.proc.stdout?.on("data", (d) => this.onStdout(String(d)));
16761
- this.proc.stdin?.on("error", () => {
16762
- });
16763
- }
16764
- scheduleRestart() {
16765
- if (this.stopped || this.restartTimer) return;
16766
- const delay2 = this.restartDelayMs;
16767
- this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS);
16768
- console.warn(`[tunnel] sidecar exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
16769
- this.restartTimer = setTimeout(() => {
16770
- this.restartTimer = null;
16771
- this.spawnSidecar();
16772
- }, delay2);
16825
+ this.sidecar.start();
16773
16826
  }
16774
16827
  // Reverse-shell map key: a build mirrored onto several runners has one shell per machine.
16775
16828
  static shellKey(build, machine) {
16776
16829
  return `${build}\0${machine}`;
16777
16830
  }
16778
- // Sidecar emits newline-delimited JSON events on stdout; `ready` carries the tunnel node, and
16779
- // shell-opened/shell-output/shell-closed carry reverse-shell traffic for a (build, machine).
16780
- onStdout = createNdjsonParser((evt) => {
16831
+ onEvent(evt) {
16781
16832
  if (evt.event === "ready" && evt.tailnetAddr && evt.sshHostKey) {
16782
16833
  this.node = { tailnetAddr: evt.tailnetAddr, sshHostKey: evt.sshHostKey };
16783
- this.restartDelayMs = RESTART_MIN_MS;
16834
+ this.sidecar.resetBackoff();
16784
16835
  console.log(`[tunnel] ready (tailnet ${evt.tailnetAddr})`);
16785
16836
  this.onChange?.();
16786
16837
  } else if ((evt.event === "shell-opened" || evt.event === "shell-output" || evt.event === "shell-closed") && evt.build) {
16787
16838
  const s = this.shellState(_TunnelServer.shellKey(evt.build, evt.machine ?? ""));
16788
16839
  if (evt.event === "shell-opened") {
16840
+ s.connected = true;
16789
16841
  s.closed = false;
16842
+ s.gen++;
16790
16843
  } else if (evt.event === "shell-output") {
16844
+ s.connected = true;
16791
16845
  s.closed = false;
16792
16846
  let next = Buffer.concat([s.buffer, Buffer.from(evt.data ?? "", "base64")]);
16793
16847
  if (next.length > SHELL_BUFFER_CAP) next = next.subarray(next.length - SHELL_BUFFER_CAP);
16794
16848
  s.buffer = next;
16795
16849
  for (const l of s.listeners) l.onActivity();
16796
16850
  } else {
16851
+ s.connected = false;
16797
16852
  s.closed = true;
16798
16853
  for (const l of [...s.listeners]) l.onClosed();
16799
16854
  }
16800
16855
  } else if (evt.event === "error") {
16801
16856
  console.warn(`[tunnel] ${evt.message ?? "error"}`);
16802
16857
  }
16803
- });
16858
+ }
16804
16859
  // --- Reverse shell (agent → user's machine via the sync client) -----------------------------
16805
16860
  // Whether reverse-shell control can be sent at all (sidecar up with a writable stdin). The tool
16806
16861
  // checks this to fail fast instead of writing into the void.
16807
16862
  shellAvailable() {
16808
- return Boolean(this.proc && !this.proc.killed && this.proc.stdin && !this.proc.stdin.destroyed);
16809
- }
16810
- writeControl(msg) {
16811
- const stdin = this.proc?.stdin;
16812
- if (!stdin || stdin.destroyed) return false;
16813
- try {
16814
- stdin.write(JSON.stringify(msg) + "\n");
16815
- return true;
16816
- } catch {
16817
- return false;
16818
- }
16863
+ return this.sidecar.alive();
16819
16864
  }
16820
16865
  // Send keystrokes/command text to a (build, machine) shell. data is UTF-8 text (may include control
16821
16866
  // bytes like \x03 for Ctrl-C); it's base64-framed on the wire.
16822
16867
  shellSendInput(build, machine, data) {
16823
- return this.writeControl({ cmd: "shell-input", build, machine, data: Buffer.from(data, "utf8").toString("base64") });
16824
- }
16825
- shellClosed(build, machine) {
16826
- return this.shells.get(_TunnelServer.shellKey(build, machine))?.closed ?? false;
16868
+ return this.sidecar.write({ cmd: "shell-input", build, machine, data: Buffer.from(data, "utf8").toString("base64") });
16869
+ }
16870
+ // Whether the client's shell channel is currently registered sidecar-side. undefined = no event
16871
+ // seen yet for this key (a client may still be connected — input discovers it); false = it was
16872
+ // there and dropped (or input bounced), so the tool fails fast instead of timing out.
16873
+ shellConnected(build, machine) {
16874
+ return this.shells.get(_TunnelServer.shellKey(build, machine))?.connected;
16875
+ }
16876
+ // True once per PTY replacement: the shell reconnected (new session, prior state lost) since the
16877
+ // tool last consumed this flag. The first-ever open is not a "reconnect".
16878
+ consumeShellReconnect(build, machine) {
16879
+ const s = this.shells.get(_TunnelServer.shellKey(build, machine));
16880
+ if (!s) return false;
16881
+ const reconnected = s.reportedGen >= 1 && s.gen > s.reportedGen;
16882
+ s.reportedGen = s.gen;
16883
+ return reconnected;
16827
16884
  }
16828
16885
  subscribeShell(build, machine, l) {
16829
16886
  const s = this.shellState(_TunnelServer.shellKey(build, machine));
@@ -16834,41 +16891,38 @@ var TunnelServer = class _TunnelServer {
16834
16891
  }
16835
16892
  // Return and clear a (build, machine) shell's buffered PTY output (UTF-8). The tool calls this
16836
16893
  // 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.
16894
+ // marker is cleared too so the next session for this key starts fresh. The entry itself is kept —
16895
+ // connected/gen are cross-call memory (tiny, bounded by builds this daemon has served).
16838
16896
  drainShell(build, machine) {
16839
- const key = _TunnelServer.shellKey(build, machine);
16840
- const s = this.shells.get(key);
16897
+ const s = this.shells.get(_TunnelServer.shellKey(build, machine));
16841
16898
  if (!s) return "";
16842
16899
  const out = s.buffer.toString("utf8");
16843
16900
  s.buffer = EMPTY_BUFFER;
16844
16901
  s.closed = false;
16845
- if (s.listeners.size === 0) this.shells.delete(key);
16846
16902
  return out;
16847
16903
  }
16848
16904
  shellState(key) {
16849
16905
  let s = this.shells.get(key);
16850
16906
  if (!s) {
16851
- s = { buffer: EMPTY_BUFFER, closed: false, listeners: /* @__PURE__ */ new Set() };
16907
+ s = { buffer: EMPTY_BUFFER, closed: false, gen: 0, reportedGen: 0, listeners: /* @__PURE__ */ new Set() };
16852
16908
  this.shells.set(key, s);
16853
16909
  }
16854
16910
  return s;
16855
16911
  }
16912
+ // Sidecar death takes every client connection with it. Entries survive (not cleared) so the
16913
+ // shells' reconnect generations still tick when clients re-register against the respawned sidecar.
16856
16914
  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();
16915
+ for (const s of this.shells.values()) {
16916
+ s.connected = false;
16917
+ s.closed = true;
16918
+ for (const l of [...s.listeners]) l.onClosed();
16919
+ }
16860
16920
  }
16861
16921
  current() {
16862
16922
  return this.node;
16863
16923
  }
16864
16924
  stop() {
16865
- this.stopped = true;
16866
- if (this.restartTimer) {
16867
- clearTimeout(this.restartTimer);
16868
- this.restartTimer = null;
16869
- }
16870
- this.proc?.kill();
16871
- this.proc = null;
16925
+ this.sidecar.stop();
16872
16926
  this.node = null;
16873
16927
  this.closeAllShells();
16874
16928
  }
@@ -16928,6 +16982,7 @@ async function startCommand(args2) {
16928
16982
  const { flags, bools } = parseFlags(args2, { f: "foreground" });
16929
16983
  if (bools.supervisor) return runSupervisor(args2);
16930
16984
  if (bools.foreground) {
16985
+ installTimestampedLogging();
16931
16986
  const { config: config2, serverUrl } = await ensureEnrolled(flags);
16932
16987
  return runWorker(config2, serverUrl);
16933
16988
  }
@@ -17049,16 +17104,25 @@ async function runWorker(config2, serverUrl) {
17049
17104
  tunnel = void 0;
17050
17105
  client.setTunnel(void 0);
17051
17106
  };
17107
+ let lastMounts = [];
17108
+ client.onMounts = (mounts) => {
17109
+ lastMounts = mounts;
17110
+ syncManager?.setMounts(mounts);
17111
+ };
17052
17112
  const enableRunner = () => {
17053
17113
  if (syncManager) return;
17054
17114
  syncManager = new SyncManager(serverUrl, cfg.token, cfg.machineId);
17055
17115
  syncManager.start();
17116
+ syncManager.setMounts(lastMounts);
17117
+ client.getMountSync = () => syncManager?.status() ?? null;
17118
+ syncManager.onHealthChanged = () => client.pokePresence();
17056
17119
  };
17057
17120
  const disableRunner = () => {
17058
17121
  if (!syncManager) return;
17059
17122
  console.log("[machine] Disabling runner: stopping worktree mirrors.");
17060
17123
  syncManager.stop();
17061
17124
  syncManager = void 0;
17125
+ client.getMountSync = void 0;
17062
17126
  };
17063
17127
  async function applyRoles(roles) {
17064
17128
  if (roles.includes("builder")) {
@@ -17396,16 +17460,16 @@ function findPreset(presets, id) {
17396
17460
  }
17397
17461
 
17398
17462
  // src/lib/modelsJson.ts
17399
- var import_node_fs7 = require("node:fs");
17463
+ var import_node_fs6 = require("node:fs");
17400
17464
  var import_node_path7 = require("node:path");
17401
17465
  function modelsJsonPath(agentDir) {
17402
17466
  return (0, import_node_path7.join)(agentDir, "models.json");
17403
17467
  }
17404
17468
  function readModelsJson(agentDir) {
17405
17469
  const p = modelsJsonPath(agentDir);
17406
- if (!(0, import_node_fs7.existsSync)(p)) return { providers: {} };
17470
+ if (!(0, import_node_fs6.existsSync)(p)) return { providers: {} };
17407
17471
  try {
17408
- const parsed = JSON.parse((0, import_node_fs7.readFileSync)(p, "utf-8"));
17472
+ const parsed = JSON.parse((0, import_node_fs6.readFileSync)(p, "utf-8"));
17409
17473
  return { providers: parsed.providers ?? {} };
17410
17474
  } catch (e) {
17411
17475
  throw new Error(`Failed to parse ${p}: ${e.message}`);
@@ -17413,8 +17477,8 @@ function readModelsJson(agentDir) {
17413
17477
  }
17414
17478
  function writeModelsJson(agentDir, config2) {
17415
17479
  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 });
17480
+ (0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(p), { recursive: true });
17481
+ (0, import_node_fs6.writeFileSync)(p, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
17418
17482
  }
17419
17483
  function upsertProvider(config2, name, entry) {
17420
17484
  return { providers: { ...config2.providers, [name]: entry } };
@@ -17751,6 +17815,32 @@ async function logoutCommand() {
17751
17815
  console.log("[tds] Run 'tds start' to enroll again.");
17752
17816
  }
17753
17817
 
17818
+ // src/lib/version.ts
17819
+ var import_fs6 = require("fs");
17820
+ var import_path2 = require("path");
17821
+ function getVersion() {
17822
+ let dir = __dirname;
17823
+ for (let i = 0; i < 6; i++) {
17824
+ const p = (0, import_path2.join)(dir, "package.json");
17825
+ if ((0, import_fs6.existsSync)(p)) {
17826
+ try {
17827
+ const pkg = JSON.parse((0, import_fs6.readFileSync)(p, "utf-8"));
17828
+ if (pkg.version) return pkg.version;
17829
+ } catch {
17830
+ }
17831
+ }
17832
+ const parent = (0, import_path2.dirname)(dir);
17833
+ if (parent === dir) break;
17834
+ dir = parent;
17835
+ }
17836
+ return "unknown";
17837
+ }
17838
+
17839
+ // src/commands/version.ts
17840
+ async function versionCommand() {
17841
+ console.log(`tds ${getVersion()}`);
17842
+ }
17843
+
17754
17844
  // src/index.ts
17755
17845
  var COMMANDS = {
17756
17846
  start: {
@@ -17820,6 +17910,13 @@ Stop the background daemon (if running), then clear ~/.tds/machine.json so the n
17820
17910
  help: `Usage: tds status
17821
17911
 
17822
17912
  Show this machine's registration and current status.`
17913
+ },
17914
+ version: {
17915
+ run: () => versionCommand(),
17916
+ summary: "Show the tds CLI version",
17917
+ help: `Usage: tds version
17918
+
17919
+ Print the installed tds CLI version.`
17823
17920
  },
17824
17921
  provider: {
17825
17922
  run: providerCommand,
@@ -17838,6 +17935,10 @@ Subcommands:
17838
17935
  var args = process.argv.slice(2);
17839
17936
  var cmd = args[0];
17840
17937
  var rest = args.slice(1);
17938
+ if (cmd === "--version" || cmd === "-v") {
17939
+ console.log(`tds ${getVersion()}`);
17940
+ process.exit(0);
17941
+ }
17841
17942
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
17842
17943
  const topic = cmd === "help" ? rest[0] : void 0;
17843
17944
  if (topic && COMMANDS[topic]) {
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.4",
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.4",
31
+ "@todos-dev/cli-darwin-x64": "0.1.4",
32
+ "@todos-dev/cli-linux-x64": "0.1.4",
33
+ "@todos-dev/cli-linux-arm64": "0.1.4",
34
+ "@todos-dev/cli-win32-x64": "0.1.4",
35
+ "@todos-dev/cli-win32-arm64": "0.1.4"
36
36
  },
37
37
  "scripts": {
38
38
  "dev": "tsx watch src/index.ts",