@bridge4dev/runner 0.11.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/dist/adapters/claude.d.ts +19 -0
  4. package/dist/adapters/claude.js +631 -0
  5. package/dist/adapters/codex-home.d.ts +61 -0
  6. package/dist/adapters/codex-home.js +234 -0
  7. package/dist/adapters/codex-protocol.d.ts +59 -0
  8. package/dist/adapters/codex-protocol.js +204 -0
  9. package/dist/adapters/codex.d.ts +61 -0
  10. package/dist/adapters/codex.js +1406 -0
  11. package/dist/adapters/types.d.ts +183 -0
  12. package/dist/adapters/types.js +5 -0
  13. package/dist/async-queue.d.ts +11 -0
  14. package/dist/async-queue.js +50 -0
  15. package/dist/attachments.d.ts +72 -0
  16. package/dist/attachments.js +149 -0
  17. package/dist/auth-relay.d.ts +57 -0
  18. package/dist/auth-relay.js +289 -0
  19. package/dist/config.d.ts +96 -0
  20. package/dist/config.js +73 -0
  21. package/dist/fsview.d.ts +20 -0
  22. package/dist/fsview.js +122 -0
  23. package/dist/git.d.ts +54 -0
  24. package/dist/git.js +168 -0
  25. package/dist/gitops.d.ts +136 -0
  26. package/dist/gitops.js +596 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +352 -0
  29. package/dist/journal.d.ts +118 -0
  30. package/dist/journal.js +300 -0
  31. package/dist/log.d.ts +7 -0
  32. package/dist/log.js +19 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +33 -0
  35. package/dist/policy.d.ts +17 -0
  36. package/dist/policy.js +272 -0
  37. package/dist/protocol.d.ts +754 -0
  38. package/dist/protocol.js +154 -0
  39. package/dist/self-update.d.ts +75 -0
  40. package/dist/self-update.js +221 -0
  41. package/dist/status-file.d.ts +14 -0
  42. package/dist/status-file.js +29 -0
  43. package/dist/supervisor.d.ts +216 -0
  44. package/dist/supervisor.js +1648 -0
  45. package/dist/version.d.ts +2 -0
  46. package/dist/version.js +3 -0
  47. package/dist/ws-client.d.ts +30 -0
  48. package/dist/ws-client.js +171 -0
  49. package/package.json +52 -0
