@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.
- package/dist/adapters/claude.js +257 -8
- package/dist/adapters/codex.js +93 -4
- package/dist/adapters/types.d.ts +64 -3
- package/dist/adapters/types.js +31 -0
- package/dist/agent-prompt.d.ts +75 -0
- package/dist/agent-prompt.js +252 -0
- package/dist/index.js +10 -0
- package/dist/policy.d.ts +61 -0
- package/dist/policy.js +56 -6
- package/dist/protocol.d.ts +92 -12
- package/dist/protocol.js +18 -0
- package/dist/supervisor.d.ts +33 -1
- package/dist/supervisor.js +221 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -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
|
|
383
|
-
// each agent itself — see
|
|
384
|
-
|
|
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.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.31.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED