@bridge4dev/runner 0.30.0 → 0.33.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 +162 -45
- package/dist/adapters/codex.js +42 -14
- package/dist/adapters/types.d.ts +25 -1
- package/dist/agent-prompt.d.ts +75 -0
- package/dist/agent-prompt.js +252 -0
- package/dist/index.js +23 -0
- package/dist/policy.d.ts +73 -1
- package/dist/policy.js +398 -53
- package/dist/protocol.d.ts +240 -30
- package/dist/protocol.js +56 -0
- package/dist/supervisor.d.ts +29 -1
- package/dist/supervisor.js +186 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { AsyncQueue } from '../async-queue.js';
|
|
5
5
|
import { log } from '../log.js';
|
|
6
6
|
import { mcpConfigPath } from '../paths.js';
|
|
7
|
-
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
7
|
+
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
8
8
|
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
|
|
9
9
|
import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
10
10
|
// Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
|
|
@@ -138,22 +138,49 @@ const MODE_TO_PERMISSION = {
|
|
|
138
138
|
auto: 'default',
|
|
139
139
|
full: 'bypassPermissions',
|
|
140
140
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
141
|
+
/**
|
|
142
|
+
* DevBridge's own rules — composed per session since session 18.
|
|
143
|
+
*
|
|
144
|
+
* It was a flat constant, and two of its lines were untrue for most sessions.
|
|
145
|
+
* «a dedicated git worktree on a session branch» is false in `workMode: DIRECT`,
|
|
146
|
+
* which has been the DEFAULT since session 16; and the push sentence is now
|
|
147
|
+
* only true when the project has «Принудительно запретить push» switched on.
|
|
148
|
+
*
|
|
149
|
+
* A system-prompt line that does not match the rule underneath it is worse than
|
|
150
|
+
* no line: the agent plans around a restriction that is not there, or walks
|
|
151
|
+
* into one it was told did not exist.
|
|
152
|
+
*/
|
|
153
|
+
function systemAppendFor(spec) {
|
|
154
|
+
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
155
|
+
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
156
|
+
return [
|
|
157
|
+
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
158
|
+
'Rules:',
|
|
159
|
+
'- Work ONLY inside the current working directory.',
|
|
160
|
+
// Session 13: pushing was refused outright by layer 1 in every trust mode,
|
|
161
|
+
// and saying so here saved the agent a turn spent discovering it. Session
|
|
162
|
+
// 18 makes it the project's decision — so the sentence has to follow the
|
|
163
|
+
// decision, which is the whole of what the owner asked for: switch it off
|
|
164
|
+
// and nothing about push is added here at all.
|
|
165
|
+
pushBanned
|
|
166
|
+
? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
|
|
167
|
+
: guarded.length > 0
|
|
168
|
+
? // Not a restriction invented here: it is the rule layer 1 will apply,
|
|
169
|
+
// stated in advance. The branch names come from the project's own
|
|
170
|
+
// settings and are already validated as git refs by the API and the
|
|
171
|
+
// protocol schema, so nothing user-typed reaches this text unchecked.
|
|
172
|
+
`- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
|
|
173
|
+
: '- Commit your work in the current branch with clear messages.',
|
|
174
|
+
'- If DevBridge MCP tools (mcp__devbridge__*) are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
175
|
+
'- The user is not in a terminal, but they DO answer: when you need a decision, use the AskUserQuestion tool. It is rendered as a card in the DevBridge dashboard and the call waits — however long it takes — until a human answers it. Only ask in plain text if the tool is unavailable.',
|
|
176
|
+
'- Never decide for the user when you asked them a question. If the tool comes back saying the question was withdrawn, stop and wait rather than guessing.',
|
|
177
|
+
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
178
|
+
].join('\n');
|
|
179
|
+
}
|
|
154
180
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
155
|
-
function composeSystemAppend(
|
|
156
|
-
|
|
181
|
+
function composeSystemAppend(spec) {
|
|
182
|
+
const base = systemAppendFor(spec);
|
|
183
|
+
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
157
184
|
}
|
|
158
185
|
/**
|
|
159
186
|
* The five levels the Agent SDK declares, and the one gate that keeps a
|
|
@@ -210,12 +237,24 @@ const TASK_LIST_CAP = 20;
|
|
|
210
237
|
* `DevSessionEvent`, so the tray coalesces rather than narrating.
|
|
211
238
|
*/
|
|
212
239
|
const TASK_PUBLISH_INTERVAL_MS = 1_500;
|
|
213
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* What the agent calls a finished task, mapped onto the tray's three states.
|
|
242
|
+
*
|
|
243
|
+
* Every way a task can STOP has to be named here, not just the two happy ones.
|
|
244
|
+
* An unlisted outcome fell through to `running`, which used to be merely
|
|
245
|
+
* cosmetic — a cancelled subagent sat in the tray spinning until the live set
|
|
246
|
+
* happened to drop it. Since the turn counters are derived from these statuses
|
|
247
|
+
* (#147) it is no longer cosmetic: a settled row degrading back to `running`
|
|
248
|
+
* would make «7 of 21 done» step backwards to «6 of 21 done» on screen.
|
|
249
|
+
*/
|
|
214
250
|
function taskStatus(raw) {
|
|
215
251
|
if (raw === 'completed')
|
|
216
252
|
return 'done';
|
|
217
253
|
if (raw === 'failed' || raw === 'killed')
|
|
218
254
|
return 'failed';
|
|
255
|
+
if (raw === 'cancelled' || raw === 'canceled' || raw === 'timed_out' || raw === 'aborted') {
|
|
256
|
+
return 'failed';
|
|
257
|
+
}
|
|
219
258
|
return 'running';
|
|
220
259
|
}
|
|
221
260
|
/**
|
|
@@ -311,9 +350,18 @@ class ClaudeSession {
|
|
|
311
350
|
tasks = new Map();
|
|
312
351
|
/** Ids currently in the agent's live set — the tray shows exactly these. */
|
|
313
352
|
liveTaskIds = [];
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
353
|
+
/**
|
|
354
|
+
* Which turn is being counted (#147).
|
|
355
|
+
*
|
|
356
|
+
* There are no `started` / `done` accumulators any more, and that is the
|
|
357
|
+
* whole fix. Two counters with different lifetimes could disagree, and did:
|
|
358
|
+
* `endTaskTurn` zeroed both while deliberately keeping the ROWS of tasks
|
|
359
|
+
* still running, so a background shell that outlived its turn was counted as
|
|
360
|
+
* finished in the next one without ever having been counted as started —
|
|
361
|
+
* «22 of 21 done». Both numbers are now derived from this map in one pass, so
|
|
362
|
+
* `done <= total` holds by cardinality rather than by clamping.
|
|
363
|
+
*/
|
|
364
|
+
turnEpoch = 0;
|
|
317
365
|
taskPublishTimer = null;
|
|
318
366
|
taskPublishedAt = 0;
|
|
319
367
|
/** Last published snapshot, minus the ages — see `flushTasks` (QA-111 M4). */
|
|
@@ -436,7 +484,7 @@ class ClaudeSession {
|
|
|
436
484
|
systemPrompt: {
|
|
437
485
|
type: 'preset',
|
|
438
486
|
preset: 'claude_code',
|
|
439
|
-
append: composeSystemAppend(spec
|
|
487
|
+
append: composeSystemAppend(spec),
|
|
440
488
|
},
|
|
441
489
|
canUseTool: (toolName, input, opts) => this.onCanUseTool(toolName, input, opts),
|
|
442
490
|
// Ticket #113: a running subagent forks its own conversation every ~30s
|
|
@@ -873,6 +921,13 @@ class ClaudeSession {
|
|
|
873
921
|
...(this.spec.agentAutoCommit === undefined
|
|
874
922
|
? {}
|
|
875
923
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
924
|
+
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
925
|
+
// The fourth entry point into `evaluateToolUse`, and it was the one
|
|
926
|
+
// that did not carry the git policy (QA-134 MINOR-1). Safe direction —
|
|
927
|
+
// without the fields everything resolves to «refused» and this function
|
|
928
|
+
// only ever releases — but a project that allows `git clean` would have
|
|
929
|
+
// left a parked card unreleased in Claude while Codex released it.
|
|
930
|
+
...(this.spec.gitPolicy ?? {}),
|
|
876
931
|
worktreePath: this.spec.cwd,
|
|
877
932
|
});
|
|
878
933
|
if (verdict.decision !== 'allow')
|
|
@@ -898,6 +953,17 @@ class ClaudeSession {
|
|
|
898
953
|
this.spec.trustMode = policy.trustMode;
|
|
899
954
|
if (policy.agentAutoCommit !== undefined)
|
|
900
955
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
956
|
+
// Replaced whole (session 18). The four fields are one decision and the API
|
|
957
|
+
// always sends them together; merging them one by one is how «push is
|
|
958
|
+
// allowed now» could arrive while a stale protected list stayed behind.
|
|
959
|
+
//
|
|
960
|
+
// Only the RULES change live. The system-prompt sentence composed at launch
|
|
961
|
+
// stays as it was until the session is restarted — telling an agent
|
|
962
|
+
// mid-turn that a rule it was given no longer applies is a bigger surprise
|
|
963
|
+
// than letting it discover the refusal is gone, and the rule is what
|
|
964
|
+
// actually enforces anything.
|
|
965
|
+
if (policy.gitPolicy !== undefined)
|
|
966
|
+
this.spec.gitPolicy = policy.gitPolicy;
|
|
901
967
|
if (!trustChanged)
|
|
902
968
|
return;
|
|
903
969
|
// The manager tightened to STRICT while this session was running with
|
|
@@ -1012,6 +1078,18 @@ class ClaudeSession {
|
|
|
1012
1078
|
switch (msg.subtype) {
|
|
1013
1079
|
case 'background_tasks_changed': {
|
|
1014
1080
|
const rows = Array.isArray(msg['tasks']) ? msg['tasks'] : [];
|
|
1081
|
+
// The level signal is the authority on what EXISTS, so it is also the
|
|
1082
|
+
// right place to forget an ambient id (#147, QA-131 m3). Without this
|
|
1083
|
+
// the set only ever shrank on a settling `task_notification`, and a
|
|
1084
|
+
// housekeeping task that is killed, or that reports a status this
|
|
1085
|
+
// runner does not recognise, left its id remembered for the life of the
|
|
1086
|
+
// session — where a reused id would be invisible forever, in the tray
|
|
1087
|
+
// and in the counters, with nothing on screen to explain why.
|
|
1088
|
+
const announced = new Set(rows.map((row) => (typeof row['task_id'] === 'string' ? row['task_id'] : '')));
|
|
1089
|
+
for (const id of [...this.skipTaskIds]) {
|
|
1090
|
+
if (!announced.has(id))
|
|
1091
|
+
this.skipTaskIds.delete(id);
|
|
1092
|
+
}
|
|
1015
1093
|
this.liveTaskIds = [];
|
|
1016
1094
|
for (const row of rows) {
|
|
1017
1095
|
const id = typeof row['task_id'] === 'string' ? row['task_id'] : null;
|
|
@@ -1066,7 +1144,11 @@ class ClaudeSession {
|
|
|
1066
1144
|
}
|
|
1067
1145
|
case 'task_progress': {
|
|
1068
1146
|
const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
|
|
1069
|
-
|
|
1147
|
+
// Housekeeping stays hidden here too. `task_started` deletes an ambient
|
|
1148
|
+
// row from the map, and without this guard the next progress frame
|
|
1149
|
+
// simply created it again — as a visible row, and as work in the turn's
|
|
1150
|
+
// denominator (#147).
|
|
1151
|
+
if (!id || this.skipTaskIds.has(id))
|
|
1070
1152
|
break;
|
|
1071
1153
|
this.touchTask(id, {
|
|
1072
1154
|
...(typeof msg['description'] === 'string' ? { title: msg['description'] } : {}),
|
|
@@ -1096,6 +1178,16 @@ class ClaudeSession {
|
|
|
1096
1178
|
const id = typeof msg['task_id'] === 'string' ? msg['task_id'] : null;
|
|
1097
1179
|
if (!id)
|
|
1098
1180
|
break;
|
|
1181
|
+
if (this.skipTaskIds.has(id)) {
|
|
1182
|
+
// Same guard as `task_progress`, plus the id's retirement: a settled
|
|
1183
|
+
// ambient task can never re-enter the live set, so this is the moment
|
|
1184
|
+
// the set can forget it without letting it back in (#147).
|
|
1185
|
+
if (taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed') !==
|
|
1186
|
+
'running') {
|
|
1187
|
+
this.skipTaskIds.delete(id);
|
|
1188
|
+
}
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1099
1191
|
this.touchTask(id, {
|
|
1100
1192
|
status: taskStatus(typeof msg['status'] === 'string' ? msg['status'] : 'completed'),
|
|
1101
1193
|
...(typeof msg['summary'] === 'string' ? { summary: msg['summary'] } : {}),
|
|
@@ -1128,31 +1220,29 @@ class ClaudeSession {
|
|
|
1128
1220
|
touchTask(id, patch) {
|
|
1129
1221
|
const existing = this.tasks.get(id);
|
|
1130
1222
|
if (existing) {
|
|
1131
|
-
const
|
|
1223
|
+
const settledAs = existing.status === 'running' ? null : existing.status;
|
|
1132
1224
|
Object.assign(existing, patch);
|
|
1133
1225
|
if (patch.title)
|
|
1134
1226
|
existing.title = truncate(patch.title, TASK_TITLE_LIMIT);
|
|
1135
1227
|
if (patch.summary)
|
|
1136
1228
|
existing.summary = truncate(patch.summary, TASK_TITLE_LIMIT);
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1229
|
+
// Settling is one-way. A task that has stopped cannot start again under
|
|
1230
|
+
// the same id: the only messages that could say so are a late
|
|
1231
|
+
// `task_updated` carrying a status this runner does not recognise, or a
|
|
1232
|
+
// frame redelivered after a reconnect — neither of which is news that the
|
|
1233
|
+
// work resumed. Letting either through would make the derived `done`
|
|
1234
|
+
// count downwards on screen.
|
|
1235
|
+
if (settledAs && existing.status === 'running')
|
|
1236
|
+
existing.status = settledAs;
|
|
1142
1237
|
return;
|
|
1143
1238
|
}
|
|
1144
|
-
this.turnTasksStarted += 1;
|
|
1145
|
-
const status = patch.status ?? 'running';
|
|
1146
|
-
// A row that arrives already finished (a notification for a task we never
|
|
1147
|
-
// saw start) counts as both, or `done` could exceed nothing at all.
|
|
1148
|
-
if (status !== 'running')
|
|
1149
|
-
this.turnTasksDone += 1;
|
|
1150
1239
|
this.tasks.set(id, {
|
|
1151
1240
|
...patch,
|
|
1152
1241
|
id,
|
|
1153
1242
|
kind: patch.kind ?? 'task',
|
|
1154
1243
|
title: truncate(patch.title ?? 'Working…', TASK_TITLE_LIMIT),
|
|
1155
|
-
status,
|
|
1244
|
+
status: patch.status ?? 'running',
|
|
1245
|
+
turnEpoch: this.turnEpoch,
|
|
1156
1246
|
startedAt: Date.now(),
|
|
1157
1247
|
// The only free-text field an agent writes with no length of its own:
|
|
1158
1248
|
// a subagent's closing `summary` runs to kilobytes, and twenty of them
|
|
@@ -1200,13 +1290,31 @@ class ClaudeSession {
|
|
|
1200
1290
|
// this machine's clock and the browser's is a different one — subtracting
|
|
1201
1291
|
// across them put the dev server's clock skew straight into the number,
|
|
1202
1292
|
// so a host ten minutes behind showed «10m 03s» on a task one second old.
|
|
1203
|
-
.map((
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1293
|
+
.map(({ turnEpoch: _turnEpoch, ...task }) => ({
|
|
1294
|
+
...task,
|
|
1295
|
+
ageMs: Math.max(0, now - task.startedAt),
|
|
1296
|
+
}));
|
|
1297
|
+
// Both numbers, one pass, one map (#147). `done` counts a SUBSET of what
|
|
1298
|
+
// `total` counts, so `done <= total` is a property of set cardinality and
|
|
1299
|
+
// cannot be broken by a message ordering, a redelivery, a re-title, an
|
|
1300
|
+
// ambient row being deleted or a turn boundary. There is no pair of
|
|
1301
|
+
// counters to keep in agreement, because there is no pair.
|
|
1302
|
+
//
|
|
1303
|
+
// Tasks that outlived an earlier turn keep that turn's epoch: they still
|
|
1304
|
+
// render as live rows — background work outliving its turn is the whole
|
|
1305
|
+
// point of the tray — but they belong to neither number here, exactly as
|
|
1306
|
+
// this file's own doctrine says («done/total are per-turn by definition.
|
|
1307
|
+
// The live set does NOT»).
|
|
1308
|
+
let total = 0;
|
|
1309
|
+
let done = 0;
|
|
1310
|
+
for (const task of this.tasks.values()) {
|
|
1311
|
+
if (task.turnEpoch !== this.turnEpoch)
|
|
1312
|
+
continue;
|
|
1313
|
+
total += 1;
|
|
1314
|
+
if (task.status !== 'running')
|
|
1315
|
+
done += 1;
|
|
1316
|
+
}
|
|
1317
|
+
const payload = { type: 'agent_tasks', tasks, done, total };
|
|
1210
1318
|
// A frame that says exactly what the last one said is not worth a row in
|
|
1211
1319
|
// the session feed (QA-111 M4). `task_progress` fires every ~30s per
|
|
1212
1320
|
// running subagent and usually carries nothing new, and with twenty of
|
|
@@ -1249,9 +1357,13 @@ class ClaudeSession {
|
|
|
1249
1357
|
if (!live.has(id))
|
|
1250
1358
|
this.tasks.delete(id);
|
|
1251
1359
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1360
|
+
// `skipTaskIds` is NOT cleared here any more (#147). An ambient task the
|
|
1361
|
+
// SDK asked consumers to hide can span a turn boundary, and clearing the
|
|
1362
|
+
// set let its id back into the live list on the next
|
|
1363
|
+
// `background_tasks_changed` — where it was drawn as a tray row and counted
|
|
1364
|
+
// as work. The id is forgotten when that task settles instead, which is the
|
|
1365
|
+
// moment it stops being able to come back.
|
|
1366
|
+
this.turnEpoch += 1;
|
|
1255
1367
|
// Straight through `flushTasks` rather than an empty frame of its own: the
|
|
1256
1368
|
// counters changed, so the fingerprint differs and it will publish — and
|
|
1257
1369
|
// what it publishes is the truth about what is still running.
|
|
@@ -1300,6 +1412,11 @@ class ClaudeSession {
|
|
|
1300
1412
|
...(this.spec.agentAutoCommit === undefined
|
|
1301
1413
|
? {}
|
|
1302
1414
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
1415
|
+
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
1416
|
+
// Session 18. Spread WHOLE rather than field by field: `resolveGitPolicy`
|
|
1417
|
+
// gives every absent field its safe reading, and an object assembled here
|
|
1418
|
+
// with three of the four would be a fourth place to get a polarity wrong.
|
|
1419
|
+
...(this.spec.gitPolicy ?? {}),
|
|
1303
1420
|
worktreePath: this.spec.cwd,
|
|
1304
1421
|
});
|
|
1305
1422
|
if (verdict.decision === 'allow') {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncQueue } from '../async-queue.js';
|
|
2
2
|
import { log } from '../log.js';
|
|
3
|
-
import { evaluateToolUse, maskSecrets, maskString } from '../policy.js';
|
|
3
|
+
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
4
4
|
import { RUNNER_VERSION } from '../version.js';
|
|
5
5
|
import { repairCodexAuth } from './codex-home.js';
|
|
6
6
|
import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
|
|
@@ -59,18 +59,36 @@ const MODE_POLICY = {
|
|
|
59
59
|
// exactly like Claude's bypassPermissions. The dashboard says so.
|
|
60
60
|
full: { approvalPolicy: 'never', sandbox: 'danger-full-access', plan: false },
|
|
61
61
|
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
/**
|
|
63
|
+
* DevBridge's own rules — composed per session since session 18, and kept
|
|
64
|
+
* deliberately in step with `systemAppendFor` in the Claude adapter.
|
|
65
|
+
*
|
|
66
|
+
* The two texts were byte-identical on the push line and both were wrong in the
|
|
67
|
+
* same two ways: «a dedicated git worktree on a session branch» is false in
|
|
68
|
+
* `workMode: DIRECT` (the default since session 16), and the push sentence is
|
|
69
|
+
* only true when the project has «Принудительно запретить push» switched on.
|
|
70
|
+
*/
|
|
71
|
+
function systemAppendFor(spec) {
|
|
72
|
+
const pushBanned = spec.gitPolicy?.agentPushBan !== false;
|
|
73
|
+
const guarded = spec.gitPolicy?.agentProtectedBranches ?? ['main', 'master'];
|
|
74
|
+
return [
|
|
75
|
+
'You are running inside a DevBridge dev session, controlled from the DevBridge dashboard.',
|
|
76
|
+
'Rules:',
|
|
77
|
+
'- Work ONLY inside the current working directory.',
|
|
78
|
+
pushBanned
|
|
79
|
+
? '- Commit your work in the current branch with clear messages. You cannot push: `git push` is blocked for this project. A human presses «Push» and «Apply» in DevBridge when the branch is ready.'
|
|
80
|
+
: guarded.length > 0
|
|
81
|
+
? `- Commit your work in the current branch with clear messages. You may push, except to these protected branches: ${guarded.join(', ')}. Name the branch explicitly — \`git push <remote> <branch>\`.`
|
|
82
|
+
: '- Commit your work in the current branch with clear messages.',
|
|
83
|
+
'- If DevBridge MCP tools are available and the task mentions tickets: fetch the ticket first, set its status to IN_PROGRESS when you start and READY_FOR_REVIEW when your implementation is complete, and leave a short summary comment.',
|
|
84
|
+
'- The user is not in a terminal: if you need a decision, use your question tool or ask in plain text and end your turn.',
|
|
85
|
+
'- Never print secrets (tokens, API keys, private keys) in your output.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
71
88
|
/** DevBridge's own rules, then whatever this workspace adds (session 13). */
|
|
72
|
-
function composeSystemAppend(
|
|
73
|
-
|
|
89
|
+
function composeSystemAppend(spec) {
|
|
90
|
+
const base = systemAppendFor(spec);
|
|
91
|
+
return spec.workspaceContext ? `${base}\n\n${spec.workspaceContext}` : base;
|
|
74
92
|
}
|
|
75
93
|
// Allowlist, not denylist: whatever secrets live in the daemon's environment
|
|
76
94
|
// must not reach the agent process. OPENAI_API_KEY is absent by design — it
|
|
@@ -411,7 +429,7 @@ class CodexSession {
|
|
|
411
429
|
cwd: this.spec.cwd,
|
|
412
430
|
approvalPolicy: policy.approvalPolicy,
|
|
413
431
|
sandbox: policy.sandbox,
|
|
414
|
-
developerInstructions: composeSystemAppend(this.spec
|
|
432
|
+
developerInstructions: composeSystemAppend(this.spec),
|
|
415
433
|
...(this.model ? { model: this.model } : {}),
|
|
416
434
|
...(this.spec.mcp ? { config: this.mcpOverlay() } : {}),
|
|
417
435
|
};
|
|
@@ -479,7 +497,7 @@ class CodexSession {
|
|
|
479
497
|
settings: {
|
|
480
498
|
model: this.model ?? this.threadModel ?? 'gpt-5.5',
|
|
481
499
|
reasoning_effort: null,
|
|
482
|
-
developer_instructions: composeSystemAppend(this.spec
|
|
500
|
+
developer_instructions: composeSystemAppend(this.spec),
|
|
483
501
|
},
|
|
484
502
|
};
|
|
485
503
|
this.lastCollabMode = wantCollab;
|
|
@@ -607,6 +625,10 @@ class CodexSession {
|
|
|
607
625
|
this.spec.trustMode = policy.trustMode;
|
|
608
626
|
if (policy.agentAutoCommit !== undefined)
|
|
609
627
|
this.spec.agentAutoCommit = policy.agentAutoCommit;
|
|
628
|
+
// Replaced whole — see the note in the Claude adapter. Only the RULES move
|
|
629
|
+
// live; the system-prompt sentence composed at launch stays until restart.
|
|
630
|
+
if (policy.gitPolicy !== undefined)
|
|
631
|
+
this.spec.gitPolicy = policy.gitPolicy;
|
|
610
632
|
if (!trustChanged)
|
|
611
633
|
return;
|
|
612
634
|
// A manager tightened the project out from under a session that is running
|
|
@@ -669,6 +691,9 @@ class CodexSession {
|
|
|
669
691
|
...(this.spec.agentAutoCommit === undefined
|
|
670
692
|
? {}
|
|
671
693
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
694
|
+
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
695
|
+
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
696
|
+
...(this.spec.gitPolicy ?? {}),
|
|
672
697
|
worktreePath: this.spec.cwd,
|
|
673
698
|
});
|
|
674
699
|
if (verdict.decision !== 'allow')
|
|
@@ -788,6 +813,9 @@ class CodexSession {
|
|
|
788
813
|
...(this.spec.agentAutoCommit === undefined
|
|
789
814
|
? {}
|
|
790
815
|
: { agentAutoCommit: this.spec.agentAutoCommit }),
|
|
816
|
+
...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
|
|
817
|
+
// Session 18 — spread whole; see the note in the Claude adapter.
|
|
818
|
+
...(this.spec.gitPolicy ?? {}),
|
|
791
819
|
worktreePath: this.spec.cwd,
|
|
792
820
|
});
|
|
793
821
|
if (verdict.decision === 'allow') {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TrustMode } from '../policy.js';
|
|
1
|
+
import type { AgentGitPolicy, TrustMode } from '../policy.js';
|
|
2
2
|
export interface McpConfig {
|
|
3
3
|
url: string;
|
|
4
4
|
token: string;
|
|
@@ -176,6 +176,23 @@ export interface SessionSpec {
|
|
|
176
176
|
* the same question — may this Bash call go through.
|
|
177
177
|
*/
|
|
178
178
|
agentAutoCommit?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Absolute path of the project's prompt file, set only when it was actually
|
|
181
|
+
* read into `workspaceContext` for this process (session 17).
|
|
182
|
+
*
|
|
183
|
+
* Rides down to `PolicyContext` so layer 1 can refuse writes to it: in
|
|
184
|
+
* `workMode: DIRECT` the project folder is the agent's own working directory,
|
|
185
|
+
* so without this the agent could rewrite its own next system prompt.
|
|
186
|
+
*/
|
|
187
|
+
agentPromptFile?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Session 18: the project's git policy. Rides with `trustMode` and
|
|
190
|
+
* `agentAutoCommit` for the same reason — it lands in the same
|
|
191
|
+
* `PolicyContext` and answers the same question, may this Bash call go
|
|
192
|
+
* through. It also decides one sentence of the system prompt, which is the
|
|
193
|
+
* part `agentAutoCommit` does not do.
|
|
194
|
+
*/
|
|
195
|
+
gitPolicy?: AgentGitPolicy;
|
|
179
196
|
mode?: AgentMode;
|
|
180
197
|
model?: string;
|
|
181
198
|
effort?: string;
|
|
@@ -453,6 +470,13 @@ export interface AgentSession {
|
|
|
453
470
|
setWorkspacePolicy(policy: {
|
|
454
471
|
trustMode?: TrustMode;
|
|
455
472
|
agentAutoCommit?: boolean;
|
|
473
|
+
/**
|
|
474
|
+
* Session 18. Replaced WHOLE, never merged field by field: the API sends
|
|
475
|
+
* all four together precisely so that a change to one cannot leave the
|
|
476
|
+
* runner holding a stale value of another — «push is allowed now» must not
|
|
477
|
+
* arrive without the list of branches it is still not allowed into.
|
|
478
|
+
*/
|
|
479
|
+
gitPolicy?: AgentGitPolicy;
|
|
456
480
|
}): void;
|
|
457
481
|
/** Interrupt the current turn (session stays resumable). */
|
|
458
482
|
interrupt(): Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's own prompt file — the one thing on this machine that a project
|
|
3
|
+
* can put into an agent's SYSTEM prompt.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a system prompt and not a file the agent reads
|
|
6
|
+
*
|
|
7
|
+
* Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
|
|
8
|
+
* and `SessionStart` hook output all arrive inside the first USER message —
|
|
9
|
+
* Claude Code's own prompt says so in as many words: «Treat feedback from hooks
|
|
10
|
+
* … as coming from the user». `systemPrompt.append` arrives in `system[]`,
|
|
11
|
+
* after the CLI's own text and last. Only the second survives a compaction, and
|
|
12
|
+
* only the second can stand next to a rule the CLI itself states.
|
|
13
|
+
*
|
|
14
|
+
* That difference is the whole feature. An Opus-5 system prompt carries
|
|
15
|
+
* «Do not call the AgentTool unless the user requested it»; a project whose
|
|
16
|
+
* process REQUIRES an independent review round has to be able to say so at the
|
|
17
|
+
* same level, or it is simply outranked and nobody can see why.
|
|
18
|
+
*
|
|
19
|
+
* ## Why this file is paranoid
|
|
20
|
+
*
|
|
21
|
+
* Its contents become system-prompt text verbatim, and the path comes off the
|
|
22
|
+
* wire. So every rule is re-derived here rather than trusted from the API: the
|
|
23
|
+
* runner is the process that opens the file, and it is the only side that can
|
|
24
|
+
* see what the path actually resolves to on this disk.
|
|
25
|
+
*/
|
|
26
|
+
/** Big enough for a real process document; small enough to stay a prompt. */
|
|
27
|
+
export declare const AGENT_PROMPT_MAX_BYTES: number;
|
|
28
|
+
export type AgentPromptResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
text: string;
|
|
31
|
+
relPath: string;
|
|
32
|
+
absPath: string;
|
|
33
|
+
bytes: number;
|
|
34
|
+
sha: string;
|
|
35
|
+
}
|
|
36
|
+
/** Already phrased for a human and safe to show — no raw paths beyond the one they typed. */
|
|
37
|
+
| {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Read the project's prompt file, or explain why it cannot be read.
|
|
43
|
+
*
|
|
44
|
+
* @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
|
|
45
|
+
* not the session worktree. Deliberate, for two reasons that always hold: the
|
|
46
|
+
* file may be uncommitted (a worktree cut from the base branch would not have
|
|
47
|
+
* it), and every session of the project then reads the same rules whatever
|
|
48
|
+
* branch it is on.
|
|
49
|
+
*
|
|
50
|
+
* A third reason used to be written here and was wrong, so it is worth saying
|
|
51
|
+
* plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
|
|
52
|
+
* file that becomes its own next system prompt. It stops it in `BRANCH` mode,
|
|
53
|
+
* where writes outside the worktree are refused — but in `workMode: DIRECT`,
|
|
54
|
+
* which is the default, the project folder IS the session's working
|
|
55
|
+
* directory. What guards it there is the layer-1 rule in `policy.ts`
|
|
56
|
+
* (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
|
|
57
|
+
* covers the file-writing tools, not a shell redirect, and `full` mode
|
|
58
|
+
* bypasses layer 1 altogether by design. The honest backstop is the `sha` in
|
|
59
|
+
* the session feed, which changes when the file does.
|
|
60
|
+
* @param relPath The configured path, relative to `projectRoot`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function readAgentPrompt(projectRoot: string, relPath: string): AgentPromptResult;
|
|
63
|
+
/** How the prompt is announced in the session feed and in the journal. */
|
|
64
|
+
export declare function agentPromptSizeLabel(bytes: number): string;
|
|
65
|
+
/**
|
|
66
|
+
* A configured path, safe to put in a line a person reads.
|
|
67
|
+
*
|
|
68
|
+
* The session feed renders a notice as plain text, so backticks would be shown
|
|
69
|
+
* literally rather than as code — and this string can be a REFUSED path, which
|
|
70
|
+
* means it never passed any of the checks above and is only as clean as the API
|
|
71
|
+
* schema made it. Quoted with guillemets, stripped of anything that could break
|
|
72
|
+
* a line, and bounded (QA-130 NIT-13).
|
|
73
|
+
*/
|
|
74
|
+
export declare function quotePath(value: string): string;
|
|
75
|
+
//# sourceMappingURL=agent-prompt.d.ts.map
|