@aloud/runner 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -9,7 +9,7 @@ outbound only: nothing listens on a port, and the server holds no address for th
9
9
  ```sh
10
10
  npm install -g @aloud/runner
11
11
 
12
- aloud login # paste the token from usealoud.com/app/settings/runners
12
+ aloud login # approve this machine in your browser
13
13
  aloud start # waits for studies and runs them here
14
14
  ```
15
15
 
@@ -19,12 +19,25 @@ The first `aloud start` downloads Chromium, about 350 MB, once.
19
19
 
20
20
  | Command | What it does |
21
21
  | --- | --- |
22
+ | `aloud setup` | Inspect this machine and complete whatever setup remains |
22
23
  | `aloud login [--token <token>]` | Connect this machine to your workspace |
23
- | `aloud start [--once] [--quiet]` | Wait for studies and run them here |
24
+ | `aloud start [--once] [--quiet] [--no-update]` | Update, then wait for studies and run them here |
24
25
  | `aloud status` | What is set up, what is not, and whether it is running |
25
26
  | `aloud allow <host>` | Let studies open this host from this machine |
26
27
  | `aloud mcp` | Connect an MCP host to the web workspace selected by its MCP access token |
27
28
  | `aloud logout` | Forget the token on this machine |
29
+ | `aloud --version` | Print the installed runner version |
30
+
31
+ ## Updates
32
+
33
+ Starting with 0.3.1, `aloud start` asks the connected Aloud server which official runner release
34
+ it recommends. If a newer one is available, it installs that exact `@aloud/runner` version and
35
+ relaunches it before claiming work. It never replaces code during an active study.
36
+
37
+ If an optional update cannot be installed, the terminal says why and a still-compatible runner may
38
+ continue. If the server requires the update, nothing starts and the command exits non-zero with the
39
+ manual repair command. `--no-update` is available for managed environments, but it cannot make an
40
+ incompatible runner claim work.
28
41
 
29
42
  ## MCP access
30
43
 
