@bridge4dev/runner 0.30.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.
@@ -873,6 +873,7 @@ class ClaudeSession {
873
873
  ...(this.spec.agentAutoCommit === undefined
874
874
  ? {}
875
875
  : { agentAutoCommit: this.spec.agentAutoCommit }),
876
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
876
877
  worktreePath: this.spec.cwd,
877
878
  });
878
879
  if (verdict.decision !== 'allow')
@@ -1300,6 +1301,7 @@ class ClaudeSession {
1300
1301
  ...(this.spec.agentAutoCommit === undefined
1301
1302
  ? {}
1302
1303
  : { agentAutoCommit: this.spec.agentAutoCommit }),
1304
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
1303
1305
  worktreePath: this.spec.cwd,
1304
1306
  });
1305
1307
  if (verdict.decision === 'allow') {
@@ -669,6 +669,7 @@ class CodexSession {
669
669
  ...(this.spec.agentAutoCommit === undefined
670
670
  ? {}
671
671
  : { agentAutoCommit: this.spec.agentAutoCommit }),
672
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
672
673
  worktreePath: this.spec.cwd,
673
674
  });
674
675
  if (verdict.decision !== 'allow')
@@ -788,6 +789,7 @@ class CodexSession {
788
789
  ...(this.spec.agentAutoCommit === undefined
789
790
  ? {}
790
791
  : { agentAutoCommit: this.spec.agentAutoCommit }),
792
+ ...(this.spec.agentPromptFile ? { agentPromptFile: this.spec.agentPromptFile } : {}),
791
793
  worktreePath: this.spec.cwd,
792
794
  });
