@bridge4dev/runner 0.22.1 → 0.26.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.
@@ -20,10 +20,31 @@ import { systemdUserHome } from './paths.js';
20
20
  export const SERVICE_NAME = 'devbridge-runner';
21
21
  const COMMAND_NAME = 'devbridge-runner';
22
22
  /** `<prefix>/bin/devbridge-runner` for an installed package, else the script. */
23
+ /**
24
+ * A path that will not exist after a reboot.
25
+ *
26
+ * `fnm` (and `nvm`/`volta` in the same spirit) puts the active version's `bin`
27
+ * into a per-shell directory under `/run/user/<uid>/fnm_multishells/…` — tmpfs,
28
+ * created for one shell session. `command -v devbridge-runner` resolves there,
29
+ * so baking it into a unit produces a service that works until the shell that
30
+ * installed it goes away, and then fails with status=127 forever. Observed on a
31
+ * real install, 2026-07-31, on a ROOT install — this is not a dedicated-user
32
+ * problem, it is a per-user node manager problem.
33
+ */
34
+ export function isEphemeralPath(target) {
35
+ // Deliberately narrow: `/run` and `/dev/shm` are tmpfs by definition, and
36
+ // `fnm_multishells` is named because fnm also offers non-tmpfs layouts. `/tmp`
37
+ // is NOT here — a package installed there is odd but survives, and tests build
38
+ // their fake installs in temp directories.
39
+ return (/^\/(run|proc)\//.test(target) ||
40
+ target.startsWith('/dev/shm/') ||
41
+ target.includes('fnm_multishells'));
42
+ }
23
43
  export function unitExecTarget(argv1 = process.argv[1] ?? '') {
24
- // Invoked through the command itself: that is already the stable path.
44
+ // Invoked through the command itself: that is already the stable path —
45
+ // unless it lives in a directory that disappears with the shell.
25
46
  try {
26
- if (fs.lstatSync(argv1).isSymbolicLink()) {
47
+ if (fs.lstatSync(argv1).isSymbolicLink() && !isEphemeralPath(path.resolve(argv1))) {
27
48
  return { execStart: path.resolve(argv1), viaCommand: true };
28
49
  }
29
50
  }
@@ -43,8 +64,9 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
43
64
  let dir = path.dirname(script);
44
65
  for (let i = 0; i < 6; i++) {
45
66
  const candidate = path.join(dir, 'bin', COMMAND_NAME);
46
- if (fs.existsSync(candidate))
67
+ if (fs.existsSync(candidate) && !isEphemeralPath(candidate)) {
47
68
  return { execStart: candidate, viaCommand: true };
69
+ }
48
70
  const parent = path.dirname(dir);
49
71
  if (parent === dir)
50
72
  break;
@@ -56,13 +78,19 @@ export function unitExecTarget(argv1 = process.argv[1] ?? '') {
56
78
  export function unitPath(home = systemdUserHome()) {
57
79
  return path.join(home, '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
58
80
  }
59
- export function buildUnit(execStart) {
81
+ export function buildUnit(execStart, nodeBinary = process.execPath) {
60
82
  const target = execStart ?? unitExecTarget().execStart;
61
- // The command carries a `#!/usr/bin/env node` shebang, so it is exec'd directly;
62
- // a bare script path needs the interpreter spelled out.
63
- const command = target.endsWith('.js')
64
- ? `${process.execPath} ${target} daemon`
65
- : `${target} daemon`;
83
+ /**
84
+ * The interpreter is ALWAYS spelled out, even for the command symlink.
85
+ *
86
+ * The file carries `#!/usr/bin/env node`, and relying on that shebang means
87
+ * relying on `node` being on the PATH systemd gives the service — which it is
88
+ * not when node came from fnm/nvm/volta. That produced `status=127` and an
89
+ * endless restart loop on a real machine. Node runs a file with a shebang
90
+ * perfectly well (it is a comment to it), so naming the interpreter costs
91
+ * nothing and removes the dependency entirely.
92
+ */
93
+ const command = `${nodeBinary} ${target} daemon`;
66
94
  return ([
67
95
  '[Unit]',
68
96
  'Description=DevBridge Dev Runner',
@@ -206,6 +234,9 @@ export function unitIsBroken(readFile = (p) => fs.readFileSync(p, 'utf8')) {
206
234
  const target = parts[0]?.endsWith('node') ? parts[1] : parts[0];
207
235
  if (!target)
208
236
  return false;
209
- return !fs.existsSync(target);
237
+ // Gone already, or living somewhere that will be gone after a reboot — the
238
+ // second one still runs today, which is exactly why it has to be repaired
239
+ // before the reboot rather than after it.
240
+ return !fs.existsSync(target) || isEphemeralPath(target) || isEphemeralPath(parts[0] ?? '');
210
241
  }
211
242
  //# sourceMappingURL=service-unit.js.map
@@ -12,8 +12,9 @@ import { VerifyRunner, runOneOffCommand, } from './verify.js';
12
12
  import { VerifyReportQueue } from './verify-queue.js';
13
13
  import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
14
14
  import { fsView } from './fsview.js';
15
- import { agentAuthStatuses, AuthRelay } from './auth-relay.js';
15
+ import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
16
16
  import { selfUpdate } from './self-update.js';
17
+ import { rememberWorkspacePath } from './environment.js';
17
18
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
18
19
  export class Supervisor {
19
20
  ws;
@@ -297,6 +298,10 @@ export class Supervisor {
297
298
  * would be a lock bought for nothing.
298
299
  */
299
300
  async prepareWorkspace(descriptor) {
301
+ // Also here, not only at bind time: a server paired before this existed has
302
+ // never sent a `validate_path`, and its projects would be invisible to
303
+ // `doctor` until somebody re-bound them.
304
+ rememberWorkspacePath(descriptor.workspace.path);
300
305
  if (descriptor.workMode === 'DIRECT') {
301
306
  return prepareDirectWorkspace(descriptor.workspace.path);
302
307
  }
@@ -794,7 +799,12 @@ export class Supervisor {
794
799
  if (!running.session)
795
800
  return;
796
801
  running.parkRequested = true;
802
+ // `code` is what the dashboard reads to tell «parked» from «your turn»
803
+ // (#124). The prose stays for runners older than 0.23.0, which the
804
+ // dashboard still matches on; delete that fallback once the fleet has
805
+ // moved, not before.
797
806
  this.sendEvent(running, 'system_note', {
807
+ code: 'session_parked',
798
808
  text: 'Session parked — the runner switched to another session. Send a message to resume.',
799
809
  });
800
810
  running.session.stop('session_parked');
@@ -867,6 +877,15 @@ export class Supervisor {
867
877
  });
868
878
  return;
869
879
  }
880
+ // The retry is spent and the agent is still refused — this is the only
881
+ // authority on a login the credentials file cannot see through (a
882
+ // provider-side revocation leaves the file looking perfectly healthy).
883
+ // The panel is told from here, not from a guess (#121).
884
+ if (isAuthCode(event.code)) {
885
+ const refused = relayAgent(String(descriptor.agent).toLowerCase());
886
+ if (refused)
887
+ noteAgentAuthFailure(refused);
888
+ }
870
889
  // Forward the code: the API stores the payload as-is, so the dashboard
871
890
  // can offer "Sign in" instead of a dead error card.
872
891
  this.sendEvent(running, 'error', {
@@ -906,6 +925,13 @@ export class Supervisor {
906
925
  }
907
926
  return;
908
927
  case 'message':
928
+ // The provider answered, so this sign-in works — drop any refusal we
929
+ // are still holding against it (#121).
930
+ if (event.role === 'assistant') {
931
+ const working = relayAgent(String(descriptor.agent).toLowerCase());
932
+ if (working)
933
+ clearAgentAuthFailure(working);
934
+ }
909
935
  this.sendEvent(running, 'message', { role: event.role, text: event.text });
910
936
  return;
911
937
  case 'thinking':
@@ -1214,6 +1240,7 @@ export class Supervisor {
1214
1240
  // Parked session: the follow-up message becomes the resume prompt.
1215
1241
  if (!this.ensureCapacity(running.descriptor.id)) {
1216
1242
  this.sendEvent(running, 'system_note', {
1243
+ code: 'runner_busy',
1217
1244
  text: (this.maxSessions === 1
1218
1245
  ? 'The runner is busy with another session — this one continues as soon as it finishes its turn.'
1219
1246
  : `The runner is busy with ${this.maxSessions} other sessions — this one continues as soon as one of them finishes its turn.`) +
@@ -1579,6 +1606,10 @@ export class Supervisor {
1579
1606
  if (!path)
1580
1607
  return void reply({ ok: false, error: 'path argument is required' });
1581
1608
  const validation = await validateWorkspacePath(path);
1609
+ // Remembered so `devbridge-runner doctor` can check the permissions
1610
+ // of the real projects without being told which they are.
1611
+ if (validation.ok)
1612
+ rememberWorkspacePath(path);
1582
1613
  return void reply({
1583
1614
  ok: validation.ok,
1584
1615
  result: validation,
@@ -2209,6 +2240,9 @@ export class Supervisor {
2209
2240
  if (!agent || !code)
2210
2241
  return void reply({ ok: false, error: 'agent and code are required' });
2211
2242
  const result = await this.authRelay.submitCode(agent, code);
2243
+ // A fresh credential outranks anything we remember about the old one.
2244
+ if (result.ok)
2245
+ clearAgentAuthFailure(agent);
2212
2246
  return void reply({
2213
2247
  ok: result.ok,
2214
2248
  result,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.22.1";
1
+ export declare const RUNNER_VERSION = "0.26.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.22.1';
2
+ export const RUNNER_VERSION = '0.26.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.22.1",
3
+ "version": "0.26.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",