package/dist/cli.js CHANGED
@@ -2286,7 +2286,7 @@ var init_registry = __esm({
2286
2286
  PinnedPromptVersions = {
2287
2287
  product_discovery: "1.0.0",
2288
2288
  study_design: "1.0.0",
2289
- persona_derivation: "2.1.0",
2289
+ persona_derivation: "2.2.0",
2290
2290
  success_criteria: "2.0.0",
2291
2291
  participant_decision: "2.2.0",
2292
2292
  participant_reaction: "1.2.0",
@@ -2685,6 +2685,11 @@ What the site appears to be, from its public pages:
2685
2685
  The flow being tested: {{targetFlow}}
2686
2686
  The goal each participant will be given: {{goal}}
2687
2687
 
2688
+ The device for this study is fixed by the operator: {{deviceDescription}} ({{device}}).
2689
+ Every person will use that device for this study. Do not put anyone on another device in their role,
2690
+ jobToBeDone, background, attentionBias, derivedFrom, rationale, or any other prose. Device is not a
2691
+ persona choice and must not appear as a field in your reply.
2692
+
2688
2693
  Audience segments relevant to this particular goal:
2689
2694
  {{relevantAudiences}}
2690
2695
 
@@ -2707,7 +2712,6 @@ For each person give:
2707
2712
  - "familiarityWithProduct", "familiarityWithDomain", "technicalConfidence", "patience", "urgency":
2708
2713
  each one of very_low, low, moderate, high, very_high
2709
2714
  - "emotionalStance": curious, neutral, sceptical, hurried, anxious, or enthusiastic
2710
- - "device": desktop or mobile_web
2711
2715
  - "background": two sentences of context that would change their behaviour
2712
2716
  - "attentionBias": what this person notices or cares about that the others would not
2713
2717
  - "completionOriented": true for exactly the one completion-oriented person, false for everyone else
@@ -2724,9 +2728,9 @@ Return JSON: {"personas": [ ... ]}
2724
2728
  `.trim();
2725
2729
  personaDerivationPrompt = definePrompt(
2726
2730
  "persona_derivation",
2727
- "2.1.0",
2731
+ "2.2.0",
2728
2732
  personaSource,
2729
- (vars) => personaSource.replace("{{productSignals}}", s(vars.productSignals)).replace("{{targetFlow}}", s(vars.targetFlow)).replace("{{goal}}", s(vars.goal)).replace("{{relevantAudiences}}", s(vars.relevantAudiences) || "The intended audience is not yet known.").replace("{{count}}", s(vars.count))
2733
+ (vars) => personaSource.replace("{{productSignals}}", s(vars.productSignals)).replace("{{targetFlow}}", s(vars.targetFlow)).replace("{{goal}}", s(vars.goal)).replace("{{deviceDescription}}", s(vars.deviceDescription)).replace("{{device}}", s(vars.device)).replace("{{relevantAudiences}}", s(vars.relevantAudiences) || "The intended audience is not yet known.").replace("{{count}}", s(vars.count))
2730
2734
  );
2731
2735
  criteriaSource = `
2732
2736
  A team wants to test this goal on their website: {{goal}}
@@ -3381,8 +3385,39 @@ function alive(pid) {
3381
3385
  }
3382
3386
 
3383
3387
  // src/version.ts
3384
- var RUNNER_VERSION = "0.3.0";
3388
+ var RUNNER_VERSION = "0.3.1";
3385
3389
  var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
3390
+ function versionParts(value) {
3391
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
3392
+ if (!match) return null;
3393
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
3394
+ return parts.every(Number.isSafeInteger) ? parts : null;
3395
+ }
3396
+ function compareRunnerVersions(left, right) {
3397
+ const a = versionParts(left);
3398
+ const b = versionParts(right);
3399
+ if (!a || !b) return null;
3400
+ for (let index = 0; index < a.length; index += 1) {
3401
+ if (a[index] > b[index]) return 1;
3402
+ if (a[index] < b[index]) return -1;
3403
+ }
3404
+ return 0;
3405
+ }
3406
+ function runnerVersionPolicyFrom(value) {
3407
+ if (!value || typeof value !== "object") return null;
3408
+ const policy = value;
3409
+ if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
3410
+ const order = compareRunnerVersions(policy.recommended, policy.minimum);
3411
+ if (order === null || order < 0) return null;
3412
+ return { minimum: policy.minimum, recommended: policy.recommended };
3413
+ }
3414
+ function runnerUpdateFor(policy, current = RUNNER_VERSION) {
3415
+ const recommendedOrder = compareRunnerVersions(policy.recommended, current);
3416
+ const minimumOrder = compareRunnerVersions(policy.minimum, current);
3417
+ if (recommendedOrder === null || minimumOrder === null) return null;
3418
+ if (recommendedOrder <= 0) return null;
3419
+ return { target: policy.recommended, required: minimumOrder > 0 };
3420
+ }
3386
3421
 
3387
3422
  // src/protocol/client.ts
3388
3423
  var LeaseLostError = class extends Error {
@@ -7485,7 +7520,7 @@ function buildRunLimits(input) {
7485
7520
  const { synthesis, snapshot } = input;
7486
7521
  if (snapshot.cast.some((persona) => persona.completionOriented === true)) {
7487
7522
  limits.push(
7488
- "One participant was deliberately completion-oriented to exercise more of the task path. They still used only visible controls, documented friction, and could stop at a genuine blocker; their persistence is a study-design choice, not a population estimate."
7523
+ "One participant was deliberately assigned the resilient goal-finisher role to exercise more of the task path. They were highly motivated to finish, but still used only visible controls, documented friction, and could stop at a genuine blocker; their persistence is a study-design choice, not a population estimate."
7489
7524
  );
7490
7525
  }
7491
7526
  if (synthesis.usableSessions.length < snapshot.cast.length) {
@@ -9378,7 +9413,6 @@ var PersonaProposalResponse = z24.object({
9378
9413
  patience: Level,
9379
9414
  urgency: Level,
9380
9415
  emotionalStance: z24.enum(["curious", "neutral", "sceptical", "hurried", "anxious", "enthusiastic"]),
9381
- device: DeviceContext,
9382
9416
  background: z24.string().max(1200).default(""),
9383
9417
  attentionBias: z24.string().max(400).default(""),
9384
9418
  completionOriented: z24.boolean().default(false),
@@ -9446,6 +9480,15 @@ init_src();
9446
9480
  init_src();
9447
9481
  var DAY_MS = 24 * 60 * 60 * 1e3;
9448
9482
 
9483
+ // ../app/src/services/runner-lease.ts
9484
+ init_src();
9485
+
9486
+ // ../app/src/services/run-finalize.ts
9487
+ init_src();
9488
+
9489
+ // ../app/src/services/runner-lease.ts
9490
+ var DEFAULT_LEASE_SECONDS = 20 * 60;
9491
+
9449
9492
  // ../app/src/application.ts
9450
9493
  var PreflightFailedError = class extends Error {
9451
9494
  constructor(preflight2) {
@@ -9661,10 +9704,6 @@ init_src();
9661
9704
  // ../app/src/services/recommendations.ts
9662
9705
  init_src();
9663
9706
 
9664
- // ../app/src/services/runner-lease.ts
9665
- init_src();
9666
- var DEFAULT_LEASE_SECONDS = 20 * 60;
9667
-
9668
9707
  // ../app/src/services/scheduling.ts
9669
9708
  init_src();
9670
9709
 
@@ -9832,7 +9871,7 @@ function text(value) {
9832
9871
  function json(value) {
9833
9872
  return text(JSON.stringify(value, null, 2));
9834
9873
  }
9835
- async function handleTool(context, name, input, execute) {
9874
+ async function handleTool(context, name, input, execute, prepare) {
9836
9875
  const { app, actor, workspaceId } = context;
9837
9876
  switch (name) {
9838
9877
  case "create_study": {
@@ -9965,6 +10004,21 @@ async function handleTool(context, name, input, execute) {
9965
10004
  }
9966
10005
  case "start_study": {
9967
10006
  const parsed = StartStudyInput.parse(input);
10007
+ const blocked = await prepare?.(parsed.studyId, parsed.idempotencyKey);
10008
+ if (blocked) {
10009
+ return {
10010
+ ...json({
10011
+ started: false,
10012
+ reason: "runner_not_ready",
10013
+ code: blocked.code,
10014
+ blockedHost: blocked.blockedHost,
10015
+ message: blocked.message,
10016
+ runId: blocked.existingRunId ?? null,
10017
+ note: blocked.existingRunId ? "The existing run was not duplicated. Fix the named machine condition and call start_study again with the same runId and idempotencyKey." : "No run was created. Fix the named machine condition and call start_study again with the same runId and idempotencyKey."
10018
+ }),
10019
+ isError: true
10020
+ };
10021
+ }
9968
10022
  let outcome;
9969
10023
  try {
9970
10024
  outcome = await app.startRun(actor, {
@@ -10308,7 +10362,7 @@ function createMcpServer(options) {
10308
10362
  function backendFor(options) {
10309
10363
  if ("backend" in options) return options.backend;
10310
10364
  return {
10311
- callTool: (name, input) => handleTool(options.context, name, input, options.execute),
10365
+ callTool: (name, input) => handleTool(options.context, name, input, options.execute, options.prepare),
10312
10366
  readResource: (parsed) => readResource(options.context, parsed)
10313
10367
  };
10314
10368
  }
@@ -10512,6 +10566,11 @@ async function main(argv = process.argv.slice(2)) {
10512
10566
  return allow(rest);
10513
10567
  case "setup":
10514
10568
  return setup();
10569
+ case "version":
10570
+ case "--version":
10571
+ case "-v":
10572
+ process.stdout.write(RUNNER_VERSION + "\n");
10573
+ return 0;
10515
10574
  case "mcp":
10516
10575
  if (rest[0] === "connect") return connectMcp(rest.slice(1));
10517
10576
  if (rest[0] === "disconnect") return disconnectMcp();
@@ -10538,12 +10597,14 @@ function printHelp() {
10538
10597
  "",
10539
10598
  " aloud setup What to do next, for a person or an agent",
10540
10599
  " aloud login [--token <token>] Connect this machine, approving it in your browser",
10541
- " aloud start [--once] [--quiet] Wait for studies and run them here",
10600
+ " aloud start [--once] [--quiet] [--no-update]",
10601
+ " Update, then wait for studies and run them here",
10542
10602
  " aloud status What is set up, and whether it is running",
10543
10603
  " aloud allow <host> Let studies open this host from this machine",
10544
10604
  " aloud mcp Serve MCP to an editor, using the saved credential",
10545
10605
  " aloud mcp connect Connect an editor, approving it in your browser",
10546
10606
  " aloud logout Forget the token on this machine",
10607
+ " aloud --version Print the installed runner version",
10547
10608
  "",
10548
10609
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
10549
10610
  ""
@@ -10728,7 +10789,7 @@ async function setup() {
10728
10789
  const running = await readRunning();
10729
10790
  const installed = onPath("aloud");
10730
10791
  const latest = await latestVersion();
10731
- const stale = latest !== null && latest !== RUNNER_VERSION;
10792
+ const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
10732
10793
  const signedIn = await signedInState(credentials);
10733
10794
  const out = (line = "") => process.stdout.write(line + "\n");
10734
10795
  out();
@@ -10751,11 +10812,11 @@ async function setup() {
10751
10812
  }
10752
10813
  if (signedIn.state === "revoked") {
10753
10814
  steps.push([
10754
- `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
10755
- "The saved one was revoked and cannot be reused."
10815
+ "aloud login",
10816
+ "The saved connection was revoked. This prints a new browser-approval link and code."
10756
10817
  ]);
10757
10818
  }
10758
- if (signedIn.state === "none" || signedIn.state === "revoked") {
10819
+ if (signedIn.state === "none") {
10759
10820
  steps.push([
10760
10821
  "aloud login",
10761
10822
  "Prints a link and a short code, then waits. Give both to the person; they approve in",
@@ -10795,7 +10856,7 @@ async function setup() {
10795
10856
  );
10796
10857
  }
10797
10858
  out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
10798
- out(" Do not install a similarly named package, and do not hand the token to anything else.");
10859
+ out(" Do not install a similarly named package.");
10799
10860
  out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
10800
10861
  out(" and the credential goes straight to this machine. Never ask anyone for a token.");
10801
10862
  out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
@@ -10935,6 +10996,139 @@ async function latestVersion() {
10935
10996
  return null;
10936
10997
  }
10937
10998
  }
10999
+ async function serverVersionPolicy(credentials) {
11000
+ try {
11001
+ const client = new RunnerClient({ server: credentials.server, token: credentials.token });
11002
+ const response = await client.request("api/runner/me", {
11003
+ retry: false
11004
+ });
11005
+ return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
11006
+ } catch {
11007
+ return null;
11008
+ }
11009
+ }
11010
+ async function updateBeforeStart(credentials, argv, overrides = {}) {
11011
+ const stdout = overrides.stdout ?? ((text2) => process.stdout.write(text2));
11012
+ const stderr = overrides.stderr ?? ((text2) => process.stderr.write(text2));
11013
+ const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
11014
+ if (!policy) return null;
11015
+ const update = runnerUpdateFor(policy);
11016
+ if (!update) return null;
11017
+ if (argv.includes("--no-update")) {
11018
+ if (!update.required) return null;
11019
+ stderr(
11020
+ `Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.
11021
+ Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.
11022
+ `
11023
+ );
11024
+ return 1;
11025
+ }
11026
+ stdout(`
11027
+ Updating Aloud runner ${RUNNER_VERSION} \u2192 ${update.target} before it starts.
11028
+ `);
11029
+ const prefix = await (overrides.npmPrefix ?? npmPrefix)();
11030
+ if (prefix?.writable === false) {
11031
+ return failedAutomaticUpdate(
11032
+ update,
11033
+ policy,
11034
+ `npm's global install directory (${prefix.path}) is not writable by this user.`,
11035
+ stderr
11036
+ );
11037
+ }
11038
+ const installed = overrides.install ? await overrides.install(update.target, stdout) : await run(
11039
+ "npm",
11040
+ ["install", "-g", `@aloud/runner@${update.target}`],
11041
+ (line = "") => stdout(line + "\n")
11042
+ );
11043
+ if (!installed) {
11044
+ return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
11045
+ }
11046
+ const entry2 = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
11047
+ if (!entry2) {
11048
+ return failedAutomaticUpdate(
11049
+ update,
11050
+ policy,
11051
+ `npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
11052
+ stderr
11053
+ );
11054
+ }
11055
+ stdout(`
11056
+ Updated to ${update.target}. Starting it now.
11057
+
11058
+ `);
11059
+ return (overrides.relaunch ?? relaunchUpdatedRunner)(entry2, argv);
11060
+ }
11061
+ function failedAutomaticUpdate(update, policy, reason, stderr) {
11062
+ const command = `npm install -g @aloud/runner@${update.target}`;
11063
+ if (update.required) {
11064
+ stderr(
11065
+ `
11066
+ ${reason}
11067
+ Runner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.
11068
+ Do not use sudo. Fix npm's global prefix, then run: ${command}
11069
+ `
11070
+ );
11071
+ return 1;
11072
+ }
11073
+ stderr(
11074
+ `
11075
+ ${reason}
11076
+ Runner ${RUNNER_VERSION} is still compatible, so it will start without updating.
11077
+ To update it later, run: ${command}
11078
+
11079
+ `
11080
+ );
11081
+ return null;
11082
+ }
11083
+ async function installedRunnerEntry(target) {
11084
+ const root = await commandOutput("npm", ["root", "--global"]);
11085
+ if (!root) return null;
11086
+ const directory = join6(root, "@aloud", "runner");
11087
+ const entry2 = join6(directory, "dist", "cli.js");
11088
+ try {
11089
+ const manifest = JSON.parse(readFileSync(join6(directory, "package.json"), "utf8"));
11090
+ return manifest.version === target && existsSync2(entry2) ? entry2 : null;
11091
+ } catch {
11092
+ return null;
11093
+ }
11094
+ }
11095
+ async function commandOutput(command, args) {
11096
+ return new Promise((resolve) => {
11097
+ const child = spawn2(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
11098
+ let output = "";
11099
+ let settled = false;
11100
+ const finish = (value) => {
11101
+ if (settled) return;
11102
+ settled = true;
11103
+ resolve(value);
11104
+ };
11105
+ child.stdout?.on("data", (chunk) => output += chunk.toString("utf8"));
11106
+ child.on("error", () => finish(null));
11107
+ child.on("close", (code) => finish(code === 0 && output.trim() ? output.trim() : null));
11108
+ });
11109
+ }
11110
+ async function relaunchUpdatedRunner(entry2, argv) {
11111
+ return new Promise((resolve) => {
11112
+ let settled = false;
11113
+ const finish = (code) => {
11114
+ if (settled) return;
11115
+ settled = true;
11116
+ resolve(code);
11117
+ };
11118
+ const child = spawn2(process.execPath, [entry2, "start", ...argv, "--no-update"], {
11119
+ stdio: "inherit"
11120
+ });
11121
+ child.on("error", (error) => {
11122
+ process.stderr.write(`The runner updated but could not relaunch: ${error.message}
11123
+ Run \`aloud start\` again.
11124
+ `);
11125
+ finish(1);
11126
+ });
11127
+ child.on("close", (code, signal) => {
11128
+ finish(code ?? (signal ? 130 : 1));
11129
+ });
11130
+ });
11131
+ }
10938
11132
  async function status() {
10939
11133
  const credentials = await loadCredentials().catch((error) => {
10940
11134
  process.stderr.write(error.message + "\n");
@@ -10992,6 +11186,17 @@ async function start(argv) {
10992
11186
  process.stderr.write("Not signed in. Run `aloud login` first.\n");
10993
11187
  return 1;
10994
11188
  }
11189
+ const existing = await readRunning();
11190
+ if (existing && existing.pid !== process.pid) {
11191
+ process.stderr.write(
11192
+ `Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.
11193
+ To restart it: kill ${existing.pid} && aloud start
11194
+ `
11195
+ );
11196
+ return 1;
11197
+ }
11198
+ const updated = await updateBeforeStart(credentials, argv);
11199
+ if (updated !== null) return updated;
10995
11200
  const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
10996
11201
  const checks = await preflight();
10997
11202
  if (!checks.chromiumInstalled) {
@@ -11091,5 +11296,6 @@ if (entry.endsWith("cli.ts") || entry.endsWith("cli.js") || entry.endsWith("/alo
11091
11296
  }
11092
11297
  export {
11093
11298
  CredentialsError,
11094
- main
11299
+ main,
11300
+ updateBeforeStart
11095
11301
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
5
5
  "license": "ISC",
6
6
  "repository": {
@@ -17,7 +17,7 @@
17
17
  ".": "./src/index.ts",
18
18
  "./*": "./src/*.ts"
19
19
  },
20
- "bin": { "aloud": "./dist/cli.js" },
20
+ "bin": { "aloud": "dist/cli.js" },
21
21
  "files": ["dist/cli.js", "src", "README.md"],
22
22
  "engines": { "node": ">=20" },
23
23
  "scripts": {
package/src/cli.ts CHANGED
@@ -31,7 +31,14 @@ import {
31
31
  } from "./config/credentials";
32
32
  import { policyFrom, type LocalPolicy } from "./config/policy";
33
33
  import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
34
- import { RUNNER_VERSION } from "./version";
34
+ import {
35
+ RUNNER_VERSION,
36
+ compareRunnerVersions,
37
+ runnerUpdateFor,
38
+ runnerVersionPolicyFrom,
39
+ type RunnerUpdate,
40
+ type RunnerVersionPolicy,
41
+ } from "./version";
35
42
  import { RunnerClient } from "./protocol/client";
36
43
  import { installChromium, preflight } from "./preflight";
37
44
  import { TerminalReporter } from "./ui/output";
@@ -57,6 +64,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
57
64
  return allow(rest);
58
65
  case "setup":
59
66
  return setup();
67
+ case "version":
68
+ case "--version":
69
+ case "-v":
70
+ process.stdout.write(RUNNER_VERSION + "\n");
71
+ return 0;
60
72
  case "mcp":
61
73
  // `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
62
74
  // credential in the first place. Same word, because from the outside they are one feature.
@@ -86,12 +98,14 @@ function printHelp(): void {
86
98
  "",
87
99
  " aloud setup What to do next, for a person or an agent",
88
100
  " aloud login [--token <token>] Connect this machine, approving it in your browser",
89
- " aloud start [--once] [--quiet] Wait for studies and run them here",
101
+ " aloud start [--once] [--quiet] [--no-update]",
102
+ " Update, then wait for studies and run them here",
90
103
  " aloud status What is set up, and whether it is running",
91
104
  " aloud allow <host> Let studies open this host from this machine",
92
105
  " aloud mcp Serve MCP to an editor, using the saved credential",
93
106
  " aloud mcp connect Connect an editor, approving it in your browser",
94
107
  " aloud logout Forget the token on this machine",
108
+ " aloud --version Print the installed runner version",
95
109
  "",
96
110
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
97
111
  "",
@@ -329,7 +343,7 @@ async function setup(): Promise<number> {
329
343
  const running = await readRunning();
330
344
  const installed = onPath("aloud");
331
345
  const latest = await latestVersion();
332
- const stale = latest !== null && latest !== RUNNER_VERSION;
346
+ const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
333
347
  const signedIn = await signedInState(credentials);
334
348
 
335
349
  const out = (line = "") => process.stdout.write(line + "\n");
@@ -369,11 +383,11 @@ async function setup(): Promise<number> {
369
383
  }
370
384
  if (signedIn.state === "revoked") {
371
385
  steps.push([
372
- `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
373
- "The saved one was revoked and cannot be reused.",
386
+ "aloud login",
387
+ "The saved connection was revoked. This prints a new browser-approval link and code.",
374
388
  ]);
375
389
  }
376
- if (signedIn.state === "none" || signedIn.state === "revoked") {
390
+ if (signedIn.state === "none") {
377
391
  steps.push([
378
392
  "aloud login",
379
393
  "Prints a link and a short code, then waits. Give both to the person; they approve in",
@@ -418,7 +432,7 @@ async function setup(): Promise<number> {
418
432
  );
419
433
  }
420
434
  out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
421
- out(" Do not install a similarly named package, and do not hand the token to anything else.");
435
+ out(" Do not install a similarly named package.");
422
436
  out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
423
437
  out(" and the credential goes straight to this machine. Never ask anyone for a token.");
424
438
  out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
@@ -624,6 +638,182 @@ async function latestVersion(): Promise<string | null> {
624
638
  }
625
639
  }
626
640
 
641
+ /**
642
+ * The release policy belongs to the control plane this machine is connected to, not to npm.
643
+ *
644
+ * That distinction lets a self-hosted deployment deliberately lag the hosted service and lets the
645
+ * hosted service publish a runner before recommending it. The response is validated before any
646
+ * part of it reaches an install command; even a compromised control plane can select only an exact
647
+ * numeric release of the official scoped package.
648
+ */
649
+ async function serverVersionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null> {
650
+ try {
651
+ const client = new RunnerClient({ server: credentials.server, token: credentials.token });
652
+ const response = await client.request<{ runnerVersionPolicy?: unknown }>("api/runner/me", {
653
+ retry: false,
654
+ });
655
+ return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
656
+ } catch {
657
+ // Startup still reaches the ordinary authenticated claim below. A revoked token, unreachable
658
+ // server, or old self-hosted control plane will be explained there; an update convenience must
659
+ // not replace the runner's established connection behavior.
660
+ return null;
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Updates before any browser or lease exists, then starts the newly installed bundle.
666
+ *
667
+ * `null` means the current process should continue. A number means startup has been handed to the
668
+ * replacement process, or could not safely continue because the server requires that replacement.
669
+ */
670
+ export interface StartUpdateDependencies {
671
+ versionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null>;
672
+ npmPrefix(): Promise<{ path: string; writable: boolean | null } | null>;
673
+ install(target: string, write: (text: string) => void): Promise<boolean>;
674
+ installedEntry(target: string): Promise<string | null>;
675
+ relaunch(entry: string, argv: readonly string[]): Promise<number>;
676
+ stdout(text: string): void;
677
+ stderr(text: string): void;
678
+ }
679
+
680
+ export async function updateBeforeStart(
681
+ credentials: Credentials,
682
+ argv: readonly string[],
683
+ overrides: Partial<StartUpdateDependencies> = {},
684
+ ): Promise<number | null> {
685
+ const stdout = overrides.stdout ?? ((text: string) => process.stdout.write(text));
686
+ const stderr = overrides.stderr ?? ((text: string) => process.stderr.write(text));
687
+ const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
688
+ if (!policy) return null;
689
+ const update = runnerUpdateFor(policy);
690
+ if (!update) return null;
691
+
692
+ if (argv.includes("--no-update")) {
693
+ if (!update.required) return null;
694
+ stderr(
695
+ `Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.\n` +
696
+ `Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.\n`,
697
+ );
698
+ return 1;
699
+ }
700
+
701
+ stdout(`\nUpdating Aloud runner ${RUNNER_VERSION} → ${update.target} before it starts.\n`);
702
+ const prefix = await (overrides.npmPrefix ?? npmPrefix)();
703
+ if (prefix?.writable === false) {
704
+ return failedAutomaticUpdate(
705
+ update,
706
+ policy,
707
+ `npm's global install directory (${prefix.path}) is not writable by this user.`,
708
+ stderr,
709
+ );
710
+ }
711
+
712
+ const installed = overrides.install
713
+ ? await overrides.install(update.target, stdout)
714
+ : await run(
715
+ "npm",
716
+ ["install", "-g", `@aloud/runner@${update.target}`],
717
+ (line = "") => stdout(line + "\n"),
718
+ );
719
+ if (!installed) {
720
+ return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
721
+ }
722
+
723
+ // Do not relaunch `process.argv[1]`: a runner started through npx or a project-local shim can
724
+ // live somewhere entirely different from the global package npm just replaced. Resolve npm's
725
+ // canonical global root and verify the exact installed version before handing it any work.
726
+ const entry = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
727
+ if (!entry) {
728
+ return failedAutomaticUpdate(
729
+ update,
730
+ policy,
731
+ `npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
732
+ stderr,
733
+ );
734
+ }
735
+
736
+ stdout(`\nUpdated to ${update.target}. Starting it now.\n\n`);
737
+ return (overrides.relaunch ?? relaunchUpdatedRunner)(entry, argv);
738
+ }
739
+
740
+ function failedAutomaticUpdate(
741
+ update: RunnerUpdate,
742
+ policy: RunnerVersionPolicy,
743
+ reason: string,
744
+ stderr: (text: string) => void,
745
+ ): number | null {
746
+ const command = `npm install -g @aloud/runner@${update.target}`;
747
+ if (update.required) {
748
+ stderr(
749
+ `\n${reason}\nRunner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.\n` +
750
+ `Do not use sudo. Fix npm's global prefix, then run: ${command}\n`,
751
+ );
752
+ return 1;
753
+ }
754
+ stderr(
755
+ `\n${reason}\nRunner ${RUNNER_VERSION} is still compatible, so it will start without updating.\n` +
756
+ `To update it later, run: ${command}\n\n`,
757
+ );
758
+ return null;
759
+ }
760
+
761
+ /** Finds the exact global bundle npm just installed, independently of how this process was run. */
762
+ async function installedRunnerEntry(target: string): Promise<string | null> {
763
+ const root = await commandOutput("npm", ["root", "--global"]);
764
+ if (!root) return null;
765
+
766
+ const directory = join(root, "@aloud", "runner");
767
+ const entry = join(directory, "dist", "cli.js");
768
+ try {
769
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as {
770
+ version?: unknown;
771
+ };
772
+ return manifest.version === target && existsSync(entry) ? entry : null;
773
+ } catch {
774
+ return null;
775
+ }
776
+ }
777
+
778
+ /** Captures one short command result without involving a shell. */
779
+ async function commandOutput(command: string, args: readonly string[]): Promise<string | null> {
780
+ return new Promise((resolve) => {
781
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
782
+ let output = "";
783
+ let settled = false;
784
+ const finish = (value: string | null) => {
785
+ if (settled) return;
786
+ settled = true;
787
+ resolve(value);
788
+ };
789
+ child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
790
+ child.on("error", () => finish(null));
791
+ child.on("close", (code: number | null) => finish(code === 0 && output.trim() ? output.trim() : null));
792
+ });
793
+ }
794
+
795
+ /** The old bundle stays only as a transparent parent while the verified new bundle runs. */
796
+ async function relaunchUpdatedRunner(entry: string, argv: readonly string[]): Promise<number> {
797
+ return new Promise((resolve) => {
798
+ let settled = false;
799
+ const finish = (code: number) => {
800
+ if (settled) return;
801
+ settled = true;
802
+ resolve(code);
803
+ };
804
+ const child = spawn(process.execPath, [entry, "start", ...argv, "--no-update"], {
805
+ stdio: "inherit",
806
+ });
807
+ child.on("error", (error: Error) => {
808
+ process.stderr.write(`The runner updated but could not relaunch: ${error.message}\nRun \`aloud start\` again.\n`);
809
+ finish(1);
810
+ });
811
+ child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
812
+ finish(code ?? (signal ? 130 : 1));
813
+ });
814
+ });
815
+ }
816
+
627
817
  /* --------------------------------- status --------------------------------- */
628
818
 
629
819
  async function status(): Promise<number> {
@@ -689,6 +879,21 @@ async function start(argv: readonly string[]): Promise<number> {
689
879
  return 1;
690
880
  }
691
881
 
882
+ // Two starts using one runner identity can claim two studies while status reports only the last
883
+ // pid written. Refuse before updating or polling; a restart has to stop the existing process
884
+ // first, which also guarantees we never replace its package while it is inside an active study.
885
+ const existing = await readRunning();
886
+ if (existing && existing.pid !== process.pid) {
887
+ process.stderr.write(
888
+ `Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.\n` +
889
+ `To restart it: kill ${existing.pid} && aloud start\n`,
890
+ );
891
+ return 1;
892
+ }
893
+
894
+ const updated = await updateBeforeStart(credentials, argv);
895
+ if (updated !== null) return updated;
896
+
692
897
  const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
693
898
 
694
899
  // Preflight runs before the first claim, deliberately. A missing Chromium makes
package/src/version.ts CHANGED
@@ -10,7 +10,62 @@
10
10
  * package.json beside it to read, and importing one into the source trips the composite build's
11
11
  * rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
12
12
  */
13
- export const RUNNER_VERSION = "0.3.0";
13
+ export const RUNNER_VERSION = "0.3.1";
14
14
 
15
15
  /** The header the server reads it from. */
16
16
  export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";
17
+
18
+ export interface RunnerVersionPolicy {
19
+ /** The oldest release that may claim new work. */
20
+ minimum: string;
21
+ /** The exact official release a start should move to when it can. */
22
+ recommended: string;
23
+ }
24
+
25
+ /** Strict because a server response eventually becomes part of an npm package specifier. */
26
+ function versionParts(value: string): [number, number, number] | null {
27
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
28
+ if (!match) return null;
29
+ const parts = [Number(match[1]), Number(match[2]), Number(match[3])] as [number, number, number];
30
+ return parts.every(Number.isSafeInteger) ? parts : null;
31
+ }
32
+
33
+ /** Numeric comparison, so 0.10.0 is newer than 0.9.9 rather than lexicographically smaller. */
34
+ export function compareRunnerVersions(left: string, right: string): number | null {
35
+ const a = versionParts(left);
36
+ const b = versionParts(right);
37
+ if (!a || !b) return null;
38
+ for (let index = 0; index < a.length; index += 1) {
39
+ if (a[index]! > b[index]!) return 1;
40
+ if (a[index]! < b[index]!) return -1;
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ /** Parses only a coherent policy. A bad control-plane response never becomes an install command. */
46
+ export function runnerVersionPolicyFrom(value: unknown): RunnerVersionPolicy | null {
47
+ if (!value || typeof value !== "object") return null;
48
+ const policy = value as { minimum?: unknown; recommended?: unknown };
49
+ if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
50
+ const order = compareRunnerVersions(policy.recommended, policy.minimum);
51
+ if (order === null || order < 0) return null;
52
+ return { minimum: policy.minimum, recommended: policy.recommended };
53
+ }
54
+
55
+ export interface RunnerUpdate {
56
+ target: string;
57
+ /** Required means the installed release cannot claim work if updating fails. */
58
+ required: boolean;
59
+ }
60
+
61
+ /** What this installed runner should do with one valid server policy. Never chooses a downgrade. */
62
+ export function runnerUpdateFor(
63
+ policy: RunnerVersionPolicy,
64
+ current: string = RUNNER_VERSION,
65
+ ): RunnerUpdate | null {
66
+ const recommendedOrder = compareRunnerVersions(policy.recommended, current);
67
+ const minimumOrder = compareRunnerVersions(policy.minimum, current);
68
+ if (recommendedOrder === null || minimumOrder === null) return null;
69
+ if (recommendedOrder <= 0) return null;
70
+ return { target: policy.recommended, required: minimumOrder > 0 };
71
+ }