793
795
  if (verdict.decision === 'allow') {
@@ -176,6 +176,15 @@ 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;
179
188
  mode?: AgentMode;
180
189
  model?: string;
181
190
  effort?: string;
@@ -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
@@ -0,0 +1,252 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { isGitInternalPath, isSecretPath } from './policy.js';
5
+ /**
6
+ * The project's own prompt file — the one thing on this machine that a project
7
+ * can put into an agent's SYSTEM prompt.
8
+ *
9
+ * ## Why a system prompt and not a file the agent reads
10
+ *
11
+ * Measured on a live request (2026-08-05), not assumed. `CLAUDE.md`, `AGENTS.md`
12
+ * and `SessionStart` hook output all arrive inside the first USER message —
13
+ * Claude Code's own prompt says so in as many words: «Treat feedback from hooks
14
+ * … as coming from the user». `systemPrompt.append` arrives in `system[]`,
15
+ * after the CLI's own text and last. Only the second survives a compaction, and
16
+ * only the second can stand next to a rule the CLI itself states.
17
+ *
18
+ * That difference is the whole feature. An Opus-5 system prompt carries
19
+ * «Do not call the AgentTool unless the user requested it»; a project whose
20
+ * process REQUIRES an independent review round has to be able to say so at the
21
+ * same level, or it is simply outranked and nobody can see why.
22
+ *
23
+ * ## Why this file is paranoid
24
+ *
25
+ * Its contents become system-prompt text verbatim, and the path comes off the
26
+ * wire. So every rule is re-derived here rather than trusted from the API: the
27
+ * runner is the process that opens the file, and it is the only side that can
28
+ * see what the path actually resolves to on this disk.
29
+ */
30
+ /** Big enough for a real process document; small enough to stay a prompt. */
31
+ export const AGENT_PROMPT_MAX_BYTES = 32 * 1024;
32
+ function deny(reason) {
33
+ return { ok: false, reason };
34
+ }
35
+ /**
36
+ * Read the project's prompt file, or explain why it cannot be read.
37
+ *
38
+ * @param projectRoot Absolute path of the PROJECT FOLDER (`workspace.path`) —
39
+ * not the session worktree. Deliberate, for two reasons that always hold: the
40
+ * file may be uncommitted (a worktree cut from the base branch would not have
41
+ * it), and every session of the project then reads the same rules whatever
42
+ * branch it is on.
43
+ *
44
+ * A third reason used to be written here and was wrong, so it is worth saying
45
+ * plainly (QA-130 MAJOR-3): this does NOT stop a session from rewriting the
46
+ * file that becomes its own next system prompt. It stops it in `BRANCH` mode,
47
+ * where writes outside the worktree are refused — but in `workMode: DIRECT`,
48
+ * which is the default, the project folder IS the session's working
49
+ * directory. What guards it there is the layer-1 rule in `policy.ts`
50
+ * (`agentPromptFile`), and that rule is a guard rather than a guarantee: it
51
+ * covers the file-writing tools, not a shell redirect, and `full` mode
52
+ * bypasses layer 1 altogether by design. The honest backstop is the `sha` in
53
+ * the session feed, which changes when the file does.
54
+ * @param relPath The configured path, relative to `projectRoot`.
55
+ */
56
+ export function readAgentPrompt(projectRoot, relPath) {
57
+ const wanted = relPath.trim();
58
+ if (wanted === '')
59
+ return deny('the path is empty');
60
+ if (wanted.length > 300)
61
+ return deny('the path is too long');
62
+ // A NUL truncates the string every syscall below sees, so it must never get
63
+ // as far as a syscall.
64
+ // eslint-disable-next-line no-control-regex -- a control character in a path is never legitimate
65
+ if (/[\u0000-\u001f\u007f]/.test(wanted))
66
+ return deny('the path contains control characters');
67
+ if (path.isAbsolute(wanted) || wanted.startsWith('~')) {
68
+ return deny('the path must be relative to the project folder');
69
+ }
70
+ if (wanted.includes('\\'))
71
+ return deny('the path must use "/" separators');
72
+ if (wanted.endsWith('/'))
73
+ return deny('the path must name a file, not a directory');
74
+ if (wanted.split('/').includes('..'))
75
+ return deny('the path must not contain ".." segments');
76
+ // The project folder itself has to exist and be a directory before anything
77
+ // can be resolved against it — otherwise `realpath` failures below would all
78
+ // read as «no such file», blaming the prompt for a moved project.
79
+ let rootReal;
80
+ try {
81
+ rootReal = fs.realpathSync(path.resolve(projectRoot));
82
+ }
83
+ catch {
84
+ return deny('the project folder is not readable');
85
+ }
86
+ const absolute = path.resolve(rootReal, wanted);
87
+ // `lstat` first, and on the path as written: a SYMLINK is refused outright
88
+ // rather than followed. Following it would mean the setting says one file and
89
+ // the agent's system prompt comes from another, which is exactly the sort of
90
+ // indirection that makes a security rule unreadable.
91
+ let link;
92
+ try {
93
+ link = fs.lstatSync(absolute);
94
+ }
95
+ catch (error) {
96
+ return deny(describeFsError(error));
97
+ }
98
+ if (link.isSymbolicLink())
99
+ return deny('the path is a symlink');
100
+ if (link.isDirectory())
101
+ return deny('the path is a directory');
102
+ if (!link.isFile())
103
+ return deny('the path is not a regular file');
104
+ // Containment is checked AFTER symlinks are resolved, because a symlinked
105
+ // PARENT directory would otherwise pass a purely textual check and land the
106
+ // read outside the project entirely (the QA-96 F13 lesson, applied here).
107
+ let realPath;
108
+ try {
109
+ realPath = fs.realpathSync(absolute);
110
+ }
111
+ catch (error) {
112
+ return deny(describeFsError(error));
113
+ }
114
+ const rel = path.relative(rootReal, realPath);
115
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
116
+ return deny('the path leaves the project folder');
117
+ }
118
+ // The same two lists layer 1 uses for every tool call. A setting must not be
119
+ // a way around them: «read .env into the system prompt» is precisely the
120
+ // shape of request this would otherwise grant.
121
+ if (isSecretPath(realPath))
122
+ return deny('that file is on the protected list');
123
+ if (isGitInternalPath(realPath))
124
+ return deny('that file is inside .git');
125
+ // Read with a hard ceiling on BYTES ACTUALLY READ rather than trusting the
126
+ // size from `stat`: a file can grow between the two calls, and «we checked
127
+ // and then read whatever was there» is not a limit.
128
+ //
129
+ // The open re-checks the file through its own descriptor rather than trusting
130
+ // the path a second time, and that closes two holes the path checks cannot
131
+ // (QA-130 MINOR-4):
132
+ //
133
+ // - **the window between `realpath` and `open`.** Every rule above was
134
+ // decided about a path; `open` resolves the name again. Replacing the last
135
+ // component with a symlink in between would hand back a file nothing here
136
+ // ever looked at. `O_NOFOLLOW` refuses that outright, and comparing
137
+ // dev/ino catches a plain swap.
138
+ // - **hard links**, which are the same capability under another name and
139
+ // which `realpath` cannot see: a link has no target to resolve. Without
140
+ // this, `ln /etc/hostname docs/prompt.md` walks past both «inside the
141
+ // project folder» and «not on the protected list», because both were
142
+ // decided about the NAME.
143
+ let buffer;
144
+ try {
145
+ buffer = readCapped(realPath, AGENT_PROMPT_MAX_BYTES + 1, link);
146
+ }
147
+ catch (error) {
148
+ if (error instanceof PromptFileRefused)
149
+ return deny(error.reason);
150
+ return deny(describeFsError(error));
151
+ }
152
+ if (buffer.length > AGENT_PROMPT_MAX_BYTES) {
153
+ return deny(`the file is larger than ${Math.floor(AGENT_PROMPT_MAX_BYTES / 1024)} KB`);
154
+ }
155
+ if (buffer.includes(0))
156
+ return deny('the file is not text');
157
+ const sha = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 12);
158
+ // Strip a UTF-8 BOM: it is invisible in every editor and would otherwise be
159
+ // the first character of the agent's system prompt.
160
+ const text = buffer
161
+ .toString('utf8')
162
+ .replace(/^\uFEFF/, '')
163
+ .trim();
164
+ if (text === '')
165
+ return deny('the file is empty');
166
+ return { ok: true, text, relPath: wanted, absPath: realPath, bytes: buffer.length, sha };
167
+ }
168
+ /** A refusal decided after the file was already open — carried out by throwing. */
169
+ class PromptFileRefused extends Error {
170
+ reason;
171
+ constructor(reason) {
172
+ super(reason);
173
+ this.reason = reason;
174
+ this.name = 'PromptFileRefused';
175
+ }
176
+ }
177
+ /**
178
+ * Read at most `limit` bytes from a file that is proved, through its own
179
+ * descriptor, to be the same plain file the checks above approved.
180
+ *
181
+ * @param expected The `lstat` taken before the path checks — the identity every
182
+ * rule above was decided about.
183
+ */
184
+ function readCapped(file, limit, expected) {
185
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
186
+ try {
187
+ const opened = fs.fstatSync(fd);
188
+ if (!opened.isFile())
189
+ throw new PromptFileRefused('the path is not a regular file');
190
+ // A second name for the same inode is a second way in, and it is the one
191
+ // `realpath` cannot see. One link is the only shape whose name and contents
192
+ // are the same fact.
193
+ if (opened.nlink !== 1)
194
+ throw new PromptFileRefused('the file has more than one name');
195
+ if (opened.dev !== expected.dev || opened.ino !== expected.ino) {
196
+ throw new PromptFileRefused('the file changed while it was being opened');
197
+ }
198
+ const buffer = Buffer.alloc(limit);
199
+ let read = 0;
200
+ while (read < limit) {
201
+ const n = fs.readSync(fd, buffer, read, limit - read, null);
202
+ if (n === 0)
203
+ break;
204
+ read += n;
205
+ }
206
+ return buffer.subarray(0, read);
207
+ }
208
+ finally {
209
+ fs.closeSync(fd);
210
+ }
211
+ }
212
+ /**
213
+ * Turn an fs error into a sentence a human can act on.
214
+ *
215
+ * Never echoes the error's own message: it carries the absolute path, and this
216
+ * text is shown in the session feed of a dashboard that may be open on somebody
217
+ * else's screen.
218
+ */
219
+ function describeFsError(error) {
220
+ const code = error?.code;
221
+ if (code === 'ENOENT')
222
+ return 'no such file in the project folder';
223
+ if (code === 'EACCES' || code === 'EPERM')
224
+ return 'the runner may not read that file';
225
+ if (code === 'EISDIR')
226
+ return 'the path is a directory';
227
+ if (code === 'ELOOP')
228
+ return 'the path is a symlink loop';
229
+ if (code === 'ENAMETOOLONG')
230
+ return 'the path is too long';
231
+ return 'the file could not be read';
232
+ }
233
+ /** How the prompt is announced in the session feed and in the journal. */
234
+ export function agentPromptSizeLabel(bytes) {
235
+ return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
236
+ }
237
+ /**
238
+ * A configured path, safe to put in a line a person reads.
239
+ *
240
+ * The session feed renders a notice as plain text, so backticks would be shown
241
+ * literally rather than as code — and this string can be a REFUSED path, which
242
+ * means it never passed any of the checks above and is only as clean as the API
243
+ * schema made it. Quoted with guillemets, stripped of anything that could break
244
+ * a line, and bounded (QA-130 NIT-13).
245
+ */
246
+ export function quotePath(value) {
247
+ const clean = Array.from(value)
248
+ .filter((ch) => ch >= ' ' && ch !== '\u007f' && ch !== '`')
249
+ .join('');
250
+ return `«${clean.length > 120 ? `${clean.slice(0, 117)}…` : clean}»`;
251
+ }
252
+ //# sourceMappingURL=agent-prompt.js.map
package/dist/index.js CHANGED
@@ -249,6 +249,16 @@ function runnerCapabilities(apiUrlOverride) {
249
249
  */
250
250
  contextRewind: true,
251
251
  contextCompaction: true,
252
+ /**
253
+ * Reads the project's own prompt file and hands it to the agent as
254
+ * SYSTEM-prompt text, for both Claude and Codex.
255
+ *
256
+ * Announced so the dashboard can refuse to offer the setting on a server
257
+ * that would ignore it. That refusal is the point of the flag: this is the
258
+ * one setting whose value is that it cannot be silently outranked, so a
259
+ * version of it that is silently ignored would be worse than none.
260
+ */
261
+ agentPrompt: true,
252
262
  /**
253
263
  * Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
254
264
  * the panel can say what is blocking an Apply instead of offering a button
package/dist/policy.d.ts CHANGED
@@ -30,6 +30,27 @@ export interface PolicyContext {
30
30
  * than this release — and there the answer stays what it has always been.
31
31
  */
32
32
  agentAutoCommit?: boolean;
33
+ /**
34
+ * Absolute path of the project's own prompt file, when this session was given
35
+ * one (session 17).
36
+ *
37
+ * Same shape and the same reason as the `.git` rule below it: this file has
38
+ * stopped being data. Its contents are the session's SYSTEM prompt, so a
39
+ * write to it is a rewrite of the rules the next process of this session will
40
+ * be given — including the rules that say what the agent may do.
41
+ *
42
+ * It only became reachable when `workMode: DIRECT` made the project folder
43
+ * the agent's own working directory (session 16, and the default). In
44
+ * `BRANCH` mode «writes outside the worktree are not allowed» already covered
45
+ * it, which is why the first version of session 17 believed it was safe
46
+ * everywhere (QA-130 MAJOR-3).
47
+ *
48
+ * A guard, not a guarantee, and the difference is worth stating: it covers
49
+ * the file-writing tools, not `sh -c 'echo … > prompt.md'`, and `full` mode
50
+ * does not consult layer 1 at all. The `sha` printed in the session feed is
51
+ * what makes a change visible when this cannot prevent it.
52
+ */
53
+ agentPromptFile?: string;
33
54
  }
