@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.
- package/dist/index.js +351 -250
- 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, {
|
|
@@ -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
|
|
14155
|
-
if (!
|
|
14156
|
-
|
|
14157
|
-
_installedGroups.set(proto,
|
|
14189
|
+
let installed2 = _installedGroups.get(proto);
|
|
14190
|
+
if (!installed2) {
|
|
14191
|
+
installed2 = /* @__PURE__ */ new Set();
|
|
14192
|
+
_installedGroups.set(proto, installed2);
|
|
14158
14193
|
}
|
|
14159
|
-
if (
|
|
14194
|
+
if (installed2.has(group))
|
|
14160
14195
|
return;
|
|
14161
|
-
|
|
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/
|
|
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/
|
|
16458
|
-
|
|
16459
|
-
|
|
16460
|
-
|
|
16461
|
-
|
|
16462
|
-
|
|
16463
|
-
|
|
16464
|
-
|
|
16465
|
-
|
|
16466
|
-
|
|
16467
|
-
|
|
16468
|
-
|
|
16469
|
-
|
|
16470
|
-
|
|
16471
|
-
|
|
16472
|
-
|
|
16473
|
-
|
|
16474
|
-
|
|
16475
|
-
|
|
16476
|
-
|
|
16477
|
-
|
|
16478
|
-
|
|
16479
|
-
|
|
16480
|
-
|
|
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
|
-
|
|
16510
|
-
|
|
16511
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
16548
|
-
|
|
16549
|
-
|
|
16550
|
-
|
|
16551
|
-
|
|
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
|
-
|
|
16577
|
-
|
|
16578
|
-
|
|
16579
|
-
|
|
16580
|
-
|
|
16581
|
-
|
|
16582
|
-
|
|
16583
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16587
|
-
|
|
16588
|
-
|
|
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
|
-
|
|
16592
|
-
|
|
16593
|
-
|
|
16594
|
-
|
|
16595
|
-
|
|
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
|
-
|
|
16721
|
-
|
|
16722
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
16824
|
-
}
|
|
16825
|
-
|
|
16826
|
-
|
|
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
|
|
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
|
|
16858
|
-
|
|
16859
|
-
|
|
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.
|
|
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
|
|
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,
|
|
17470
|
+
if (!(0, import_node_fs6.existsSync)(p)) return { providers: {} };
|
|
17407
17471
|
try {
|
|
17408
|
-
const parsed = JSON.parse((0,
|
|
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,
|
|
17417
|
-
(0,
|
|
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.
|
|
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.
|
|
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.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",
|