@todos-dev/cli 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +208 -171
- 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
|
-
|
|
365
|
-
|
|
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(
|
|
392
|
+
const picked = resolveMachine(runnersRef.current, p.machine);
|
|
388
393
|
if ("error" in picked) return text(picked.error, { closed: true, truncated: false });
|
|
389
|
-
const
|
|
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
|
|
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 {
|
|
454
|
+
return { runner: hit };
|
|
447
455
|
}
|
|
448
|
-
if (runners.length === 1) return {
|
|
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(
|
|
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() === ""
|
|
461
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
|
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,
|
|
1214
|
+
makeClientShellTool(tunnel, conversationId, runnersRef),
|
|
1193
1215
|
...makeTodoTools(serverUrl, token, step)
|
|
1194
1216
|
];
|
|
1195
1217
|
const session = await createSession(sdk, {
|
|
@@ -16169,6 +16191,12 @@ var PollClient = class {
|
|
|
16169
16191
|
roles = [];
|
|
16170
16192
|
tunnel;
|
|
16171
16193
|
onRolesChanged;
|
|
16194
|
+
// Per-mount mirror health provider (set while the runner role is active). Rides the presence
|
|
16195
|
+
// heartbeat so the server can tell a dead mirror from a healthy one; null omits the field.
|
|
16196
|
+
getMountSync;
|
|
16197
|
+
// Desired mounts pushed back on the presence response (runner machines only) — presence is the
|
|
16198
|
+
// runner's single poll; the SyncManager forwards the set to the mirror daemon.
|
|
16199
|
+
onMounts;
|
|
16172
16200
|
// Only a builder claims and runs build steps; a runner-only machine still reports presence.
|
|
16173
16201
|
get canClaim() {
|
|
16174
16202
|
return this.roles.includes("builder");
|
|
@@ -16277,6 +16305,8 @@ ${node.sshHostKey}` : null };
|
|
|
16277
16305
|
async reportPresence() {
|
|
16278
16306
|
try {
|
|
16279
16307
|
const body = { load: this.runningTasks.size };
|
|
16308
|
+
const mountSync = this.getMountSync?.();
|
|
16309
|
+
if (mountSync) body.mountSync = mountSync;
|
|
16280
16310
|
const { node, key } = this.tunnelSnapshot();
|
|
16281
16311
|
if (key !== this.lastSentTunnelKey) {
|
|
16282
16312
|
body.sshHostKey = node?.sshHostKey ?? null;
|
|
@@ -16299,6 +16329,7 @@ ${node.sshHostKey}` : null };
|
|
|
16299
16329
|
if (Array.isArray(data?.roles)) {
|
|
16300
16330
|
this.setRoles(data.roles.filter((r) => r === "builder" || r === "runner"));
|
|
16301
16331
|
}
|
|
16332
|
+
if (Array.isArray(data?.mounts)) this.onMounts?.(data.mounts);
|
|
16302
16333
|
this.lastSentTunnelKey = key;
|
|
16303
16334
|
}
|
|
16304
16335
|
} catch {
|
|
@@ -16382,13 +16413,10 @@ function sleep(ms, signal) {
|
|
|
16382
16413
|
}
|
|
16383
16414
|
|
|
16384
16415
|
// src/SyncManager.ts
|
|
16385
|
-
var
|
|
16416
|
+
var import_node_child_process2 = require("node:child_process");
|
|
16386
16417
|
var import_node_os4 = require("node:os");
|
|
16387
16418
|
var import_node_path6 = require("node:path");
|
|
16388
16419
|
|
|
16389
|
-
// src/lib/mirror.ts
|
|
16390
|
-
var import_node_child_process2 = require("node:child_process");
|
|
16391
|
-
|
|
16392
16420
|
// src/lib/bin.ts
|
|
16393
16421
|
var import_node_fs5 = require("node:fs");
|
|
16394
16422
|
var import_node_path5 = require("node:path");
|
|
@@ -16454,145 +16482,127 @@ function createNdjsonParser(onEvent) {
|
|
|
16454
16482
|
};
|
|
16455
16483
|
}
|
|
16456
16484
|
|
|
16457
|
-
// src/lib/mirror.ts
|
|
16458
|
-
function spawnMirror(opts) {
|
|
16459
|
-
const proc = (0, import_node_child_process2.spawn)(TUNNEL_BIN, [
|
|
16460
|
-
"connect",
|
|
16461
|
-
"--server",
|
|
16462
|
-
opts.serverUrl,
|
|
16463
|
-
"--token",
|
|
16464
|
-
opts.token,
|
|
16465
|
-
"--build",
|
|
16466
|
-
opts.buildId,
|
|
16467
|
-
"--dest",
|
|
16468
|
-
opts.dest,
|
|
16469
|
-
"--default-branch",
|
|
16470
|
-
opts.defaultBranch,
|
|
16471
|
-
"--machine",
|
|
16472
|
-
opts.machineId,
|
|
16473
|
-
...opts.allowExec ? ["--allow-exec"] : []
|
|
16474
|
-
], { stdio: ["ignore", "pipe", "inherit"] });
|
|
16475
|
-
if (opts.onEvent) {
|
|
16476
|
-
const parse3 = createNdjsonParser(opts.onEvent);
|
|
16477
|
-
proc.stdout?.on("data", (d) => parse3(String(d)));
|
|
16478
|
-
}
|
|
16479
|
-
const exited = new Promise((resolve) => {
|
|
16480
|
-
proc.on("exit", (code) => resolve(code ?? 0));
|
|
16481
|
-
proc.on("error", (err) => {
|
|
16482
|
-
opts.onEvent?.({ event: "error", message: err.message });
|
|
16483
|
-
resolve(1);
|
|
16484
|
-
});
|
|
16485
|
-
});
|
|
16486
|
-
return {
|
|
16487
|
-
exited,
|
|
16488
|
-
stop: async () => {
|
|
16489
|
-
if (proc.exitCode === null) proc.kill();
|
|
16490
|
-
await exited;
|
|
16491
|
-
}
|
|
16492
|
-
};
|
|
16493
|
-
}
|
|
16494
|
-
|
|
16495
16485
|
// src/SyncManager.ts
|
|
16496
|
-
var
|
|
16486
|
+
var RESTART_MIN_MS = 1e3;
|
|
16487
|
+
var RESTART_MAX_MS = 6e4;
|
|
16497
16488
|
function expandHome(p) {
|
|
16498
16489
|
if (p === "~") return (0, import_node_os4.homedir)();
|
|
16499
16490
|
if (p.startsWith("~/") || p.startsWith("~\\")) return (0, import_node_path6.join)((0, import_node_os4.homedir)(), p.slice(2));
|
|
16500
16491
|
return p;
|
|
16501
16492
|
}
|
|
16502
16493
|
var SyncManager = class {
|
|
16503
|
-
//
|
|
16494
|
+
// by mountId
|
|
16504
16495
|
constructor(serverUrl, token, machineId) {
|
|
16505
16496
|
this.serverUrl = serverUrl;
|
|
16506
16497
|
this.token = token;
|
|
16507
16498
|
this.machineId = machineId;
|
|
16508
16499
|
}
|
|
16509
|
-
|
|
16510
|
-
|
|
16511
|
-
|
|
16500
|
+
proc = null;
|
|
16501
|
+
stopped = false;
|
|
16502
|
+
restartTimer = null;
|
|
16503
|
+
restartDelayMs = RESTART_MIN_MS;
|
|
16504
|
+
desired = [];
|
|
16505
|
+
health = /* @__PURE__ */ new Map();
|
|
16512
16506
|
start() {
|
|
16513
|
-
|
|
16514
|
-
this.running = true;
|
|
16507
|
+
this.stopped = false;
|
|
16515
16508
|
console.log(`[machine] runner: managing worktree mirrors (machineId=${this.machineId})`);
|
|
16516
|
-
|
|
16517
|
-
void this.reconcile();
|
|
16518
|
-
};
|
|
16519
|
-
tick();
|
|
16520
|
-
this.timer = setInterval(tick, RECONCILE_INTERVAL_MS);
|
|
16509
|
+
this.spawnSidecar();
|
|
16521
16510
|
}
|
|
16522
16511
|
stop() {
|
|
16523
|
-
this.
|
|
16524
|
-
if (this.
|
|
16525
|
-
|
|
16526
|
-
this.
|
|
16512
|
+
this.stopped = true;
|
|
16513
|
+
if (this.restartTimer) {
|
|
16514
|
+
clearTimeout(this.restartTimer);
|
|
16515
|
+
this.restartTimer = null;
|
|
16527
16516
|
}
|
|
16528
|
-
|
|
16517
|
+
this.proc?.kill();
|
|
16518
|
+
this.proc = null;
|
|
16529
16519
|
}
|
|
16530
|
-
//
|
|
16531
|
-
|
|
16532
|
-
|
|
16520
|
+
// Desired mounts, straight off the presence response. Declarative: resolve dests, remember the
|
|
16521
|
+
// set (a respawned daemon gets it replayed on its 'up' event), and forward it.
|
|
16522
|
+
setMounts(resolved) {
|
|
16523
|
+
this.desired = resolved.map((r) => ({
|
|
16524
|
+
mountId: r.mountId,
|
|
16525
|
+
buildId: r.buildId,
|
|
16526
|
+
dest: r.destDir ? expandHome(r.destDir) : (0, import_node_path6.join)((0, import_node_os4.homedir)(), defaultMirrorSubpath(r.repoFullName, r.seqNum)),
|
|
16527
|
+
defaultBranch: r.defaultBranch,
|
|
16528
|
+
allowExec: r.allowExec
|
|
16529
|
+
}));
|
|
16530
|
+
const keep = new Set(this.desired.map((m) => m.mountId));
|
|
16531
|
+
for (const id of [...this.health.keys()]) if (!keep.has(id)) this.health.delete(id);
|
|
16532
|
+
this.sendMounts();
|
|
16533
|
+
}
|
|
16534
|
+
// Per-mount mirror health for the presence heartbeat: age of the last completed cycle (computed
|
|
16535
|
+
// here so no other machine ever compares clocks) + last error. The web mounts panel and sync
|
|
16536
|
+
// indicator read this — presence alone can't tell a dead mirror from a healthy one.
|
|
16537
|
+
status() {
|
|
16538
|
+
const now = Date.now();
|
|
16539
|
+
return this.desired.map((m) => {
|
|
16540
|
+
const h = this.health.get(m.mountId);
|
|
16541
|
+
return {
|
|
16542
|
+
mountId: m.mountId,
|
|
16543
|
+
ageMs: h?.syncedAt ? now - h.syncedAt : null,
|
|
16544
|
+
...h?.lastError ? { error: h.lastError } : {}
|
|
16545
|
+
};
|
|
16546
|
+
});
|
|
16533
16547
|
}
|
|
16534
|
-
|
|
16548
|
+
spawnSidecar() {
|
|
16535
16549
|
try {
|
|
16536
|
-
|
|
16537
|
-
|
|
16538
|
-
|
|
16539
|
-
|
|
16540
|
-
|
|
16541
|
-
|
|
16542
|
-
return await res.json();
|
|
16550
|
+
this.proc = (0, import_node_child_process2.spawn)(
|
|
16551
|
+
TUNNEL_BIN,
|
|
16552
|
+
["mirror", "--server", this.serverUrl, "--token", this.token, "--machine", this.machineId],
|
|
16553
|
+
// stdin is piped: the desired mount set goes down as NDJSON control messages.
|
|
16554
|
+
{ stdio: ["pipe", "pipe", "inherit"] }
|
|
16555
|
+
);
|
|
16543
16556
|
} catch {
|
|
16544
|
-
|
|
16545
|
-
}
|
|
16546
|
-
}
|
|
16547
|
-
async reconcile() {
|
|
16548
|
-
if (!this.running) return;
|
|
16549
|
-
const resolved = await this.fetchMounts();
|
|
16550
|
-
if (resolved === null) return;
|
|
16551
|
-
if (!this.running) return;
|
|
16552
|
-
const desired = new Map(resolved.map((r) => [r.mountId, r]));
|
|
16553
|
-
for (const [mountId, t] of [...this.tunnels]) {
|
|
16554
|
-
const want = desired.get(mountId);
|
|
16555
|
-
if (!want || want.buildId !== t.buildId || want.allowExec !== t.allowExec) this.teardown(mountId);
|
|
16556
|
-
}
|
|
16557
|
-
for (const r of resolved) {
|
|
16558
|
-
if (!this.tunnels.has(r.mountId)) this.startTunnel(r);
|
|
16559
|
-
}
|
|
16560
|
-
}
|
|
16561
|
-
startTunnel(r) {
|
|
16562
|
-
const dest = r.destDir ? expandHome(r.destDir) : this.defaultDest(r);
|
|
16563
|
-
try {
|
|
16564
|
-
(0, import_node_fs6.mkdirSync)(dest, { recursive: true });
|
|
16565
|
-
} catch (e) {
|
|
16566
|
-
console.warn(`[machine] runner: cannot create ${dest}: ${e.message} \u2014 skipping mount ${r.mountId}`);
|
|
16557
|
+
console.warn("[machine] runner: tds-tunnel not available \u2014 mirroring disabled");
|
|
16567
16558
|
return;
|
|
16568
16559
|
}
|
|
16569
|
-
|
|
16570
|
-
|
|
16571
|
-
|
|
16572
|
-
|
|
16573
|
-
|
|
16574
|
-
|
|
16575
|
-
|
|
16576
|
-
machineId: this.machineId,
|
|
16577
|
-
allowExec: r.allowExec,
|
|
16578
|
-
onEvent: (e) => {
|
|
16579
|
-
if (e.event === "error") console.warn(`[machine] runner: mount ${r.mountId}: ${e.message ?? "error"}`);
|
|
16580
|
-
else if (e.event === "synced" && (e.changed || e.deleted)) {
|
|
16581
|
-
console.log(`[machine] runner: #${r.seqNum} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
|
|
16582
|
-
}
|
|
16583
|
-
}
|
|
16560
|
+
this.proc.on("error", () => {
|
|
16561
|
+
console.warn("[machine] runner: mirror daemon failed to start \u2014 mirroring disabled");
|
|
16562
|
+
this.proc = null;
|
|
16563
|
+
});
|
|
16564
|
+
this.proc.on("exit", () => {
|
|
16565
|
+
this.proc = null;
|
|
16566
|
+
this.scheduleRestart();
|
|
16584
16567
|
});
|
|
16585
|
-
this.
|
|
16586
|
-
|
|
16587
|
-
const cur = this.tunnels.get(r.mountId);
|
|
16588
|
-
if (cur && cur.handle === handle) this.tunnels.delete(r.mountId);
|
|
16568
|
+
this.proc.stdout?.on("data", (d) => this.onStdout(String(d)));
|
|
16569
|
+
this.proc.stdin?.on("error", () => {
|
|
16589
16570
|
});
|
|
16590
16571
|
}
|
|
16591
|
-
|
|
16592
|
-
|
|
16593
|
-
|
|
16594
|
-
this.
|
|
16595
|
-
|
|
16572
|
+
scheduleRestart() {
|
|
16573
|
+
if (this.stopped || this.restartTimer) return;
|
|
16574
|
+
const delay2 = this.restartDelayMs;
|
|
16575
|
+
this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS);
|
|
16576
|
+
console.warn(`[machine] runner: mirror daemon exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
|
|
16577
|
+
this.restartTimer = setTimeout(() => {
|
|
16578
|
+
this.restartTimer = null;
|
|
16579
|
+
this.spawnSidecar();
|
|
16580
|
+
}, delay2);
|
|
16581
|
+
}
|
|
16582
|
+
onStdout = createNdjsonParser((e) => {
|
|
16583
|
+
if (e.event === "up") {
|
|
16584
|
+
this.restartDelayMs = RESTART_MIN_MS;
|
|
16585
|
+
this.sendMounts();
|
|
16586
|
+
} else if (e.event === "synced" && e.mount) {
|
|
16587
|
+
this.health.set(e.mount, { syncedAt: Date.now(), lastError: null });
|
|
16588
|
+
if (e.changed || e.deleted) console.log(`[machine] runner: mount ${e.mount} \u2193 ${e.changed ?? 0} changed, ${e.deleted ?? 0} deleted`);
|
|
16589
|
+
} else if (e.event === "error") {
|
|
16590
|
+
if (e.mount) {
|
|
16591
|
+
const h = this.health.get(e.mount);
|
|
16592
|
+
this.health.set(e.mount, { syncedAt: h?.syncedAt ?? 0, lastError: e.message ?? "error" });
|
|
16593
|
+
console.warn(`[machine] runner: mount ${e.mount}: ${e.message ?? "error"}`);
|
|
16594
|
+
} else {
|
|
16595
|
+
console.warn(`[machine] runner: ${e.message ?? "error"}`);
|
|
16596
|
+
}
|
|
16597
|
+
}
|
|
16598
|
+
});
|
|
16599
|
+
sendMounts() {
|
|
16600
|
+
const stdin = this.proc?.stdin;
|
|
16601
|
+
if (!stdin || stdin.destroyed) return;
|
|
16602
|
+
try {
|
|
16603
|
+
stdin.write(JSON.stringify({ cmd: "mounts", mounts: this.desired }) + "\n");
|
|
16604
|
+
} catch {
|
|
16605
|
+
}
|
|
16596
16606
|
}
|
|
16597
16607
|
};
|
|
16598
16608
|
|
|
@@ -16706,8 +16716,8 @@ async function syncModelsToServer(serverUrl, token, runtime) {
|
|
|
16706
16716
|
// src/lib/tunnel.ts
|
|
16707
16717
|
var import_node_child_process3 = require("node:child_process");
|
|
16708
16718
|
var SHELL_BUFFER_CAP = 256 * 1024;
|
|
16709
|
-
var
|
|
16710
|
-
var
|
|
16719
|
+
var RESTART_MIN_MS2 = 1e3;
|
|
16720
|
+
var RESTART_MAX_MS2 = 6e4;
|
|
16711
16721
|
var EMPTY_BUFFER = Buffer.alloc(0);
|
|
16712
16722
|
var TunnelServer = class _TunnelServer {
|
|
16713
16723
|
constructor(serverUrl, token) {
|
|
@@ -16719,7 +16729,7 @@ var TunnelServer = class _TunnelServer {
|
|
|
16719
16729
|
onChange = null;
|
|
16720
16730
|
stopped = false;
|
|
16721
16731
|
restartTimer = null;
|
|
16722
|
-
restartDelayMs =
|
|
16732
|
+
restartDelayMs = RESTART_MIN_MS2;
|
|
16723
16733
|
// Reverse shells, keyed by (build, machine) — a build mirrored onto several runners has one shell
|
|
16724
16734
|
// per machine. Output is buffered even with no live subscriber so output a backgrounded command
|
|
16725
16735
|
// emits between tool calls survives until the next drain.
|
|
@@ -16764,7 +16774,7 @@ var TunnelServer = class _TunnelServer {
|
|
|
16764
16774
|
scheduleRestart() {
|
|
16765
16775
|
if (this.stopped || this.restartTimer) return;
|
|
16766
16776
|
const delay2 = this.restartDelayMs;
|
|
16767
|
-
this.restartDelayMs = Math.min(this.restartDelayMs * 2,
|
|
16777
|
+
this.restartDelayMs = Math.min(this.restartDelayMs * 2, RESTART_MAX_MS2);
|
|
16768
16778
|
console.warn(`[tunnel] sidecar exited \u2014 restarting in ${Math.round(delay2 / 1e3)}s`);
|
|
16769
16779
|
this.restartTimer = setTimeout(() => {
|
|
16770
16780
|
this.restartTimer = null;
|
|
@@ -16780,20 +16790,24 @@ var TunnelServer = class _TunnelServer {
|
|
|
16780
16790
|
onStdout = createNdjsonParser((evt) => {
|
|
16781
16791
|
if (evt.event === "ready" && evt.tailnetAddr && evt.sshHostKey) {
|
|
16782
16792
|
this.node = { tailnetAddr: evt.tailnetAddr, sshHostKey: evt.sshHostKey };
|
|
16783
|
-
this.restartDelayMs =
|
|
16793
|
+
this.restartDelayMs = RESTART_MIN_MS2;
|
|
16784
16794
|
console.log(`[tunnel] ready (tailnet ${evt.tailnetAddr})`);
|
|
16785
16795
|
this.onChange?.();
|
|
16786
16796
|
} else if ((evt.event === "shell-opened" || evt.event === "shell-output" || evt.event === "shell-closed") && evt.build) {
|
|
16787
16797
|
const s = this.shellState(_TunnelServer.shellKey(evt.build, evt.machine ?? ""));
|
|
16788
16798
|
if (evt.event === "shell-opened") {
|
|
16799
|
+
s.connected = true;
|
|
16789
16800
|
s.closed = false;
|
|
16801
|
+
s.gen++;
|
|
16790
16802
|
} else if (evt.event === "shell-output") {
|
|
16803
|
+
s.connected = true;
|
|
16791
16804
|
s.closed = false;
|
|
16792
16805
|
let next = Buffer.concat([s.buffer, Buffer.from(evt.data ?? "", "base64")]);
|
|
16793
16806
|
if (next.length > SHELL_BUFFER_CAP) next = next.subarray(next.length - SHELL_BUFFER_CAP);
|
|
16794
16807
|
s.buffer = next;
|
|
16795
16808
|
for (const l of s.listeners) l.onActivity();
|
|
16796
16809
|
} else {
|
|
16810
|
+
s.connected = false;
|
|
16797
16811
|
s.closed = true;
|
|
16798
16812
|
for (const l of [...s.listeners]) l.onClosed();
|
|
16799
16813
|
}
|
|
@@ -16822,8 +16836,20 @@ var TunnelServer = class _TunnelServer {
|
|
|
16822
16836
|
shellSendInput(build, machine, data) {
|
|
16823
16837
|
return this.writeControl({ cmd: "shell-input", build, machine, data: Buffer.from(data, "utf8").toString("base64") });
|
|
16824
16838
|
}
|
|
16825
|
-
|
|
16826
|
-
|
|
16839
|
+
// Whether the client's shell channel is currently registered sidecar-side. undefined = no event
|
|
16840
|
+
// seen yet for this key (a client may still be connected — input discovers it); false = it was
|
|
16841
|
+
// there and dropped (or input bounced), so the tool fails fast instead of timing out.
|
|
16842
|
+
shellConnected(build, machine) {
|
|
16843
|
+
return this.shells.get(_TunnelServer.shellKey(build, machine))?.connected;
|
|
16844
|
+
}
|
|
16845
|
+
// True once per PTY replacement: the shell reconnected (new session, prior state lost) since the
|
|
16846
|
+
// tool last consumed this flag. The first-ever open is not a "reconnect".
|
|
16847
|
+
consumeShellReconnect(build, machine) {
|
|
16848
|
+
const s = this.shells.get(_TunnelServer.shellKey(build, machine));
|
|
16849
|
+
if (!s) return false;
|
|
16850
|
+
const reconnected = s.reportedGen >= 1 && s.gen > s.reportedGen;
|
|
16851
|
+
s.reportedGen = s.gen;
|
|
16852
|
+
return reconnected;
|
|
16827
16853
|
}
|
|
16828
16854
|
subscribeShell(build, machine, l) {
|
|
16829
16855
|
const s = this.shellState(_TunnelServer.shellKey(build, machine));
|
|
@@ -16834,29 +16860,32 @@ var TunnelServer = class _TunnelServer {
|
|
|
16834
16860
|
}
|
|
16835
16861
|
// Return and clear a (build, machine) shell's buffered PTY output (UTF-8). The tool calls this
|
|
16836
16862
|
// after a quiet period to get everything the shell printed since the last drain; the closed
|
|
16837
|
-
// marker is cleared too so the next session for this key starts fresh.
|
|
16863
|
+
// marker is cleared too so the next session for this key starts fresh. The entry itself is kept —
|
|
16864
|
+
// connected/gen are cross-call memory (tiny, bounded by builds this daemon has served).
|
|
16838
16865
|
drainShell(build, machine) {
|
|
16839
|
-
const
|
|
16840
|
-
const s = this.shells.get(key);
|
|
16866
|
+
const s = this.shells.get(_TunnelServer.shellKey(build, machine));
|
|
16841
16867
|
if (!s) return "";
|
|
16842
16868
|
const out = s.buffer.toString("utf8");
|
|
16843
16869
|
s.buffer = EMPTY_BUFFER;
|
|
16844
16870
|
s.closed = false;
|
|
16845
|
-
if (s.listeners.size === 0) this.shells.delete(key);
|
|
16846
16871
|
return out;
|
|
16847
16872
|
}
|
|
16848
16873
|
shellState(key) {
|
|
16849
16874
|
let s = this.shells.get(key);
|
|
16850
16875
|
if (!s) {
|
|
16851
|
-
s = { buffer: EMPTY_BUFFER, closed: false, listeners: /* @__PURE__ */ new Set() };
|
|
16876
|
+
s = { buffer: EMPTY_BUFFER, closed: false, gen: 0, reportedGen: 0, listeners: /* @__PURE__ */ new Set() };
|
|
16852
16877
|
this.shells.set(key, s);
|
|
16853
16878
|
}
|
|
16854
16879
|
return s;
|
|
16855
16880
|
}
|
|
16881
|
+
// Sidecar death takes every client connection with it. Entries survive (not cleared) so the
|
|
16882
|
+
// shells' reconnect generations still tick when clients re-register against the respawned sidecar.
|
|
16856
16883
|
closeAllShells() {
|
|
16857
|
-
const
|
|
16858
|
-
|
|
16859
|
-
|
|
16884
|
+
for (const s of this.shells.values()) {
|
|
16885
|
+
s.connected = false;
|
|
16886
|
+
s.closed = true;
|
|
16887
|
+
for (const l of [...s.listeners]) l.onClosed();
|
|
16888
|
+
}
|
|
16860
16889
|
}
|
|
16861
16890
|
current() {
|
|
16862
16891
|
return this.node;
|
|
@@ -17049,16 +17078,24 @@ async function runWorker(config2, serverUrl) {
|
|
|
17049
17078
|
tunnel = void 0;
|
|
17050
17079
|
client.setTunnel(void 0);
|
|
17051
17080
|
};
|
|
17081
|
+
let lastMounts = [];
|
|
17082
|
+
client.onMounts = (mounts) => {
|
|
17083
|
+
lastMounts = mounts;
|
|
17084
|
+
syncManager?.setMounts(mounts);
|
|
17085
|
+
};
|
|
17052
17086
|
const enableRunner = () => {
|
|
17053
17087
|
if (syncManager) return;
|
|
17054
17088
|
syncManager = new SyncManager(serverUrl, cfg.token, cfg.machineId);
|
|
17055
17089
|
syncManager.start();
|
|
17090
|
+
syncManager.setMounts(lastMounts);
|
|
17091
|
+
client.getMountSync = () => syncManager?.status() ?? null;
|
|
17056
17092
|
};
|
|
17057
17093
|
const disableRunner = () => {
|
|
17058
17094
|
if (!syncManager) return;
|
|
17059
17095
|
console.log("[machine] Disabling runner: stopping worktree mirrors.");
|
|
17060
17096
|
syncManager.stop();
|
|
17061
17097
|
syncManager = void 0;
|
|
17098
|
+
client.getMountSync = void 0;
|
|
17062
17099
|
};
|
|
17063
17100
|
async function applyRoles(roles) {
|
|
17064
17101
|
if (roles.includes("builder")) {
|
|
@@ -17396,16 +17433,16 @@ function findPreset(presets, id) {
|
|
|
17396
17433
|
}
|
|
17397
17434
|
|
|
17398
17435
|
// src/lib/modelsJson.ts
|
|
17399
|
-
var
|
|
17436
|
+
var import_node_fs6 = require("node:fs");
|
|
17400
17437
|
var import_node_path7 = require("node:path");
|
|
17401
17438
|
function modelsJsonPath(agentDir) {
|
|
17402
17439
|
return (0, import_node_path7.join)(agentDir, "models.json");
|
|
17403
17440
|
}
|
|
17404
17441
|
function readModelsJson(agentDir) {
|
|
17405
17442
|
const p = modelsJsonPath(agentDir);
|
|
17406
|
-
if (!(0,
|
|
17443
|
+
if (!(0, import_node_fs6.existsSync)(p)) return { providers: {} };
|
|
17407
17444
|
try {
|
|
17408
|
-
const parsed = JSON.parse((0,
|
|
17445
|
+
const parsed = JSON.parse((0, import_node_fs6.readFileSync)(p, "utf-8"));
|
|
17409
17446
|
return { providers: parsed.providers ?? {} };
|
|
17410
17447
|
} catch (e) {
|
|
17411
17448
|
throw new Error(`Failed to parse ${p}: ${e.message}`);
|
|
@@ -17413,8 +17450,8 @@ function readModelsJson(agentDir) {
|
|
|
17413
17450
|
}
|
|
17414
17451
|
function writeModelsJson(agentDir, config2) {
|
|
17415
17452
|
const p = modelsJsonPath(agentDir);
|
|
17416
|
-
(0,
|
|
17417
|
-
(0,
|
|
17453
|
+
(0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(p), { recursive: true });
|
|
17454
|
+
(0, import_node_fs6.writeFileSync)(p, JSON.stringify(config2, null, 2) + "\n", { mode: 384 });
|
|
17418
17455
|
}
|
|
17419
17456
|
function upsertProvider(config2, name, entry) {
|
|
17420
17457
|
return { providers: { ...config2.providers, [name]: entry } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@todos-dev/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"bin": {
|
|
5
5
|
"tds": "dist/index.js"
|
|
6
6
|
},
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
"@tds/types": "0.1.0"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"@todos-dev/cli-darwin-arm64": "0.1.
|
|
31
|
-
"@todos-dev/cli-darwin-x64": "0.1.
|
|
32
|
-
"@todos-dev/cli-linux-x64": "0.1.
|
|
33
|
-
"@todos-dev/cli-linux-arm64": "0.1.
|
|
34
|
-
"@todos-dev/cli-win32-x64": "0.1.
|
|
35
|
-
"@todos-dev/cli-win32-arm64": "0.1.
|
|
30
|
+
"@todos-dev/cli-darwin-arm64": "0.1.3",
|
|
31
|
+
"@todos-dev/cli-darwin-x64": "0.1.3",
|
|
32
|
+
"@todos-dev/cli-linux-x64": "0.1.3",
|
|
33
|
+
"@todos-dev/cli-linux-arm64": "0.1.3",
|
|
34
|
+
"@todos-dev/cli-win32-x64": "0.1.3",
|
|
35
|
+
"@todos-dev/cli-win32-arm64": "0.1.3"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"dev": "tsx watch src/index.ts",
|