34
55
  export interface PolicyDecision {
35
56
  decision: 'allow' | 'deny' | 'ask';
package/dist/policy.js CHANGED
@@ -706,6 +706,17 @@ export function evaluateToolUse(toolName, input, ctx) {
706
706
  if (WRITE_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
707
707
  return { decision: 'deny', reason: 'writes outside the session worktree are not allowed' };
708
708
  }
709
+ // The project's own prompt file — the same rule as `.git` above, for the
710
+ // same reason: what is written here is not data, it is the instructions the
711
+ // next process of this session will be started with.
712
+ if (WRITE_TOOLS.has(toolName) &&
713
+ ctx.agentPromptFile &&
714
+ resolved === path.resolve(ctx.agentPromptFile)) {
715
+ return {
716
+ decision: 'deny',
717
+ reason: 'this file is the system prompt of this session — a human edits it, not the agent it instructs',
718
+ };
719
+ }
709
720
  if (trust === 'STRICT')
710
721
  return { decision: 'ask', reason: 'strict mode' };
711
722
  if (READ_TOOLS.has(toolName) && !isInsideWorktree(resolved, ctx.worktreePath)) {
@@ -26,6 +26,18 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
26
26
  * every project the moment a runner updated ahead of its API.
27
27
  */
28
28
  agentAutoCommit: z.ZodOptional<z.ZodBoolean>;
29
+ /**
30
+ * The project's own prompt file, relative to `path` — its contents reach
31
+ * the agent as SYSTEM-prompt text (see `agent-prompt.ts` for why the level
32
+ * matters).
33
+ *
34
+ * `.catch(undefined)` for the same reason as `branchHint` and `branchPlan`:
35
+ * this field travels inside `hello_ack`, which carries EVERY session of the
36
+ * server, so a malformed value must cost its own session's prompt at most —
37
+ * never the frame (QA-100 MAJOR-1). The runner re-validates the path from
38
+ * scratch anyway; the bounds here only keep nonsense off the wire.
39
+ */
40
+ agentPromptPath: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
29
41
  budgetUsd: z.ZodNullable<z.ZodNumber>;
30
42
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
31
43
  }, "strip", z.ZodTypeAny, {
@@ -36,6 +48,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
36
48
  budgetUsd: number | null;
37
49
  budgetMinutes: number | null;
38
50
  agentAutoCommit?: boolean | undefined;
51
+ agentPromptPath?: string | null | undefined;
39
52
  }, {
40
53
  id: string;
41
54
  path: string;
@@ -44,7 +57,14 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
44
57
  budgetUsd: number | null;
45
58
  budgetMinutes: number | null;
46
59
  agentAutoCommit?: boolean | undefined;
60
+ agentPromptPath?: unknown;
47
61
  }>;
62
+ /**
63
+ * «Run this one without the project's agent prompt.» Absent means no — both
64
+ * because an older API never sends it and because that is the default a human
65
+ * gets when they do not touch the checkbox.
66
+ */
67
+ skipAgentPrompt: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
48
68
  tickets: z.ZodArray<z.ZodObject<{
49
69
  id: z.ZodString;
50
70
  number: z.ZodNumber;
@@ -119,8 +139,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
119
139
  }>>;
120
140
  }, "strip", z.ZodTypeAny, {
121
141
  mode: "ask" | "plan" | "auto" | "full";
122
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
123
142
  id: string;
143
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
124
144
  agent: "CLAUDE" | "CODEX";
125
145
  kind: "TICKET" | "CHAT";
126
146
  model: string | null;
@@ -140,6 +160,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
140
160
  budgetUsd: number | null;
141
161
  budgetMinutes: number | null;
142
162
  agentAutoCommit?: boolean | undefined;
163
+ agentPromptPath?: string | null | undefined;
143
164
  };
144
165
  tickets: {
145
166
  number: number;
@@ -150,6 +171,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
150
171
  url: string;
151
172
  token: string;
152
173
  } | undefined;
174
+ skipAgentPrompt?: boolean | undefined;
153
175
  branchHint?: string | undefined;
154
176
  branchPlan?: {
155
177
  branch: string;
@@ -159,8 +181,8 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
159
181
  } | undefined;
160
182
  workMode?: "DIRECT" | "BRANCH" | undefined;
161
183
  }, {
162
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
163
184
  id: string;
185
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
164
186
  agent: "CLAUDE" | "CODEX";
165
187
  kind: "TICKET" | "CHAT";
166
188
  prompt: string;
@@ -173,6 +195,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
173
195
  budgetUsd: number | null;
174
196
  budgetMinutes: number | null;
175
197
  agentAutoCommit?: boolean | undefined;
198
+ agentPromptPath?: unknown;
176
199
  };
