@prohost/cli 0.13.3 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/README.md +129 -0
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +221 -9
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/hooks.d.ts +178 -0
- package/dist/codehost/hooks.js +726 -0
- package/dist/codehost/host.d.ts +290 -0
- package/dist/codehost/host.js +625 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +170 -0
- package/dist/codehost/relay.js +548 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +163 -0
- package/dist/codehost/sizing.js +394 -0
- package/dist/codehost/terminals.d.ts +390 -0
- package/dist/codehost/terminals.js +965 -0
- package/dist/codehost/tmux.d.ts +196 -0
- package/dist/codehost/tmux.js +391 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which local checkout a Code thread's `repo` means, and its worktree.
|
|
3
|
+
*
|
|
4
|
+
* A thread names its repository the way the server knows it (`Owner/name`);
|
|
5
|
+
* this machine knows checkouts by path — the `--repos` the operator gave
|
|
6
|
+
* `agent run`. A checkout matches when its `origin` remote is that repository,
|
|
7
|
+
* or, failing any remote match, when its directory has the repository's name.
|
|
8
|
+
*
|
|
9
|
+
* Each thread gets its own worktree through the harness's existing
|
|
10
|
+
* {@link WorktreeManager} conventions — `$PROHOST_HOME/worktrees/<lane>/<repo>`,
|
|
11
|
+
* detached at the remote's default branch, pruned after two idle weeks only if
|
|
12
|
+
* clean — on a lane of its own (`code-thread/<terminal>`), and is then switched
|
|
13
|
+
* to the thread's branch.
|
|
14
|
+
*/
|
|
15
|
+
import { WorktreeManager } from '../agent/worktrees.js';
|
|
16
|
+
export type CheckoutResult = {
|
|
17
|
+
ok: true;
|
|
18
|
+
path: string;
|
|
19
|
+
source: string;
|
|
20
|
+
/**
|
|
21
|
+
* The worktree carries nothing the owner's checkout doesn't already have: made by this very
|
|
22
|
+
* call (never one kept from an earlier start, where an agent may have left files), clean
|
|
23
|
+
* (`git status --porcelain --ignored` empty: no untracked or ignored file, no edit), and on
|
|
24
|
+
* a commit that is the source's local HEAD or an ancestor of it. `false` otherwise, or
|
|
25
|
+
* when anything is unknown.
|
|
26
|
+
*/
|
|
27
|
+
fromLocalHistory?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* A folder start: the opened folder the thread works in (its realpath), moved to the front
|
|
30
|
+
* of the opened-folders list once the thread's window is open. In place, `path` is this
|
|
31
|
+
* folder itself; with a separate copy, `path` is in a worktree of its checkout.
|
|
32
|
+
*/
|
|
33
|
+
folder?: string;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
export type Git = (args: string[]) => Promise<{
|
|
39
|
+
ok: boolean;
|
|
40
|
+
stdout: string;
|
|
41
|
+
stderr: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** `owner/name` from a GitHub-style remote URL or slug, lower-cased; `undefined` if it has no such shape. */
|
|
44
|
+
export declare function repoSlug(value: string): string | undefined;
|
|
45
|
+
export declare function repoName(value: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* The `--repos` source for `repo`, or `undefined`.
|
|
48
|
+
*
|
|
49
|
+
* A checkout whose `origin` names a repository is that repository and no
|
|
50
|
+
* other: the directory-name fallback only considers checkouts with no
|
|
51
|
+
* readable `origin`, so `Other/app` never lands in an `app/` that is
|
|
52
|
+
* `Acme/app` (an agent there would commit and push to the wrong place).
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveSource(repo: string, sources: string[], git?: Git): Promise<string | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* How a checkout is offered to Code threads: its `origin` as `Owner/name`, as
|
|
57
|
+
* written there, or its directory name when it has no GitHub-style `origin`.
|
|
58
|
+
* The server matches a thread's repository the way {@link resolveSource}
|
|
59
|
+
* does, so what is offered is exactly what a start can find.
|
|
60
|
+
*/
|
|
61
|
+
export declare function offeredName(source: string, git?: Git): Promise<string>;
|
|
62
|
+
/** What a person reads when a thread names a repository this Mac has no checkout of. */
|
|
63
|
+
export declare function notOfferedReason(repo: string, offered: string[]): string;
|
|
64
|
+
/** Why a separate copy can't open in `relative`: the branch it checked out has no such folder. */
|
|
65
|
+
export declare function separateCopyMissingReason(relative: string, branch?: string): string;
|
|
66
|
+
export interface CodeThreadCheckoutsOptions {
|
|
67
|
+
repos: string[];
|
|
68
|
+
env?: NodeJS.ProcessEnv;
|
|
69
|
+
log?: (line: string) => void;
|
|
70
|
+
git?: Git;
|
|
71
|
+
/** Seam for tests: the manager for one source. */
|
|
72
|
+
manager?: (source: string) => Pick<WorktreeManager, 'prepare'>;
|
|
73
|
+
}
|
|
74
|
+
export declare class CodeThreadCheckouts {
|
|
75
|
+
private readonly options;
|
|
76
|
+
private readonly managers;
|
|
77
|
+
private readonly git;
|
|
78
|
+
constructor(options: CodeThreadCheckoutsOptions);
|
|
79
|
+
private managerFor;
|
|
80
|
+
/**
|
|
81
|
+
* The repositories this Mac offers Code threads, in `--repos` order, each
|
|
82
|
+
* once ({@link offeredName}). Declared to the server, which lists them for
|
|
83
|
+
* the person picking a repository and refuses a start for any other.
|
|
84
|
+
*/
|
|
85
|
+
/** Every checkout's name, unbounded: for telling a person what this Mac has. */
|
|
86
|
+
names(): Promise<string[]>;
|
|
87
|
+
offered(): Promise<string[] | undefined>;
|
|
88
|
+
/**
|
|
89
|
+
* A folder start (`folder` in `code_thread.start_requested`): only a folder this Mac has opened,
|
|
90
|
+
* or one inside it (`folders.ts`). In place, the thread runs in that folder: no worktree, no
|
|
91
|
+
* branch. With `separateCopy`, the folder must be in a git work tree, and the thread gets that
|
|
92
|
+
* checkout's own worktree and branch, exactly as a repository thread does — in the same
|
|
93
|
+
* subfolder of it. A subfolder the branch doesn't have (untracked, ignored, or not on that
|
|
94
|
+
* branch) is refused, never swapped for the worktree's root; so is one that resolves outside the
|
|
95
|
+
* worktree on that branch (a symlink there), which would put the thread back in the owner's files.
|
|
96
|
+
*/
|
|
97
|
+
prepareFolder(folder: string, terminalId: string, options?: {
|
|
98
|
+
separateCopy?: boolean;
|
|
99
|
+
branch?: string;
|
|
100
|
+
}): Promise<CheckoutResult>;
|
|
101
|
+
/**
|
|
102
|
+
* Remove a worktree this very start made (never one it reused: an earlier start of this thread
|
|
103
|
+
* may have left work in it) and then refused, before anything ran in it — and only while it
|
|
104
|
+
* holds nothing but its commit: `git worktree remove` deletes ignored files (a `.env`, build
|
|
105
|
+
* output) even from a tree git calls clean. Never `--force`. The branch stays.
|
|
106
|
+
*/
|
|
107
|
+
private discardWorktree;
|
|
108
|
+
/** A start in `folder` (a realpath) succeeded: it goes to the front of the opened folders. */
|
|
109
|
+
folderStarted(folder: string): void;
|
|
110
|
+
prepare(repo: string, terminalId: string, branch?: string): Promise<CheckoutResult>;
|
|
111
|
+
/**
|
|
112
|
+
* The thread's own worktree of `source`, on `branch` when there is one, and whether this call
|
|
113
|
+
* made it (`git worktree add`) rather than reusing one an earlier start of the thread left.
|
|
114
|
+
*/
|
|
115
|
+
private worktreeOf;
|
|
116
|
+
/**
|
|
117
|
+
* Whether what the worktree has checked out is in the source checkout's own history, decided
|
|
118
|
+
* from the commit itself (never from how the branch was chosen): its HEAD is the source's
|
|
119
|
+
* local HEAD or an ancestor of it. A remote branch, a tracked branch that moved on, a reused
|
|
120
|
+
* worktree with commits of its own, a default branch the owner hasn't pulled: all `false`.
|
|
121
|
+
*/
|
|
122
|
+
/** Nothing in the worktree but its commit: no edit, no untracked or ignored file. */
|
|
123
|
+
private isClean;
|
|
124
|
+
private inSourceHistory;
|
|
125
|
+
/**
|
|
126
|
+
* Onto `branch`: a local branch of that name, else the remote's (tracked, explicitly), else a
|
|
127
|
+
* new one cut from here. Returns why it couldn't, or `undefined`.
|
|
128
|
+
*/
|
|
129
|
+
private switchBranch;
|
|
130
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which local checkout a Code thread's `repo` means, and its worktree.
|
|
3
|
+
*
|
|
4
|
+
* A thread names its repository the way the server knows it (`Owner/name`);
|
|
5
|
+
* this machine knows checkouts by path — the `--repos` the operator gave
|
|
6
|
+
* `agent run`. A checkout matches when its `origin` remote is that repository,
|
|
7
|
+
* or, failing any remote match, when its directory has the repository's name.
|
|
8
|
+
*
|
|
9
|
+
* Each thread gets its own worktree through the harness's existing
|
|
10
|
+
* {@link WorktreeManager} conventions — `$PROHOST_HOME/worktrees/<lane>/<repo>`,
|
|
11
|
+
* detached at the remote's default branch, pruned after two idle weeks only if
|
|
12
|
+
* clean — on a lane of its own (`code-thread/<terminal>`), and is then switched
|
|
13
|
+
* to the thread's branch.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { WorktreeManager } from '../agent/worktrees.js';
|
|
18
|
+
import { isWithin, realDirectory, resolveOpenedFolder, SEPARATE_COPY_NEEDS_GIT, touchFolder } from './folders.js';
|
|
19
|
+
const defaultGit = (args) => new Promise((resolve) => {
|
|
20
|
+
execFile('git', args, { timeout: 60_000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
21
|
+
resolve({ ok: error === null, stdout: String(stdout), stderr: String(stderr) || (error?.message ?? '') });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
/** `owner/name` from a GitHub-style remote URL or slug, lower-cased; `undefined` if it has no such shape. */
|
|
25
|
+
export function repoSlug(value) {
|
|
26
|
+
const trimmed = value.trim().replace(/\.git$/i, '').replace(/\/+$/, '');
|
|
27
|
+
const match = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed.replace(/^[^:]+:(?!\/\/)/, '/'));
|
|
28
|
+
return match ? `${match[1]}/${match[2]}`.toLowerCase() : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function repoName(value) {
|
|
31
|
+
const slug = repoSlug(value);
|
|
32
|
+
return (slug ? slug.split('/')[1] : value.trim()).toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The `--repos` source for `repo`, or `undefined`.
|
|
36
|
+
*
|
|
37
|
+
* A checkout whose `origin` names a repository is that repository and no
|
|
38
|
+
* other: the directory-name fallback only considers checkouts with no
|
|
39
|
+
* readable `origin`, so `Other/app` never lands in an `app/` that is
|
|
40
|
+
* `Acme/app` (an agent there would commit and push to the wrong place).
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveSource(repo, sources, git = defaultGit) {
|
|
43
|
+
const wantedSlug = repoSlug(repo);
|
|
44
|
+
const anonymous = [];
|
|
45
|
+
for (const source of sources) {
|
|
46
|
+
const remote = await git(['-C', source, 'remote', 'get-url', 'origin']);
|
|
47
|
+
const slug = remote.ok ? repoSlug(remote.stdout) : undefined;
|
|
48
|
+
if (!slug)
|
|
49
|
+
anonymous.push(source);
|
|
50
|
+
else if (wantedSlug && slug === wantedSlug)
|
|
51
|
+
return source;
|
|
52
|
+
}
|
|
53
|
+
const wantedName = repoName(repo);
|
|
54
|
+
return anonymous.find((source) => path.basename(source).toLowerCase() === wantedName);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* How a checkout is offered to Code threads: its `origin` as `Owner/name`, as
|
|
58
|
+
* written there, or its directory name when it has no GitHub-style `origin`.
|
|
59
|
+
* The server matches a thread's repository the way {@link resolveSource}
|
|
60
|
+
* does, so what is offered is exactly what a start can find.
|
|
61
|
+
*/
|
|
62
|
+
export async function offeredName(source, git = defaultGit) {
|
|
63
|
+
const remote = await git(['-C', source, 'remote', 'get-url', 'origin']);
|
|
64
|
+
const trimmed = remote.ok ? remote.stdout.trim().replace(/\.git$/i, '').replace(/\/+$/, '') : '';
|
|
65
|
+
const match = /([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed.replace(/^[^:]+:(?!\/\/)/, '/'));
|
|
66
|
+
return match ? `${match[1]}/${match[2]}` : path.basename(source);
|
|
67
|
+
}
|
|
68
|
+
/** Bounds on what is declared, within the server's own (`MAX_CODE_HOST_REPOS`, and a status frame's 8 KB). */
|
|
69
|
+
const MAX_OFFERED_REPOS = 50;
|
|
70
|
+
const MAX_OFFERED_CHARS = 4_000;
|
|
71
|
+
const OFFERED_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
72
|
+
/** What a person reads when a thread names a repository this Mac has no checkout of. */
|
|
73
|
+
export function notOfferedReason(repo, offered) {
|
|
74
|
+
if (offered.length === 0) {
|
|
75
|
+
return `This Mac isn't set up with any repositories for Code threads yet, so it can't work on ${repo}.`;
|
|
76
|
+
}
|
|
77
|
+
return `This Mac isn't set up for ${repo}. Pick one of its repositories: ${offered.join(', ')}.`;
|
|
78
|
+
}
|
|
79
|
+
/** Why a separate copy can't open in `relative`: the branch it checked out has no such folder. */
|
|
80
|
+
export function separateCopyMissingReason(relative, branch) {
|
|
81
|
+
return (`${relative} isn't in ${branch ? `the branch ${branch}` : 'the separate copy'} (it may be untracked or ignored), ` +
|
|
82
|
+
'so a separate copy can’t start there. Start it in the repository’s folder, or work in place.');
|
|
83
|
+
}
|
|
84
|
+
export class CodeThreadCheckouts {
|
|
85
|
+
options;
|
|
86
|
+
managers = new Map();
|
|
87
|
+
git;
|
|
88
|
+
constructor(options) {
|
|
89
|
+
this.options = options;
|
|
90
|
+
this.git = options.git ?? defaultGit;
|
|
91
|
+
}
|
|
92
|
+
managerFor(source) {
|
|
93
|
+
let manager = this.managers.get(source);
|
|
94
|
+
if (!manager) {
|
|
95
|
+
manager =
|
|
96
|
+
this.options.manager?.(source) ??
|
|
97
|
+
new WorktreeManager({ repos: [source], env: this.options.env, log: this.options.log });
|
|
98
|
+
this.managers.set(source, manager);
|
|
99
|
+
}
|
|
100
|
+
return manager;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The repositories this Mac offers Code threads, in `--repos` order, each
|
|
104
|
+
* once ({@link offeredName}). Declared to the server, which lists them for
|
|
105
|
+
* the person picking a repository and refuses a start for any other.
|
|
106
|
+
*/
|
|
107
|
+
/** Every checkout's name, unbounded: for telling a person what this Mac has. */
|
|
108
|
+
async names() {
|
|
109
|
+
const names = [];
|
|
110
|
+
for (const source of this.options.repos) {
|
|
111
|
+
const name = await offeredName(source, this.git);
|
|
112
|
+
if (!names.some((seen) => seen.toLowerCase() === name.toLowerCase()))
|
|
113
|
+
names.push(name);
|
|
114
|
+
}
|
|
115
|
+
return names;
|
|
116
|
+
}
|
|
117
|
+
async offered() {
|
|
118
|
+
const names = [];
|
|
119
|
+
let chars = 0;
|
|
120
|
+
for (const source of this.options.repos) {
|
|
121
|
+
const name = await offeredName(source, this.git);
|
|
122
|
+
if (names.some((seen) => seen.toLowerCase() === name.toLowerCase()))
|
|
123
|
+
continue;
|
|
124
|
+
// A list the server would cut short (a name it drops, or more than a frame carries) would
|
|
125
|
+
// have it refuse the checkouts left out: then none is declared, and nothing is refused.
|
|
126
|
+
if (name.length > 200 || !OFFERED_NAME.test(name))
|
|
127
|
+
return undefined;
|
|
128
|
+
if (names.length >= MAX_OFFERED_REPOS || chars + name.length + 3 > MAX_OFFERED_CHARS)
|
|
129
|
+
return undefined;
|
|
130
|
+
names.push(name);
|
|
131
|
+
chars += name.length + 3;
|
|
132
|
+
}
|
|
133
|
+
return names;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A folder start (`folder` in `code_thread.start_requested`): only a folder this Mac has opened,
|
|
137
|
+
* or one inside it (`folders.ts`). In place, the thread runs in that folder: no worktree, no
|
|
138
|
+
* branch. With `separateCopy`, the folder must be in a git work tree, and the thread gets that
|
|
139
|
+
* checkout's own worktree and branch, exactly as a repository thread does — in the same
|
|
140
|
+
* subfolder of it. A subfolder the branch doesn't have (untracked, ignored, or not on that
|
|
141
|
+
* branch) is refused, never swapped for the worktree's root; so is one that resolves outside the
|
|
142
|
+
* worktree on that branch (a symlink there), which would put the thread back in the owner's files.
|
|
143
|
+
*/
|
|
144
|
+
async prepareFolder(folder, terminalId, options = {}) {
|
|
145
|
+
const resolved = resolveOpenedFolder(folder, this.options.env);
|
|
146
|
+
if (!resolved.ok)
|
|
147
|
+
return resolved;
|
|
148
|
+
const real = resolved.path;
|
|
149
|
+
if (!options.separateCopy)
|
|
150
|
+
return { ok: true, path: real, source: real, fromLocalHistory: false, folder: real };
|
|
151
|
+
const inside = await this.git(['-C', real, 'rev-parse', '--is-inside-work-tree', '--show-toplevel']);
|
|
152
|
+
const [isWorkTree, top] = inside.ok ? inside.stdout.trim().split('\n') : [];
|
|
153
|
+
const toplevel = isWorkTree === 'true' && top ? realDirectory(top) : undefined;
|
|
154
|
+
if (!toplevel)
|
|
155
|
+
return { ok: false, reason: SEPARATE_COPY_NEEDS_GIT };
|
|
156
|
+
const made = await this.worktreeOf(toplevel, terminalId, options.branch);
|
|
157
|
+
const prepared = made.checkout;
|
|
158
|
+
if (!prepared.ok)
|
|
159
|
+
return prepared;
|
|
160
|
+
const relative = path.relative(toplevel, real);
|
|
161
|
+
if (!relative)
|
|
162
|
+
return { ...prepared, folder: real };
|
|
163
|
+
const worktree = realDirectory(prepared.path) ?? prepared.path;
|
|
164
|
+
const sub = realDirectory(path.join(worktree, relative));
|
|
165
|
+
const refusal = !sub
|
|
166
|
+
? separateCopyMissingReason(relative, options.branch)
|
|
167
|
+
: !isWithin(sub, worktree)
|
|
168
|
+
? `${relative} leads out of the separate copy on ${options.branch || 'its branch'}, so a thread can't start there.`
|
|
169
|
+
: undefined;
|
|
170
|
+
if (refusal || !sub) {
|
|
171
|
+
// Nothing ran in it: the copy goes, so it neither lingers nor keeps its branch checked out.
|
|
172
|
+
if (made.created)
|
|
173
|
+
await this.discardWorktree(toplevel, prepared.path);
|
|
174
|
+
return { ok: false, reason: refusal ?? separateCopyMissingReason(relative, options.branch) };
|
|
175
|
+
}
|
|
176
|
+
// Trust and history are judged on what the thread opens in: that subfolder.
|
|
177
|
+
return { ...prepared, path: sub, source: real, folder: real };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Remove a worktree this very start made (never one it reused: an earlier start of this thread
|
|
181
|
+
* may have left work in it) and then refused, before anything ran in it — and only while it
|
|
182
|
+
* holds nothing but its commit: `git worktree remove` deletes ignored files (a `.env`, build
|
|
183
|
+
* output) even from a tree git calls clean. Never `--force`. The branch stays.
|
|
184
|
+
*/
|
|
185
|
+
async discardWorktree(source, worktree) {
|
|
186
|
+
if (!(await this.isClean(worktree))) {
|
|
187
|
+
this.options.log?.(` ! kept the refused separate copy ${worktree}: it holds files besides its commit`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const removed = await this.git(['-C', source, 'worktree', 'remove', worktree]);
|
|
191
|
+
if (!removed.ok)
|
|
192
|
+
this.options.log?.(` ! kept the refused separate copy ${worktree}: ${lastLine(removed.stderr)}`);
|
|
193
|
+
}
|
|
194
|
+
/** A start in `folder` (a realpath) succeeded: it goes to the front of the opened folders. */
|
|
195
|
+
folderStarted(folder) {
|
|
196
|
+
touchFolder(folder, this.options.env);
|
|
197
|
+
}
|
|
198
|
+
async prepare(repo, terminalId, branch) {
|
|
199
|
+
// Read back by the person who started the thread: what this Mac can work on, never a flag.
|
|
200
|
+
if (this.options.repos.length === 0)
|
|
201
|
+
return { ok: false, reason: notOfferedReason(repo, []) };
|
|
202
|
+
const source = await resolveSource(repo, this.options.repos, this.git);
|
|
203
|
+
if (!source)
|
|
204
|
+
return { ok: false, reason: notOfferedReason(repo, await this.names()) };
|
|
205
|
+
return (await this.worktreeOf(source, terminalId, branch)).checkout;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The thread's own worktree of `source`, on `branch` when there is one, and whether this call
|
|
209
|
+
* made it (`git worktree add`) rather than reusing one an earlier start of the thread left.
|
|
210
|
+
*/
|
|
211
|
+
async worktreeOf(source, terminalId, branch) {
|
|
212
|
+
const prepared = await this.managerFor(source).prepare(`code-thread/${terminalId}`);
|
|
213
|
+
const checkout = prepared.checkouts[0];
|
|
214
|
+
if (!checkout) {
|
|
215
|
+
return { checkout: { ok: false, reason: prepared.failures[0]?.reason ?? 'the worktree could not be made' }, created: false };
|
|
216
|
+
}
|
|
217
|
+
const created = checkout.created === true;
|
|
218
|
+
if (branch) {
|
|
219
|
+
// An agent started anywhere but the thread's branch would commit to the wrong place.
|
|
220
|
+
const failure = await this.switchBranch(checkout.path, branch);
|
|
221
|
+
if (failure)
|
|
222
|
+
return { checkout: { ok: false, reason: failure }, created };
|
|
223
|
+
}
|
|
224
|
+
const fromLocalHistory = created && (await this.isClean(checkout.path)) && (await this.inSourceHistory(source, checkout.path));
|
|
225
|
+
return { checkout: { ok: true, path: checkout.path, source, fromLocalHistory }, created };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Whether what the worktree has checked out is in the source checkout's own history, decided
|
|
229
|
+
* from the commit itself (never from how the branch was chosen): its HEAD is the source's
|
|
230
|
+
* local HEAD or an ancestor of it. A remote branch, a tracked branch that moved on, a reused
|
|
231
|
+
* worktree with commits of its own, a default branch the owner hasn't pulled: all `false`.
|
|
232
|
+
*/
|
|
233
|
+
/** Nothing in the worktree but its commit: no edit, no untracked or ignored file. */
|
|
234
|
+
async isClean(worktree) {
|
|
235
|
+
const status = await this.git(['-C', worktree, 'status', '--porcelain', '--ignored']);
|
|
236
|
+
return status.ok && status.stdout.trim() === '';
|
|
237
|
+
}
|
|
238
|
+
async inSourceHistory(source, worktree) {
|
|
239
|
+
const head = await this.git(['-C', worktree, 'rev-parse', '--verify', '--quiet', 'HEAD^{commit}']);
|
|
240
|
+
const sha = head.ok ? head.stdout.trim() : '';
|
|
241
|
+
if (!/^[0-9a-f]{40,64}$/.test(sha))
|
|
242
|
+
return false;
|
|
243
|
+
return (await this.git(['-C', source, 'merge-base', '--is-ancestor', sha, 'HEAD'])).ok;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Onto `branch`: a local branch of that name, else the remote's (tracked, explicitly), else a
|
|
247
|
+
* new one cut from here. Returns why it couldn't, or `undefined`.
|
|
248
|
+
*/
|
|
249
|
+
async switchBranch(checkout, branch) {
|
|
250
|
+
const valid = await this.git(['check-ref-format', '--branch', branch]);
|
|
251
|
+
if (!valid.ok)
|
|
252
|
+
return `${JSON.stringify(branch)} is not a valid branch name`;
|
|
253
|
+
const current = await this.git(['-C', checkout, 'symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
254
|
+
if (current.ok && current.stdout.trim() === branch)
|
|
255
|
+
return undefined;
|
|
256
|
+
// `--no-guess`: a local branch only; a remote one is tracked below, by name, not by guess.
|
|
257
|
+
if ((await this.git(['-C', checkout, 'switch', '--no-guess', branch])).ok)
|
|
258
|
+
return undefined;
|
|
259
|
+
const remote = await this.git(['-C', checkout, 'rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`]);
|
|
260
|
+
const switched = remote.ok
|
|
261
|
+
? await this.git(['-C', checkout, 'switch', '-c', branch, '--track', `origin/${branch}`])
|
|
262
|
+
: await this.git(['-C', checkout, 'switch', '-c', branch]);
|
|
263
|
+
if (switched.ok)
|
|
264
|
+
return undefined;
|
|
265
|
+
return `could not switch the worktree to ${branch} (${lastLine(switched.stderr)})`;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function lastLine(stderr) {
|
|
269
|
+
return stderr.trim().split('\n').pop() || 'git switch failed';
|
|
270
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's workspace trust, for the worktrees this host makes.
|
|
3
|
+
*
|
|
4
|
+
* Every Code thread runs in a worktree of its own, a directory Claude Code has
|
|
5
|
+
* never seen, so it opens on "Is this a project you created or one you
|
|
6
|
+
* trust?" before the goal runs. The dialog is a security check: a folder's
|
|
7
|
+
* `.claude/` settings and hooks run code. So a worktree is recorded as trusted
|
|
8
|
+
* (`projects[<path>].hasTrustDialogAccepted` in that account's global config,
|
|
9
|
+
* `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`, as Claude Code
|
|
10
|
+
* itself does when the dialog is accepted) only when it carries nothing the
|
|
11
|
+
* owner hasn't already trusted: its checkout is one they trust in Claude Code
|
|
12
|
+
* ({@link claudeTrusts}), and what the worktree has checked out is that
|
|
13
|
+
* checkout's own history (the caller's check: its HEAD is an ancestor of, or
|
|
14
|
+
* is, the checkout's local HEAD). Otherwise the dialog shows, and the host
|
|
15
|
+
* tells the thread it is waiting on it.
|
|
16
|
+
*
|
|
17
|
+
* That file is Claude Code's, and running sessions rewrite it. So the write
|
|
18
|
+
* takes the same lock Claude Code does (an exclusive `<file>.lock` directory),
|
|
19
|
+
* changes only that one project's flag, and lands by rename. It never steals
|
|
20
|
+
* the lock and never creates the file: when it can't do it safely it does
|
|
21
|
+
* nothing, and the dialog shows as before.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The config dir Claude Code runs on for this environment: `CLAUDE_CONFIG_DIR`, else its default
|
|
25
|
+
* `~/.claude`. Where it keeps its sessions (`projects/`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function claudeConfigDir(env: NodeJS.ProcessEnv): string;
|
|
28
|
+
/** The global config Claude Code reads for this environment (beside its dir by default: `~/.claude.json`). */
|
|
29
|
+
export declare function claudeConfigPath(env: NodeJS.ProcessEnv): string;
|
|
30
|
+
/**
|
|
31
|
+
* Whether the owner already trusts `workspace` in Claude Code (they ran it there and accepted
|
|
32
|
+
* the dialog): its own entry, by real path, as Claude Code keys a repository.
|
|
33
|
+
*/
|
|
34
|
+
export declare function claudeTrusts(workspace: string, env: NodeJS.ProcessEnv): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Record `workspace` as trusted in Claude Code's config for `env`. Returns
|
|
37
|
+
* whether it is trusted afterwards (already, or now). `false` means nothing
|
|
38
|
+
* was written; Claude Code will ask, as it would anyway.
|
|
39
|
+
*/
|
|
40
|
+
export declare function trustClaudeWorkspace(workspace: string, env: NodeJS.ProcessEnv, waitMs?: number): Promise<boolean>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's workspace trust, for the worktrees this host makes.
|
|
3
|
+
*
|
|
4
|
+
* Every Code thread runs in a worktree of its own, a directory Claude Code has
|
|
5
|
+
* never seen, so it opens on "Is this a project you created or one you
|
|
6
|
+
* trust?" before the goal runs. The dialog is a security check: a folder's
|
|
7
|
+
* `.claude/` settings and hooks run code. So a worktree is recorded as trusted
|
|
8
|
+
* (`projects[<path>].hasTrustDialogAccepted` in that account's global config,
|
|
9
|
+
* `$CLAUDE_CONFIG_DIR/.claude.json`, else `~/.claude.json`, as Claude Code
|
|
10
|
+
* itself does when the dialog is accepted) only when it carries nothing the
|
|
11
|
+
* owner hasn't already trusted: its checkout is one they trust in Claude Code
|
|
12
|
+
* ({@link claudeTrusts}), and what the worktree has checked out is that
|
|
13
|
+
* checkout's own history (the caller's check: its HEAD is an ancestor of, or
|
|
14
|
+
* is, the checkout's local HEAD). Otherwise the dialog shows, and the host
|
|
15
|
+
* tells the thread it is waiting on it.
|
|
16
|
+
*
|
|
17
|
+
* That file is Claude Code's, and running sessions rewrite it. So the write
|
|
18
|
+
* takes the same lock Claude Code does (an exclusive `<file>.lock` directory),
|
|
19
|
+
* changes only that one project's flag, and lands by rename. It never steals
|
|
20
|
+
* the lock and never creates the file: when it can't do it safely it does
|
|
21
|
+
* nothing, and the dialog shows as before.
|
|
22
|
+
*/
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** How long to wait for Claude Code to finish a config write of its own. */
|
|
28
|
+
const LOCK_WAIT_MS = 3_000;
|
|
29
|
+
const LOCK_POLL_MS = 50;
|
|
30
|
+
/**
|
|
31
|
+
* The config dir Claude Code runs on for this environment: `CLAUDE_CONFIG_DIR`, else its default
|
|
32
|
+
* `~/.claude`. Where it keeps its sessions (`projects/`).
|
|
33
|
+
*/
|
|
34
|
+
export function claudeConfigDir(env) {
|
|
35
|
+
return env.CLAUDE_CONFIG_DIR?.trim() || path.join(env.HOME || os.homedir(), '.claude');
|
|
36
|
+
}
|
|
37
|
+
/** The global config Claude Code reads for this environment (beside its dir by default: `~/.claude.json`). */
|
|
38
|
+
export function claudeConfigPath(env) {
|
|
39
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim();
|
|
40
|
+
return dir ? path.join(dir, '.claude.json') : path.join(env.HOME || os.homedir(), '.claude.json');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Whether the owner already trusts `workspace` in Claude Code (they ran it there and accepted
|
|
44
|
+
* the dialog): its own entry, by real path, as Claude Code keys a repository.
|
|
45
|
+
*/
|
|
46
|
+
export function claudeTrusts(workspace, env) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(readFileSync(claudeConfigPath(env), 'utf8'));
|
|
49
|
+
const projects = parsed?.projects;
|
|
50
|
+
return projects?.[realpathSync(workspace)]?.hasTrustDialogAccepted === true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Record `workspace` as trusted in Claude Code's config for `env`. Returns
|
|
58
|
+
* whether it is trusted afterwards (already, or now). `false` means nothing
|
|
59
|
+
* was written; Claude Code will ask, as it would anyway.
|
|
60
|
+
*/
|
|
61
|
+
export async function trustClaudeWorkspace(workspace, env, waitMs = LOCK_WAIT_MS) {
|
|
62
|
+
let key;
|
|
63
|
+
try {
|
|
64
|
+
// Claude Code keys a project by its real path (a worktree under /tmp is /private/tmp/... on macOS).
|
|
65
|
+
key = realpathSync(workspace);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const file = claudeConfigPath(env);
|
|
71
|
+
const lock = `${file}.lock`;
|
|
72
|
+
const deadline = Date.now() + waitMs;
|
|
73
|
+
for (;;) {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(lock);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code !== 'EEXIST' || Date.now() >= deadline)
|
|
80
|
+
return false;
|
|
81
|
+
// Waited without blocking: the daemon keeps serving acks, terminals and approvals meanwhile.
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
let config;
|
|
87
|
+
let mode;
|
|
88
|
+
try {
|
|
89
|
+
mode = statSync(file).mode & 0o777;
|
|
90
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
91
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
92
|
+
return false;
|
|
93
|
+
config = parsed;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// No config yet, or one that doesn't parse: Claude Code's to create or repair, not ours.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const projects = config.projects && typeof config.projects === 'object' && !Array.isArray(config.projects)
|
|
100
|
+
? config.projects
|
|
101
|
+
: {};
|
|
102
|
+
const current = projects[key];
|
|
103
|
+
const entry = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
|
|
104
|
+
if (entry.hasTrustDialogAccepted === true)
|
|
105
|
+
return true;
|
|
106
|
+
const next = { ...config, projects: { ...projects, [key]: { ...entry, hasTrustDialogAccepted: true } } };
|
|
107
|
+
const temp = `${file}.prohost-${randomBytes(6).toString('hex')}`;
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { mode });
|
|
110
|
+
renameSync(temp, file);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
rmSync(temp, { force: true });
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
try {
|
|
123
|
+
rmdirSync(lock);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* gone already */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prohost agent code-host …` — the machine owner's switches for hosting Code
|
|
3
|
+
* threads on this Mac. Only someone with a shell on this machine can run it;
|
|
4
|
+
* nothing the server sends can change them.
|
|
5
|
+
*
|
|
6
|
+
* prohost agent code-host status
|
|
7
|
+
* prohost agent code-host enable [--allow-remote-terminal]
|
|
8
|
+
* prohost agent code-host remote on|off
|
|
9
|
+
* prohost agent code-host disable
|
|
10
|
+
* prohost agent code-host folders add <path> | list [--json] | remove <path>
|
|
11
|
+
*
|
|
12
|
+
* A running `agent run` reads the switches at start, and the remote switch
|
|
13
|
+
* again on every attach: `remote off` refuses the next viewer at once; `enable`
|
|
14
|
+
* and `disable` take effect when the agent restarts.
|
|
15
|
+
*
|
|
16
|
+
* `folders` is the list of folders this Mac has opened for Code threads
|
|
17
|
+
* (`folders.ts`): a thread started from a phone or the web may only run in one
|
|
18
|
+
* of them, or in a folder inside one. The desktop app adds each folder picked
|
|
19
|
+
* in its folder picker; a running agent reads the list at every start.
|
|
20
|
+
*/
|
|
21
|
+
type Flags = Record<string, string | boolean>;
|
|
22
|
+
export declare function codeHostCommand(args: string[], flags: Flags, out?: (line: string) => void, err?: (line: string) => void): number;
|
|
23
|
+
/** `prohost agent code-host folders …`. Prints paths only; nothing in the list is secret. */
|
|
24
|
+
export declare function foldersCommand(args: string[], flags: Flags, out: (line: string) => void, err: (line: string) => void): number;
|
|
25
|
+
export {};
|