package/dist/git.js ADDED
@@ -0,0 +1,168 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { worktreesDir } from './paths.js';
6
+ const execFileAsync = promisify(execFile);
7
+ const GIT_TIMEOUT_MS = 30_000;
8
+ async function git(cwd, ...args) {
9
+ const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
10
+ return stdout.trim();
11
+ }
12
+ export async function validateWorkspacePath(workspacePath) {
13
+ if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
14
+ return { ok: false, exists: false, isGitRepo: false, error: 'Directory does not exist' };
15
+ }
16
+ try {
17
+ const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree');
18
+ if (inside !== 'true') {
19
+ return { ok: false, exists: true, isGitRepo: false, error: 'Not a git work tree' };
20
+ }
21
+ const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
22
+ return { ok: true, exists: true, isGitRepo: true, branch };
23
+ }
24
+ catch (error) {
25
+ return {
26
+ ok: false,
27
+ exists: true,
28
+ isGitRepo: false,
29
+ error: `git check failed: ${String(error instanceof Error ? error.message : error).slice(0, 300)}`,
30
+ };
31
+ }
32
+ }
33
+ export function sessionShortId(sessionId) {
34
+ return sessionId.replace(/-/g, '').slice(0, 8);
35
+ }
36
+ export function sessionWorktreePath(sessionId) {
37
+ return path.join(worktreesDir(), sessionShortId(sessionId));
38
+ }
39
+ /**
40
+ * A stable key identifying the *shared* repository behind any path inside it.
41
+ *
42
+ * A linked worktree and its main checkout are the same repo: they share one
43
+ * object store, one index lock namespace and one branch namespace. Locking a
44
+ * commit by worktree path while locking a squash-merge by workspace path gave
45
+ * two different keys and therefore no mutual exclusion at all — a commit inside
46
+ * the worktree could interleave with a merge reading that same branch tip.
47
+ * `git rev-parse --git-common-dir` collapses both to the main `.git` directory.
48
+ */
49
+ export async function repoKeyFor(pathInsideRepo) {
50
+ try {
51
+ const common = await git(pathInsideRepo, 'rev-parse', '--git-common-dir');
52
+ return fs.realpathSync(path.resolve(pathInsideRepo, common));
53
+ }
54
+ catch {
55
+ // Not a repo (yet), or git is unavailable: fall back to the path itself so
56
+ // callers still serialise against themselves rather than not at all.
57
+ try {
58
+ return fs.realpathSync(pathInsideRepo);
59
+ }
60
+ catch {
61
+ return path.resolve(pathInsideRepo);
62
+ }
63
+ }
64
+ }
65
+ /**
66
+ * Accept a branch name from the API only if git would accept it too. The value
67
+ * reaches a `git worktree add -b` argument, so anything odd — a leading dash, a
68
+ * `..`, a control character, a trailing `.lock` — is rejected in favour of the
69
+ * id-derived default rather than passed through.
70
+ */
71
+ export function sanitizeBranch(hint) {
72
+ if (!hint)
73
+ return null;
74
+ const trimmed = hint.trim();
75
+ if (trimmed.length === 0 || trimmed.length > 120)
76
+ return null;
77
+ if (!/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(trimmed))
78
+ return null;
79
+ if (trimmed.includes('..') || trimmed.includes('//') || trimmed.endsWith('.lock'))
80
+ return null;
81
+ if (trimmed.endsWith('/') || trimmed.endsWith('.'))
82
+ return null;
83
+ return trimmed;
84
+ }
85
+ /**
86
+ * One branch + worktree per session (plan §7). The worktree lives under the
87
+ * runner's state dir so the user's checkout stays untouched; the branch lives
88
+ * in the workspace repo, so the work survives worktree cleanup.
89
+ */
90
+ export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
91
+ const short = sessionShortId(sessionId);
92
+ // A ticket group gets a branch named after its tickets so the work reads as
93
+ // one thing in git history. The worktree DIRECTORY stays id-derived — it is
94
+ // the collision guard below, and two sessions must never share one.
95
+ const branch = sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
96
+ const worktreePath = sessionWorktreePath(sessionId);
97
+ if (fs.existsSync(path.join(worktreePath, '.git'))) {
98
+ // Runner restart — reuse, but verify the worktree really is ours: a
99
+ // short-id collision would silently share a worktree between sessions.
100
+ const head = await git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
101
+ if (head !== branch) {
102
+ throw new Error(`Worktree ${worktreePath} is on branch ${head ?? 'unknown'}, expected ${branch}`);
103
+ }
104
+ return { branch, worktreePath };
105
+ }
106
+ fs.mkdirSync(worktreesDir(), { recursive: true, mode: 0o700 });
107
+ // Repair stale registrations left by a deleted worktree dir.
108
+ await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
109
+ const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
110
+ .then(() => true)
111
+ .catch(() => false);
112
+ if (branchExists) {
113
+ await git(workspacePath, 'worktree', 'add', worktreePath, branch);
114
+ }
115
+ else {
116
+ if (options.requireExistingBranch) {
117
+ // The caller is restoring a session that already produced work. Creating
118
+ // a fresh branch off HEAD here would look like success while quietly
119
+ // hiding every commit the agent made — fail loudly instead.
120
+ throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
121
+ }
122
+ await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, 'HEAD');
123
+ }
124
+ return { branch, worktreePath };
125
+ }
126
+ /**
127
+ * Drop a session branch after its worktree is gone. Only ever called when the
128
+ * API confirmed the work was already applied to the base branch — `-D` because
129
+ * «Применить» is a squash-merge, which git never recognises as a merge, so `-d`
130
+ * would refuse exactly the branches that ARE safe to delete.
131
+ */
132
+ export async function deleteSessionBranch(workspacePath, branch) {
133
+ const safe = sanitizeBranch(branch);
134
+ if (!safe)
135
+ throw new Error(`refusing to delete an unsafe branch name: ${branch}`);
136
+ await git(workspacePath, 'branch', '-D', safe);
137
+ }
138
+ /**
139
+ * Remove the session worktree (the branch — and the work — stays in the
140
+ * repo). The main repo is recovered from the worktree's `.git` pointer file,
141
+ * so the caller only needs the session id.
142
+ */
143
+ export async function removeSessionWorktree(sessionId) {
144
+ const worktreePath = sessionWorktreePath(sessionId);
145
+ if (!fs.existsSync(worktreePath))
146
+ return;
147
+ const gitFile = path.join(worktreePath, '.git');
148
+ let mainRepo = null;
149
+ if (fs.existsSync(gitFile) && fs.statSync(gitFile).isFile()) {
150
+ // "gitdir: /repo/.git/worktrees/<name>" → /repo
151
+ const pointer = fs
152
+ .readFileSync(gitFile, 'utf8')
153
+ .match(/^gitdir:\s*(.+)$/m)?.[1]
154
+ ?.trim();
155
+ if (pointer) {
156
+ const dotGit = path.resolve(pointer, '..', '..');
157
+ if (path.basename(dotGit) === '.git')
158
+ mainRepo = path.dirname(dotGit);
159
+ }
160
+ }
161
+ if (mainRepo) {
162
+ await git(mainRepo, 'worktree', 'remove', '--force', worktreePath);
163
+ }
164
+ else {
165
+ fs.rmSync(worktreePath, { recursive: true, force: true });
166
+ }
167
+ }
168
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,136 @@
1
+ export interface GitFileEntry {
2
+ path: string;
3
+ /** A | M | D | R (git name-status letter). */
4
+ status: string;
5
+ additions: number | null;
6
+ deletions: number | null;
7
+ /** Present in the working tree but not committed yet. */
8
+ uncommitted: boolean;
9
+ }
10
+ export interface GitStatusResult {
11
+ branch: string;
12
+ baseBranch: string;
13
+ files: GitFileEntry[];
14
+ additions: number;
15
+ deletions: number;
16
+ /** Commits the agent made on the session branch (base..HEAD). */
17
+ agentCommits: number;
18
+ uncommittedFiles: number;
19
+ }
20
+ export declare function gitStatus(worktreePath: string, workspacePath: string, sessionBranch: string): Promise<GitStatusResult>;
21
+ export interface GitDiffResult {
22
+ diff: string;
23
+ truncated: boolean;
24
+ }
25
+ /** Exposed for tests: the header check is the control worth pinning directly. */
26
+ export declare function capDiffForTest(diff: string): GitDiffResult;
27
+ export declare function gitDiff(worktreePath: string, workspacePath: string, sessionBranch: string, filePath: string): Promise<GitDiffResult>;
28
+ export interface GitCommitResult {
29
+ committed: boolean;
30
+ sha?: string;
31
+ reason?: string;
32
+ }
33
+ export declare function gitCommit(worktreePath: string, message: string): Promise<GitCommitResult>;
34
+ export interface ApplyResult {
35
+ applied: boolean;
36
+ commitSha?: string;
37
+ conflict?: boolean;
38
+ /** On conflict: the branch the agent must merge into its session branch. */
39
+ baseBranch?: string;
40
+ error?: string;
41
+ }
42
+ export declare function applySession(workspacePath: string, worktreePath: string, sessionBranch: string, message: string): Promise<ApplyResult>;
43
+ /**
44
+ * Reading history is the one git surface where the *repository*, not the
45
+ * session worktree, is the right place to stand: a finished session may have
46
+ * had its worktree cleaned up while its branch — and everything the agent
47
+ * committed — is still in the repo. So every function here runs in
48
+ * `workspacePath` and names the branch explicitly.
49
+ *
50
+ * Read-only by construction. Nothing in this section can move a ref, and the
51
+ * two ways history could leak something it should not — an argument that git
52
+ * reads as an option, and a diff of a secret file — are closed by
53
+ * `assertRefArgument` / `sanitizeBranch` and by the same denylist the Changes
54
+ * panel uses.
55
+ */
56
+ export interface GitLogCommit {
57
+ sha: string;
58
+ /** Empty for a root commit, more than one for a merge. */
59
+ parents: string[];
60
+ author: string;
61
+ /** Author date, ISO-8601 with offset. */
62
+ date: string;
63
+ subject: string;
64
+ /** Branch/tag names pointing at this commit (`HEAD -> ` already stripped). */
65
+ refs: string[];
66
+ }
67
+ /** Which slice of history to show. */
68
+ export type GitLogScope = 'session' | 'branch' | 'all';
69
+ export interface GitLogResult {
70
+ branch: string;
71
+ baseBranch: string;
72
+ /** Where the session branch and the base last agreed. */
73
+ mergeBase: string | null;
74
+ commits: GitLogCommit[];
75
+ hasMore: boolean;
76
+ }
77
+ export interface GitCommitFileEntry {
78
+ path: string;
79
+ status: string;
80
+ additions: number | null;
81
+ deletions: number | null;
82
+ /** On the secret denylist: the name is shown, the diff is refused. */
83
+ protected: boolean;
84
+ }
85
+ export interface GitCommitDetail {
86
+ kind: 'commit';
87
+ sha: string;
88
+ parents: string[];
89
+ author: string;
90
+ authorEmail: string;
91
+ date: string;
92
+ subject: string;
93
+ body: string;
94
+ refs: string[];
95
+ files: GitCommitFileEntry[];
96
+ /** The file list hit the cap — the commit touched more than we return. */
97
+ truncated: boolean;
98
+ }
99
+ export interface GitCommitFileDiff {
100
+ kind: 'diff';
101
+ diff: string;
102
+ truncated: boolean;
103
+ }
104
+ /** A full or abbreviated commit hash — never a ref name, never an option. */
105
+ export declare function isCommitSha(value: string): boolean;
106
+ /**
107
+ * `--numstat` names a rename as `old => new` (or `dir/{old => new}/file`),
108
+ * while `--name-status` gives the plain new path. Keying the two maps by
109
+ * different strings is why every renamed file showed `null` additions.
110
+ */
111
+ export declare function normalizeNumstatPath(raw: string): string;
112
+ export declare function gitLog(input: {
113
+ workspacePath: string;
114
+ branch: string;
115
+ scope?: GitLogScope;
116
+ limit?: number;
117
+ skip?: number;
118
+ }): Promise<GitLogResult>;
119
+ /**
120
+ * One commit: its metadata and the files it touched, or — when `filePath` is
121
+ * given — the diff of that one file.
122
+ *
123
+ * `--diff-merges=first-parent` on purpose: without it `git show` renders a
124
+ * merge as a combined diff that hides everything already present in one of the
125
+ * parents, and the answer to "what did this merge bring onto my branch" comes
126
+ * out empty. For an ordinary commit the flag changes nothing.
127
+ */
128
+ export declare function gitShow(workspacePath: string, sha: string, filePath?: string, branch?: string): Promise<GitCommitDetail | GitCommitFileDiff>;
129
+ export interface RevertResult {
130
+ reverted: boolean;
131
+ revertSha?: string;
132
+ conflict?: boolean;
133
+ error?: string;
134
+ }
135
+ export declare function revertApply(workspacePath: string, commitSha: string): Promise<RevertResult>;
136
+ //# sourceMappingURL=gitops.d.ts.map