@bridge4dev/runner 0.31.0 → 0.34.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.
@@ -22,6 +22,34 @@ import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
22
22
  /** Refusals shared by every checkpoint command (ticket #126). */
23
23
  const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
24
24
  const AGENT_BUSY = 'The agent is still working — stop the turn first';
25
+ /**
26
+ * Adapter events that exist only because the agent is producing output right
27
+ * now (ticket #185). `message` is deliberately absent — a user echo carries the
28
+ * same type and proves nothing about the agent — and is handled at the call site.
29
+ */
30
+ const AGENT_OUTPUT_EVENTS = new Set(['tool', 'thinking']);
31
+ /**
32
+ * The project's git policy, lifted out of a session descriptor (session 18).
33
+ *
34
+ * One function so that «which descriptor fields are the git policy» is answered
35
+ * once. Absent fields are passed through as absent — `resolveGitPolicy` in
36
+ * `policy.ts` is the single place that decides what absent MEANS, and inventing
37
+ * a default here would put a second such place in the codebase, facing the
38
+ * other way.
39
+ */
40
+ function gitPolicyOf(descriptor) {
41
+ const w = descriptor.workspace;
42
+ return {
43
+ ...(w.agentPushBan !== undefined ? { agentPushBan: w.agentPushBan } : {}),
44
+ ...(w.agentProtectedBranches !== undefined
45
+ ? { agentProtectedBranches: w.agentProtectedBranches }
46
+ : {}),
47
+ ...(w.agentAllowForcePush !== undefined ? { agentAllowForcePush: w.agentAllowForcePush } : {}),
48
+ ...(w.agentAllowDestructiveGit !== undefined
49
+ ? { agentAllowDestructiveGit: w.agentAllowDestructiveGit }
50
+ : {}),
51
+ };
52
+ }
25
53
  export class Supervisor {
26
54
  ws;
27
55
  opts;
@@ -131,11 +159,43 @@ export class Supervisor {
131
159
  if (frame.agentAutoCommit !== undefined) {
132
160
  running.descriptor.workspace.agentAutoCommit = frame.agentAutoCommit;
133
161
  }
162
+ /**
163
+ * Session 18: the git policy, applied live for the same reason as the
164
+ * two above — it is read on every tool call, so «switch push back
165
+ * off» must not mean «stop the agent first».
166
+ *
167
+ * The four fields move together, and the API sends all four in every
168
+ * frame that touches any of them, so a partial update cannot leave
169
+ * the runner with «push is allowed now» beside a protected list from
170
+ * before the change. `!== undefined` on the FRAME is what tells a
171
+ * policy change apart from a trust-only frame; inside the object,
172
+ * `undefined` keeps its restrictive meaning as everywhere else.
173
+ */
174
+ const policyChanged = frame.agentPushBan !== undefined ||
175
+ frame.agentProtectedBranches !== undefined ||
176
+ frame.agentAllowForcePush !== undefined ||
177
+ frame.agentAllowDestructiveGit !== undefined;
178
+ if (policyChanged) {
179
+ const next = {
180
+ ...(frame.agentPushBan !== undefined ? { agentPushBan: frame.agentPushBan } : {}),
181
+ ...(frame.agentProtectedBranches !== undefined
182
+ ? { agentProtectedBranches: frame.agentProtectedBranches }
183
+ : {}),
184
+ ...(frame.agentAllowForcePush !== undefined
185
+ ? { agentAllowForcePush: frame.agentAllowForcePush }
186
+ : {}),
187
+ ...(frame.agentAllowDestructiveGit !== undefined
188
+ ? { agentAllowDestructiveGit: frame.agentAllowDestructiveGit }
189
+ : {}),
190
+ };
191
+ Object.assign(running.descriptor.workspace, next);
192
+ }
134
193
  running.session?.setWorkspacePolicy({
135
194
  ...(frame.trustMode !== undefined ? { trustMode: frame.trustMode } : {}),
136
195
  ...(frame.agentAutoCommit !== undefined
137
196
  ? { agentAutoCommit: frame.agentAutoCommit }
138
197
  : {}),
198
+ ...(policyChanged ? { gitPolicy: gitPolicyOf(running.descriptor) } : {}),
139
199
  });
140
200
  }
141
201
  break;
@@ -406,6 +466,11 @@ export class Supervisor {
406
466
  ...(descriptor.workspace.agentAutoCommit === undefined
407
467
  ? {}
408
468
  : { agentAutoCommit: descriptor.workspace.agentAutoCommit }),
469
+ // Session 18. Always present, even when every field inside it is absent:
470
+ // an absent OBJECT and an object of absent fields resolve identically
471
+ // (`resolveGitPolicy` gives both the restrictive reading), and passing it
472
+ // unconditionally keeps one code path instead of two.
473
+ gitPolicy: gitPolicyOf(descriptor),
409
474
  mode: running.mode,
410
475
  ...(running.model ? { model: running.model } : {}),
411
476
  ...(running.effort ? { effort: running.effort } : {}),
@@ -987,8 +1052,47 @@ export class Supervisor {
987
1052
  }
988
1053
  running.session.stop('session_parked');
989
1054
  }
1055
+ /**
1056
+ * The agent is producing output while this runner still says a human is
1057
+ * expected — put `lastReported` back to RUNNING (ticket #185).
1058
+ *
1059
+ * This is not cosmetics on top of the API's own repair. While `lastReported`
1060
+ * lies, two things break here and only here:
1061
+ *
1062
+ * - `isParkable()` reads WAITING_INPUT with no open question as «idle», so
1063
+ * `ensureCapacity` may KILL a live agent process to hand its slot to
1064
+ * another session;
1065
+ * - `isBillable()` counts only RUNNING/STARTING, so `agentActiveMs` stops
1066
+ * growing and the time budget never fires for work that is really happening.
1067
+ *
1068
+ * It happens for real: a background subagent's report wakes a new turn inside
1069
+ * the agent process (SDK 0.3.226 emits a second `system:init` and a `result`
1070
+ * carrying `origin: {kind:'task-notification'}`), and nothing here reports a
1071
+ * turn the runner did not start. Compaction does the same, and so does a
1072
+ * question this runner withdraws by itself.
1073
+ *
1074
+ * An open question is the one thing that must survive: there, WAITING_INPUT
1075
+ * means a tool call is parked on a person, and the agent narrating around its
1076
+ * own question must not take the card off the screen.
1077
+ */
1078
+ noteAgentIsWorking(running) {
1079
+ if (running.openQuestions.size > 0)
1080
+ return;
1081
+ if (running.stopRequested || running.parkRequested || running.budgetSpent)
1082
+ return;
1083
+ if (running.lastReported !== 'WAITING_INPUT' && running.lastReported !== 'REVIEW')
1084
+ return;
1085
+ running.lastReported = 'RUNNING';
1086
+ this.syncBudgetClock(running);
1087
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
1088
+ }
990
1089
  forwardEvent(running, event) {
991
1090
  const { descriptor } = running;
1091
+ // Anything below that is the agent talking means the agent is working. Read
1092
+ // before the switch so every such case gets it, including the ones added
1093
+ // after this line was written.
1094
+ if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type))
1095
+ this.noteAgentIsWorking(running);
992
1096
  switch (event.type) {
993
1097
  case 'provider_session': {
994
1098
  running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
@@ -2372,6 +2476,21 @@ export class Supervisor {
2372
2476
  error: 'The agent could not compact right now — wait for the current turn to finish',
2373
2477
  });
2374
2478
  }
