@bridge4dev/runner 0.29.0 → 0.31.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.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { log } from './log.js';
4
4
  import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
5
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
6
+ import { agentPromptSizeLabel, quotePath, readAgentPrompt } from './agent-prompt.js';
6
7
  import { JournalStore } from './journal.js';
7
8
  import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
8
9
  import { readRecipeProposal } from './recipe.js';
@@ -17,6 +18,7 @@ import { selfUpdate } from './self-update.js';
17
18
  import { rememberWorkspacePath } from './environment.js';
18
19
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
19
20
  import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
21
+ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
20
22
  /** Refusals shared by every checkpoint command (ticket #126). */
21
23
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
22
24
  const AGENT_BUSY = 'The agent is still working — stop the turn first';
@@ -379,9 +381,11 @@ export class Supervisor {
379
381
  return false;
380
382
  }
381
383
  running.lastPrompt = prompt;
382
- // Facts about this session only. The project's own documentation is read by
383
- // each agent itself — see `composeWorkspaceContext`.
384
- const workspaceContext = composeWorkspaceContext(descriptor);
384
+ // Facts about this session only, plus the one file the project named. The
385
+ // rest of the project's documentation is read by each agent itself — see
386
+ // `composeWorkspaceContext`.
387
+ const agentPrompt = this.resolveAgentPrompt(running);
388
+ const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
385
389
  const rewind = running.rewindAnchor;
386
390
  delete running.rewindAnchor;
387
391
  // A rewind resumes the conversation the POINT names, which is not always
