@bridge4dev/runner 0.13.1 → 0.22.1

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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +402 -4
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +28 -1
  35. package/dist/service-unit.d.ts +48 -1
  36. package/dist/service-unit.js +109 -4
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +1 -1
@@ -0,0 +1,224 @@
1
+ import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { scrubbedEnv } from './adapters/claude.js';
3
+ import { gitBranchDiff } from './gitops.js';
4
+ import { isSecretPath, maskString } from './policy.js';
5
+ /**
6
+ * «Propose a commit message» — level 1 (session 14).
7
+ *
8
+ * A ONE-SHOT run, not a message into the live session. Sending it to the open
9
+ * session would cost three things at once: the feed fills with a request nobody
10
+ * made, the session's context window pays for the whole diff, and the call
11
+ * collides with the worktree lock the agent is already holding mid-turn.
12
+ *
13
+ * It always uses the Claude SDK, whichever agent the session runs. The SDK
14
+ * ships its own CLI, so it is present on every runner — a Codex session gets a
15
+ * proposal too, instead of the button being greyed out on half the machines.
16
+ *
17
+ * The diff is masked and stripped of protected files BEFORE it becomes prompt
18
+ * text. A model prompt is an outbound channel exactly like the Files panel, and
19
+ * QA-105 is the reminder that the two have to be held to the same rule.
20
+ */
21
+ /** How much diff is worth a commit message. Beyond this it is a code review. */
22
+ const DIFF_PROMPT_CAP = 60_000;
23
+ /** The whole answer is one commit message; anything longer is a monologue. */
24
+ const MESSAGE_CAP = 4_000;
25
+ /** A model that has not answered by now is not going to. */
26
+ const PROPOSE_TIMEOUT_MS = 90_000;
27
+ /**
28
+ * Drop whole file sections that are on the secret denylist.
29
+ *
30
+ * `gitDiff` throws in this situation; here that would mean «no proposal at all
31
+ * because the branch touches `.env.example`». Removing the file and saying so
32
+ * is both safer (the bytes never leave) and more useful.
33
+ */
34
+ export function stripProtectedFiles(diff) {
35
+ const sections = diff.split(/(?=^diff --git )/m);
36
+ let redacted = 0;
37
+ const kept = sections.filter((section) => {
38
+ // The first section of a diff is whatever came before the first header —
39
+ // usually empty. It carries no file, so there is nothing to protect.
40
+ if (!/^diff --git /.test(section))
41
+ return true;
42
+ const paths = diffHeaderPaths(section);
43
+ // Fail CLOSED. A header shape we cannot read is a file we cannot check,
44
+ // and the two outcomes are not comparable: dropping it costs a slightly
45
+ // less informed commit message, keeping it puts an unexamined file into a
46
+ // prompt that leaves the machine. Session 15 found this with the shape
47
+ // that actually occurs — git QUOTES a header whose path contains `"`, a
48
+ // backslash, a tab or a newline (`diff --git "a/we\"ird/.env" …`), and
49
+ // `core.quotePath=false` does not turn that off. It only suppresses the
50
+ // octal escaping of non-ASCII.
51
+ if (!paths) {
52
+ redacted += 1;
53
+ return false;
54
+ }
55
+ if (paths.some(isSecretPath)) {
56
+ redacted += 1;
57
+ return false;
58
+ }
59
+ return true;
60
+ });
61
+ return { diff: kept.join(''), redacted };
62
+ }
63
+ /**
64
+ * The `a/` and `b/` paths out of a `diff --git` header, quoted or not.
65
+ *
66
+ * Git writes the quoted form whenever a path contains a character it cannot
67
+ * put in the line as-is, and escapes it the way C does. Unescaping is enough
68
+ * here: these strings are only ever compared against the secret-path denylist,
69
+ * never used to open anything.
70
+ */
71
+ function diffHeaderPaths(section) {
72
+ const line = section.split('\n', 1)[0] ?? '';
73
+ const plain = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
74
+ if (plain)
75
+ return [plain[1] ?? '', plain[2] ?? ''];
76
+ const quoted = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/.exec(line);
77
+ if (quoted)
78
+ return [unescapeGitPath(quoted[1] ?? ''), unescapeGitPath(quoted[2] ?? '')];
79
+ // One side quoted and the other not — git does not write that, and a header
80
+ // we cannot account for is one the caller must treat as unknown.
81
+ return null;
82
+ }
83
+ function unescapeGitPath(value) {
84
+ return value.replace(/\\([0-7]{3}|.)/g, (_match, escaped) => {
85
+ if (/^[0-7]{3}$/.test(escaped))
86
+ return String.fromCharCode(parseInt(escaped, 8));
87
+ const simple = { n: '\n', t: '\t', r: '\r', '\\': '\\', '"': '"' };
88
+ return simple[escaped] ?? escaped;
89
+ });
90
+ }
91
+ function buildPrompt(input, diff) {
92
+ const language = input.language?.trim() || 'English';
93
+ const convention = input.convention?.trim() ||
94
+ 'Conventional Commits: `type(scope): subject`, where type is one of feat/fix/docs/refactor/test/chore/perf/build/ci.';
95
+ const commits = (input.commitSubjects ?? []).slice(0, 20);
96
+ return [
97
+ 'Write ONE git commit message for the change below. Output the message and nothing else —',
98
+ 'no preamble, no explanation, no code fences.',
99
+ '',
100
+ 'Rules:',
101
+ `- Language: ${language}.`,
102
+ `- Convention: ${convention}`,
103
+ '- The subject line is at most 72 characters and does not end with a period.',
104
+ '- After the subject: a blank line, then 1–3 sentences on what changed and why, then a blank',
105
+ ' line and up to 5 bullets of the notable points. Skip the bullets if there is nothing to say.',
106
+ '- Describe what the diff actually does. Do not speculate about intent you cannot see.',
107
+ '- Do NOT add ticket references, `Co-Authored-By`, `Generated with`, emoji or any trailer —',
108
+ ' DevBridge appends the trailers itself and strips anything else.',
109
+ '',
110
+ `Branch: ${input.branch}${input.baseBranch ? ` (forked from ${input.baseBranch})` : ''}`,
111
+ ...(commits.length
112
+ ? ['', 'Commits already on this branch, newest first:', ...commits.map((s) => `- ${s}`)]
113
+ : []),
114
+ '',
115
+ 'Diff:',
116
+ '```diff',
117
+ diff,
118
+ '```',
119
+ ].join('\n');
120
+ }
121
+ /**
122
+ * The one-shot itself.
123
+ *
124
+ * `maxTurns: 1` and every tool refused: this run must read nothing, write
125
+ * nothing and call nothing. It gets the diff in its prompt and answers with
126
+ * text. `settingSources: []` for the same reason the session adapter uses it —
127
+ * a `.claude/settings.json` inside the worktree is a file the agent can write.
128
+ */
129
+ export async function proposeCommitMessage(input, queryFn = query) {
130
+ const raw = await gitBranchDiff({
131
+ worktreePath: input.worktreePath,
132
+ workspacePath: input.workspacePath,
133
+ sessionBranch: input.branch,
134
+ ...(input.baseBranch ? { baseBranch: input.baseBranch } : {}),
135
+ maxBytes: DIFF_PROMPT_CAP,
136
+ }).catch(() => null);
137
+ if (!raw)
138
+ return { ok: false, error: 'Could not read the diff of this branch' };
139
+ const { diff, redacted } = stripProtectedFiles(raw.diff);
140
+ if (!diff.trim()) {
141
+ return {
142
+ ok: false,
143
+ error: redacted > 0
144
+ ? 'Everything this branch changed is on the protected-paths list — nothing can be described'
145
+ : 'There is nothing to describe yet — this branch matches its base branch',
146
+ redactedFiles: redacted,
147
+ };
148
+ }
149
+ let text = '';
150
+ try {
151
+ const run = queryFn({
152
+ prompt: buildPrompt(input, diff),
153
+ options: {
154
+ cwd: input.worktreePath,
155
+ env: scrubbedEnv(),
156
+ settingSources: [],
157
+ maxTurns: 1,
158
+ // Belt and braces: an empty allowlist is a statement of intent, the
159
+ // callback is the mechanism. Both, because this run has no human
160
+ // attached to answer a permission prompt.
161
+ allowedTools: [],
162
+ canUseTool: () => Promise.resolve({
163
+ behavior: 'deny',
164
+ message: 'This run only writes a commit message — it may not use tools.',
165
+ }),
166
+ systemPrompt: {
167
+ type: 'preset',
168
+ preset: 'claude_code',
169
+ append: 'You are writing a single git commit message for DevBridge. Answer with the message text only.',
170
+ },
171
+ },
172
+ });
173
+ const deadline = setTimeout(() => void run.interrupt?.().catch(() => undefined), PROPOSE_TIMEOUT_MS);
174
+ deadline.unref();
175
+ try {
176
+ for await (const message of run) {
177
+ if (message.type !== 'assistant')
178
+ continue;
179
+ for (const block of message.message.content) {
180
+ if (block.type === 'text')
181
+ text += block.text;
182
+ }
183
+ }
184
+ }
185
+ finally {
186
+ clearTimeout(deadline);
187
+ }
188
+ }
189
+ catch (error) {
190
+ return {
191
+ ok: false,
192
+ error: maskString(String(error instanceof Error ? error.message : error)).slice(0, 300),
193
+ };
194
+ }
195
+ const message = cleanProposal(text);
196
+ if (!message)
197
+ return { ok: false, error: 'The agent returned nothing usable' };
198
+ return {
199
+ ok: true,
200
+ message,
201
+ ...(redacted > 0 ? { redactedFiles: redacted } : {}),
202
+ ...(raw.truncated ? { diffTruncated: true } : {}),
203
+ };
204
+ }
205
+ /**
206
+ * What comes back is text a model wrote, so it is treated as such: fences off,
207
+ * an opening «Here is the commit message:» off, masked, capped.
208
+ *
209
+ * The trailer and signature filtering lives on the API side, where it also
210
+ * catches what a human pastes into the box — one filter, one place.
211
+ */
212
+ export function cleanProposal(text) {
213
+ let value = maskString(text).trim();
214
+ const fenced = /^```(?:[a-z]*)\n([\s\S]*?)\n?```$/i.exec(value);
215
+ if (fenced?.[1])
216
+ value = fenced[1].trim();
217
+ // `here(?:'s| is)` — with the apostrophe attached, not spaced. «Here's the
218
+ // commit message:» is the commonest preamble a model writes and the original
219
+ // `here (?:is|'s)` required a space before it, so that one form survived into
220
+ // the box (session 15).
221
+ value = value.replace(/^(?:here(?:'s| is)[^\n:]*:|commit message:)\s*\n+/i, '').trim();
222
+ return value.slice(0, MESSAGE_CAP);
223
+ }
224
+ //# sourceMappingURL=commit-message.js.map
package/dist/config.d.ts CHANGED
@@ -47,6 +47,23 @@ declare const ConfigSchema: z.ZodObject<{
47
47
  }, {
48
48
  max_sessions: number;
49
49
  }>>;
50
+ /**
51
+ * Session 14: the machine owner's veto over running project recipes.
52
+ *
53
+ * Verification executes commands a DevBridge manager approved — a build, a
54
+ * test run, possibly a deploy. That is a real execution channel on somebody
55
+ * else's server, and the person who owns the server gets the last word on
56
+ * whether it exists at all. `enabled = false` here means the capability is
57
+ * not even announced, so the dashboard never draws the card: a control that
58
+ * is switched off must not look like a control that is broken.
59
+ */
60
+ verify: z.ZodOptional<z.ZodObject<{
61
+ enabled: z.ZodDefault<z.ZodBoolean>;
62
+ }, "strip", z.ZodTypeAny, {
63
+ enabled: boolean;
64
+ }, {
65
+ enabled?: boolean | undefined;
66
+ }>>;
50
67
  }, "strip", z.ZodTypeAny, {
51
68
  api: {
52
69
  url: string;
@@ -57,16 +74,19 @@ declare const ConfigSchema: z.ZodObject<{
57
74
  name: string;
58
75
  token: string;
59
76
  };
60
- codex?: {
61
- auth: "link" | "own";
62
- } | undefined;
63
77
  mcp?: {
64
78
  url: string;
65
79
  token: string;
66
80
  } | undefined;
81
+ codex?: {
82
+ auth: "link" | "own";
83
+ } | undefined;
67
84
  limits?: {
68
85
  max_sessions: number;
69
86
  } | undefined;
87
+ verify?: {
88
+ enabled: boolean;
89
+ } | undefined;
70
90
  }, {
71
91
  api: {
72
92
  url: string;
@@ -77,16 +97,19 @@ declare const ConfigSchema: z.ZodObject<{
77
97
  name: string;
78
98
  token: string;
79
99
  };
80
- codex?: {
81
- auth?: "link" | "own" | undefined;
82
- } | undefined;
83
100
  mcp?: {
84
101
  url: string;
85
102
  token: string;
86
103
  } | undefined;
104
+ codex?: {
105
+ auth?: "link" | "own" | undefined;
106
+ } | undefined;
87
107
  limits?: {
88
108
  max_sessions: number;
89
109
  } | undefined;
110
+ verify?: {
111
+ enabled?: boolean | undefined;
112
+ } | undefined;
90
113
  }>;
91
114
  export type RunnerConfig = z.infer<typeof ConfigSchema>;
92
115
  export declare function loadConfig(): RunnerConfig | null;
package/dist/config.js CHANGED
@@ -44,6 +44,21 @@ const ConfigSchema = z.object({
44
44
  max_sessions: z.number().int().min(1).max(64),
45
45
  })
46
46
  .optional(),
47
+ /**
48
+ * Session 14: the machine owner's veto over running project recipes.
49
+ *
50
+ * Verification executes commands a DevBridge manager approved — a build, a
51
+ * test run, possibly a deploy. That is a real execution channel on somebody
52
+ * else's server, and the person who owns the server gets the last word on
53
+ * whether it exists at all. `enabled = false` here means the capability is
54
+ * not even announced, so the dashboard never draws the card: a control that
55
+ * is switched off must not look like a control that is broken.
56
+ */
57
+ verify: z
58
+ .object({
59
+ enabled: z.boolean().default(true),
60
+ })
61
+ .optional(),
47
62
  });
48
63
  export function loadConfig() {
49
64
  const file = configFilePath();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Why the previous runner process died — written by the dying process, read once
3
+ * by its successor, reported in `hello`.
4
+ *
5
+ * The 2026-07-30 incident (QA-112) could not be classified after the fact: three
6
+ * live sessions were parked with «Runner reconnected», and the only evidence the
7
+ * daemon had even restarted was that its in-memory session map was empty. The
8
+ * process itself left nothing, and the machine's journal is unreachable from
9
+ * DevBridge. This file closes that gap for the two causes that matter:
10
+ *
11
+ * - an unhandled error, which the process can describe itself;
12
+ * - a kernel OOM kill, which it cannot — the note is absent and the cgroup's
13
+ * `memory.events` counter is read instead (`readOomKills`).
14
+ *
15
+ * "Absent note + oom_kill went up" is therefore a positive OOM diagnosis rather
16
+ * than a shrug, which is exactly the distinction the incident needed.
17
+ */
18
+ export interface CrashNote {
19
+ kind: string;
20
+ detail: string;
21
+ at: string;
22
+ version: string;
23
+ activeSessionIds: string[];
24
+ }
25
+ /** What the successor reports upstream. `kind: 'oom'` is inferred, not written. */
26
+ export interface LastExit {
27
+ kind: string;
28
+ at?: string;
29
+ detail?: string;
30
+ version?: string;
31
+ /** Sessions the dead process was running — how much the death actually cost. */
32
+ activeSessionIds?: string[];
33
+ /** Cumulative `oom_kill` from the service cgroup, when readable. */
34
+ oomKills?: number;
35
+ }
36
+ export declare function recordCrash(note: CrashNote): void;
37
+ /**
38
+ * `oom_kill` from the cgroup this process lives in.
39
+ *
40
+ * cgroup v2 exposes it in `memory.events` of the service slice. The counter is
41
+ * cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
42
+ * (the cgroup outlives the process), so the interesting quantity is the delta —
43
+ * hence the small sidecar file.
44
+ */
45
+ export declare function readOomKills(readFile?: (p: string) => string): number | null;
46
+ /**
47
+ * Read and CONSUME the note left by the previous process.
48
+ *
49
+ * Consuming matters: without it every reconnect for the rest of the daemon's
50
+ * life would keep reporting a death that happened once, and the dashboard would
51
+ * show a permanent crash badge on a healthy server.
52
+ */
53
+ export declare function takeLastExit(readOom?: () => number | null): LastExit | null;
54
+ //# sourceMappingURL=crash-note.d.ts.map
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { stateDir } from './paths.js';
4
+ function crashNotePath() {
5
+ return path.join(stateDir(), 'crash.json');
6
+ }
7
+ function oomCounterPath() {
8
+ return path.join(stateDir(), 'oom-kills');
9
+ }
10
+ export function recordCrash(note) {
11
+ const dir = stateDir();
12
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
13
+ // Written in place, not atomically: a rename needs a second syscall and this
14
+ // runs on a process that is already failing. A truncated note still carries
15
+ // its `kind`, and a missing one is handled as "unknown" upstream.
16
+ fs.writeFileSync(crashNotePath(), JSON.stringify(note), { mode: 0o600 });
17
+ }
18
+ /**
19
+ * `oom_kill` from the cgroup this process lives in.
20
+ *
21
+ * cgroup v2 exposes it in `memory.events` of the service slice. The counter is
22
+ * cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
23
+ * (the cgroup outlives the process), so the interesting quantity is the delta —
24
+ * hence the small sidecar file.
25
+ */
26
+ export function readOomKills(readFile = (p) => fs.readFileSync(p, 'utf8')) {
27
+ try {
28
+ // `/proc/self/cgroup` on v2 is a single line: `0::/user.slice/.../x.service`
29
+ const rel = readFile('/proc/self/cgroup')
30
+ .split('\n')
31
+ .map((line) => line.split(':'))
32
+ .find((parts) => parts[0] === '0' && parts[1] === '')?.[2];
33
+ if (!rel)
34
+ return null;
35
+ const events = readFile(path.join('/sys/fs/cgroup', rel, 'memory.events'));
36
+ const line = events.split('\n').find((l) => l.startsWith('oom_kill '));
37
+ if (!line)
38
+ return null;
39
+ const value = Number.parseInt(line.slice('oom_kill '.length).trim(), 10);
40
+ return Number.isFinite(value) ? value : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Read and CONSUME the note left by the previous process.
48
+ *
49
+ * Consuming matters: without it every reconnect for the rest of the daemon's
50
+ * life would keep reporting a death that happened once, and the dashboard would
51
+ * show a permanent crash badge on a healthy server.
52
+ */
53
+ export function takeLastExit(readOom = readOomKills) {
54
+ let note;
55
+ try {
56
+ note = JSON.parse(fs.readFileSync(crashNotePath(), 'utf8'));
57
+ }
58
+ catch {
59
+ note = null;
60
+ }
61
+ try {
62
+ fs.rmSync(crashNotePath(), { force: true });
63
+ }
64
+ catch {
65
+ /* a note we cannot delete is better than refusing to start */
66
+ }
67
+ const oomKills = readOom();
68
+ let previousOomKills;
69
+ try {
70
+ previousOomKills = Number.parseInt(fs.readFileSync(oomCounterPath(), 'utf8').trim(), 10);
71
+ if (!Number.isFinite(previousOomKills))
72
+ previousOomKills = null;
73
+ }
74
+ catch {
75
+ previousOomKills = null;
76
+ }
77
+ if (oomKills !== null) {
78
+ try {
79
+ fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
80
+ fs.writeFileSync(oomCounterPath(), String(oomKills), { mode: 0o600 });
81
+ }
82
+ catch {
83
+ /* the absolute count is still reported below */
84
+ }
85
+ }
86
+ const oomSinceLastStart = oomKills !== null && previousOomKills !== null ? oomKills - previousOomKills : null;
87
+ if (note) {
88
+ return {
89
+ kind: note.kind,
90
+ at: note.at,
91
+ detail: note.detail,
92
+ version: note.version,
93
+ activeSessionIds: note.activeSessionIds,
94
+ ...(oomKills === null ? {} : { oomKills }),
95
+ };
96
+ }
97
+ // No note. If the cgroup's OOM counter moved since we last looked, the kernel
98
+ // killed something in this service — the honest answer the incident lacked.
99
+ if (oomSinceLastStart !== null && oomSinceLastStart > 0) {
100
+ return { kind: 'oom', oomKills: oomKills ?? undefined };
101
+ }
102
+ // First ever start, a clean stop, or a SIGKILL from outside. Nothing to claim.
103
+ return null;
104
+ }
105
+ //# sourceMappingURL=crash-note.js.map
package/dist/git.d.ts CHANGED
@@ -22,6 +22,26 @@ export declare function repoKeyFor(pathInsideRepo: string): Promise<string>;
22
22
  export interface SessionWorktree {
23
23
  branch: string;
24
24
  worktreePath: string;
25
+ /** The branch this one forked from, when we created it just now. */
26
+ baseBranch?: string;
27
+ baseSha?: string;
28
+ }
29
+ /**
30
+ * What the API decided about this session's branch (session 13).
31
+ *
32
+ * Before this, the runner guessed: a branch that already existed was silently
33
+ * reused — so a second session on the same tickets inherited a stranger's
34
+ * commits — and a branch that did not was created off whatever the project
35
+ * folder was on. Both guesses are now errors, because both of them lose work
36
+ * without saying anything.
37
+ */
38
+ export interface BranchPlan {
39
+ branch: string;
40
+ /** `NEW` — create it, and fail if it is already there. `CONTINUE` — the opposite. */
41
+ source: 'NEW' | 'CONTINUE';
42
+ /** Fork point for a NEW branch. `baseSha` wins when both are given. */
43
+ baseBranch?: string;
44
+ baseSha?: string;
25
45
  }
26
46
  /**
27
47
  * Accept a branch name from the API only if git would accept it too. The value
@@ -37,7 +57,58 @@ export declare function sanitizeBranch(hint: string | undefined): string | null;
37
57
  */
38
58
  export declare function ensureSessionWorktree(workspacePath: string, sessionId: string, branchHint?: string, options?: {
39
59
  requireExistingBranch?: boolean;
60
+ plan?: BranchPlan;
40
61
  }): Promise<SessionWorktree>;
62
+ /**
63
+ * DIRECT mode (session 16): the session's workplace IS the project folder.
64
+ *
65
+ * Nothing is created and nothing is moved. The folder stays on the branch it is
66
+ * on, and that branch is the session's branch — which is the whole point: the
67
+ * work the agent does is already where the person expects to find it, with no
68
+ * «Apply» step in between and nothing to lose if the session is never applied.
69
+ *
70
+ * The two refusals are both about NOT guessing:
71
+ *
72
+ * - not a git work tree — every git surface downstream would fail one call at
73
+ * a time instead of once, here, with a sentence that names the folder;
74
+ * - a detached HEAD — commits would land on no branch at all and be reachable
75
+ * only by sha. A person who checked out a tag to look at something must not
76
+ * discover an agent committed onto it.
77
+ *
78
+ * The path returned is the repository ROOT, not necessarily the folder that was
79
+ * configured. Every path in the Source Control panel is repo-root-relative
80
+ * because that is what `git status` prints, so the root is the only place the
81
+ * paths and the commands agree — and it is also the confinement root layer 1
82
+ * hands the agent.
83
+ */
84
+ export declare function prepareDirectWorkspace(workspacePath: string): Promise<SessionWorktree>;
85
+ /**
86
+ * One preview checkout per repository, and never the project folder itself.
87
+ *
88
+ * The honest constraint behind «show me branch B while branch A is running»:
89
+ * the docker build context IS the project folder, so a rebuild there replaces
90
+ * the single running copy. A second worktree is the only way to have both — and
91
+ * the runner NEVER switches the branch in the project folder, because that
92
+ * silently moves the base, and the target of «Apply», for every session of the
93
+ * project at once.
94
+ *
95
+ * Checked out DETACHED at a sha rather than on the branch: git refuses to have
96
+ * one branch checked out twice, and a preview is a snapshot of a commit, not a
97
+ * place anybody commits.
98
+ */
99
+ export declare function previewWorktreePath(workspaceKey: string): string;
100
+ export interface PreviewCheckout {
101
+ worktreePath: string;
102
+ branch: string;
103
+ sha: string;
104
+ }
105
+ export declare function ensurePreviewWorktree(input: {
106
+ workspacePath: string;
107
+ workspaceKey: string;
108
+ branch: string;
109
+ }): Promise<PreviewCheckout>;
110
+ /** Give the slot back. The branch is untouched — it was never checked out. */
111
+ export declare function removePreviewWorktree(workspaceKey: string): Promise<boolean>;
41
112
  /**
42
113
  * Drop a session branch after its worktree is gone. Only ever called when the
43
114
  * API confirmed the work was already applied to the base branch — `-D` because