2479
+ // Ticket #185. Compaction is a whole agent turn — `/compact` goes in
2480
+ // as an ordinary user message — and until now nobody said so: the
2481
+ // session read «Ваш ход» for the entire squeeze, and the dashboard
2482
+ // papered over it with `compactingSince`, a variable that exists only
2483
+ // in the tab that pressed the button. A second tab, a teammate or a
2484
+ // reload saw a session waiting for a person who had nothing to do.
2485
+ //
2486
+ // It is also not only cosmetic here: a session reading WAITING_INPUT
2487
+ // with no open question is parkable, and parking it would kill the
2488
+ // compaction mid-flight.
2489
+ if (running.lastReported !== 'RUNNING') {
2490
+ running.lastReported = 'RUNNING';
2491
+ this.syncBudgetClock(running);
2492
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
2493
+ }
2375
2494
  return void reply({ ok: true, result: { started: true } });
2376
2495
  }
2377
2496
  case 'git_status': {
@@ -3358,16 +3477,58 @@ export function composeInitialPrompt(descriptor) {
3358
3477
  export function composeWorkspaceContext(descriptor, agentPrompt) {
3359
3478
  const sections = [];
3360
3479
  const plan = descriptor.branchPlan;
3480
+ /**
3481
+ * What this session may do about push, in one sentence (session 18).
3482
+ *
3483
+ * `!== false` — silence refuses, exactly as `resolveGitPolicy` reads it. This
3484
+ * text and the rule in `policy.ts` have to agree; an agent told it may push
3485
+ * and then refused has been lied to, and an agent told it may not while the
3486
+ * project allows it wastes the freedom the owner paid for.
3487
+ */
3488
+ const pushLine = (() => {
3489
+ // `?.` and not `.`: this function is exported and called with descriptors
3490
+ // assembled by hand in tests and by older code paths. A missing `workspace`
3491
+ // must land on the refusing branch like every other unknown here, not throw
3492
+ // and take the whole session start with it.
3493
+ if (descriptor.workspace?.agentPushBan !== false) {
3494
+ return '- You cannot push: `git push` is refused for this project. A human presses «Push» in DevBridge when they want the branch on the remote.';
3495
+ }
3496
+ const guarded = descriptor.workspace?.agentProtectedBranches ?? ['main', 'master'];
3497
+ return guarded.length > 0
3498
+ ? `- You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
3499
+ : '- You may push.';
3500
+ })();
3361
3501
  if (plan) {
3362
3502
  const forked = plan.baseBranch ? ` It was branched from \`${plan.baseBranch}\`.` : '';
3363
3503
  sections.push([
3364
3504
  'Git in this session:',
3365
3505
  `- You are in a dedicated worktree on branch \`${plan.branch}\`.${forked}`,
3366
3506
  '- Commit to this branch. Do not switch branches and do not merge into the base branch yourself — a human presses «Apply» in DevBridge, which squash-merges your branch for them.',
3367
- '- You cannot push: `git push` is refused. A human presses «Push» when they want the branch on the remote.',
3507
+ pushLine,
3368
3508
  `- Put a plan or working notes in \`docs/devbridge/${plan.branch.replace(/\//g, '-')}.md\`, unless this repository already has its own convention for where such documents live — if it does, follow that.`,
3369
3509
  ].join('\n'));
3370
3510
  }
3511
+ else {
3512
+ /**
3513
+ * A DIRECT session got NO «Git in this session» block at all, and that is
3514
+ * the older half of this bug (session 16 shipped DIRECT as the default;
3515
+ * this block stayed gated on `branchPlan`, which only a BRANCH session
3516
+ * has). So the arrangement that writes into the person's OWN project
3517
+ * folder was the one told nothing about where it was — while the adapter's
3518
+ * static line went on claiming «a dedicated git worktree on a session
3519
+ * branch», which in DIRECT is simply false.
3520
+ *
3521
+ * Being in somebody's working copy is the case that needs saying MORE, not
3522
+ * less: there is no «Apply» to undo it, and uncommitted changes in that
3523
+ * folder may not be the agent's.
3524
+ */
3525
+ sections.push([
3526
+ 'Git in this session:',
3527
+ '- You are working in the PROJECT FOLDER ITSELF, on the branch it is already checked out on — not in a worktree of your own. There is no session branch and nothing to «apply» afterwards: your commits are immediately on the branch the person works on.',
3528
+ '- Uncommitted changes you find here may be a human’s, or another session’s. Never discard or reset what you did not write.',
3529
+ pushLine,
3530
+ ].join('\n'));
3531
+ }
3371
3532
  // The ticket convention lives HERE, not in the first chat message (session
3372
3533
  // 16). It is a standing rule of the project — true on the twentieth turn as
3373
3534
  // much as the first — and putting it in the visible prompt only trained the
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.31.0";
1
+ export declare const RUNNER_VERSION = "0.34.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.31.0';
2
+ export const RUNNER_VERSION = '0.34.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.31.0",
3
+ "version": "0.34.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",
@@ -37,16 +37,16 @@
37
37
  "prepublishOnly": "tsc"
38
38
  },
39
39
  "dependencies": {
40
- "@anthropic-ai/claude-agent-sdk": "^0.3.218",
41
- "smol-toml": "^1.7.0",
42
- "ws": "^8.21.1",
43
- "zod": "^3.24.0"
40
+ "@anthropic-ai/claude-agent-sdk": "^0.3.226",
41
+ "smol-toml": "^1.7.1",
42
+ "ws": "^8.21.3",
43
+ "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "^22.10.2",
46
+ "@types/node": "^22.20.1",
47
47
  "@types/ws": "^8.18.1",
48
- "tsx": "^4.19.2",
49
- "typescript": "~5.7.2",
50
- "vitest": "^2.1.9"
48
+ "tsx": "^4.23.11",
49
+ "typescript": "~5.7.3",
50
+ "vitest": "^3.2.7"
51
51
  }
52
52
  }