@aictiq/cli 0.2.0 → 0.3.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.
package/dist/index.js CHANGED
@@ -1201,8 +1201,9 @@ async function resolveAgentId(ctx, agent) {
1201
1201
 
1202
1202
  // src/commands/runner.ts
1203
1203
  import { Command as Command7 } from "commander";
1204
+ import { randomUUID as randomUUID2 } from "crypto";
1204
1205
  import { existsSync as existsSync3 } from "fs";
1205
- import { arch, platform } from "os";
1206
+ import { arch, homedir as homedir4, platform } from "os";
1206
1207
  import { resolve as resolve2 } from "path";
1207
1208
 
1208
1209
  // src/runner/client.ts
@@ -1245,6 +1246,10 @@ var RunnerClient = class {
1245
1246
  async claim(harnesses2, slots, signal2) {
1246
1247
  return await this.send("/runner/runs/claim", { harnesses: harnesses2, slots }, signal2) ?? null;
1247
1248
  }
1249
+ /** Hands a claimed run back before it starts; the instance queues it again and revokes its token. */
1250
+ async release(runId) {
1251
+ await this.send(`/runner/runs/${runId}/release`);
1252
+ }
1248
1253
  async started(runId) {
1249
1254
  await this.send(`/runner/runs/${runId}/started`);
1250
1255
  }
@@ -1338,6 +1343,7 @@ var RunnerClient = class {
1338
1343
  };
1339
1344
 
1340
1345
  // src/runner/config.ts
1346
+ import { createHash, randomUUID } from "crypto";
1341
1347
  import { chmodSync as chmodSync3, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1342
1348
  import { homedir as homedir3 } from "os";
1343
1349
  import { dirname as dirname2, join as join3 } from "path";
@@ -1750,22 +1756,23 @@ function readRunnerConfig(path = runnerConfigPath()) {
1750
1756
  return null;
1751
1757
  }
1752
1758
  if (typeof parsed !== "object" || parsed === null) return null;
1753
- const { url, token, name, workspaces, repoRoots, attachments } = parsed;
1754
- if (typeof url !== "string" || typeof token !== "string") return null;
1755
- const mapped = {};
1756
- if (typeof workspaces === "object" && workspaces !== null) {
1757
- for (const [key, value] of Object.entries(workspaces)) {
1758
- if (typeof value === "string") mapped[key] = value;
1759
- }
1759
+ const raw = parsed;
1760
+ let profiles;
1761
+ if (Array.isArray(raw.profiles)) {
1762
+ profiles = raw.profiles.flatMap((entry) => {
1763
+ const profile = readProfile(entry);
1764
+ return profile ? [profile] : [];
1765
+ });
1766
+ } else {
1767
+ const legacy = readProfile(raw);
1768
+ if (!legacy) return null;
1769
+ profiles = [legacy];
1760
1770
  }
1761
- const roots = Array.isArray(repoRoots) ? repoRoots.filter((root) => typeof root === "string" && root.trim() !== "") : [];
1762
- const rawLimits = typeof attachments === "object" && attachments !== null ? attachments : {};
1771
+ const rawLimits = typeof raw.attachments === "object" && raw.attachments !== null ? raw.attachments : {};
1763
1772
  return {
1764
- url,
1765
- token,
1766
- ...typeof name === "string" ? { name } : {},
1767
- workspaces: mapped,
1768
- repoRoots: roots,
1773
+ machineId: typeof raw.machineId === "string" && UuidPattern.test(raw.machineId) ? raw.machineId : randomUUID(),
1774
+ ...typeof raw.name === "string" ? { name: raw.name } : {},
1775
+ profiles,
1769
1776
  attachments: {
1770
1777
  maxCount: validLimit(rawLimits.maxCount, DefaultAttachmentMaxCount),
1771
1778
  maxBytes: validLimit(rawLimits.maxBytes, DefaultAttachmentMaxBytes)
@@ -1774,10 +1781,56 @@ function readRunnerConfig(path = runnerConfigPath()) {
1774
1781
  }
1775
1782
  function writeRunnerConfig(config, path = runnerConfigPath()) {
1776
1783
  mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
1777
- writeFileSync3(path, `${JSON.stringify(config, null, 2)}
1784
+ const document = {
1785
+ machineId: config.machineId,
1786
+ ...config.name ? { name: config.name } : {},
1787
+ attachments: config.attachments,
1788
+ profiles: config.profiles.map((profile) => ({
1789
+ ...profile.organization ? { organization: profile.organization } : {},
1790
+ url: profile.url,
1791
+ token: profile.token,
1792
+ workspaces: profile.workspaces,
1793
+ repoRoots: profile.repoRoots
1794
+ }))
1795
+ };
1796
+ writeFileSync3(path, `${JSON.stringify(document, null, 2)}
1778
1797
  `, { mode: 384 });
1779
1798
  chmodSync3(path, 384);
1780
1799
  }
1800
+ function profileLabel(profile) {
1801
+ if (profile.organization) return profile.organization;
1802
+ try {
1803
+ return new URL(profile.url).host;
1804
+ } catch {
1805
+ return profile.url;
1806
+ }
1807
+ }
1808
+ function profileKey(profile) {
1809
+ return createHash("sha256").update(profile.token).digest("hex").slice(0, 16);
1810
+ }
1811
+ function sameInstance(left, right) {
1812
+ return left.replace(/\/+$/, "").toLowerCase() === right.replace(/\/+$/, "").toLowerCase();
1813
+ }
1814
+ var UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1815
+ function readProfile(value) {
1816
+ if (typeof value !== "object" || value === null) return null;
1817
+ const { url, token, organization, workspaces, repoRoots } = value;
1818
+ if (typeof url !== "string" || typeof token !== "string") return null;
1819
+ const mapped = {};
1820
+ if (typeof workspaces === "object" && workspaces !== null) {
1821
+ for (const [key, path] of Object.entries(workspaces)) {
1822
+ if (typeof path === "string") mapped[key] = path;
1823
+ }
1824
+ }
1825
+ const roots = Array.isArray(repoRoots) ? repoRoots.filter((root) => typeof root === "string" && root.trim() !== "") : [];
1826
+ return {
1827
+ url,
1828
+ token,
1829
+ ...typeof organization === "string" && organization !== "" ? { organization } : {},
1830
+ workspaces: mapped,
1831
+ repoRoots: roots
1832
+ };
1833
+ }
1781
1834
  function validLimit(value, fallback) {
1782
1835
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
1783
1836
  }
@@ -2119,7 +2172,7 @@ async function executeRun(run, options) {
2119
2172
  mcpServer: options.workspace.mcpServer
2120
2173
  });
2121
2174
  const env = {
2122
- ...process.env,
2175
+ ...inheritedEnv(process.env),
2123
2176
  ...invocation.env,
2124
2177
  ...workspace.env,
2125
2178
  // The agent reaches the instance as itself, never as the runner: `aictiq mcp` and the
@@ -2278,6 +2331,9 @@ function truncate(value, max) {
2278
2331
  if (!value) return null;
2279
2332
  return value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
2280
2333
  }
2334
+ function inheritedEnv(env) {
2335
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith("AICTIQ_")));
2336
+ }
2281
2337
  function message2(error) {
2282
2338
  return error instanceof Error ? error.message : String(error);
2283
2339
  }
@@ -2613,6 +2669,171 @@ async function probeHarnesses(env) {
2613
2669
  return found.filter((info) => info !== null);
2614
2670
  }
2615
2671
 
2672
+ // src/runner/service.ts
2673
+ import { posix } from "path";
2674
+ var RevokedExitCode = 5;
2675
+ function servicePlatform(platform2 = process.platform) {
2676
+ if (platform2 === "darwin") return "macos";
2677
+ if (platform2 === "win32") return "windows";
2678
+ return "linux";
2679
+ }
2680
+ function serviceDefinition(platform2, options) {
2681
+ switch (platform2) {
2682
+ case "macos":
2683
+ return launchdAgent(options);
2684
+ case "windows":
2685
+ return scheduledTaskInstaller(options);
2686
+ default:
2687
+ return systemdUnit(options);
2688
+ }
2689
+ }
2690
+ function systemdUnit({ node, entry, parallel, path }) {
2691
+ return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
2692
+ # systemctl --user daemon-reload
2693
+ # systemctl --user enable --now aictiq-runner
2694
+ # loginctl enable-linger "$USER" # keep it running after you log out
2695
+ #
2696
+ # The runner executes agents as this user, with this user's harness sign-ins and git
2697
+ # credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
2698
+ # TimeoutStopSec bounds how long systemd waits before it cancels them.
2699
+ [Unit]
2700
+ Description=Aictiq runner
2701
+ After=network-online.target
2702
+ Wants=network-online.target
2703
+
2704
+ [Service]
2705
+ Type=simple
2706
+ ExecStart=${node} ${entry} runner start --parallel ${parallel}
2707
+ Restart=on-failure
2708
+ RestartSec=10
2709
+ # Exit ${RevokedExitCode} means the secret was revoked; restarting cannot fix that.
2710
+ RestartPreventExitStatus=${RevokedExitCode}
2711
+ KillMode=mixed
2712
+ TimeoutStopSec=15min
2713
+ Environment=PATH=${path}
2714
+
2715
+ [Install]
2716
+ WantedBy=default.target
2717
+ `;
2718
+ }
2719
+ var LaunchdLabel = "com.aictiq.runner";
2720
+ var LaunchdWrapper = `"$1" "$2" runner start --parallel "$3" &
2721
+ child=$!
2722
+ trap 'kill -TERM "$child" 2>/dev/null' TERM INT
2723
+ wait "$child"
2724
+ status=$?
2725
+ while kill -0 "$child" 2>/dev/null; do wait "$child"; status=$?; done
2726
+ if [ "$status" -eq ${RevokedExitCode} ]; then exit 0; fi
2727
+ exit "$status"`;
2728
+ function launchdAgent({ node, entry, parallel, path, home }) {
2729
+ const log = posix.join(home, "Library", "Logs", "aictiq-runner.log");
2730
+ const plistPath = `~/Library/LaunchAgents/${LaunchdLabel}.plist`;
2731
+ const string = (value) => `<string>${xml(value)}</string>`;
2732
+ return `<?xml version="1.0" encoding="UTF-8"?>
2733
+ <!--
2734
+ Save as ${plistPath}, then:
2735
+ launchctl bootstrap gui/$(id -u) ${plistPath}
2736
+ Check it: launchctl print gui/$(id -u)/${LaunchdLabel}
2737
+ Log: tail -f ${xmlComment(log)}
2738
+ Remove it: launchctl bootout gui/$(id -u)/${LaunchdLabel}
2739
+
2740
+ The runner executes agents as this user, with this user's harness sign-ins and git
2741
+ credentials: one runner is one trust domain. It runs while this user is logged in.
2742
+ A stop lets runs in flight finish for up to 15 minutes before launchd kills them.
2743
+ -->
2744
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2745
+ <plist version="1.0">
2746
+ <dict>
2747
+ <key>Label</key>
2748
+ ${string(LaunchdLabel)}
2749
+ <key>ProgramArguments</key>
2750
+ <array>
2751
+ ${string("/bin/sh")}
2752
+ ${string("-c")}
2753
+ ${string(LaunchdWrapper)}
2754
+ ${string("aictiq-runner")}
2755
+ ${string(node)}
2756
+ ${string(entry)}
2757
+ ${string(parallel)}
2758
+ </array>
2759
+ <key>EnvironmentVariables</key>
2760
+ <dict>
2761
+ <key>PATH</key>
2762
+ ${string(path)}
2763
+ </dict>
2764
+ <key>RunAtLoad</key>
2765
+ <true/>
2766
+ <key>KeepAlive</key>
2767
+ <dict>
2768
+ <key>SuccessfulExit</key>
2769
+ <false/>
2770
+ </dict>
2771
+ <key>ThrottleInterval</key>
2772
+ <integer>10</integer>
2773
+ <key>ExitTimeOut</key>
2774
+ <integer>900</integer>
2775
+ <key>ProcessType</key>
2776
+ ${string("Background")}
2777
+ <key>StandardOutPath</key>
2778
+ ${string(log)}
2779
+ <key>StandardErrorPath</key>
2780
+ ${string(log)}
2781
+ </dict>
2782
+ </plist>
2783
+ `;
2784
+ }
2785
+ var ScheduledTaskName = "Aictiq runner";
2786
+ function scheduledTaskInstaller({ node, entry, parallel, path }) {
2787
+ const loop = `# Written by \`aictiq runner install-service\`; the "${ScheduledTaskName}" task runs it at logon.
2788
+ $ErrorActionPreference = 'Continue'
2789
+ $env:PATH = ${ps(path)}
2790
+ $log = Join-Path $env:LOCALAPPDATA 'aictiq\\runner.log'
2791
+ while ($true) {
2792
+ # "$_" turns stderr's error records back into the plain lines the runner wrote.
2793
+ & ${ps(node)} ${ps(entry)} runner start --parallel ${ps(parallel)} 2>&1 |
2794
+ ForEach-Object { "$_" } | Add-Content -Path $log -Encoding utf8
2795
+ if ($LASTEXITCODE -eq ${RevokedExitCode}) {
2796
+ Add-Content -Path $log -Encoding utf8 -Value 'The runner secret was revoked; not restarting.'
2797
+ break
2798
+ }
2799
+ Start-Sleep -Seconds 10
2800
+ }
2801
+ `;
2802
+ return `# Aictiq runner as a Windows scheduled task. Save as install-runner.ps1, then run:
2803
+ # powershell -NoProfile -ExecutionPolicy Bypass -File install-runner.ps1
2804
+ # Check it: Get-ScheduledTask -TaskName '${ScheduledTaskName}' | Get-ScheduledTaskInfo
2805
+ # Log: Get-Content -Wait "$env:LOCALAPPDATA\\aictiq\\runner.log"
2806
+ # Remove it: Unregister-ScheduledTask -TaskName '${ScheduledTaskName}' -Confirm:$false
2807
+ #
2808
+ # The task starts at logon and runs while this user is logged in, as this user, with this
2809
+ # user's harness sign-ins and git credentials: one runner is one trust domain. Stopping the
2810
+ # task ends the runner at once; runs in flight are cancelled, not finished.
2811
+ $ErrorActionPreference = 'Stop'
2812
+ $dir = Join-Path $env:LOCALAPPDATA 'aictiq'
2813
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
2814
+ $script = Join-Path $dir 'runner-service.ps1'
2815
+ Set-Content -Path $script -Encoding utf8 -Value @'
2816
+ ${loop}'@
2817
+
2818
+ $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \`"$script\`""
2819
+ $trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"
2820
+ $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Limited
2821
+ $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
2822
+ Register-ScheduledTask -TaskName '${ScheduledTaskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
2823
+ Start-ScheduledTask -TaskName '${ScheduledTaskName}'
2824
+ Write-Host "Installed and started '${ScheduledTaskName}'. Log: $(Join-Path $dir 'runner.log')"
2825
+ `;
2826
+ }
2827
+ function xml(value) {
2828
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2829
+ }
2830
+ function xmlComment(value) {
2831
+ return value.replace(/--/g, "- -");
2832
+ }
2833
+ function ps(value) {
2834
+ return `'${value.replace(/'/g, "''")}'`;
2835
+ }
2836
+
2616
2837
  // src/runner/loop.ts
2617
2838
  var KnownHarnesses = ["claude", "codex", "opencode"];
2618
2839
  var RunnerRevokedError = class extends Error {
@@ -2650,6 +2871,7 @@ var RunnerLoop = class {
2650
2871
  let capabilities = await this.options.probe();
2651
2872
  const hello = await this.retrying(() => client.hello(capabilities));
2652
2873
  if (!hello) return;
2874
+ this.options.onHello?.(hello);
2653
2875
  local(
2654
2876
  `Runner "${hello.name}" connected to ${client.baseUrl} (${hello.organizationSlug}); harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none detected"}`
2655
2877
  );
@@ -2664,9 +2886,15 @@ var RunnerLoop = class {
2664
2886
  }
2665
2887
  })();
2666
2888
  }, hello.heartbeatIntervalSeconds * 1e3);
2889
+ const { floor } = this.options;
2890
+ const floorKey = this.options.floorKey ?? client.baseUrl;
2667
2891
  try {
2668
2892
  let attempt = 0;
2669
2893
  while (!this.stopping) {
2894
+ if (floor) {
2895
+ await floor.turn(floorKey, this.claimAbort.signal);
2896
+ if (this.stopping) break;
2897
+ }
2670
2898
  const free = this.options.parallel - this.active.size;
2671
2899
  if (free <= 0) {
2672
2900
  await this.waitForSlot();
@@ -2693,6 +2921,8 @@ var RunnerLoop = class {
2693
2921
  continue;
2694
2922
  }
2695
2923
  if (!claimed) continue;
2924
+ if (floor && !floor.enter(floorKey) && !await this.handBack(claimed, floor, floorKey))
2925
+ continue;
2696
2926
  local(`Claimed ${claimed.itemKey} (run ${claimed.runId}, ${claimed.harness})`);
2697
2927
  const execution = this.options.execute(claimed, hello, this.shutdown.signal).then(() => void 0).catch((error) => {
2698
2928
  if (error instanceof RunnerHttpError && error.revoked) this.revoke(error);
@@ -2701,6 +2931,7 @@ var RunnerLoop = class {
2701
2931
  `Run ${claimed.runId} ended with an error: ${error instanceof Error ? error.message : String(error)}`
2702
2932
  );
2703
2933
  }).finally(() => {
2934
+ floor?.leave(floorKey);
2704
2935
  this.active.delete(execution);
2705
2936
  this.wake?.();
2706
2937
  });
@@ -2713,6 +2944,32 @@ var RunnerLoop = class {
2713
2944
  }
2714
2945
  if (this.revoked) throw this.revoked;
2715
2946
  }
2947
+ /**
2948
+ * A run claimed while another organization took the floor. It goes back to the queue;
2949
+ * true only when it could not be given back and has now waited for the floor instead,
2950
+ * so the caller executes it.
2951
+ */
2952
+ async handBack(run, floor, key) {
2953
+ const { client, local } = this.options;
2954
+ try {
2955
+ await client.release(run.runId);
2956
+ local(
2957
+ `Gave ${run.itemKey} back to the queue: another organization has runs executing on this machine`
2958
+ );
2959
+ return false;
2960
+ } catch (error) {
2961
+ if (error instanceof RunnerHttpError && error.revoked) {
2962
+ this.revoke(error);
2963
+ return false;
2964
+ }
2965
+ if (error instanceof RunnerHttpError && error.problem?.type) return false;
2966
+ local(
2967
+ `Could not give ${run.itemKey} back (${error instanceof Error ? error.message : String(error)}); it starts when the other organization's runs finish`
2968
+ );
2969
+ while (!floor.enter(key)) await floor.turn(key);
2970
+ return true;
2971
+ }
2972
+ }
2716
2973
  revoke(error) {
2717
2974
  this.revoked ??= new RunnerRevokedError(
2718
2975
  error.problem?.type?.endsWith("token-revoked") ? "This runner was disabled, deleted or rotated. Register it again with `aictiq runner register`." : `The instance refused the runner secret (${error.message}). Register it again with \`aictiq runner register\`.`
@@ -2757,12 +3014,208 @@ var RunnerLoop = class {
2757
3014
  }
2758
3015
  };
2759
3016
 
3017
+ // src/runner/floor.ts
3018
+ var Floor = class {
3019
+ constructor(handoffMs = 2e3) {
3020
+ this.handoffMs = handoffMs;
3021
+ }
3022
+ handoffMs;
3023
+ holder = null;
3024
+ count = 0;
3025
+ last = null;
3026
+ freedAt = 0;
3027
+ waiting = /* @__PURE__ */ new Map();
3028
+ changed = deferred();
3029
+ /** The organization with runs executing, or null when the floor is free. */
3030
+ get current() {
3031
+ return this.holder;
3032
+ }
3033
+ /**
3034
+ * Resolves when `key` may claim: the floor is free, or its own runs hold it. Also resolves
3035
+ * when `signal` aborts; the caller checks why.
3036
+ */
3037
+ async turn(key, signal2) {
3038
+ this.waiting.set(key, (this.waiting.get(key) ?? 0) + 1);
3039
+ try {
3040
+ while (!signal2?.aborted) {
3041
+ const yieldFor = this.yieldFor(key);
3042
+ if (this.count > 0 ? this.holder === key : yieldFor === 0) return;
3043
+ const waits = [this.changed.promise];
3044
+ let timer;
3045
+ if (yieldFor > 0)
3046
+ waits.push(new Promise((resolve3) => timer = setTimeout(resolve3, yieldFor)));
3047
+ let onAbort;
3048
+ if (signal2) {
3049
+ waits.push(
3050
+ new Promise((resolve3) => {
3051
+ onAbort = () => resolve3();
3052
+ signal2.addEventListener("abort", onAbort, { once: true });
3053
+ })
3054
+ );
3055
+ }
3056
+ await Promise.race(waits);
3057
+ clearTimeout(timer);
3058
+ if (onAbort) signal2?.removeEventListener("abort", onAbort);
3059
+ }
3060
+ } finally {
3061
+ const left = (this.waiting.get(key) ?? 1) - 1;
3062
+ if (left === 0) this.waiting.delete(key);
3063
+ else this.waiting.set(key, left);
3064
+ }
3065
+ }
3066
+ /** Takes the floor for one run of `key`; false when another organization's runs hold it. */
3067
+ enter(key) {
3068
+ if (this.count > 0 && this.holder !== key) return false;
3069
+ this.holder = key;
3070
+ this.count++;
3071
+ this.notify();
3072
+ return true;
3073
+ }
3074
+ leave(key) {
3075
+ if (this.holder !== key || this.count === 0) return;
3076
+ this.count--;
3077
+ if (this.count === 0) {
3078
+ this.holder = null;
3079
+ this.last = key;
3080
+ this.freedAt = Date.now();
3081
+ }
3082
+ this.notify();
3083
+ }
3084
+ /** How long `key` stands back from a free floor so a waiting organization goes first. */
3085
+ yieldFor(key) {
3086
+ if (this.count > 0 || key !== this.last) return 0;
3087
+ const othersWaiting = [...this.waiting.keys()].some((other) => other !== key);
3088
+ if (!othersWaiting) return 0;
3089
+ return Math.max(0, this.freedAt + this.handoffMs - Date.now());
3090
+ }
3091
+ notify() {
3092
+ const previous = this.changed;
3093
+ this.changed = deferred();
3094
+ previous.resolve();
3095
+ }
3096
+ };
3097
+ function deferred() {
3098
+ let resolve3;
3099
+ const promise = new Promise((done) => resolve3 = done);
3100
+ return { promise, resolve: resolve3 };
3101
+ }
3102
+
3103
+ // src/runner/supervisor.ts
3104
+ var RunnerSupervisor = class {
3105
+ options;
3106
+ floor;
3107
+ running = /* @__PURE__ */ new Map();
3108
+ /** Secrets that failed: revoked ones for good, others until the time given. */
3109
+ failed = /* @__PURE__ */ new Map();
3110
+ stopping = false;
3111
+ settle;
3112
+ constructor(options) {
3113
+ this.options = options;
3114
+ this.floor = options.floor ?? new Floor();
3115
+ }
3116
+ get inFlight() {
3117
+ let total = 0;
3118
+ for (const { loop } of this.running.values()) total += loop.inFlight;
3119
+ return total;
3120
+ }
3121
+ stop() {
3122
+ this.stopping = true;
3123
+ for (const { loop } of this.running.values()) loop.stop();
3124
+ this.settle?.();
3125
+ }
3126
+ abort() {
3127
+ this.stopping = true;
3128
+ for (const { loop } of this.running.values()) loop.abort();
3129
+ this.settle?.();
3130
+ }
3131
+ async run() {
3132
+ const reload = setInterval(() => this.sync(), this.options.reloadMs ?? 5e3);
3133
+ try {
3134
+ this.sync();
3135
+ while (!this.stopping) {
3136
+ const revoked = this.allRevoked();
3137
+ if (revoked) throw revoked;
3138
+ await new Promise((resolve3) => this.settle = resolve3);
3139
+ }
3140
+ await Promise.all([...this.running.values()].map(({ done }) => done));
3141
+ } finally {
3142
+ clearInterval(reload);
3143
+ this.settle = void 0;
3144
+ }
3145
+ }
3146
+ /** Every configured profile is revoked and nothing runs: the error to exit with, else null. */
3147
+ allRevoked() {
3148
+ if (this.running.size > 0) return null;
3149
+ const profiles = this.options.readConfig()?.profiles ?? [];
3150
+ if (profiles.length === 0) return null;
3151
+ let last = null;
3152
+ for (const profile of profiles) {
3153
+ const failure = this.failed.get(profile.token);
3154
+ if (!failure?.revoked) return null;
3155
+ last = failure.revoked;
3156
+ }
3157
+ return profiles.length === 1 || !last ? last : new RunnerRevokedError(
3158
+ "Every organization this runner served disabled, deleted or rotated its runner. Register it again with `aictiq runner register`."
3159
+ );
3160
+ }
3161
+ sync() {
3162
+ if (this.stopping) return;
3163
+ const config = this.options.readConfig();
3164
+ const profiles = config?.profiles ?? [];
3165
+ const wanted = new Map(profiles.map((profile) => [profile.token, profile]));
3166
+ for (const [token, running] of this.running) {
3167
+ if (wanted.has(token) || running.retired) continue;
3168
+ running.retired = true;
3169
+ this.options.local(
3170
+ `[${profileLabel(running.profile)}] Removed from runner.json; stopping after its runs finish`
3171
+ );
3172
+ running.loop.stop();
3173
+ }
3174
+ for (const profile of wanted.values()) {
3175
+ if (this.running.has(profile.token)) continue;
3176
+ const failure = this.failed.get(profile.token);
3177
+ if (failure && (failure.revoked || Date.now() < failure.retryAt)) continue;
3178
+ this.failed.delete(profile.token);
3179
+ this.start(profile);
3180
+ }
3181
+ this.settle?.();
3182
+ }
3183
+ start(profile) {
3184
+ const label = () => profileLabel(profile);
3185
+ const loop = this.options.createLoop(profile, this.floor, (hello) => {
3186
+ if (profile.organization !== hello.organizationSlug) {
3187
+ profile.organization = hello.organizationSlug;
3188
+ this.options.learned?.(profile, hello);
3189
+ }
3190
+ });
3191
+ const running = { profile, loop, done: Promise.resolve(), retired: false };
3192
+ running.done = loop.run().catch((error) => {
3193
+ if (error instanceof RunnerRevokedError) {
3194
+ this.options.local(`[${label()}] ${error.message}`);
3195
+ this.failed.set(profile.token, { revoked: error, retryAt: Number.POSITIVE_INFINITY });
3196
+ return;
3197
+ }
3198
+ const retryMs = this.options.retryMs ?? 6e4;
3199
+ this.options.local(
3200
+ `[${label()}] Stopped: ${error instanceof Error ? error.message : String(error)}; trying again in ${Math.round(retryMs / 1e3)}s`
3201
+ );
3202
+ this.failed.set(profile.token, { revoked: null, retryAt: Date.now() + retryMs });
3203
+ }).finally(() => {
3204
+ this.running.delete(profile.token);
3205
+ this.settle?.();
3206
+ });
3207
+ this.running.set(profile.token, running);
3208
+ }
3209
+ };
3210
+
2760
3211
  // src/commands/runner.ts
2761
3212
  function runnerCommand(globals) {
2762
3213
  const runner = new Command7("runner").description(
2763
3214
  "Run agent work dispatched from a Aictiq instance on this machine"
2764
3215
  );
2765
- runner.command("register").description("Store a runner secret (jrn_\u2026) from Factory \u2192 Runners and check it").option("--url <url>", "Base URL of the Aictiq instance").option("--token <token>", "Runner secret; read from stdin when omitted").option("--name <name>", "A local label for this runner (the instance keeps its own name)").action(async (options) => {
3216
+ runner.command("register").description(
3217
+ "Store a runner secret (jrn_\u2026) from Factory \u2192 Runners and check it. A secret from another organization adds it to this machine"
3218
+ ).option("--url <url>", "Base URL of the Aictiq instance").option("--token <token>", "Runner secret; read from stdin when omitted").option("--name <name>", "A local label for this machine (each instance keeps its own name)").action(async (options) => {
2766
3219
  const global = globals();
2767
3220
  const rawUrl = options.url ?? global.url;
2768
3221
  if (!rawUrl) {
@@ -2779,92 +3232,150 @@ function runnerCommand(globals) {
2779
3232
  );
2780
3233
  }
2781
3234
  const url = rawUrl.replace(/\/+$/, "");
2782
- const capabilities = await probe(1);
3235
+ const existing = readRunnerConfig();
3236
+ const machineId = existing?.machineId ?? randomUUID2();
3237
+ const capabilities = await probe(1, machineId);
2783
3238
  const hello = await new RunnerClient({ baseUrl: url, token }).hello(capabilities).catch((error) => {
2784
3239
  throw asCliError(error);
2785
3240
  });
2786
- const existing = readRunnerConfig();
2787
- const config = {
3241
+ const profiles = existing?.profiles ?? [];
3242
+ const index = await matchingProfile(profiles, url, token, hello.organizationSlug, capabilities);
3243
+ const previous = index >= 0 ? profiles[index] : void 0;
3244
+ const profile = {
2788
3245
  url,
2789
3246
  token,
2790
- ...options.name ? { name: options.name } : existing?.name ? { name: existing.name } : {},
2791
- // Re-registering (a rotated secret) keeps the repository map someone built up.
2792
- workspaces: existing?.workspaces ?? {},
2793
- repoRoots: existing?.repoRoots ?? [],
2794
- attachments: existing?.attachments ?? { maxCount: DefaultAttachmentMaxCount, maxBytes: DefaultAttachmentMaxBytes }
3247
+ organization: hello.organizationSlug,
3248
+ // Re-registering one organization (a rotated secret) keeps the repository map
3249
+ // someone built up; a new organization starts with its own, empty one.
3250
+ workspaces: previous?.workspaces ?? {},
3251
+ repoRoots: previous?.repoRoots ?? []
2795
3252
  };
2796
- writeRunnerConfig(config);
3253
+ const next = index >= 0 ? profiles.map((p, i) => i === index ? profile : p) : [...profiles, profile];
3254
+ const name = options.name ?? existing?.name;
3255
+ writeRunnerConfig({
3256
+ machineId,
3257
+ ...name ? { name } : {},
3258
+ profiles: next,
3259
+ attachments: existing?.attachments ?? { maxCount: DefaultAttachmentMaxCount, maxBytes: DefaultAttachmentMaxBytes }
3260
+ });
2797
3261
  if (globals().json) {
2798
- printJson({ url, runner: hello, capabilities, configPath: runnerConfigPath() });
3262
+ printJson({
3263
+ url,
3264
+ runner: hello,
3265
+ capabilities,
3266
+ configPath: runnerConfigPath(),
3267
+ organizations: next.map(profileLabel)
3268
+ });
2799
3269
  return;
2800
3270
  }
2801
- print(
2802
- `Registered runner "${hello.name}" with ${url} (${hello.organizationSlug}).
2803
- Harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none found on PATH"}
2804
- Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
2805
- );
3271
+ const organizations = next.map(profileLabel);
3272
+ const lines = [
3273
+ `Registered runner "${hello.name}" with ${url} (${hello.organizationSlug}).`,
3274
+ `Harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none found on PATH"}`,
3275
+ `Config written to ${runnerConfigPath()}.`
3276
+ ];
3277
+ if (next.length > 1) {
3278
+ lines.push(
3279
+ "",
3280
+ `This machine now runs for ${next.length} organizations: ${organizations.join(", ")}.`,
3281
+ "Runs from different organizations never execute at the same time.",
3282
+ `Repository roots and mappings are per organization: \`aictiq runner root <path> --org ${hello.organizationSlug}\`.`,
3283
+ "A running `aictiq runner start` picks this up within a few seconds; otherwise start it."
3284
+ );
3285
+ } else {
3286
+ lines.push("Start it with `aictiq runner start`.");
3287
+ }
3288
+ print(lines.join("\n"));
2806
3289
  });
2807
3290
  runner.command("map").description(
2808
3291
  "Map a project key to a local clone, for projects whose repository source is local"
2809
- ).argument("<projectKey>", "Project key, e.g. ACME").argument("[path]", "Path of the clone (default: the current directory); omit with --remove").option("--remove", "Remove the mapping").action((projectKey, path, options) => {
3292
+ ).argument("<projectKey>", "Project key, e.g. ACME").argument("[path]", "Path of the clone (default: the current directory); omit with --remove").option("--remove", "Remove the mapping").option("--org <slug>", "The organization the mapping is for, when this machine runs for several").action((projectKey, path, options) => {
2810
3293
  const config = requireConfig();
3294
+ const profile = selectProfile(config, options.org ?? globals().org);
2811
3295
  const key = projectKey.toUpperCase();
2812
3296
  if (options.remove) {
2813
- delete config.workspaces[key];
3297
+ delete profile.workspaces[key];
2814
3298
  } else {
2815
3299
  const absolute = resolve2(path ?? ".");
2816
3300
  if (!existsSync3(absolute))
2817
3301
  throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
2818
- config.workspaces[key] = absolute;
3302
+ profile.workspaces[key] = absolute;
2819
3303
  }
2820
3304
  writeRunnerConfig(config);
3305
+ const where = config.profiles.length > 1 ? ` (${profileLabel(profile)})` : "";
2821
3306
  print(
2822
- options.remove ? `Removed the mapping for ${key}.` : `${key} \u2192 ${config.workspaces[key]}`
3307
+ options.remove ? `Removed the mapping for ${key}${where}.` : `${key} \u2192 ${profile.workspaces[key]}${where}`
2823
3308
  );
2824
3309
  });
2825
3310
  runner.command("root").description(
2826
3311
  "Trust the web UI's path hint for local projects whose clone is under this directory"
2827
- ).argument("[path]", "Directory that holds your clones (default: the current directory)").option("--remove", "Stop trusting path hints under this directory").action((path, options) => {
3312
+ ).argument("[path]", "Directory that holds your clones (default: the current directory)").option("--remove", "Stop trusting path hints under this directory").option("--org <slug>", "The organization whose path hints to trust, when this machine runs for several").action((path, options) => {
2828
3313
  const config = requireConfig();
3314
+ const profile = selectProfile(config, options.org ?? globals().org);
2829
3315
  const absolute = resolve2(path ?? ".");
2830
3316
  if (options.remove) {
2831
- config.repoRoots = config.repoRoots.filter((root) => resolve2(root) !== absolute);
3317
+ profile.repoRoots = profile.repoRoots.filter((root) => resolve2(root) !== absolute);
2832
3318
  } else {
2833
3319
  if (!existsSync3(absolute))
2834
3320
  throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
2835
- if (!config.repoRoots.some((root) => resolve2(root) === absolute))
2836
- config.repoRoots.push(absolute);
3321
+ if (!profile.repoRoots.some((root) => resolve2(root) === absolute))
3322
+ profile.repoRoots.push(absolute);
2837
3323
  }
2838
3324
  writeRunnerConfig(config);
3325
+ const whose = config.profiles.length > 1 ? `${profileLabel(profile)}'s path hints` : "Path hints";
2839
3326
  print(
2840
- options.remove ? `Path hints under ${absolute} are no longer used.` : `Path hints under ${absolute} are used for projects without a mapping.`
3327
+ options.remove ? `${whose} under ${absolute} are no longer used.` : `${whose} under ${absolute} are used for projects without a mapping.`
2841
3328
  );
2842
3329
  });
2843
- runner.command("status").description("Show the registration, detected harnesses and mapped repositories").action(async () => {
3330
+ runner.command("remove").description("Stop running for one organization on this machine (the other organizations are kept)").argument("<org>", "Organization slug, as `aictiq runner status` lists it").action((org) => {
2844
3331
  const config = requireConfig();
2845
- const capabilities = await probe(1);
2846
- let registration;
2847
- let registrationText;
2848
- try {
2849
- const hello = await new RunnerClient({ baseUrl: config.url, token: config.token }).hello(
2850
- capabilities
2851
- );
2852
- registration = hello;
2853
- registrationText = `ok - "${hello.name}" in ${hello.organizationSlug}`;
2854
- } catch (error) {
2855
- registration = { error: error instanceof Error ? error.message : String(error) };
2856
- registrationText = `failing - ${error instanceof Error ? error.message : String(error)}`;
2857
- if (error instanceof RunnerHttpError && error.revoked) process.exitCode = ExitCode.Auth;
2858
- }
3332
+ const profile = selectProfile(config, org);
3333
+ config.profiles = config.profiles.filter((p) => p !== profile);
3334
+ writeRunnerConfig(config);
3335
+ print(
3336
+ `Removed ${profileLabel(profile)} from this machine. A running \`aictiq runner start\` stops taking its runs within a few seconds.
3337
+ Its secret still works until you disable or delete the runner in ${profileLabel(profile)}'s Factory \u2192 Runners.`
3338
+ );
3339
+ });
3340
+ runner.command("status").description("Show each registration, detected harnesses and mapped repositories").action(async () => {
3341
+ const config = requireConfig();
3342
+ const capabilities = await probe(1, config.machineId);
3343
+ const registrations = await Promise.all(
3344
+ config.profiles.map(async (profile) => {
3345
+ try {
3346
+ const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
3347
+ return { profile, hello, text: `ok - "${hello.name}" in ${hello.organizationSlug}`, revoked: false };
3348
+ } catch (error) {
3349
+ const message3 = error instanceof Error ? error.message : String(error);
3350
+ return {
3351
+ profile,
3352
+ error: message3,
3353
+ text: `failing - ${message3}`,
3354
+ revoked: error instanceof RunnerHttpError && error.revoked
3355
+ };
3356
+ }
3357
+ })
3358
+ );
3359
+ if (registrations.some((r) => r.revoked)) process.exitCode = ExitCode.Auth;
2859
3360
  if (globals().json) {
2860
- printJson({ url: config.url, registration, capabilities, workspaces: config.workspaces, repoRoots: config.repoRoots, attachments: config.attachments });
3361
+ printJson({
3362
+ machineId: config.machineId,
3363
+ capabilities,
3364
+ attachments: config.attachments,
3365
+ profiles: registrations.map((r) => ({
3366
+ url: r.profile.url,
3367
+ organization: r.hello?.organizationSlug ?? r.profile.organization ?? null,
3368
+ registration: r.hello ?? { error: r.error },
3369
+ workspaces: r.profile.workspaces,
3370
+ repoRoots: r.profile.repoRoots
3371
+ }))
3372
+ });
2861
3373
  return;
2862
3374
  }
2863
3375
  print(
2864
3376
  renderFields([
2865
- ["Instance", config.url],
2866
- ["Registration", registrationText],
2867
3377
  ["Config", runnerConfigPath()],
3378
+ ["Machine", config.machineId],
2868
3379
  ["Workspaces", defaultWorkspaceRoot()],
2869
3380
  ["Attachments", `${config.attachments.maxCount} files / ${formatBytes2(config.attachments.maxBytes)} per run`],
2870
3381
  ["CLI", `${version} (${capabilities.os}/${capabilities.arch})`]
@@ -2877,52 +3388,80 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
2877
3388
  { header: "VERSION", value: (h) => h.version ?? "" }
2878
3389
  ])
2879
3390
  );
2880
- print("");
2881
- const mapped = Object.entries(config.workspaces);
2882
- print(
2883
- mapped.length === 0 ? "No local repositories mapped (`aictiq runner map <KEY> <path>`)." : renderTable(mapped, [
2884
- { header: "PROJECT", value: ([key]) => key },
2885
- { header: "REPOSITORY", value: ([, path]) => path }
2886
- ])
2887
- );
2888
- print("");
2889
- print(
2890
- config.repoRoots.length === 0 ? "No repository roots; path hints from the web UI are not used (`aictiq runner root <path>`)." : renderTable(config.repoRoots, [{ header: "REPOSITORY ROOT", value: (root) => root }])
2891
- );
3391
+ for (const { profile, hello, text } of registrations) {
3392
+ const org = hello?.organizationSlug ?? profileLabel(profile);
3393
+ print("");
3394
+ print(
3395
+ renderFields([
3396
+ ["Organization", org],
3397
+ ["Instance", profile.url],
3398
+ ["Registration", text]
3399
+ ])
3400
+ );
3401
+ const mapped = Object.entries(profile.workspaces);
3402
+ print(
3403
+ mapped.length === 0 ? `No local repositories mapped (\`aictiq runner map <KEY> <path> --org ${org}\`).` : renderTable(mapped, [
3404
+ { header: "PROJECT", value: ([key]) => key },
3405
+ { header: "REPOSITORY", value: ([, path]) => path }
3406
+ ])
3407
+ );
3408
+ print(
3409
+ profile.repoRoots.length === 0 ? `No repository roots; path hints from ${org} are not used (\`aictiq runner root <path> --org ${org}\`).` : renderTable(profile.repoRoots, [{ header: "REPOSITORY ROOT", value: (root) => root }])
3410
+ );
3411
+ }
2892
3412
  });
2893
- runner.command("start").description("Poll for runs and execute them until interrupted").option("--parallel <n>", "Runs executed at the same time", "1").option("--keep-workspaces", "Leave each run\u2019s checkout on disk after it finishes").option("--workspace-root <path>", "Where run checkouts are created").action(
3413
+ runner.command("start").description("Poll every registered organization for runs and execute them until interrupted").option("--parallel <n>", "Runs of one organization executed at the same time", "1").option("--keep-workspaces", "Leave each run\u2019s checkout on disk after it finishes").option("--workspace-root <path>", "Where run checkouts are created").action(
2894
3414
  async (options) => {
2895
3415
  const config = requireConfig();
2896
3416
  const parallel = Number.parseInt(options.parallel, 10);
2897
3417
  if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
2898
3418
  throw new CliError("--parallel is between 1 and 16.", ExitCode.Validation);
2899
3419
  }
2900
- const client = new RunnerClient({ baseUrl: config.url, token: config.token });
2901
- const local = (message3) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${message3}
3420
+ writeRunnerConfig(config);
3421
+ const log = (message3) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${message3}
2902
3422
  `);
2903
3423
  const workspaceRoot = resolve2(options.workspaceRoot ?? defaultWorkspaceRoot());
2904
- const loop = new RunnerLoop({
2905
- client,
2906
- parallel,
2907
- probe: () => probe(parallel),
2908
- local,
2909
- execute: (run, hello, shutdown) => {
2910
- const current = readRunnerConfig() ?? config;
2911
- return executeRun(run, {
3424
+ const supervisor = new RunnerSupervisor({
3425
+ readConfig: () => readRunnerConfig(),
3426
+ local: log,
3427
+ learned: (profile) => {
3428
+ const current = readRunnerConfig();
3429
+ const stored = current?.profiles.find((p) => p.token === profile.token);
3430
+ if (!current || !stored || stored.organization === profile.organization) return;
3431
+ stored.organization = profile.organization;
3432
+ writeRunnerConfig(current);
3433
+ },
3434
+ createLoop: (profile, floor, onHello) => {
3435
+ const client = new RunnerClient({ baseUrl: profile.url, token: profile.token });
3436
+ const local = (message3) => log(`[${profileLabel(profile)}] ${message3}`);
3437
+ return new RunnerLoop({
2912
3438
  client,
2913
- hello,
2914
- adapters: harnesses,
2915
- runnerToken: config.token,
2916
- shutdown,
3439
+ parallel,
3440
+ probe: () => probe(parallel, config.machineId),
2917
3441
  local,
2918
- workspace: {
2919
- root: workspaceRoot,
2920
- repositories: current.workspaces,
2921
- repoRoots: current.repoRoots,
2922
- keep: options.keepWorkspaces === true,
2923
- mcpServer: mcpServerCommand(),
2924
- attachmentMaxCount: current.attachments.maxCount,
2925
- attachmentMaxBytes: current.attachments.maxBytes
3442
+ floor,
3443
+ floorKey: profileKey(profile),
3444
+ onHello,
3445
+ execute: (run, hello, shutdown) => {
3446
+ const current = readRunnerConfig();
3447
+ const own = current?.profiles.find((p) => p.token === profile.token) ?? profile;
3448
+ return executeRun(run, {
3449
+ client,
3450
+ hello,
3451
+ adapters: harnesses,
3452
+ runnerToken: profile.token,
3453
+ shutdown,
3454
+ local,
3455
+ workspace: {
3456
+ root: workspaceRoot,
3457
+ repositories: own.workspaces,
3458
+ repoRoots: own.repoRoots,
3459
+ keep: options.keepWorkspaces === true,
3460
+ mcpServer: mcpServerCommand(),
3461
+ attachmentMaxCount: (current ?? config).attachments.maxCount,
3462
+ attachmentMaxBytes: (current ?? config).attachments.maxBytes
3463
+ }
3464
+ });
2926
3465
  }
2927
3466
  });
2928
3467
  }
@@ -2930,22 +3469,22 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
2930
3469
  let interrupts = 0;
2931
3470
  const onSignal = (signal2) => {
2932
3471
  interrupts++;
2933
- if (interrupts === 1 && loop.inFlight > 0) {
2934
- local(
2935
- `${signal2}: finishing ${loop.inFlight} run(s) in flight; send it again to cancel them`
3472
+ if (interrupts === 1 && supervisor.inFlight > 0) {
3473
+ log(
3474
+ `${signal2}: finishing ${supervisor.inFlight} run(s) in flight; send it again to cancel them`
2936
3475
  );
2937
- loop.stop();
3476
+ supervisor.stop();
2938
3477
  } else if (interrupts === 1) {
2939
- loop.stop();
3478
+ supervisor.stop();
2940
3479
  } else {
2941
- local(`${signal2}: cancelling runs in flight`);
2942
- loop.abort();
3480
+ log(`${signal2}: cancelling runs in flight`);
3481
+ supervisor.abort();
2943
3482
  }
2944
3483
  };
2945
3484
  process.on("SIGINT", onSignal);
2946
3485
  process.on("SIGTERM", onSignal);
2947
3486
  try {
2948
- await loop.run();
3487
+ await supervisor.run();
2949
3488
  } catch (error) {
2950
3489
  throw asCliError(error);
2951
3490
  } finally {
@@ -2954,50 +3493,72 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
2954
3493
  }
2955
3494
  }
2956
3495
  );
2957
- runner.command("install-service").description("Print a systemd user unit that keeps `aictiq runner start` running").option("--parallel <n>", "Runs executed at the same time", "1").action((options) => {
2958
- print(systemdUnit(process.execPath, cliEntry(), options.parallel));
3496
+ runner.command("install-service").description(
3497
+ "Print a service definition that keeps `aictiq runner start` running: a systemd user unit (Linux), a launchd agent (macOS) or a Task Scheduler installer (Windows)"
3498
+ ).option("--parallel <n>", "Runs executed at the same time", "1").option("--platform <platform>", "linux, macos or windows (default: this machine)").action((options) => {
3499
+ const platform2 = options.platform ?? servicePlatform();
3500
+ if (!isServicePlatform(platform2)) {
3501
+ throw new CliError(
3502
+ `Unknown platform "${platform2}". Use linux, macos or windows.`,
3503
+ ExitCode.Validation
3504
+ );
3505
+ }
3506
+ print(
3507
+ serviceDefinition(platform2, {
3508
+ node: process.execPath,
3509
+ entry: cliEntry(),
3510
+ parallel: options.parallel,
3511
+ path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
3512
+ home: homedir4()
3513
+ })
3514
+ );
2959
3515
  });
2960
3516
  return runner;
2961
3517
  }
2962
- function systemdUnit(node, entry, parallel) {
2963
- return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
2964
- # systemctl --user daemon-reload
2965
- # systemctl --user enable --now aictiq-runner
2966
- # loginctl enable-linger "$USER" # keep it running after you log out
2967
- #
2968
- # The runner executes agents as this user, with this user's harness sign-ins and git
2969
- # credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
2970
- # TimeoutStopSec bounds how long systemd waits before it cancels them.
2971
- [Unit]
2972
- Description=Aictiq runner
2973
- After=network-online.target
2974
- Wants=network-online.target
2975
-
2976
- [Service]
2977
- Type=simple
2978
- ExecStart=${node} ${entry} runner start --parallel ${parallel}
2979
- Restart=on-failure
2980
- RestartSec=10
2981
- # Exit 5 means the secret was revoked; restarting cannot fix that.
2982
- RestartPreventExitStatus=5
2983
- KillMode=mixed
2984
- TimeoutStopSec=15min
2985
- Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
2986
-
2987
- [Install]
2988
- WantedBy=default.target
2989
- `;
2990
- }
2991
- async function probe(maxParallel) {
3518
+ async function probe(maxParallel, machineId) {
2992
3519
  return {
2993
3520
  v: 1,
2994
3521
  harnesses: await probeHarnesses(),
2995
3522
  os: platform(),
2996
3523
  arch: arch(),
2997
3524
  cliVersion: version,
2998
- maxParallel
3525
+ maxParallel,
3526
+ machineId
2999
3527
  };
3000
3528
  }
3529
+ async function matchingProfile(profiles, url, token, organization, capabilities) {
3530
+ const exact = profiles.findIndex(
3531
+ (p) => p.token === token || sameInstance(p.url, url) && p.organization === organization
3532
+ );
3533
+ if (exact >= 0) return exact;
3534
+ for (const [index, profile] of profiles.entries()) {
3535
+ if (profile.organization || !sameInstance(profile.url, url)) continue;
3536
+ try {
3537
+ const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
3538
+ profile.organization = hello.organizationSlug;
3539
+ if (hello.organizationSlug === organization) return index;
3540
+ } catch (error) {
3541
+ if (error instanceof RunnerHttpError && error.revoked && profiles.length === 1) return index;
3542
+ }
3543
+ }
3544
+ return -1;
3545
+ }
3546
+ function selectProfile(config, org) {
3547
+ const serves = config.profiles.map(profileLabel).join(", ");
3548
+ if (org) {
3549
+ const wanted = org.toLowerCase();
3550
+ const found = config.profiles.find((p) => profileLabel(p).toLowerCase() === wanted);
3551
+ if (!found) {
3552
+ throw new CliError(`This machine does not run for ${org}. It runs for: ${serves}.`, ExitCode.Validation);
3553
+ }
3554
+ return found;
3555
+ }
3556
+ if (config.profiles.length === 1) return config.profiles[0];
3557
+ throw new CliError(
3558
+ `This machine runs for several organizations (${serves}). Say which with --org <slug>.`,
3559
+ ExitCode.Validation
3560
+ );
3561
+ }
3001
3562
  function cliEntry() {
3002
3563
  return resolve2(process.argv[1] ?? "aictiq");
3003
3564
  }
@@ -3009,7 +3570,7 @@ function formatBytes2(value) {
3009
3570
  }
3010
3571
  function requireConfig() {
3011
3572
  const config = readRunnerConfig();
3012
- if (!config) {
3573
+ if (!config || config.profiles.length === 0) {
3013
3574
  throw new CliError(
3014
3575
  `This machine is not registered as a runner (${runnerConfigPath()}). Run \`aictiq runner register --url <url> --token <jrn_\u2026>\`.`,
3015
3576
  ExitCode.Auth
@@ -3027,6 +3588,9 @@ function asCliError(error) {
3027
3588
  }
3028
3589
  return error;
3029
3590
  }
3591
+ function isServicePlatform(value) {
3592
+ return value === "linux" || value === "macos" || value === "windows";
3593
+ }
3030
3594
 
3031
3595
  // src/commands/sprint.ts
3032
3596
  import { Command as Command8 } from "commander";