177
200
  tickets: {
178
201
  number: number;
@@ -191,6 +214,7 @@ export declare const SessionDescriptorSchema: z.ZodObject<{
191
214
  costUsd?: number | undefined;
192
215
  activeMsBase?: number | undefined;
193
216
  extraBudgetMinutes?: number | null | undefined;
217
+ skipAgentPrompt?: unknown;
194
218
  branchHint?: unknown;
195
219
  branchPlan?: unknown;
196
220
  workMode?: unknown;
@@ -228,6 +252,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
228
252
  * every project the moment a runner updated ahead of its API.
229
253
  */
230
254
  agentAutoCommit: z.ZodOptional<z.ZodBoolean>;
255
+ /**
256
+ * The project's own prompt file, relative to `path` — its contents reach
257
+ * the agent as SYSTEM-prompt text (see `agent-prompt.ts` for why the level
258
+ * matters).
259
+ *
260
+ * `.catch(undefined)` for the same reason as `branchHint` and `branchPlan`:
261
+ * this field travels inside `hello_ack`, which carries EVERY session of the
262
+ * server, so a malformed value must cost its own session's prompt at most —
263
+ * never the frame (QA-100 MAJOR-1). The runner re-validates the path from
264
+ * scratch anyway; the bounds here only keep nonsense off the wire.
265
+ */
266
+ agentPromptPath: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
231
267
  budgetUsd: z.ZodNullable<z.ZodNumber>;
232
268
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
233
269
  }, "strip", z.ZodTypeAny, {
@@ -238,6 +274,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
238
274
  budgetUsd: number | null;
239
275
  budgetMinutes: number | null;
240
276
  agentAutoCommit?: boolean | undefined;
277
+ agentPromptPath?: string | null | undefined;
241
278
  }, {
242
279
  id: string;
243
280
  path: string;
@@ -246,7 +283,14 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
246
283
  budgetUsd: number | null;
247
284
  budgetMinutes: number | null;
248
285
  agentAutoCommit?: boolean | undefined;
286
+ agentPromptPath?: unknown;
249
287
  }>;
288
+ /**
289
+ * «Run this one without the project's agent prompt.» Absent means no — both
290
+ * because an older API never sends it and because that is the default a human
291
+ * gets when they do not touch the checkbox.
292
+ */
293
+ skipAgentPrompt: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
250
294
  tickets: z.ZodArray<z.ZodObject<{
251
295
  id: z.ZodString;
252
296
  number: z.ZodNumber;
@@ -321,8 +365,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
321
365
  }>>;
322
366
  }, "strip", z.ZodTypeAny, {
323
367
  mode: "ask" | "plan" | "auto" | "full";
324
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
325
368
  id: string;
369
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
326
370
  agent: "CLAUDE" | "CODEX";
327
371
  kind: "TICKET" | "CHAT";
328
372
  model: string | null;
@@ -342,6 +386,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
342
386
  budgetUsd: number | null;
343
387
  budgetMinutes: number | null;
344
388
  agentAutoCommit?: boolean | undefined;
389
+ agentPromptPath?: string | null | undefined;
345
390
  };
