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