@@ -395,6 +399,9 @@ export class Supervisor {
395
399
  cwd: running.worktreePath,
396
400
  ...(prompt ? { prompt } : {}),
397
401
  ...(workspaceContext ? { workspaceContext } : {}),
402
+ // Only when it was actually read: layer 1 must refuse writes to the file
403
+ // this process was given, not to a path it was merely told about.
404
+ ...(agentPrompt ? { agentPromptFile: agentPrompt.absPath } : {}),
398
405
  trustMode: descriptor.workspace.trustMode,
399
406
  ...(descriptor.workspace.agentAutoCommit === undefined
400
407
  ? {}
@@ -425,6 +432,70 @@ export class Supervisor {
425
432
  void this.pumpEvents(running);
426
433
  return true;
427
434
  }
435
+ /**
436
+ * The project's own prompt file, read fresh for THIS agent process.
437
+ *
438
+ * Read per launch rather than per session on purpose: a system prompt only
439
+ * ever changes when the process restarts, so «edit the file, then press
440
+ * Continue» is the honest contract, and re-reading is what makes it true.
441
+ *
442
+ * Every outcome is said out loud. The whole point of moving the project's
443
+ * rules into the system prompt is that they can no longer be quietly
444
+ * outranked — so «the prompt did not load» must never be indistinguishable
445
+ * from «the prompt loaded». Supervisor notices repeat on every launch (only
446
+ * ADAPTER notices are de-duplicated — gotcha #148), which is exactly what is
447
+ * wanted here: each agent process either has the prompt or does not.
448
+ *
449
+ * Returns the text AND the absolute file, because layer 1 needs the second to
450
+ * refuse writes to it: in `workMode: DIRECT` the project folder is the
451
+ * agent's own working directory, so without that rule a session could rewrite
452
+ * the prompt it will itself be started with next time (QA-130 MAJOR-3).
453
+ */
454
+ resolveAgentPrompt(running) {
455
+ const { descriptor } = running;
456
+ const configured = descriptor.workspace.agentPromptPath?.trim();
457
+ if (!configured)
458
+ return undefined;
459
+ // The session was started with «without the project's agent prompt». Said
460
+ // out loud too: a session behaving unlike every other session on the
461
+ // project should carry the reason in its own feed.
462
+ if (descriptor.skipAgentPrompt) {
463
+ this.sendEvent(running, 'notice', {
464
+ level: 'info',
465
+ text: 'Project prompt is switched off for this session. DevBridge rules still apply.',
466
+ });
467
+ return undefined;
468
+ }
469
+ const result = readAgentPrompt(descriptor.workspace.path, configured);
470
+ if (!result.ok) {
471
+ log.warn('agent prompt not loaded', {
472
+ sessionId: descriptor.id,
473
+ path: configured,
474
+ reason: result.reason,
475
+ });
476
+ this.sendEvent(running, 'notice', {
477
+ level: 'warn',
478
+ text: `Project prompt ${quotePath(configured)} was NOT loaded: ${result.reason}. The agent is running without it.`,
479
+ });
480
+ return undefined;
481
+ }
482
+ log.info('agent prompt loaded', {
483
+ sessionId: descriptor.id,
484
+ path: result.relPath,
485
+ bytes: result.bytes,
486
+ sha: result.sha,
487
+ });
488
+ // The `sha` is in the line a person reads, not only in journald. It is the
489
+ // one signal that the file behind an unchanged path has changed — which is
490
+ // exactly what an agent editing its own rules in DIRECT mode looks like
491
+ // (QA-130 MAJOR-3), and layer 1 cannot promise to prevent that in every
492
+ // mode.
493
+ this.sendEvent(running, 'notice', {
494
+ level: 'info',
495
+ text: `Project prompt loaded from ${quotePath(result.relPath)} — ${agentPromptSizeLabel(result.bytes)}, sha ${result.sha}.`,
496
+ });
497
+ return { text: result.text, absPath: result.absPath };
498
+ }
428
499
  // ─── Time budget (session 7) ───────────────────────────────────────
429
500
  //
430
501
  // The budget measures the AGENT's working time, not the calendar. Everything
@@ -626,6 +697,54 @@ export class Supervisor {
626
697
  this.launchAgent(running, prompt, running.descriptor.providerSessionId);
627
698
  return;
628
699
  }
700
+ // The mode crossed the `full` boundary: a NEW process, resuming the same
701
+ // conversation (ticket #156). Placed with the other one-shot relaunches and
702
+ // after the cleanup above, which is the whole point of routing it here.
703
+ if (running.modeRelaunch && !running.stopRequested) {
704
+ const { priorStatus } = running.modeRelaunch;
705
+ delete running.modeRelaunch;
706
+ running.parkRequested = false;
707
+ running.session = null;
708
+ running.costBaseUsd = running.costUsd; // the next process starts from here
709
+ const mode = running.mode;
710
+ // «Your conversation is kept» is only true when there IS one to keep
711
+ // (QA-128): a session switched before its first turn has no provider
712
+ // session id, so the new process starts the conversation over — and
713
+ // saying otherwise is a promise the feed can be checked against.
714
+ const kept = Boolean(running.descriptor.providerSessionId);
715
+ this.sendEvent(running, 'notice', {
716
+ level: 'info',
717
+ text: (mode === 'full'
718
+ ? 'Switched to «Unrestricted». The agent was restarted — it no longer asks about anything. '
719
+ : 'Left «Unrestricted». The agent was restarted — permission checks are back on. ') +
720
+ (kept
721
+ ? 'Your conversation is kept; send a message to carry on.'
722
+ : 'This session had not started a conversation yet, so nothing was lost — send a message to begin.'),
723
+ });
724
+ this.sendEvent(running, 'settings', { mode });
725
+ // Empty prompt: the agent boots, reports its capabilities and waits, the
726
+ // same as a free CHAT session. It must NOT start a turn of its own here.
727
+ if (this.launchAgent(running, '', running.descriptor.providerSessionId)) {
728
+ // Anything typed during the park window is waiting on disk (see
729
+ // `deliverMessage`), and the new process is the one that can take it.
730
+ this.flushPendingMessages(running);
731
+ if (priorStatus === 'REVIEW') {
732
+ this.reportStatus(descriptor.id, 'REVIEW', {
733
+ costUsd: running.costUsd,
734
+ activeMs: running.activeMs,
735
+ });
736
+ }
737
+ return;
738
+ }
739
+ // The agent did not start — an exhausted budget is the only way here. The
740
+ // session stays parked and resumable rather than silently disappearing.
741
+ this.reportStatus(descriptor.id, statusForReport(running), {
742
+ costUsd: running.costUsd,
743
+ activeMs: running.activeMs,
744
+ });
745
+ this.drainSessionsWaitingForCapacity();
746
+ return;
747
+ }
629
748
  if (running.stopRequested) {
630
749
  // Report before removing from the map — reportStatus records
631
750
  // lastReported on the live entry, and the journal cleanup below
@@ -1373,12 +1492,22 @@ export class Supervisor {
1373
1492
  this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
1374
1493
  }
1375
1494
  };
1376
- if (running.session) {
1495
+ if (running.session && !running.parkRequested) {
1377
1496
  running.session.send(text);
1378
1497
  settle();
1379
1498
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
1380
1499
  return;
1381
1500
  }
1501
+ // The process is on its way out and `running.session` has not been cleared
1502
+ // yet — parking only ASKS it to stop (QA-128). Handing the text to a dying
1503
+ // process and then reporting it delivered is the one outcome worse than
1504
+ // making the user wait: the words are gone and the interface says they
1505
+ // arrived. This window is short but it is exactly when somebody types,
1506
+ // because they have just changed the mode.
1507
+ if (running.parkRequested) {
1508
+ this.requeue(running, held, text, originSeq);
1509
+ return;
1510
+ }
1382
1511
  // Parked session: the follow-up message becomes the resume prompt.
1383
1512
  if (!this.ensureCapacity(running.descriptor.id)) {
1384
1513
  this.sendEvent(running, 'system_note', {
@@ -1573,6 +1702,22 @@ export class Supervisor {
1573
1702
  const running = this.sessions.get(sessionId);
1574
1703
  if (!running)
1575
1704
  return;
1705
+ // Validated HERE, before anything is remembered (QA-128). `running.mode` is
1706
+ // what a parked session launches with and what the API is told the session
1707
+ // is in, so storing a mode this workspace forbids made a parked session
1708
+ // report itself as «Unrestricted» and made the adapter refuse it again on
1709
+ // every single relaunch — a red line in the feed with no user action behind
1710
+ // it. The adapters keep their own guard; this one stops the value from ever
1711
+ // being written down.
1712
+ if (mode && !availableModes(running.descriptor.workspace.trustMode).includes(mode)) {
1713
+ this.sendEvent(running, 'notice', { level: 'warn', text: MODE_REFUSED_TEXT });
1714
+ this.sendEvent(running, 'settings', { mode: running.mode });
1715
+ mode = undefined;
1716
+ if (model === undefined && effort === undefined)
1717
+ return;
1718
+ }
1719
+ // Kept so a refused switch can put the picker back where it was.
1720
+ const previousMode = running.mode;
1576
1721
  // Remember first: a parked session applies them on its next launch.
1577
1722
  if (model)
1578
1723
  running.model = model;
@@ -1586,6 +1731,67 @@ export class Supervisor {
1586
1731
  this.sendEvent(running, 'settings', { model, mode, effort });
1587
1732
  return;
1588
1733
  }
1734
+ // «Unrestricted» is the one mode the agent cannot be talked into on a
1735
+ // process that was not launched for it (ticket #156). The CLI refuses the
1736
+ // control request outright — `Cannot set permission mode to
1737
+ // bypassPermissions because the session was not launched with
1738
+ // --dangerously-skip-permissions` — and until this branch existed that
1739
+ // refusal became a `notice` in the catch below, leaving the picker reading
1740
+ // «Unrestricted» over a session that went on asking.
1741
+ const needsRelaunch = Boolean(mode && running.session.modeSwitchNeedsRelaunch(mode));
1742
+ if (needsRelaunch && this.isMidTurn(running)) {
1743
+ // A new process would take the turn — and the open card, and the parked
1744
+ // question — down with it. The dashboard locks the picker while the agent
1745
+ // RUNS, but a session sitting on a permission card is WAITING_PERMISSION
1746
+ // and the picker is live there by design (that is the very moment somebody
1747
+ // reaches for it), so this guard is reachable through the interface and
1748
+ // not only through a direct API call (QA-128).
1749
+ this.sendEvent(running, 'notice', {
1750
+ level: 'warn',
1751
+ // Both directions: getting OUT of «Unrestricted» needs a new process
1752
+ // just as much as getting in, and a sentence written for one of them
1753
+ // reads as nonsense during the other.
1754
+ text: (mode === 'full'
1755
+ ? 'Switching to «Unrestricted» starts a new agent process, so it can only be done between turns. '
1756
+ : 'Leaving «Unrestricted» starts a new agent process, so it can only be done between turns. ') +
1757
+ (running.openQuestions.size > 0 || running.lastReported === 'WAITING_PERMISSION'
1758
+ ? 'Answer what the agent is asking first, or press Stop.'
1759
+ : 'Stop the turn, or wait for it to finish.'),
1760
+ });
1761
+ running.mode = previousMode;
1762
+ this.sendEvent(running, 'settings', { mode: previousMode });
1763
+ // The rest of the request still stands — refusing the mode is no reason
1764
+ // to drop a model or effort change that travelled with it (QA-128).
1765
+ await this.applyLiveSettings(running, model, undefined, effort);
1766
+ return;
1767
+ }
1768
+ if (needsRelaunch && mode) {
1769
+ await this.applyLiveSettings(running, model, undefined, effort);
1770
+ // Handed to `pumpEvents` rather than done here, exactly like the auth and
1771
+ // stale-resume recoveries above it (QA-128). `park()` only ASKS the
1772
+ // process to stop; the cleanup that follows — settling the active clock,
1773
+ // clearing the budget timers, moving the cost baseline so the next
1774
+ // process does not re-count what this one spent — happens when the event
1775
+ // stream ends. Relaunching inline wins the race against all of it: the
1776
+ // new session lands in `running.session` first, and the old pump then
1777
+ // sees `running.session !== session` and returns without cleaning up.
1778
+ running.modeRelaunch = { priorStatus: running.lastReported };
1779
+ this.park(running, { quiet: true });
1780
+ return;
1781
+ }
1782
+ await this.applyLiveSettings(running, model, mode, effort);
1783
+ }
1784
+ /** Is a turn (or a question the agent is parked on) in flight right now? */
1785
+ isMidTurn(running) {
1786
+ return (running.lastReported === 'RUNNING' ||
1787
+ running.lastReported === 'STARTING' ||
1788
+ running.lastReported === 'WAITING_PERMISSION' ||
1789
+ running.openQuestions.size > 0);
1790
+ }
1791
+ /** The three live setters, in the order that lets an explicit pick win. */
1792
+ async applyLiveSettings(running, model, mode, effort) {
1793
+ if (!running.session)
1794
+ return;
1589
1795
  try {
1590
1796
  // Model first: switching models can invalidate the picked effort, and
1591
1797
  // the adapter drops it in that case — applying effort after lets an
@@ -3140,8 +3346,16 @@ export function composeInitialPrompt(descriptor) {
3140
3346
  * A repository that wants both agents equipped ships both files, or symlinks
3141
3347
  * one to the other. That is a repository convention and not something a runner
3142
3348
  * should paper over.
3349
+ *
3350
+ * `agentPrompt` is the one exception, and it is an exception for a reason this
3351
+ * function cannot do anything about: `CLAUDE.md` and `AGENTS.md` arrive at the
3352
+ * USER level, and a project's standing process rules have to sit at the system
3353
+ * level to survive both a compaction and a rule the CLI states about itself
3354
+ * (`agent-prompt.ts`). It arrives here already read and already checked — this
3355
+ * function still opens no files — and it goes LAST, so a project overrides us
3356
+ * exactly the way `CLAUDE.md` does by being read last.
3143
3357
  */
3144
- export function composeWorkspaceContext(descriptor) {
3358
+ export function composeWorkspaceContext(descriptor, agentPrompt) {
3145
3359
  const sections = [];
3146
3360
  const plan = descriptor.branchPlan;
3147
3361
  if (plan) {
@@ -3169,6 +3383,8 @@ export function composeWorkspaceContext(descriptor) {
3169
3383
  else if (descriptor.tickets.length > 0) {
3170
3384
  sections.push('DevBridge tickets are attached to this chat for CONTEXT only. Read them with the DevBridge MCP tools; do not change their status — this session is not assigned to them.');
3171
3385
  }
3386
+ if (agentPrompt)
3387
+ sections.push(agentPrompt);
3172
3388
  return sections.join('\n\n');
3173
3389
  }
3174
3390
  //# sourceMappingURL=supervisor.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.29.0";
1
+ export declare const RUNNER_VERSION = "0.31.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.29.0';
2
+ export const RUNNER_VERSION = '0.31.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.29.0",
3
+ "version": "0.31.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",