346
391
  tickets: {
347
392
  number: number;
@@ -352,6 +397,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
352
397
  url: string;
353
398
  token: string;
354
399
  } | undefined;
400
+ skipAgentPrompt?: boolean | undefined;
355
401
  branchHint?: string | undefined;
356
402
  branchPlan?: {
357
403
  branch: string;
@@ -361,8 +407,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
361
407
  } | undefined;
362
408
  workMode?: "DIRECT" | "BRANCH" | undefined;
363
409
  }, {
364
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
365
410
  id: string;
411
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
366
412
  agent: "CLAUDE" | "CODEX";
367
413
  kind: "TICKET" | "CHAT";
368
414
  prompt: string;
@@ -375,6 +421,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
375
421
  budgetUsd: number | null;
376
422
  budgetMinutes: number | null;
377
423
  agentAutoCommit?: boolean | undefined;
424
+ agentPromptPath?: unknown;
378
425
  };
379
426
  tickets: {
380
427
  number: number;
@@ -393,6 +440,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
393
440
  costUsd?: number | undefined;
394
441
  activeMsBase?: number | undefined;
395
442
  extraBudgetMinutes?: number | null | undefined;
443
+ skipAgentPrompt?: unknown;
396
444
  branchHint?: unknown;
397
445
  branchPlan?: unknown;
398
446
  workMode?: unknown;
@@ -403,8 +451,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
403
451
  serverId: string;
404
452
  sessions: {
405
453
  mode: "ask" | "plan" | "auto" | "full";
406
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
407
454
  id: string;
455
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
408
456
  agent: "CLAUDE" | "CODEX";
409
457
  kind: "TICKET" | "CHAT";
410
458
  model: string | null;
@@ -424,6 +472,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
424
472
  budgetUsd: number | null;
425
473
  budgetMinutes: number | null;
426
474
  agentAutoCommit?: boolean | undefined;
475
+ agentPromptPath?: string | null | undefined;
427
476
  };
428
477
  tickets: {
429
478
  number: number;
@@ -434,6 +483,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
434
483
  url: string;
435
484
  token: string;
436
485
  } | undefined;
486
+ skipAgentPrompt?: boolean | undefined;
437
487
  branchHint?: string | undefined;
438
488
  branchPlan?: {
439
489
  branch: string;
@@ -449,8 +499,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
449
499
  serverName: string;
450
500
  serverId: string;
451
501
  sessions: {
452
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
453
502
  id: string;
503
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
454
504
  agent: "CLAUDE" | "CODEX";
455
505
  kind: "TICKET" | "CHAT";
456
506
  prompt: string;
@@ -463,6 +513,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
463
513
  budgetUsd: number | null;
464
514
  budgetMinutes: number | null;
465
515
  agentAutoCommit?: boolean | undefined;
516
+ agentPromptPath?: unknown;
466
517
  };
467
518
  tickets: {
468
519
  number: number;
@@ -481,6 +532,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
481
532
  costUsd?: number | undefined;
482
533
  activeMsBase?: number | undefined;
483
534
  extraBudgetMinutes?: number | null | undefined;
535
+ skipAgentPrompt?: unknown;
484
536
  branchHint?: unknown;
485
537
  branchPlan?: unknown;
486
538
  workMode?: unknown;
@@ -542,6 +594,18 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
542
594
  * every project the moment a runner updated ahead of its API.
543
595
  */
544
596
  agentAutoCommit: z.ZodOptional<z.ZodBoolean>;
597
+ /**
598
+ * The project's own prompt file, relative to `path` — its contents reach
599
+ * the agent as SYSTEM-prompt text (see `agent-prompt.ts` for why the level
600
+ * matters).
601
+ *
602
+ * `.catch(undefined)` for the same reason as `branchHint` and `branchPlan`:
603
+ * this field travels inside `hello_ack`, which carries EVERY session of the
604
+ * server, so a malformed value must cost its own session's prompt at most —
605
+ * never the frame (QA-100 MAJOR-1). The runner re-validates the path from
606
+ * scratch anyway; the bounds here only keep nonsense off the wire.
607
+ */
608
+ agentPromptPath: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
545
609
  budgetUsd: z.ZodNullable<z.ZodNumber>;
546
610
  budgetMinutes: z.ZodNullable<z.ZodNumber>;
547
611
  }, "strip", z.ZodTypeAny, {
@@ -552,6 +616,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
552
616
  budgetUsd: number | null;
553
617
  budgetMinutes: number | null;
554
618
  agentAutoCommit?: boolean | undefined;
619
+ agentPromptPath?: string | null | undefined;
555
620
  }, {
556
621
  id: string;
557
622
  path: string;
@@ -560,7 +625,14 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
560
625
  budgetUsd: number | null;
561
626
  budgetMinutes: number | null;
562
627
  agentAutoCommit?: boolean | undefined;
628
+ agentPromptPath?: unknown;
563
629
  }>;
630
+ /**
631
+ * «Run this one without the project's agent prompt.» Absent means no — both
632
+ * because an older API never sends it and because that is the default a human
633
+ * gets when they do not touch the checkbox.
634
+ */
635
+ skipAgentPrompt: z.ZodCatch<z.ZodOptional<z.ZodBoolean>>;
564
636
  tickets: z.ZodArray<z.ZodObject<{
565
637
  id: z.ZodString;
566
638
  number: z.ZodNumber;
@@ -635,8 +707,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
635
707
  }>>;
636
708
  }, "strip", z.ZodTypeAny, {
637
709
  mode: "ask" | "plan" | "auto" | "full";
638
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
639
710
  id: string;
711
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
640
712
  agent: "CLAUDE" | "CODEX";
641
713
  kind: "TICKET" | "CHAT";
642
714
  model: string | null;
@@ -656,6 +728,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
656
728
  budgetUsd: number | null;
657
729
  budgetMinutes: number | null;
658
730
  agentAutoCommit?: boolean | undefined;
731
+ agentPromptPath?: string | null | undefined;
659
732
  };
660
733
  tickets: {
661
734
  number: number;
@@ -666,6 +739,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
666
739
  url: string;
667
740
  token: string;
668
741
  } | undefined;
742
+ skipAgentPrompt?: boolean | undefined;
669
743
  branchHint?: string | undefined;
670
744
  branchPlan?: {
671
745
  branch: string;
@@ -675,8 +749,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
675
749
  } | undefined;
676
750
  workMode?: "DIRECT" | "BRANCH" | undefined;
677
751
  }, {
678
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
679
752
  id: string;
753
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
680
754
  agent: "CLAUDE" | "CODEX";
681
755
  kind: "TICKET" | "CHAT";
682
756
  prompt: string;
@@ -689,6 +763,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
689
763
  budgetUsd: number | null;
690
764
  budgetMinutes: number | null;
691
765
  agentAutoCommit?: boolean | undefined;
766
+ agentPromptPath?: unknown;
692
767
  };
