teamloop 0.1.0

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.
@@ -0,0 +1,1615 @@
1
+ #!/usr/bin/env node
2
+
3
+ import os from "node:os";
4
+ import net from "node:net";
5
+ import tls from "node:tls";
6
+ import path from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { execFileSync, spawn } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ const DEFAULT_URL = "https://teamloop.kangkona.workers.dev";
13
+ const DEFAULT_CAPABILITIES = ["local repo", "tests", "diff summary"];
14
+ const DEFAULT_AGENT_TIMEOUT_MS = 30 * 60 * 1000;
15
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const DEFAULT_WATCH_INTERVAL_MS = 15 * 1000;
17
+ const OUTPUT_CAPTURE_LIMIT = 64 * 1024;
18
+ const SERVICE_LABEL_PREFIX = "com.teamloop.runner";
19
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
20
+
21
+ main().catch((error) => {
22
+ console.error(`TeamLoop runner error: ${error.message}`);
23
+ if (error.cause) console.error(`Cause: ${error.cause.message ?? error.cause}`);
24
+ process.exit(1);
25
+ });
26
+
27
+ async function main() {
28
+ const [command = "help", ...args] = process.argv.slice(2);
29
+ const flags = parseFlags(args);
30
+
31
+ if (command === "connect") return connect(flags);
32
+ if (command === "poll") return poll(flags);
33
+ if (command === "work") return work(flags);
34
+ if (command === "setup") return setupService(flags);
35
+ if (command === "start") return startService(flags);
36
+ if (command === "stop") return stopService(flags);
37
+ if (command === "uninstall") return uninstallService(flags);
38
+ if (command === "status") return statusService(flags);
39
+ if (command === "logs") return logsService(flags);
40
+ if (command === "resume") return resumeWork(flags);
41
+ if (command === "doctor") return doctor(flags);
42
+ if (command === "heartbeat") return heartbeat(flags);
43
+ if (command === "evidence") return evidence(flags);
44
+ if (command === "complete") return complete(flags);
45
+ return help();
46
+ }
47
+
48
+ async function connect(flags) {
49
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
50
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
51
+ const pair = flags.pair ?? process.env.TEAMLOOP_PAIR;
52
+ if (!pair) throw new Error("Missing --pair. Create a pairing token in TeamLoop first.");
53
+
54
+ const name = flags.name ?? process.env.TEAMLOOP_RUNNER_NAME ?? `${os.hostname()} / Codex`;
55
+ const agentKind = flags.agent ?? flags.agentKind ?? process.env.TEAMLOOP_AGENT ?? "codex";
56
+ const capabilities = normalizeCapabilities(flags.capabilities ?? process.env.TEAMLOOP_CAPABILITIES);
57
+ const repoPath = resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
58
+
59
+ console.log(`Connecting ${name} to ${baseUrl}...`);
60
+ console.log(`Repo: ${repoPath}`);
61
+ const result = await request(`${baseUrl}/api/runners/register`, {
62
+ method: "POST",
63
+ body: { pair, name, agentKind, capabilities, repoPath },
64
+ proxy
65
+ });
66
+
67
+ if (!result.ok) throw new Error(result.error ?? "Runner registration failed.");
68
+
69
+ console.log("Connected.");
70
+ console.log(`Runner ID: ${result.runner.id}`);
71
+ console.log(`Workspace ID: ${result.runner.workspaceId}`);
72
+ console.log("");
73
+ console.log("Next:");
74
+ console.log(` pnpm --dir /Users/mingyoo/repos/teamloop runner:poll -- --url ${baseUrl} --runner ${result.runner.id}`);
75
+ }
76
+
77
+ async function poll(flags) {
78
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
79
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
80
+ const runnerId = flags.runner ?? flags.runnerId ?? process.env.TEAMLOOP_RUNNER_ID;
81
+ if (!runnerId) throw new Error("Missing --runner. Run connect first and use the printed Runner ID.");
82
+
83
+ const result = await claimNextTask(baseUrl, proxy, runnerId);
84
+ if (!result.task) {
85
+ console.log("No queued task yet. Create a task in TeamLoop, then poll again.");
86
+ return;
87
+ }
88
+
89
+ console.log(`Task: ${result.task.title}`);
90
+ console.log(`Task ID: ${result.task.taskId}`);
91
+ console.log(`Description: ${result.task.description}`);
92
+ console.log("");
93
+ console.log("Definition of done:");
94
+ for (const item of result.task.definitionOfDone) console.log(` - ${item}`);
95
+ console.log("");
96
+ console.log("Claim:");
97
+ console.log(JSON.stringify(result.claim, null, 2));
98
+ const repoPath = resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
99
+ const claimPath = saveClaim(repoPath, result.claim);
100
+ console.log("");
101
+ console.log(`Saved claim: ${claimPath}`);
102
+ console.log("");
103
+ console.log("Next:");
104
+ console.log(` pnpm --dir ${repoPath} runner:evidence -- --claim ${claimPath} --kind test --status pass --title "Verification" --summary "Describe the passing check"`);
105
+ console.log(` pnpm --dir ${repoPath} runner:complete -- --claim ${claimPath} --summary "Describe what changed"`);
106
+ }
107
+
108
+ async function work(flags) {
109
+ flags = withRunnerConfig(flags);
110
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
111
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
112
+ const runnerId = flags.runner ?? flags.runnerId ?? process.env.TEAMLOOP_RUNNER_ID;
113
+ if (!runnerId) throw new Error("Missing --runner. Run connect first and use the printed Runner ID.");
114
+
115
+ const repoPath = resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
116
+ const watch = flagEnabled(flags.watch);
117
+ const intervalMs = parseDurationMs(flags.interval ?? process.env.TEAMLOOP_WORK_INTERVAL, DEFAULT_WATCH_INTERVAL_MS);
118
+ const lock = acquireWorkLock(repoPath, runnerId);
119
+ let stopping = false;
120
+ const stop = () => {
121
+ stopping = true;
122
+ console.log("Stopping TeamLoop worker after the current step...");
123
+ };
124
+
125
+ process.once("SIGINT", stop);
126
+ process.once("SIGTERM", stop);
127
+
128
+ try {
129
+ console.log(`TeamLoop work runner started in ${watch ? "watch" : "once"} mode.`);
130
+ console.log(`Runner: ${runnerId}`);
131
+ console.log(`Repo: ${repoPath}`);
132
+ console.log(`URL: ${baseUrl}`);
133
+
134
+ do {
135
+ try {
136
+ await flushOutbox(repoPath, baseUrl, proxy);
137
+ await request(`${baseUrl}/api/runners/${encodeURIComponent(runnerId)}/heartbeat`, {
138
+ method: "POST",
139
+ body: { repoPath },
140
+ proxy
141
+ });
142
+ const didWork = await workOnce({ baseUrl, proxy, runnerId, repoPath, flags });
143
+ if (!watch || stopping) break;
144
+ if (!didWork) {
145
+ console.log(`No queued task. Waiting ${Math.round(intervalMs / 1000)}s...`);
146
+ await sleep(intervalMs);
147
+ }
148
+ } catch (error) {
149
+ if (!watch) throw error;
150
+ console.error(`TeamLoop worker iteration failed: ${error.message}`);
151
+ await sleep(intervalMs);
152
+ }
153
+ } while (!stopping);
154
+ } finally {
155
+ process.removeListener("SIGINT", stop);
156
+ process.removeListener("SIGTERM", stop);
157
+ lock.release();
158
+ }
159
+ }
160
+
161
+ async function setupService(flags) {
162
+ const saved = saveRunnerConfig(flags);
163
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
164
+ installOrUpdateService(definition);
165
+ console.log("TeamLoop runner service configured and started.");
166
+ printServiceDefinition(definition);
167
+ }
168
+
169
+ async function startService(flags) {
170
+ const saved = flags.runner && flags.repo ? saveRunnerConfig(flags) : loadRunnerConfigRequired(flags);
171
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
172
+ installOrUpdateService(definition);
173
+ console.log("TeamLoop runner service started.");
174
+ printServiceDefinition(definition);
175
+ }
176
+
177
+ async function stopService(flags) {
178
+ const saved = loadRunnerConfigRequired(flags);
179
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
180
+ stopPlatformService(definition);
181
+ console.log("TeamLoop runner service stopped.");
182
+ printServiceDefinition(definition);
183
+ }
184
+
185
+ async function uninstallService(flags) {
186
+ const saved = loadRunnerConfigRequired(flags);
187
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
188
+ stopPlatformService(definition);
189
+ removePlatformService(definition);
190
+ console.log("TeamLoop runner service uninstalled. Config and logs were left in place.");
191
+ printServiceDefinition(definition);
192
+ }
193
+
194
+ async function statusService(flags) {
195
+ const saved = loadRunnerConfigOptional(flags);
196
+ if (!saved) {
197
+ console.log("TeamLoop runner service is not configured for this repo.");
198
+ console.log(`Expected config: ${runnerConfigPath(flags, path.resolve(flags.repo ?? process.cwd()))}`);
199
+ return;
200
+ }
201
+
202
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
203
+ const status = platformServiceStatus(definition);
204
+ console.log(`Configured: yes`);
205
+ console.log(`Platform: ${status.platform}`);
206
+ console.log(`Installed: ${status.installed ? "yes" : "no"}`);
207
+ console.log(`Running: ${status.running ? "yes" : "no"}`);
208
+ if (status.pid) console.log(`PID: ${status.pid}`);
209
+ printServiceDefinition(definition);
210
+ printOutboxStatus(saved.config.repo);
211
+ }
212
+
213
+ async function logsService(flags) {
214
+ const saved = loadRunnerConfigRequired(flags);
215
+ const definition = buildServiceDefinition(saved.configPath, saved.config);
216
+ const lines = Number(flags.lines ?? 80);
217
+ console.log(`Log: ${definition.logPath}`);
218
+ console.log("");
219
+ console.log(tailFile(definition.logPath, Number.isFinite(lines) ? lines : 80));
220
+ }
221
+
222
+ async function resumeWork(flags) {
223
+ flags = withRunnerConfig(flags);
224
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
225
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
226
+ const repoPath = resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
227
+ const result = await flushOutbox(repoPath, baseUrl, proxy, { verbose: true });
228
+ clearCurrentWorkIfOutboxEmpty(repoPath);
229
+ console.log(`Resume complete. Sent ${result.sent} pending item(s); ${pendingOutboxCount(repoPath)} remain.`);
230
+ }
231
+
232
+ async function doctor(flags) {
233
+ flags = withRunnerConfig(flags, { optional: true });
234
+ const repoValue = flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd();
235
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
236
+ const agentKind = flags.agent ?? flags.agentKind ?? process.env.TEAMLOOP_AGENT ?? "codex";
237
+ let repoPath;
238
+ console.log("TeamLoop runner doctor");
239
+ console.log(`URL: ${baseUrl}`);
240
+ try {
241
+ repoPath = resolveRepoPath(repoValue);
242
+ console.log(`Repo: ${repoPath}`);
243
+ } catch (error) {
244
+ console.log(`Repo: FAIL - ${error.message}`);
245
+ }
246
+
247
+ console.log(`Node: ${process.version} (${process.execPath})`);
248
+ console.log(`PATH: ${process.env.PATH ?? ""}`);
249
+ console.log(`Proxy: ${proxyForUrl(baseUrl, flags.proxy ?? process.env.TEAMLOOP_PROXY) ?? "none"}`);
250
+
251
+ if (repoPath) {
252
+ const config = loadRunnerConfigOptional({ ...flags, repo: repoPath });
253
+ console.log(`Config: ${config?.configPath ?? "not found"}`);
254
+ printOutboxStatus(repoPath);
255
+ const testCommand = verificationCommand(flags.test, detectTestCommand(repoPath));
256
+ console.log(`Detected test command: ${testCommand ?? "none"}`);
257
+ }
258
+
259
+ if (flags.agentCommand && !flagEnabled(flags.allowAgentCommand)) {
260
+ console.log("Custom agent command: FAIL - pass --allow-agent-command to acknowledge local command execution.");
261
+ } else if (flags.agentCommand) {
262
+ console.log(`Custom agent command: allowed (${flags.agentCommand})`);
263
+ }
264
+
265
+ const agentCommand = agentKind === "claude-code" || agentKind === "claude" ? "claude" : agentKind === "codex" ? "codex" : agentKind;
266
+ if (agentKind === "noop") {
267
+ console.log("Agent: noop");
268
+ } else if (commandExists(agentCommand)) {
269
+ const version = await runProcess(agentCommand, ["--version"], { cwd: repoPath ?? process.cwd(), timeoutMs: 5000 });
270
+ console.log(`Agent: ${agentCommand} found`);
271
+ console.log(`Agent version: ${truncateText((version.stdout || version.stderr || "").trim(), 500) || "unknown"}`);
272
+ } else {
273
+ console.log(`Agent: FAIL - ${agentCommand} not found on PATH`);
274
+ }
275
+
276
+ if (flagEnabled(flags.network) && flags.runner) {
277
+ await request(`${baseUrl}/api/runners/${encodeURIComponent(flags.runner)}/heartbeat`, {
278
+ method: "POST",
279
+ body: repoPath ? { repoPath } : undefined,
280
+ proxy: flags.proxy ?? process.env.TEAMLOOP_PROXY
281
+ });
282
+ console.log("Network heartbeat: ok");
283
+ }
284
+ }
285
+
286
+ async function heartbeat(flags) {
287
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
288
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
289
+ const runnerId = flags.runner ?? flags.runnerId ?? process.env.TEAMLOOP_RUNNER_ID;
290
+ const repoPath = flags.repo || process.env.TEAMLOOP_REPO ? resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO) : undefined;
291
+ if (!runnerId) throw new Error("Missing --runner. Run connect first and use the printed Runner ID.");
292
+
293
+ await request(`${baseUrl}/api/runners/${encodeURIComponent(runnerId)}/heartbeat`, { method: "POST", body: repoPath ? { repoPath } : undefined, proxy });
294
+ console.log("Heartbeat accepted.");
295
+ }
296
+
297
+ async function evidence(flags) {
298
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
299
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
300
+ const claim = readClaim(flags);
301
+ const title = flags.title ?? "Runner evidence";
302
+ const kind = flags.kind ?? "manual_note";
303
+ const status = flags.status ?? "info";
304
+ const body = {
305
+ ...claim,
306
+ idempotencyKey: flags.idempotency ?? buildIdempotencyKey([claim.runnerId, claim.taskId, "evidence", kind, title]),
307
+ kind,
308
+ status,
309
+ title,
310
+ summary: flags.summary ?? title,
311
+ rawRef: flags.rawRef
312
+ };
313
+ const result = await request(`${baseUrl}/api/runs/${encodeURIComponent(claim.runId)}/evidence`, { method: "POST", body, proxy });
314
+ if (!result.ok) throw new Error(result.error ?? "Evidence post failed.");
315
+ console.log(`Evidence accepted: ${result.evidence.id}`);
316
+ }
317
+
318
+ async function complete(flags) {
319
+ const baseUrl = normalizeUrl(flags.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL);
320
+ const proxy = flags.proxy ?? process.env.TEAMLOOP_PROXY;
321
+ const claim = readClaim(flags);
322
+ const body = {
323
+ ...claim,
324
+ idempotencyKey: flags.idempotency ?? buildIdempotencyKey([claim.runnerId, claim.taskId, "complete"]),
325
+ status: flags.status ?? "success",
326
+ summary: flags.summary ?? "Runner completed the task.",
327
+ errorType: flags.errorType,
328
+ failureCode: flags.failureCode
329
+ };
330
+ const result = await request(`${baseUrl}/api/runs/${encodeURIComponent(claim.runId)}/complete`, { method: "POST", body, proxy });
331
+ if (!result.ok) throw new Error(result.error ?? "Completion post failed.");
332
+ console.log(`Run completed: ${result.envelope.runId}`);
333
+ console.log(`Readiness status: ${result.state.reviews.find((review) => review.runId === claim.runId)?.readiness ?? "pending"}`);
334
+ }
335
+
336
+ async function claimNextTask(baseUrl, proxy, runnerId) {
337
+ return request(`${baseUrl}/api/runners/${encodeURIComponent(runnerId)}/next-task`, { method: "GET", proxy });
338
+ }
339
+
340
+ async function workOnce(context) {
341
+ const { baseUrl, proxy, runnerId, repoPath, flags } = context;
342
+ const result = await claimNextTask(baseUrl, proxy, runnerId);
343
+ if (!result.task) return false;
344
+
345
+ const claimPath = saveClaim(repoPath, result.claim);
346
+ const workPath = saveCurrentWork(repoPath, result);
347
+ console.log(`Claimed task: ${result.task.title}`);
348
+ console.log(`Claim: ${claimPath}`);
349
+
350
+ const agentKind = flags.agent ?? flags.agentKind ?? process.env.TEAMLOOP_AGENT ?? "codex";
351
+ const task = result.task;
352
+ const claim = result.claim;
353
+ const logParts = [];
354
+ let finalStatus = "success";
355
+ let failureCode;
356
+ let errorType;
357
+ let completionSummary = "Runner completed the task and attached evidence.";
358
+
359
+ try {
360
+ await postRunEvent(baseUrl, proxy, claim, {
361
+ type: "agent_started",
362
+ title: "Loop executor started",
363
+ summary: `TeamLoop started ${agentKind} for ${task.title}.`,
364
+ metadata: { repoPath, agentKind }
365
+ }, repoPath);
366
+
367
+ const agentPrompt = buildAgentPrompt(task, repoPath);
368
+ const agentResult = await runAgent(agentKind, agentPrompt, repoPath, flags);
369
+ logParts.push(formatCommandLog("agent", agentResult));
370
+
371
+ const agentLogRef = writeWorkLog(repoPath, claim.runId, logParts.join("\n\n"));
372
+ await postEvidence(baseUrl, proxy, claim, {
373
+ kind: "agent_message",
374
+ status: agentResult.ok ? "info" : "fail",
375
+ title: `${agentLabel(agentKind)} result`,
376
+ summary: summarizeAgentResult(agentResult),
377
+ rawRef: agentLogRef
378
+ }, repoPath);
379
+
380
+ if (!agentResult.ok) {
381
+ finalStatus = "failed";
382
+ failureCode = agentResult.failureCode ?? "result_missing";
383
+ errorType = agentResult.timedOut ? "timeout" : "agent";
384
+ completionSummary = `${agentLabel(agentKind)} did not complete successfully. Evidence and logs are attached.`;
385
+ }
386
+
387
+ const diffResult = await collectDiff(repoPath);
388
+ logParts.push(formatCommandLog("diff", diffResult));
389
+ const diffLogRef = writeWorkLog(repoPath, claim.runId, logParts.join("\n\n"));
390
+ await postEvidence(baseUrl, proxy, claim, {
391
+ kind: "diff",
392
+ status: diffResult.summary.includes("No git diff") ? "warning" : "info",
393
+ title: "Diff summary",
394
+ summary: diffResult.summary,
395
+ rawRef: diffLogRef
396
+ }, repoPath);
397
+
398
+ const checks = await runVerificationChecks(repoPath, flags);
399
+ for (const check of checks) {
400
+ logParts.push(formatCommandLog(check.title, check.result));
401
+ const checkLogRef = writeWorkLog(repoPath, claim.runId, logParts.join("\n\n"));
402
+ await postEvidence(baseUrl, proxy, claim, {
403
+ kind: check.kind,
404
+ status: check.result.ok ? "pass" : "fail",
405
+ title: check.title,
406
+ summary: summarizeCommandResult(check.result),
407
+ rawRef: checkLogRef
408
+ }, repoPath);
409
+ }
410
+
411
+ if (checks.length === 0 && finalStatus === "success") {
412
+ finalStatus = "partial_success";
413
+ failureCode = "missing_evidence";
414
+ completionSummary = "Runner finished the agent pass, but no verification command was configured or detected.";
415
+ await postEvidence(baseUrl, proxy, claim, {
416
+ kind: "test",
417
+ status: "warning",
418
+ title: "Verification not configured",
419
+ summary: "No test command was provided and package.json did not expose a test script."
420
+ }, repoPath);
421
+ } else if (checks.some((check) => !check.result.ok)) {
422
+ finalStatus = "failed";
423
+ failureCode = "test_failed";
424
+ errorType = "internal";
425
+ completionSummary = "Runner completed the agent pass, but verification failed. Evidence is attached.";
426
+ }
427
+
428
+ await completeRun(baseUrl, proxy, claim, {
429
+ status: finalStatus,
430
+ summary: completionSummary,
431
+ failureCode,
432
+ errorType
433
+ }, repoPath);
434
+ clearCurrentWork(workPath);
435
+ console.log(`Completed task: ${task.title} (${finalStatus})`);
436
+ return true;
437
+ } catch (error) {
438
+ await tryPostFailure(baseUrl, proxy, claim, error, repoPath);
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ async function runAgent(agentKind, prompt, repoPath, flags) {
444
+ const timeoutMs = parseDurationMs(flags.agentTimeout ?? process.env.TEAMLOOP_AGENT_TIMEOUT, DEFAULT_AGENT_TIMEOUT_MS);
445
+ if (agentKind === "noop") {
446
+ return {
447
+ command: "noop",
448
+ args: [],
449
+ ok: true,
450
+ exitCode: 0,
451
+ stdout: "Noop agent selected. TeamLoop skipped local code generation.",
452
+ stderr: "",
453
+ durationMs: 0,
454
+ timedOut: false
455
+ };
456
+ }
457
+
458
+ if (flags.agentCommand) {
459
+ if (!flagEnabled(flags.allowAgentCommand)) {
460
+ return {
461
+ command: "agent-command",
462
+ args: [],
463
+ ok: false,
464
+ exitCode: null,
465
+ stdout: "",
466
+ stderr: "Refusing --agent-command without --allow-agent-command. TeamLoop never runs custom local commands unless the local operator explicitly opts in.",
467
+ durationMs: 0,
468
+ timedOut: false,
469
+ failureCode: "agent_needs_repair"
470
+ };
471
+ }
472
+ return runShell(flags.agentCommand, { cwd: repoPath, timeoutMs, input: prompt });
473
+ }
474
+
475
+ if (agentKind === "claude-code" || agentKind === "claude") {
476
+ if (!commandExists("claude")) return missingAgentResult("claude");
477
+ const args = ["--print", "--permission-mode", flags.permissionMode ?? "auto", prompt];
478
+ if (flags.model) args.unshift("--model", flags.model);
479
+ return runProcess("claude", args, { cwd: repoPath, timeoutMs });
480
+ }
481
+
482
+ if (agentKind === "codex") {
483
+ if (!commandExists("codex")) return missingAgentResult("codex");
484
+ const args = ["exec", "--cd", repoPath, "--full-auto", "-"];
485
+ if (flags.model) args.splice(1, 0, "--model", flags.model);
486
+ return runProcess("codex", args, { cwd: repoPath, timeoutMs, input: prompt });
487
+ }
488
+
489
+ return missingAgentResult(agentKind);
490
+ }
491
+
492
+ async function runVerificationChecks(repoPath, flags) {
493
+ const checks = [];
494
+ const timeoutMs = parseDurationMs(flags.commandTimeout ?? process.env.TEAMLOOP_COMMAND_TIMEOUT, DEFAULT_COMMAND_TIMEOUT_MS);
495
+ const testCommand = verificationCommand(flags.test, detectTestCommand(repoPath));
496
+ const typecheckCommand = verificationCommand(flags.typecheck, null);
497
+ const lintCommand = verificationCommand(flags.lint, null);
498
+
499
+ if (testCommand) {
500
+ checks.push({ kind: "test", title: `Test: ${testCommand}`, result: await runShell(testCommand, { cwd: repoPath, timeoutMs }) });
501
+ }
502
+ if (typecheckCommand) {
503
+ checks.push({ kind: "typecheck", title: `Typecheck: ${typecheckCommand}`, result: await runShell(typecheckCommand, { cwd: repoPath, timeoutMs }) });
504
+ }
505
+ if (lintCommand) {
506
+ checks.push({ kind: "lint", title: `Lint: ${lintCommand}`, result: await runShell(lintCommand, { cwd: repoPath, timeoutMs }) });
507
+ }
508
+
509
+ return checks;
510
+ }
511
+
512
+ function verificationCommand(value, fallback) {
513
+ if (value === "none" || value === "false" || value === "0") return null;
514
+ return value ?? fallback;
515
+ }
516
+
517
+ function detectTestCommand(repoPath) {
518
+ const packageJson = path.join(repoPath, "package.json");
519
+ if (!existsSync(packageJson)) return null;
520
+ try {
521
+ const pkg = JSON.parse(readFileSync(packageJson, "utf8"));
522
+ if (!pkg.scripts?.test) return null;
523
+ if (existsSync(path.join(repoPath, "pnpm-lock.yaml"))) return "pnpm test";
524
+ if (existsSync(path.join(repoPath, "yarn.lock"))) return "yarn test";
525
+ return "npm test";
526
+ } catch {
527
+ return null;
528
+ }
529
+ }
530
+
531
+ async function collectDiff(repoPath) {
532
+ if (!existsSync(path.join(repoPath, ".git"))) {
533
+ return {
534
+ command: "git diff",
535
+ args: [],
536
+ ok: true,
537
+ exitCode: 0,
538
+ stdout: "",
539
+ stderr: "",
540
+ durationMs: 0,
541
+ timedOut: false,
542
+ summary: "No git repository detected, so TeamLoop could not collect a diff summary."
543
+ };
544
+ }
545
+
546
+ const stat = await runProcess("git", ["diff", "--stat"], { cwd: repoPath, timeoutMs: 30_000 });
547
+ const names = await runProcess("git", ["diff", "--name-only"], { cwd: repoPath, timeoutMs: 30_000 });
548
+ const changedFiles = names.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
549
+ const statText = stat.stdout.trim();
550
+ const summary = statText
551
+ ? `${changedFiles.length} changed file(s): ${changedFiles.slice(0, 12).join(", ")}\n${statText}`
552
+ : "No git diff detected after the agent pass.";
553
+ return { ...stat, summary };
554
+ }
555
+
556
+ async function postRunEvent(baseUrl, proxy, claim, event, repoPath) {
557
+ const body = {
558
+ ...claim,
559
+ idempotencyKey: buildIdempotencyKey([claim.runnerId, claim.taskId, "event", event.type, event.title]),
560
+ type: event.type,
561
+ title: event.title,
562
+ summary: event.summary,
563
+ metadata: event.metadata
564
+ };
565
+ const result = await sendRunnerPost(baseUrl, proxy, repoPath, {
566
+ method: "POST",
567
+ path: `/api/runs/${encodeURIComponent(claim.runId)}/events`,
568
+ body
569
+ });
570
+ if (!result.ok) throw new Error(result.error ?? "Runner event post failed.");
571
+ return result;
572
+ }
573
+
574
+ async function postEvidence(baseUrl, proxy, claim, evidence, repoPath) {
575
+ const body = {
576
+ ...claim,
577
+ idempotencyKey: evidence.idempotencyKey ?? buildIdempotencyKey([claim.runnerId, claim.taskId, "evidence", evidence.kind, evidence.title]),
578
+ kind: evidence.kind,
579
+ status: evidence.status,
580
+ title: evidence.title,
581
+ summary: evidence.summary,
582
+ rawRef: evidence.rawRef
583
+ };
584
+ const result = await sendRunnerPost(baseUrl, proxy, repoPath, {
585
+ method: "POST",
586
+ path: `/api/runs/${encodeURIComponent(claim.runId)}/evidence`,
587
+ body
588
+ });
589
+ if (!result.ok) throw new Error(result.error ?? "Evidence post failed.");
590
+ return result;
591
+ }
592
+
593
+ async function completeRun(baseUrl, proxy, claim, completion, repoPath) {
594
+ const body = {
595
+ ...claim,
596
+ idempotencyKey: buildIdempotencyKey([claim.runnerId, claim.taskId, "complete"]),
597
+ status: completion.status,
598
+ summary: completion.summary,
599
+ errorType: completion.errorType,
600
+ failureCode: completion.failureCode
601
+ };
602
+ const result = await sendRunnerPost(baseUrl, proxy, repoPath, {
603
+ method: "POST",
604
+ path: `/api/runs/${encodeURIComponent(claim.runId)}/complete`,
605
+ body
606
+ });
607
+ if (!result.ok) throw new Error(result.error ?? "Completion post failed.");
608
+ return result;
609
+ }
610
+
611
+ async function tryPostFailure(baseUrl, proxy, claim, error, repoPath) {
612
+ let evidenceError;
613
+ try {
614
+ await postEvidence(baseUrl, proxy, claim, {
615
+ kind: "risk",
616
+ status: "fail",
617
+ title: "Runner work failed",
618
+ summary: truncateText(error instanceof Error ? error.message : String(error), 2000)
619
+ }, repoPath);
620
+ } catch (postError) {
621
+ evidenceError = postError;
622
+ }
623
+
624
+ try {
625
+ await completeRun(baseUrl, proxy, claim, {
626
+ status: "failed",
627
+ summary: "Runner failed before completing the local loop. Failure evidence is attached.",
628
+ errorType: "internal",
629
+ failureCode: "result_missing"
630
+ }, repoPath);
631
+ } catch (postError) {
632
+ console.error(`Could not post failure completion: ${postError.message}`);
633
+ }
634
+
635
+ if (evidenceError) {
636
+ console.error(`Could not post failure evidence: ${evidenceError.message}`);
637
+ }
638
+ }
639
+
640
+ async function sendRunnerPost(baseUrl, proxy, repoPath, post) {
641
+ if (!repoPath) return request(`${baseUrl}${post.path}`, { method: post.method, body: post.body, proxy });
642
+ const record = queueOutbound(repoPath, post);
643
+ try {
644
+ const result = await sendOutbound(baseUrl, proxy, record);
645
+ removeOutbound(repoPath, record.id);
646
+ return result;
647
+ } catch (error) {
648
+ throw new Error(`Queued outbound runner post for retry (${record.id}): ${error.message}`);
649
+ }
650
+ }
651
+
652
+ async function request(url, options) {
653
+ const proxy = proxyForUrl(url, options.proxy);
654
+ if (proxy) {
655
+ return requestViaHttpProxy(url, options, proxy);
656
+ }
657
+
658
+ const controller = new AbortController();
659
+ const timeout = setTimeout(() => controller.abort(), 20_000);
660
+
661
+ try {
662
+ const response = await fetch(url, {
663
+ method: options.method,
664
+ headers: options.body ? { "content-type": "application/json" } : undefined,
665
+ body: options.body ? JSON.stringify(options.body) : undefined,
666
+ signal: controller.signal
667
+ });
668
+ const text = await response.text();
669
+ const payload = text ? JSON.parse(text) : {};
670
+ if (!response.ok) throw new Error(payload.error ?? `HTTP ${response.status}`);
671
+ return payload;
672
+ } catch (error) {
673
+ const causeMessage = error.cause?.message ?? "";
674
+ const causeCode = error.cause?.code ?? "";
675
+ if (error.name === "AbortError" || causeCode === "UND_ERR_CONNECT_TIMEOUT" || /timeout/i.test(causeMessage)) {
676
+ throw new Error(
677
+ "Timed out while reaching TeamLoop. If the browser works but CLI does not, check terminal proxy/VPN settings such as HTTPS_PROXY."
678
+ );
679
+ }
680
+ throw error;
681
+ } finally {
682
+ clearTimeout(timeout);
683
+ }
684
+ }
685
+
686
+ async function requestViaHttpProxy(url, options, proxyUrl) {
687
+ const target = new URL(url);
688
+ const proxy = new URL(proxyUrl);
689
+ if (target.protocol !== "https:") throw new Error(`Proxy mode only supports HTTPS targets, got ${target.protocol}`);
690
+
691
+ const body = options.body ? JSON.stringify(options.body) : "";
692
+ const requestText = [
693
+ `${options.method} ${target.pathname}${target.search} HTTP/1.1`,
694
+ `Host: ${target.host}`,
695
+ "User-Agent: teamloop-runner/0.1",
696
+ "Accept: application/json",
697
+ "Connection: close",
698
+ ...(body ? ["content-type: application/json", `content-length: ${Buffer.byteLength(body)}`] : []),
699
+ "",
700
+ body
701
+ ].join("\r\n");
702
+
703
+ const socket = await connectProxyTunnel(target, proxy);
704
+ const secureSocket = await connectTls(socket, target.hostname);
705
+ return new Promise((resolve, reject) => {
706
+ const chunks = [];
707
+ secureSocket.setTimeout(20_000, () => {
708
+ secureSocket.destroy();
709
+ reject(new Error("Timed out while waiting for TeamLoop through the configured proxy."));
710
+ });
711
+ secureSocket.on("data", (chunk) => chunks.push(chunk));
712
+ secureSocket.on("error", reject);
713
+ secureSocket.on("end", () => {
714
+ try {
715
+ const { status, body: responseBody } = parseHttpResponse(Buffer.concat(chunks).toString("utf8"));
716
+ const payload = responseBody ? JSON.parse(responseBody) : {};
717
+ if (status < 200 || status >= 300) reject(new Error(payload.error ?? `HTTP ${status}`));
718
+ else resolve(payload);
719
+ } catch (error) {
720
+ reject(error);
721
+ }
722
+ });
723
+ secureSocket.write(requestText);
724
+ });
725
+ }
726
+
727
+ function connectProxyTunnel(target, proxy) {
728
+ return new Promise((resolve, reject) => {
729
+ const port = Number(proxy.port || 8080);
730
+ const socket = net.createConnection({ host: proxy.hostname, port });
731
+ const auth = proxy.username ? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`).toString("base64")}\r\n` : "";
732
+ let buffer = "";
733
+
734
+ socket.setTimeout(20_000, () => {
735
+ socket.destroy();
736
+ reject(new Error(`Timed out while connecting to proxy ${proxy.hostname}:${port}.`));
737
+ });
738
+ socket.on("connect", () => {
739
+ socket.write(`CONNECT ${target.hostname}:${target.port || 443} HTTP/1.1\r\nHost: ${target.hostname}:${target.port || 443}\r\n${auth}\r\n`);
740
+ });
741
+ socket.on("data", (chunk) => {
742
+ buffer += chunk.toString("utf8");
743
+ if (!buffer.includes("\r\n\r\n")) return;
744
+ const statusLine = buffer.split("\r\n", 1)[0] ?? "";
745
+ if (!statusLine.includes(" 200 ")) {
746
+ socket.destroy();
747
+ reject(new Error(`Proxy CONNECT failed: ${statusLine}`));
748
+ return;
749
+ }
750
+ socket.removeAllListeners("data");
751
+ socket.setTimeout(0);
752
+ resolve(socket);
753
+ });
754
+ socket.on("error", reject);
755
+ });
756
+ }
757
+
758
+ function connectTls(socket, hostname) {
759
+ return new Promise((resolve, reject) => {
760
+ const secureSocket = tls.connect({ socket, servername: hostname });
761
+ secureSocket.once("secureConnect", () => resolve(secureSocket));
762
+ secureSocket.once("error", reject);
763
+ });
764
+ }
765
+
766
+ function parseHttpResponse(raw) {
767
+ const headerEnd = raw.indexOf("\r\n\r\n");
768
+ if (headerEnd === -1) throw new Error("Invalid HTTP response from TeamLoop.");
769
+ const header = raw.slice(0, headerEnd);
770
+ const body = raw.slice(headerEnd + 4);
771
+ const status = Number(header.split("\r\n", 1)[0]?.split(" ")[1]);
772
+ if (!Number.isFinite(status)) throw new Error("Missing HTTP status from TeamLoop response.");
773
+ return { status, body };
774
+ }
775
+
776
+ function withRunnerConfig(flags, options = {}) {
777
+ const loaded = loadRunnerConfigOptional(flags);
778
+ if (!loaded) {
779
+ if (options.optional) return flags;
780
+ return flags;
781
+ }
782
+ return { ...loaded.config, ...flags, config: flags.config ?? loaded.configPath };
783
+ }
784
+
785
+ function saveRunnerConfig(flags) {
786
+ const merged = withRunnerConfig(flags, { optional: true });
787
+ const repo = resolveRepoPath(merged.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
788
+ const runner = merged.runner ?? merged.runnerId ?? process.env.TEAMLOOP_RUNNER_ID;
789
+ if (!runner) throw new Error("Missing --runner. Run connect first and use the printed Runner ID.");
790
+ if (merged.agentCommand && !flagEnabled(merged.allowAgentCommand)) {
791
+ throw new Error("Refusing to save --agent-command without --allow-agent-command. Custom agent commands are local operator trust decisions.");
792
+ }
793
+
794
+ const configPath = runnerConfigPath(merged, repo);
795
+ const existing = readJsonFile(configPath, {});
796
+ const config = {
797
+ schemaVersion: 1,
798
+ url: normalizeUrl(merged.url ?? process.env.TEAMLOOP_URL ?? DEFAULT_URL),
799
+ runner,
800
+ repo,
801
+ agent: merged.agent ?? merged.agentKind ?? process.env.TEAMLOOP_AGENT ?? "codex",
802
+ capabilities: normalizeCapabilities(merged.capabilities ?? process.env.TEAMLOOP_CAPABILITIES),
803
+ interval: String(merged.interval ?? process.env.TEAMLOOP_WORK_INTERVAL ?? Math.round(DEFAULT_WATCH_INTERVAL_MS / 1000)),
804
+ test: merged.test ?? detectTestCommand(repo) ?? "none",
805
+ typecheck: merged.typecheck,
806
+ lint: merged.lint,
807
+ model: merged.model,
808
+ proxy: merged.proxy ?? process.env.TEAMLOOP_PROXY,
809
+ agentTimeout: merged.agentTimeout,
810
+ commandTimeout: merged.commandTimeout,
811
+ permissionMode: merged.permissionMode,
812
+ agentCommand: merged.agentCommand,
813
+ allowAgentCommand: flagEnabled(merged.allowAgentCommand),
814
+ createdAt: existing.createdAt ?? new Date().toISOString(),
815
+ updatedAt: new Date().toISOString()
816
+ };
817
+
818
+ if (!config.agentCommand) delete config.agentCommand;
819
+ if (!config.allowAgentCommand) delete config.allowAgentCommand;
820
+ for (const key of ["typecheck", "lint", "model", "proxy", "agentTimeout", "commandTimeout", "permissionMode"]) {
821
+ if (!config[key]) delete config[key];
822
+ }
823
+
824
+ mkdirSync(path.dirname(configPath), { recursive: true });
825
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
826
+ return { configPath, config };
827
+ }
828
+
829
+ function loadRunnerConfigRequired(flags) {
830
+ const loaded = loadRunnerConfigOptional(flags);
831
+ if (!loaded) throw new Error(`Runner service config not found. Run runner:setup first or pass --config.`);
832
+ return loaded;
833
+ }
834
+
835
+ function loadRunnerConfigOptional(flags) {
836
+ const repoBase = path.resolve(flags.repo ?? process.env.TEAMLOOP_REPO ?? process.cwd());
837
+ const configPath = runnerConfigPath(flags, repoBase);
838
+ if (!existsSync(configPath)) return null;
839
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
840
+ if (raw.schemaVersion !== 1) throw new Error(`Unsupported runner config schema in ${configPath}.`);
841
+ if (!raw.runner) throw new Error(`Runner config is missing runner: ${configPath}`);
842
+ if (!raw.repo) throw new Error(`Runner config is missing repo: ${configPath}`);
843
+ return { configPath, config: { ...raw, repo: path.resolve(raw.repo) } };
844
+ }
845
+
846
+ function runnerConfigPath(flags, repoPath) {
847
+ return path.resolve(flags.config ?? process.env.TEAMLOOP_RUNNER_CONFIG ?? path.join(repoPath, ".teamloop", "runner-service.json"));
848
+ }
849
+
850
+ function buildServiceDefinition(configPath, config) {
851
+ const repoPath = resolveRepoPath(config.repo);
852
+ const serviceId = runnerServiceId(config.runner, repoPath);
853
+ const label = `${SERVICE_LABEL_PREFIX}.${serviceId}`;
854
+ const serviceName = `teamloop-runner-${serviceId}.service`;
855
+ const logPath = path.join(repoPath, ".teamloop", "service.log");
856
+ return {
857
+ config,
858
+ configPath: path.resolve(configPath),
859
+ repoPath,
860
+ serviceId,
861
+ label,
862
+ serviceName,
863
+ logPath,
864
+ nodePath: process.execPath,
865
+ scriptPath: SCRIPT_PATH,
866
+ envPath: process.env.PATH || defaultServicePath()
867
+ };
868
+ }
869
+
870
+ function runnerServiceId(runnerId, repoPath) {
871
+ return createHash("sha256").update(`${runnerId}\n${repoPath}`).digest("hex").slice(0, 12);
872
+ }
873
+
874
+ function installOrUpdateService(definition) {
875
+ mkdirSync(path.dirname(definition.logPath), { recursive: true });
876
+ if (process.platform === "darwin") return installLaunchAgent(definition);
877
+ if (process.platform === "linux") return installSystemdUserService(definition);
878
+ throw new Error(`TeamLoop service install is not implemented for ${process.platform}. Use runner:work --watch in the foreground.`);
879
+ }
880
+
881
+ function stopPlatformService(definition) {
882
+ if (process.platform === "darwin") return stopLaunchAgent(definition);
883
+ if (process.platform === "linux") return stopSystemdUserService(definition);
884
+ throw new Error(`TeamLoop service stop is not implemented for ${process.platform}. Stop the foreground runner:work --watch process manually.`);
885
+ }
886
+
887
+ function removePlatformService(definition) {
888
+ if (process.platform === "darwin") {
889
+ removeFileIfExists(launchAgentPath(definition));
890
+ return;
891
+ }
892
+ if (process.platform === "linux") {
893
+ removeFileIfExists(systemdUserUnitPath(definition));
894
+ tryExec("systemctl", ["--user", "daemon-reload"]);
895
+ return;
896
+ }
897
+ throw new Error(`TeamLoop service uninstall is not implemented for ${process.platform}.`);
898
+ }
899
+
900
+ function platformServiceStatus(definition) {
901
+ if (process.platform === "darwin") return launchAgentStatus(definition);
902
+ if (process.platform === "linux") return systemdUserServiceStatus(definition);
903
+ return { platform: process.platform, installed: false, running: false, pid: null };
904
+ }
905
+
906
+ function installLaunchAgent(definition) {
907
+ const plistPath = launchAgentPath(definition);
908
+ mkdirSync(path.dirname(plistPath), { recursive: true });
909
+ writeFileSync(plistPath, buildLaunchAgentPlist(definition), "utf8");
910
+ const domain = preferredLaunchdDomain();
911
+ const target = `${domain}/${definition.label}`;
912
+ stopLaunchAgent(definition);
913
+ execFileSync("launchctl", ["bootstrap", domain, plistPath], { stdio: "pipe" });
914
+ tryExec("launchctl", ["kickstart", "-kp", target]);
915
+ }
916
+
917
+ function stopLaunchAgent(definition) {
918
+ for (const domain of launchdDomains()) {
919
+ tryExec("launchctl", ["bootout", `${domain}/${definition.label}`]);
920
+ }
921
+ }
922
+
923
+ function launchAgentStatus(definition) {
924
+ const plistPath = launchAgentPath(definition);
925
+ const installed = existsSync(plistPath);
926
+ for (const domain of launchdDomains()) {
927
+ const output = tryExec("launchctl", ["print", `${domain}/${definition.label}`]);
928
+ if (!output.ok) continue;
929
+ const pid = Number(output.text.match(/\bpid\s*=\s*(\d+)/)?.[1] ?? "");
930
+ return {
931
+ platform: "launchd",
932
+ installed,
933
+ running: /state\s*=\s*running/.test(output.text) || Number.isFinite(pid),
934
+ pid: Number.isFinite(pid) ? pid : null
935
+ };
936
+ }
937
+ return { platform: "launchd", installed, running: false, pid: null };
938
+ }
939
+
940
+ function buildLaunchAgentPlist(definition) {
941
+ const args = [definition.nodePath, definition.scriptPath, "work", "--config", definition.configPath, "--watch"];
942
+ const argXml = args.map((item) => ` <string>${xmlEscape(item)}</string>`).join("\n");
943
+ return `<?xml version="1.0" encoding="UTF-8"?>
944
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
945
+ <plist version="1.0">
946
+ <dict>
947
+ <key>Label</key>
948
+ <string>${xmlEscape(definition.label)}</string>
949
+ <key>ProgramArguments</key>
950
+ <array>
951
+ ${argXml}
952
+ </array>
953
+ <key>WorkingDirectory</key>
954
+ <string>${xmlEscape(definition.repoPath)}</string>
955
+ <key>RunAtLoad</key>
956
+ <true/>
957
+ <key>KeepAlive</key>
958
+ <true/>
959
+ <key>StandardOutPath</key>
960
+ <string>${xmlEscape(definition.logPath)}</string>
961
+ <key>StandardErrorPath</key>
962
+ <string>${xmlEscape(definition.logPath)}</string>
963
+ <key>EnvironmentVariables</key>
964
+ <dict>
965
+ <key>HOME</key>
966
+ <string>${xmlEscape(os.homedir())}</string>
967
+ <key>PATH</key>
968
+ <string>${xmlEscape(definition.envPath)}</string>
969
+ </dict>
970
+ </dict>
971
+ </plist>
972
+ `;
973
+ }
974
+
975
+ function launchAgentPath(definition) {
976
+ return path.join(os.homedir(), "Library", "LaunchAgents", `${definition.label}.plist`);
977
+ }
978
+
979
+ function launchdDomains() {
980
+ const uid = currentUid();
981
+ return [`gui/${uid}`, `user/${uid}`];
982
+ }
983
+
984
+ function preferredLaunchdDomain() {
985
+ const [gui, user] = launchdDomains();
986
+ return tryExec("launchctl", ["print", gui]).ok ? gui : user;
987
+ }
988
+
989
+ function currentUid() {
990
+ return execFileSync("id", ["-u"], { encoding: "utf8" }).trim();
991
+ }
992
+
993
+ function installSystemdUserService(definition) {
994
+ const unitPath = systemdUserUnitPath(definition);
995
+ mkdirSync(path.dirname(unitPath), { recursive: true });
996
+ writeFileSync(unitPath, buildSystemdUserUnit(definition), "utf8");
997
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
998
+ execFileSync("systemctl", ["--user", "enable", "--now", definition.serviceName], { stdio: "pipe" });
999
+ }
1000
+
1001
+ function stopSystemdUserService(definition) {
1002
+ tryExec("systemctl", ["--user", "stop", definition.serviceName]);
1003
+ }
1004
+
1005
+ function systemdUserServiceStatus(definition) {
1006
+ const unitPath = systemdUserUnitPath(definition);
1007
+ const installed = existsSync(unitPath);
1008
+ const active = tryExec("systemctl", ["--user", "is-active", "--quiet", definition.serviceName]).ok;
1009
+ const show = tryExec("systemctl", ["--user", "show", definition.serviceName, "--property=MainPID", "--value"]);
1010
+ const pid = Number(show.text.trim());
1011
+ return { platform: "systemd user", installed, running: installed && active, pid: Number.isFinite(pid) && pid > 0 ? pid : null };
1012
+ }
1013
+
1014
+ function buildSystemdUserUnit(definition) {
1015
+ const execStart = [definition.nodePath, definition.scriptPath, "work", "--config", definition.configPath, "--watch"].map(systemdQuote).join(" ");
1016
+ return `[Unit]
1017
+ Description=TeamLoop local runner ${definition.config.runner}
1018
+
1019
+ [Service]
1020
+ Type=simple
1021
+ WorkingDirectory=${systemdQuote(definition.repoPath)}
1022
+ Environment=PATH=${systemdQuote(definition.envPath)}
1023
+ ExecStart=${execStart}
1024
+ Restart=always
1025
+ RestartSec=5
1026
+ StandardOutput=append:${definition.logPath}
1027
+ StandardError=append:${definition.logPath}
1028
+
1029
+ [Install]
1030
+ WantedBy=default.target
1031
+ `;
1032
+ }
1033
+
1034
+ function systemdUserUnitPath(definition) {
1035
+ return path.join(os.homedir(), ".config", "systemd", "user", definition.serviceName);
1036
+ }
1037
+
1038
+ function systemdQuote(value) {
1039
+ return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1040
+ }
1041
+
1042
+ function printServiceDefinition(definition) {
1043
+ console.log(`Config: ${definition.configPath}`);
1044
+ console.log(`Repo: ${definition.repoPath}`);
1045
+ console.log(`Runner: ${definition.config.runner}`);
1046
+ console.log(`Agent: ${definition.config.agent}`);
1047
+ console.log(`Log: ${definition.logPath}`);
1048
+ if (process.platform === "darwin") console.log(`LaunchAgent: ${launchAgentPath(definition)}`);
1049
+ if (process.platform === "linux") console.log(`Systemd unit: ${systemdUserUnitPath(definition)}`);
1050
+ }
1051
+
1052
+ function defaultServicePath() {
1053
+ return "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin";
1054
+ }
1055
+
1056
+ function xmlEscape(value) {
1057
+ return String(value)
1058
+ .replaceAll("&", "&amp;")
1059
+ .replaceAll("<", "&lt;")
1060
+ .replaceAll(">", "&gt;")
1061
+ .replaceAll('"', "&quot;")
1062
+ .replaceAll("'", "&apos;");
1063
+ }
1064
+
1065
+ function tryExec(command, args) {
1066
+ try {
1067
+ return { ok: true, text: execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }) };
1068
+ } catch (error) {
1069
+ return { ok: false, text: `${error.stdout ?? ""}${error.stderr ?? ""}` };
1070
+ }
1071
+ }
1072
+
1073
+ function queueOutbound(repoPath, post) {
1074
+ const id = outboundId(post);
1075
+ const filePath = outboundPath(repoPath, id);
1076
+ const record = {
1077
+ id,
1078
+ schemaVersion: 1,
1079
+ method: post.method,
1080
+ path: post.path,
1081
+ body: post.body,
1082
+ createdAt: new Date().toISOString()
1083
+ };
1084
+ mkdirSync(path.dirname(filePath), { recursive: true });
1085
+ if (!existsSync(filePath)) {
1086
+ writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
1087
+ }
1088
+ return readJsonFile(filePath, record);
1089
+ }
1090
+
1091
+ async function flushOutbox(repoPath, baseUrl, proxy, options = {}) {
1092
+ let sent = 0;
1093
+ const records = outboxFiles(repoPath)
1094
+ .map((filePath) => ({ filePath, record: JSON.parse(readFileSync(filePath, "utf8")) }))
1095
+ .sort((left, right) => Date.parse(left.record.createdAt) - Date.parse(right.record.createdAt));
1096
+ for (const { filePath, record } of records) {
1097
+ try {
1098
+ await sendOutbound(baseUrl, proxy, record);
1099
+ unlinkSync(filePath);
1100
+ sent += 1;
1101
+ if (options.verbose) console.log(`Sent pending runner post: ${record.id}`);
1102
+ } catch (error) {
1103
+ if (options.verbose) console.error(`Could not send pending runner post ${record.id}: ${error.message}`);
1104
+ throw error;
1105
+ }
1106
+ }
1107
+ return { sent };
1108
+ }
1109
+
1110
+ async function sendOutbound(baseUrl, proxy, record) {
1111
+ return request(`${baseUrl}${record.path}`, { method: record.method, body: record.body, proxy });
1112
+ }
1113
+
1114
+ function removeOutbound(repoPath, id) {
1115
+ try {
1116
+ unlinkSync(outboundPath(repoPath, id));
1117
+ } catch (error) {
1118
+ if (error.code !== "ENOENT") throw error;
1119
+ }
1120
+ }
1121
+
1122
+ function outboundId(post) {
1123
+ return createHash("sha256")
1124
+ .update(`${post.method}\n${post.path}\n${post.body?.idempotencyKey ?? JSON.stringify(post.body)}`)
1125
+ .digest("hex")
1126
+ .slice(0, 24);
1127
+ }
1128
+
1129
+ function outboxDir(repoPath) {
1130
+ return path.join(repoPath, ".teamloop", "outbox");
1131
+ }
1132
+
1133
+ function outboundPath(repoPath, id) {
1134
+ return path.join(outboxDir(repoPath), `${safeFileName(id)}.json`);
1135
+ }
1136
+
1137
+ function outboxFiles(repoPath) {
1138
+ const dir = outboxDir(repoPath);
1139
+ if (!existsSync(dir)) return [];
1140
+ return readdirSync(dir)
1141
+ .filter((file) => file.endsWith(".json"))
1142
+ .sort()
1143
+ .map((file) => path.join(dir, file));
1144
+ }
1145
+
1146
+ function pendingOutboxCount(repoPath) {
1147
+ return outboxFiles(repoPath).length;
1148
+ }
1149
+
1150
+ function clearCurrentWorkIfOutboxEmpty(repoPath) {
1151
+ if (pendingOutboxCount(repoPath) > 0) return;
1152
+ clearCurrentWork(path.join(repoPath, ".teamloop", "current-work.json"));
1153
+ }
1154
+
1155
+ function printOutboxStatus(repoPath) {
1156
+ console.log(`Outbox pending: ${pendingOutboxCount(repoPath)}`);
1157
+ }
1158
+
1159
+ function tailFile(filePath, lines) {
1160
+ if (!existsSync(filePath)) return "No log file yet.";
1161
+ const content = readFileSync(filePath, "utf8");
1162
+ return content.split(/\r?\n/).slice(-Math.max(1, lines)).join("\n");
1163
+ }
1164
+
1165
+ function removeFileIfExists(filePath) {
1166
+ try {
1167
+ unlinkSync(filePath);
1168
+ } catch (error) {
1169
+ if (error.code !== "ENOENT") throw error;
1170
+ }
1171
+ }
1172
+
1173
+ function runProcess(command, args, options = {}) {
1174
+ const started = Date.now();
1175
+ const timeoutMs = options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
1176
+ return new Promise((resolve) => {
1177
+ let stdout = "";
1178
+ let stderr = "";
1179
+ let stdoutTruncated = false;
1180
+ let stderrTruncated = false;
1181
+ let settled = false;
1182
+ const child = spawn(command, args, {
1183
+ cwd: options.cwd,
1184
+ env: process.env,
1185
+ shell: options.shell ?? false,
1186
+ stdio: ["pipe", "pipe", "pipe"]
1187
+ });
1188
+
1189
+ const timeout = setTimeout(() => {
1190
+ if (settled) return;
1191
+ child.kill("SIGTERM");
1192
+ resolveOnce({
1193
+ command,
1194
+ args,
1195
+ ok: false,
1196
+ exitCode: null,
1197
+ stdout,
1198
+ stderr: appendLimited(stderr, `\nTimed out after ${Math.round(timeoutMs / 1000)}s.`, OUTPUT_CAPTURE_LIMIT).value,
1199
+ durationMs: Date.now() - started,
1200
+ timedOut: true,
1201
+ stdoutTruncated,
1202
+ stderrTruncated
1203
+ });
1204
+ }, timeoutMs);
1205
+
1206
+ function resolveOnce(result) {
1207
+ if (settled) return;
1208
+ settled = true;
1209
+ clearTimeout(timeout);
1210
+ resolve(result);
1211
+ }
1212
+
1213
+ child.stdout.on("data", (chunk) => {
1214
+ const next = appendLimited(stdout, chunk.toString("utf8"), OUTPUT_CAPTURE_LIMIT);
1215
+ stdout = next.value;
1216
+ stdoutTruncated ||= next.truncated;
1217
+ });
1218
+ child.stderr.on("data", (chunk) => {
1219
+ const next = appendLimited(stderr, chunk.toString("utf8"), OUTPUT_CAPTURE_LIMIT);
1220
+ stderr = next.value;
1221
+ stderrTruncated ||= next.truncated;
1222
+ });
1223
+ child.on("error", (error) => {
1224
+ resolveOnce({
1225
+ command,
1226
+ args,
1227
+ ok: false,
1228
+ exitCode: null,
1229
+ stdout,
1230
+ stderr: error.message,
1231
+ durationMs: Date.now() - started,
1232
+ timedOut: false,
1233
+ stdoutTruncated,
1234
+ stderrTruncated,
1235
+ failureCode: error.code === "ENOENT" ? "agent_not_detected" : "result_missing"
1236
+ });
1237
+ });
1238
+ child.on("close", (code) => {
1239
+ resolveOnce({
1240
+ command,
1241
+ args,
1242
+ ok: code === 0,
1243
+ exitCode: code,
1244
+ stdout,
1245
+ stderr,
1246
+ durationMs: Date.now() - started,
1247
+ timedOut: false,
1248
+ stdoutTruncated,
1249
+ stderrTruncated
1250
+ });
1251
+ });
1252
+
1253
+ if (options.input) {
1254
+ child.stdin.write(options.input);
1255
+ }
1256
+ child.stdin.end();
1257
+ });
1258
+ }
1259
+
1260
+ function runShell(command, options = {}) {
1261
+ return runProcess(command, [], { ...options, shell: true });
1262
+ }
1263
+
1264
+ function appendLimited(current, addition, limit) {
1265
+ if (current.length >= limit) return { value: current, truncated: true };
1266
+ const remaining = limit - current.length;
1267
+ const text = addition.length > remaining ? addition.slice(0, remaining) : addition;
1268
+ return { value: current + text, truncated: addition.length > remaining };
1269
+ }
1270
+
1271
+ function commandExists(command) {
1272
+ try {
1273
+ execFileSync("which", [command], { encoding: "utf8", stdio: "ignore", timeout: 2000 });
1274
+ return true;
1275
+ } catch {
1276
+ return false;
1277
+ }
1278
+ }
1279
+
1280
+ function missingAgentResult(command) {
1281
+ return {
1282
+ command,
1283
+ args: [],
1284
+ ok: false,
1285
+ exitCode: null,
1286
+ stdout: "",
1287
+ stderr: `Agent command not found on PATH: ${command}`,
1288
+ durationMs: 0,
1289
+ timedOut: false,
1290
+ failureCode: "agent_not_detected"
1291
+ };
1292
+ }
1293
+
1294
+ function proxyForUrl(url, explicitProxy) {
1295
+ const target = new URL(url);
1296
+ if (isLocalTarget(target.hostname)) return null;
1297
+ return explicitProxy || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.ALL_PROXY || macSystemProxy();
1298
+ }
1299
+
1300
+ function isLocalTarget(hostname) {
1301
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".local");
1302
+ }
1303
+
1304
+ function macSystemProxy() {
1305
+ if (process.platform !== "darwin") return null;
1306
+ try {
1307
+ const output = execFileSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 2000 });
1308
+ if (!/\bHTTPSEnable\s*:\s*1\b/.test(output)) return null;
1309
+ const host = output.match(/\bHTTPSProxy\s*:\s*(\S+)/)?.[1];
1310
+ const port = output.match(/\bHTTPSPort\s*:\s*(\d+)/)?.[1];
1311
+ return host && port ? `http://${host}:${port}` : null;
1312
+ } catch {
1313
+ return null;
1314
+ }
1315
+ }
1316
+
1317
+ function parseFlags(args) {
1318
+ const flags = {};
1319
+ for (let index = 0; index < args.length; index += 1) {
1320
+ const arg = args[index];
1321
+ if (arg === "--") continue;
1322
+ if (!arg.startsWith("--")) continue;
1323
+ const key = arg.slice(2);
1324
+ const next = args[index + 1];
1325
+ if (!next || next.startsWith("--")) {
1326
+ flags[key] = "true";
1327
+ flags[toCamelFlag(key)] = "true";
1328
+ continue;
1329
+ }
1330
+ flags[key] = next;
1331
+ flags[toCamelFlag(key)] = next;
1332
+ index += 1;
1333
+ }
1334
+ return flags;
1335
+ }
1336
+
1337
+ function toCamelFlag(key) {
1338
+ return key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
1339
+ }
1340
+
1341
+ function normalizeUrl(value) {
1342
+ return value.replace(/\/+$/, "");
1343
+ }
1344
+
1345
+ function normalizeCapabilities(value) {
1346
+ if (!value) return DEFAULT_CAPABILITIES;
1347
+ return value
1348
+ .split(",")
1349
+ .map((item) => item.trim())
1350
+ .filter(Boolean);
1351
+ }
1352
+
1353
+ function resolveRepoPath(value) {
1354
+ const repoPath = path.resolve(value);
1355
+ if (!existsSync(repoPath)) throw new Error(`Repo path does not exist: ${repoPath}`);
1356
+ if (!statSync(repoPath).isDirectory()) throw new Error(`Repo path is not a directory: ${repoPath}`);
1357
+ if (!looksLikeRepo(repoPath)) {
1358
+ console.warn(`Warning: ${repoPath} does not contain .git or package.json. TeamLoop will store it, but it may not be a runnable repo.`);
1359
+ }
1360
+ return repoPath;
1361
+ }
1362
+
1363
+ function looksLikeRepo(repoPath) {
1364
+ return existsSync(path.join(repoPath, ".git")) || existsSync(path.join(repoPath, "package.json"));
1365
+ }
1366
+
1367
+ function saveClaim(repoPath, claim) {
1368
+ const stateDir = path.join(repoPath, ".teamloop");
1369
+ mkdirSync(stateDir, { recursive: true });
1370
+ const claimPath = path.join(stateDir, "last-claim.json");
1371
+ writeFileSync(claimPath, `${JSON.stringify(claim, null, 2)}\n`, "utf8");
1372
+ return claimPath;
1373
+ }
1374
+
1375
+ function readClaim(flags) {
1376
+ const repoPath = flags.repo || process.env.TEAMLOOP_REPO ? resolveRepoPath(flags.repo ?? process.env.TEAMLOOP_REPO) : process.cwd();
1377
+ const claimPath = path.resolve(flags.claim ?? process.env.TEAMLOOP_CLAIM_FILE ?? path.join(repoPath, ".teamloop", "last-claim.json"));
1378
+ if (!existsSync(claimPath)) throw new Error(`Claim file does not exist: ${claimPath}. Run runner:poll first.`);
1379
+ const claim = JSON.parse(readFileSync(claimPath, "utf8"));
1380
+ for (const key of ["workspaceId", "runnerId", "taskId", "runId", "attemptId", "claimToken"]) {
1381
+ if (!claim[key]) throw new Error(`Claim file is missing ${key}: ${claimPath}`);
1382
+ }
1383
+ return claim;
1384
+ }
1385
+
1386
+ function buildIdempotencyKey(parts) {
1387
+ return parts
1388
+ .map((part) => String(part ?? "").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-"))
1389
+ .filter(Boolean)
1390
+ .join(":");
1391
+ }
1392
+
1393
+ function buildAgentPrompt(task, repoPath) {
1394
+ const definition = (task.definitionOfDone ?? []).map((item) => `- ${item}`).join("\n") || "- Implementation is complete\n- Verification evidence is attached";
1395
+ return `You are running as a local TeamLoop loop executor.
1396
+
1397
+ Repository:
1398
+ ${repoPath}
1399
+
1400
+ Task:
1401
+ ${task.title}
1402
+
1403
+ Description:
1404
+ ${task.description || "No description provided."}
1405
+
1406
+ Definition of done:
1407
+ ${definition}
1408
+
1409
+ Execution rules:
1410
+ - Work only inside the repository above.
1411
+ - Do not commit, push, create pull requests, or change remote services.
1412
+ - Keep the change focused on the task.
1413
+ - Prefer small, reviewable edits.
1414
+ - If you cannot complete the task, leave the repository in the safest state you can and explain the blocker.
1415
+
1416
+ Final response:
1417
+ - Summarize what changed.
1418
+ - List verification you ran or explain why it could not run.
1419
+ - Call out remaining risks.`;
1420
+ }
1421
+
1422
+ function summarizeAgentResult(result) {
1423
+ if (result.ok) {
1424
+ const summary = result.stdout.trim() || result.stderr.trim() || "Agent completed without output.";
1425
+ return truncateText(summary, 4000);
1426
+ }
1427
+ return truncateText(result.stderr.trim() || result.stdout.trim() || `Agent exited with ${result.exitCode ?? "no exit code"}.`, 4000);
1428
+ }
1429
+
1430
+ function summarizeCommandResult(result) {
1431
+ const status = result.ok ? "passed" : result.timedOut ? "timed out" : "failed";
1432
+ const output = result.stdout.trim() || result.stderr.trim() || "No output.";
1433
+ return truncateText(`${result.command} ${result.args.join(" ")} ${status} in ${Math.round(result.durationMs / 1000)}s.\n${output}`, 4000);
1434
+ }
1435
+
1436
+ function formatCommandLog(label, result) {
1437
+ return [
1438
+ `# ${label}`,
1439
+ `$ ${result.command} ${result.args.join(" ")}`.trim(),
1440
+ `exit: ${result.exitCode ?? "none"}`,
1441
+ `duration_ms: ${result.durationMs}`,
1442
+ result.timedOut ? "timed_out: true" : "timed_out: false",
1443
+ "",
1444
+ "## stdout",
1445
+ result.stdout || "",
1446
+ "",
1447
+ "## stderr",
1448
+ result.stderr || ""
1449
+ ].join("\n");
1450
+ }
1451
+
1452
+ function writeWorkLog(repoPath, runId, content) {
1453
+ const logsDir = path.join(repoPath, ".teamloop", "logs");
1454
+ mkdirSync(logsDir, { recursive: true });
1455
+ const logPath = path.join(logsDir, `${safeFileName(runId)}.log`);
1456
+ writeFileSync(logPath, `${content}\n`, "utf8");
1457
+ return path.relative(repoPath, logPath);
1458
+ }
1459
+
1460
+ function saveCurrentWork(repoPath, workItem) {
1461
+ const stateDir = path.join(repoPath, ".teamloop");
1462
+ mkdirSync(stateDir, { recursive: true });
1463
+ const workPath = path.join(stateDir, "current-work.json");
1464
+ writeFileSync(workPath, `${JSON.stringify({ ...workItem, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
1465
+ return workPath;
1466
+ }
1467
+
1468
+ function clearCurrentWork(workPath) {
1469
+ try {
1470
+ unlinkSync(workPath);
1471
+ } catch (error) {
1472
+ if (error.code !== "ENOENT") throw error;
1473
+ }
1474
+ }
1475
+
1476
+ function acquireWorkLock(repoPath, runnerId) {
1477
+ const stateDir = path.join(repoPath, ".teamloop");
1478
+ mkdirSync(stateDir, { recursive: true });
1479
+ const lockPath = path.join(stateDir, "runner-work.lock");
1480
+ const payload = {
1481
+ pid: process.pid,
1482
+ runnerId,
1483
+ repoPath,
1484
+ startedAt: new Date().toISOString()
1485
+ };
1486
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1487
+ try {
1488
+ writeFileSync(lockPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
1489
+ return {
1490
+ release() {
1491
+ try {
1492
+ const existing = JSON.parse(readFileSync(lockPath, "utf8"));
1493
+ if (existing.pid === process.pid) unlinkSync(lockPath);
1494
+ } catch {
1495
+ // Best-effort lock cleanup.
1496
+ }
1497
+ }
1498
+ };
1499
+ } catch (error) {
1500
+ if (error.code !== "EEXIST") throw error;
1501
+ const existing = readJsonFile(lockPath, null);
1502
+ if (existing?.pid && !processAlive(existing.pid)) {
1503
+ unlinkSync(lockPath);
1504
+ continue;
1505
+ }
1506
+ throw new Error(`Another TeamLoop worker is already active for this repo. Lock: ${lockPath}`);
1507
+ }
1508
+ }
1509
+ throw new Error(`Could not acquire TeamLoop worker lock: ${lockPath}`);
1510
+ }
1511
+
1512
+ function readJsonFile(filePath, fallback) {
1513
+ try {
1514
+ return JSON.parse(readFileSync(filePath, "utf8"));
1515
+ } catch {
1516
+ return fallback;
1517
+ }
1518
+ }
1519
+
1520
+ function processAlive(pid) {
1521
+ try {
1522
+ process.kill(pid, 0);
1523
+ return true;
1524
+ } catch (error) {
1525
+ return error.code === "EPERM";
1526
+ }
1527
+ }
1528
+
1529
+ function flagEnabled(value) {
1530
+ return value === true || value === "true" || value === "1" || value === "yes";
1531
+ }
1532
+
1533
+ function parseDurationMs(value, fallback) {
1534
+ if (!value) return fallback;
1535
+ const numeric = Number(value);
1536
+ if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
1537
+ return numeric < 1000 ? numeric * 1000 : numeric;
1538
+ }
1539
+
1540
+ function truncateText(value, limit) {
1541
+ return value.length > limit ? `${value.slice(0, limit)}\n...truncated...` : value;
1542
+ }
1543
+
1544
+ function safeFileName(value) {
1545
+ return String(value).replace(/[^a-zA-Z0-9_.-]+/g, "-");
1546
+ }
1547
+
1548
+ function agentLabel(agentKind) {
1549
+ if (agentKind === "claude-code" || agentKind === "claude") return "Claude Code";
1550
+ if (agentKind === "codex") return "Codex";
1551
+ return agentKind;
1552
+ }
1553
+
1554
+ function sleep(ms) {
1555
+ return new Promise((resolve) => setTimeout(resolve, ms));
1556
+ }
1557
+
1558
+ function help() {
1559
+ console.log(`TeamLoop runner CLI
1560
+
1561
+ Commands:
1562
+ connect Register this machine as a TeamLoop runner.
1563
+ poll Claim the next queued task for a connected runner.
1564
+ work Claim and execute tasks with a local Codex or Claude loop.
1565
+ setup Write local runner config and install/start a background service.
1566
+ start Start or update the configured background service.
1567
+ stop Stop the configured background service.
1568
+ uninstall Stop and remove the configured background service file.
1569
+ status Show service, config, and outbox status.
1570
+ logs Print the service log tail.
1571
+ resume Retry queued runner posts after a network or service interruption.
1572
+ doctor Diagnose repo, agent, proxy, config, and optional network heartbeat.
1573
+ heartbeat Mark a connected runner online.
1574
+ evidence Attach evidence to the last claimed task.
1575
+ complete Complete the last claimed task and generate readiness.
1576
+
1577
+ Examples:
1578
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:connect -- --pair tlp_xxx
1579
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:poll -- --runner runner_xxx
1580
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:work -- --runner runner_xxx --repo /Users/mingyoo/repos/teamloop --agent codex --once
1581
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:setup -- --runner runner_xxx --repo /Users/mingyoo/repos/teamloop --agent codex --test "pnpm test"
1582
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:resume -- --repo /Users/mingyoo/repos/teamloop
1583
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:doctor -- --repo /Users/mingyoo/repos/teamloop --agent codex
1584
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:evidence -- --kind test --status pass --title "Typecheck" --summary "pnpm typecheck passed"
1585
+ pnpm --dir /Users/mingyoo/repos/teamloop runner:complete -- --summary "Implementation and verification are attached"
1586
+
1587
+ Options:
1588
+ --url TeamLoop URL. Defaults to ${DEFAULT_URL}
1589
+ --config Runner service config. Defaults to <repo>/.teamloop/runner-service.json.
1590
+ --pair One-time pairing token from the TeamLoop UI.
1591
+ --runner Runner ID printed by connect.
1592
+ --name Runner display name. Defaults to "<hostname> / Codex".
1593
+ --agent Agent kind: codex, claude-code, or custom.
1594
+ --agent-command Shell command for a custom local agent. Receives the TeamLoop prompt on stdin.
1595
+ --allow-agent-command Required acknowledgement before TeamLoop runs --agent-command.
1596
+ --agent-timeout Agent timeout in seconds. Defaults to 1800.
1597
+ --capabilities Comma-separated capability labels.
1598
+ --repo Local repository path. Defaults to the current working directory.
1599
+ --once Run a single claim/execute/complete cycle. This is the default.
1600
+ --watch Keep polling in the foreground.
1601
+ --interval Watch polling interval in seconds. Defaults to 15.
1602
+ --test Test command. Defaults to package.json test script when present. Use "none" to skip.
1603
+ --typecheck Optional typecheck command to run after the agent.
1604
+ --lint Optional lint command to run after the agent.
1605
+ --command-timeout Verification command timeout in seconds. Defaults to 600.
1606
+ --claim Claim JSON saved by runner:poll. Defaults to .teamloop/last-claim.json.
1607
+ --kind Evidence kind: test, lint, typecheck, diff, screenshot, agent_message, risk, manual_note.
1608
+ --status Evidence status or completion status.
1609
+ --title Evidence or event title.
1610
+ --summary Evidence or completion summary.
1611
+ --idempotency Stable idempotency key for repeated posts.
1612
+ --proxy Optional HTTP proxy, for example http://127.0.0.1:7897.
1613
+ --network doctor only: send a safe runner heartbeat to verify network/auth.
1614
+ `);
1615
+ }