693
768
  tickets: {
694
769
  number: number;
@@ -707,6 +782,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
707
782
  costUsd?: number | undefined;
708
783
  activeMsBase?: number | undefined;
709
784
  extraBudgetMinutes?: number | null | undefined;
785
+ skipAgentPrompt?: unknown;
710
786
  branchHint?: unknown;
711
787
  branchPlan?: unknown;
712
788
  workMode?: unknown;
@@ -715,8 +791,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
715
791
  type: "session_start";
716
792
  session: {
717
793
  mode: "ask" | "plan" | "auto" | "full";
718
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
719
794
  id: string;
795
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
720
796
  agent: "CLAUDE" | "CODEX";
721
797
  kind: "TICKET" | "CHAT";
722
798
  model: string | null;
@@ -736,6 +812,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
736
812
  budgetUsd: number | null;
737
813
  budgetMinutes: number | null;
738
814
  agentAutoCommit?: boolean | undefined;
815
+ agentPromptPath?: string | null | undefined;
739
816
  };
740
817
  tickets: {
741
818
  number: number;
@@ -746,6 +823,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
746
823
  url: string;
747
824
  token: string;
748
825
  } | undefined;
826
+ skipAgentPrompt?: boolean | undefined;
749
827
  branchHint?: string | undefined;
750
828
  branchPlan?: {
751
829
  branch: string;
@@ -758,8 +836,8 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
758
836
  }, {
759
837
  type: "session_start";
760
838
  session: {
761
- status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
762
839
  id: string;
840
+ status: "RUNNING" | "FAILED" | "STARTING" | "WAITING_INPUT" | "WAITING_PERMISSION" | "REVIEW" | "DONE" | "STOPPED";
763
841
  agent: "CLAUDE" | "CODEX";
764
842
  kind: "TICKET" | "CHAT";
765
843
  prompt: string;
@@ -772,6 +850,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
772
850
  budgetUsd: number | null;
773
851
  budgetMinutes: number | null;
774
852
  agentAutoCommit?: boolean | undefined;
853
+ agentPromptPath?: unknown;
775
854
  };
776
855
  tickets: {
777
856
  number: number;
@@ -790,6 +869,7 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
790
869
  costUsd?: number | undefined;
791
870
  activeMsBase?: number | undefined;
792
871
  extraBudgetMinutes?: number | null | undefined;
872
+ skipAgentPrompt?: unknown;
793
873
  branchHint?: unknown;
794
874
  branchPlan?: unknown;
795
875
  workMode?: unknown;
@@ -815,9 +895,9 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
815
895
  fileSize: number;
816
896
  }>, "many">>>;
817
897
  }, "strip", z.ZodTypeAny, {
898
+ text: string;
818
899
  sessionId: string;
819
900
  type: "session_message";
820
- text: string;
821
901
  attachments?: {
822
902
  id: string;
823
903
  fileName: string;
@@ -825,9 +905,9 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
825
905
  fileSize: number;
826
906
  }[] | undefined;
827
907
  }, {
908
+ text: string;
828
909
  sessionId: string;
829
910
  type: "session_message";
830
- text: string;
831
911
  attachments?: unknown;
832
912
  }>, z.ZodObject<{
833
913
  type: z.ZodLiteral<"permission_answer">;
package/dist/protocol.js CHANGED
@@ -52,9 +52,27 @@ export const SessionDescriptorSchema = z.object({
52
52
  * every project the moment a runner updated ahead of its API.
53
53
  */
54
54
  agentAutoCommit: z.boolean().optional(),
55
+ /**
56
+ * The project's own prompt file, relative to `path` — its contents reach
57
+ * the agent as SYSTEM-prompt text (see `agent-prompt.ts` for why the level
58
+ * matters).
59
+ *
60
+ * `.catch(undefined)` for the same reason as `branchHint` and `branchPlan`:
61
+ * this field travels inside `hello_ack`, which carries EVERY session of the
62
+ * server, so a malformed value must cost its own session's prompt at most —
63
+ * never the frame (QA-100 MAJOR-1). The runner re-validates the path from
64
+ * scratch anyway; the bounds here only keep nonsense off the wire.
65
+ */
66
+ agentPromptPath: z.string().max(300).nullable().optional().catch(undefined),
55
67
  budgetUsd: z.number().nullable(),
56
68
  budgetMinutes: z.number().nullable(),
57
69
  }),
70
+ /**
71
+ * «Run this one without the project's agent prompt.» Absent means no — both
72
+ * because an older API never sends it and because that is the default a human
73
+ * gets when they do not touch the checkbox.
74
+ */
75
+ skipAgentPrompt: z.boolean().optional().catch(undefined),
58
76
  tickets: z.array(z.object({ id: z.string().uuid(), number: z.number(), title: z.string() })),
59
77
  // Branch the API would like this session to use (derived from its ticket
60
78
  // group). Optional: an older API omits it and the runner keeps its
@@ -118,6 +118,26 @@ export declare class Supervisor {
118
118
  * stay queued rather than be marked delivered (session 9).
119
119
  */
120
120
  private launchAgent;
121
+ /**
122
+ * The project's own prompt file, read fresh for THIS agent process.
123
+ *
124
+ * Read per launch rather than per session on purpose: a system prompt only
125
+ * ever changes when the process restarts, so «edit the file, then press
126
+ * Continue» is the honest contract, and re-reading is what makes it true.
127
+ *
128
+ * Every outcome is said out loud. The whole point of moving the project's
129
+ * rules into the system prompt is that they can no longer be quietly
130
+ * outranked — so «the prompt did not load» must never be indistinguishable
131
+ * from «the prompt loaded». Supervisor notices repeat on every launch (only
132
+ * ADAPTER notices are de-duplicated — gotcha #148), which is exactly what is
133
+ * wanted here: each agent process either has the prompt or does not.
134
+ *
135
+ * Returns the text AND the absolute file, because layer 1 needs the second to
136
+ * refuse writes to it: in `workMode: DIRECT` the project folder is the
137
+ * agent's own working directory, so without that rule a session could rewrite
138
+ * the prompt it will itself be started with next time (QA-130 MAJOR-3).
139
+ */
140
+ private resolveAgentPrompt;
121
141
  /** Warn the user when this share of the budget is gone. */
122
142
  private static readonly BUDGET_WARN_RATIO;
123
143
  /** Is the agent actually working right now (i.e. should the clock run)? */
@@ -408,6 +428,14 @@ export declare function composeInitialPrompt(descriptor: SessionDescriptor): str
408
428
  * A repository that wants both agents equipped ships both files, or symlinks
409
429
  * one to the other. That is a repository convention and not something a runner
410
430
  * should paper over.
431
+ *
432
+ * `agentPrompt` is the one exception, and it is an exception for a reason this
433
+ * function cannot do anything about: `CLAUDE.md` and `AGENTS.md` arrive at the
434
+ * USER level, and a project's standing process rules have to sit at the system
435
+ * level to survive both a compaction and a rule the CLI states about itself
436
+ * (`agent-prompt.ts`). It arrives here already read and already checked — this
437
+ * function still opens no files — and it goes LAST, so a project overrides us
438
+ * exactly the way `CLAUDE.md` does by being read last.
411
439
  */
412
- export declare function composeWorkspaceContext(descriptor: SessionDescriptor): string;
440
+ export declare function composeWorkspaceContext(descriptor: SessionDescriptor, agentPrompt?: string): string;
413
441
  //# sourceMappingURL=supervisor.d.ts.map
@@ -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';
@@ -380,9 +381,11 @@ export class Supervisor {
380
381
  return false;
381
382
  }
382
383
  running.lastPrompt = prompt;
383
- // Facts about this session only. The project's own documentation is read by
384
- // each agent itself — see `composeWorkspaceContext`.
385
- 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);
386
389
  const rewind = running.rewindAnchor;
387
390
  delete running.rewindAnchor;
388
391
  // A rewind resumes the conversation the POINT names, which is not always
@@ -396,6 +399,9 @@ export class Supervisor {
396
399
  cwd: running.worktreePath,
397
400
  ...(prompt ? { prompt } : {}),
398
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 } : {}),
399
405
  trustMode: descriptor.workspace.trustMode,
400
406
  ...(descriptor.workspace.agentAutoCommit === undefined
401
407
  ? {}
@@ -426,6 +432,70 @@ export class Supervisor {
426
432
  void this.pumpEvents(running);
427
433
  return true;
428
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
+ }
429
499
  // ─── Time budget (session 7) ───────────────────────────────────────
430
500
  //
431
501
  // The budget measures the AGENT's working time, not the calendar. Everything
@@ -3276,8 +3346,16 @@ export function composeInitialPrompt(descriptor) {
3276
3346
  * A repository that wants both agents equipped ships both files, or symlinks
3277
3347
  * one to the other. That is a repository convention and not something a runner
3278
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.
3279
3357
  */
3280
- export function composeWorkspaceContext(descriptor) {
3358
+ export function composeWorkspaceContext(descriptor, agentPrompt) {
3281
3359
  const sections = [];
3282
3360
  const plan = descriptor.branchPlan;
3283
3361
  if (plan) {
@@ -3305,6 +3383,8 @@ export function composeWorkspaceContext(descriptor) {
3305
3383
  else if (descriptor.tickets.length > 0) {
3306
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.');
3307
3385
  }
3386
+ if (agentPrompt)
3387
+ sections.push(agentPrompt);
3308
3388
  return sections.join('\n\n');
3309
3389
  }
3310
3390
  //# sourceMappingURL=supervisor.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.30.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.30.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.30.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",