@tt-a1i/openpi 0.1.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/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Built-in subagent role names shared by configuration and spawning. */
|
|
2
|
+
export const SUBAGENT_ROLE_NAMES = [
|
|
3
|
+
"explorer",
|
|
4
|
+
"implementer",
|
|
5
|
+
"reviewer",
|
|
6
|
+
"advisor",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type SubagentRoleName = (typeof SUBAGENT_ROLE_NAMES)[number];
|
|
10
|
+
|
|
11
|
+
export interface SubagentRoleModel {
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly model: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SubagentRoleModels = Partial<
|
|
17
|
+
Record<SubagentRoleName, SubagentRoleModel>
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export function isSubagentRoleName(value: string): value is SubagentRoleName {
|
|
21
|
+
return SUBAGENT_ROLE_NAMES.includes(value as SubagentRoleName);
|
|
22
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// OSC may end in BEL or ST. DCS/SOS/PM/APC end only in ST; treating BEL as
|
|
2
|
+
// their terminator would expose the rest of a still-hidden payload. Both
|
|
3
|
+
// patterns consume an unterminated string through the bounded input's end.
|
|
4
|
+
// eslint-disable-next-line no-control-regex
|
|
5
|
+
const OSC_PATTERN =
|
|
6
|
+
/(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c|$)/g;
|
|
7
|
+
// eslint-disable-next-line no-control-regex
|
|
8
|
+
const ST_STRING_PATTERN =
|
|
9
|
+
/(?:\u001b[PX^_]|[\u0090\u0098\u009e\u009f])(?:[^\u001b\u009c]|\u001b(?!\\))*(?:\u001b\\|\u009c|$)/g;
|
|
10
|
+
// eslint-disable-next-line no-control-regex
|
|
11
|
+
const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
|
|
12
|
+
// Remaining two-byte/charset escape forms (for example ESC ( 0).
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
|
|
15
|
+
// Invisible Unicode format controls can reorder or disguise untrusted text.
|
|
16
|
+
// Keep only ZWNJ/ZWJ (U+200C/U+200D), which carry legitimate shaping and emoji
|
|
17
|
+
// semantics; variation selectors are marks rather than Cf and remain intact.
|
|
18
|
+
const UNSAFE_FORMAT_PATTERN = /(?![\u200c\u200d])\p{Cf}/gu;
|
|
19
|
+
// eslint-disable-next-line no-control-regex
|
|
20
|
+
const TERMINAL_CONTROL_PATTERN =
|
|
21
|
+
/[\u0000-\u001f\u007f-\u009f]|(?:(?![\u200c\u200d])\p{Cf})/u;
|
|
22
|
+
|
|
23
|
+
/** Strip terminal control sequences and direction-spoofing format controls. */
|
|
24
|
+
export function sanitizeTerminalText(text: string) {
|
|
25
|
+
return text
|
|
26
|
+
.replace(OSC_PATTERN, "")
|
|
27
|
+
.replace(ST_STRING_PATTERN, "")
|
|
28
|
+
.replace(CSI_PATTERN, "")
|
|
29
|
+
.replace(ESCAPE_PATTERN, "")
|
|
30
|
+
.replace(UNSAFE_FORMAT_PATTERN, "")
|
|
31
|
+
.replaceAll("\t", " ")
|
|
32
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reject raw terminal or direction-spoofing controls in display patterns. */
|
|
36
|
+
export function hasTerminalControls(text: string) {
|
|
37
|
+
return TERMINAL_CONTROL_PATTERN.test(text);
|
|
38
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const CHILD_TOOL_CALL_TIMEOUT_MS = 3 * 60 * 1_000;
|
|
4
|
+
|
|
5
|
+
interface ToolRegistry {
|
|
6
|
+
getAllTools(): Array<{ name: string }>;
|
|
7
|
+
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatTimeout(timeoutMs: number) {
|
|
11
|
+
if (timeoutMs % 60_000 === 0) {
|
|
12
|
+
const minutes = timeoutMs / 60_000;
|
|
13
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
14
|
+
}
|
|
15
|
+
if (timeoutMs % 1_000 === 0) {
|
|
16
|
+
const seconds = timeoutMs / 1_000;
|
|
17
|
+
return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
|
18
|
+
}
|
|
19
|
+
return `${timeoutMs} ms`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ToolCallTimeoutError extends Error {
|
|
23
|
+
constructor(toolName: string, timeoutMs: number) {
|
|
24
|
+
super(
|
|
25
|
+
`Tool call "${toolName}" timed out after ${formatTimeout(timeoutMs)}.`,
|
|
26
|
+
);
|
|
27
|
+
this.name = "ToolCallTimeoutError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runWithToolCallTimeout<T>(
|
|
32
|
+
toolName: string,
|
|
33
|
+
timeoutMs: number,
|
|
34
|
+
signal: AbortSignal | undefined,
|
|
35
|
+
execute: (signal: AbortSignal) => Promise<T>,
|
|
36
|
+
) {
|
|
37
|
+
const timeoutController = new AbortController();
|
|
38
|
+
const executionSignal = signal
|
|
39
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
40
|
+
: timeoutController.signal;
|
|
41
|
+
const timeoutError = new ToolCallTimeoutError(toolName, timeoutMs);
|
|
42
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
44
|
+
timer = setTimeout(() => {
|
|
45
|
+
reject(timeoutError);
|
|
46
|
+
timeoutController.abort(timeoutError);
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let removeAbortListener: (() => void) | undefined;
|
|
51
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
52
|
+
if (!signal) return;
|
|
53
|
+
const onAbort = () => {
|
|
54
|
+
reject(
|
|
55
|
+
signal.reason instanceof Error
|
|
56
|
+
? signal.reason
|
|
57
|
+
: new Error(`Tool call "${toolName}" was aborted.`),
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
if (signal.aborted) {
|
|
61
|
+
onAbort();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
65
|
+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return await Promise.race([execute(executionSignal), timeout, aborted]);
|
|
70
|
+
} finally {
|
|
71
|
+
if (timer) clearTimeout(timer);
|
|
72
|
+
removeAbortListener?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Wrap every currently registered child tool with an independent execution
|
|
78
|
+
* timeout. Calling apply() again is safe and picks up tools registered later.
|
|
79
|
+
*/
|
|
80
|
+
export function createToolCallTimeoutGuard(
|
|
81
|
+
timeoutMs = CHILD_TOOL_CALL_TIMEOUT_MS,
|
|
82
|
+
) {
|
|
83
|
+
const wrapped = new WeakSet<ToolDefinition>();
|
|
84
|
+
|
|
85
|
+
const wrap = (definition: ToolDefinition) => {
|
|
86
|
+
if (wrapped.has(definition)) return;
|
|
87
|
+
wrapped.add(definition);
|
|
88
|
+
|
|
89
|
+
const execute = definition.execute;
|
|
90
|
+
definition.execute = async (toolCallId, params, signal, onUpdate, ctx) =>
|
|
91
|
+
runWithToolCallTimeout(definition.name, timeoutMs, signal, (signal) =>
|
|
92
|
+
execute.call(definition, toolCallId, params, signal, onUpdate, ctx),
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
apply(session: ToolRegistry) {
|
|
98
|
+
for (const { name } of session.getAllTools()) {
|
|
99
|
+
const definition = session.getToolDefinition(name);
|
|
100
|
+
if (definition) wrap(definition);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git worktree isolation for child agents.
|
|
3
|
+
*
|
|
4
|
+
* Parallel children that share one working copy share a git index and a file
|
|
5
|
+
* tree: two agents editing the same file, or staging at the same time, silently
|
|
6
|
+
* clobber each other. A worktree gives each child its own checkout on its own
|
|
7
|
+
* branch, so their edits are separable and the parent decides what to merge.
|
|
8
|
+
*
|
|
9
|
+
* Design decisions, each forced by measured git behavior:
|
|
10
|
+
*
|
|
11
|
+
* - **Worktrees live under `.git/pi-worktrees/`,** not in the project tree.
|
|
12
|
+
* Anything inside the working copy shows up as an untracked entry in the
|
|
13
|
+
* parent's `git status` (verified: `?? .pi/`), which would corrupt the very
|
|
14
|
+
* signal a coding agent reads to see what it changed. `.git/` is already
|
|
15
|
+
* outside every status walk, and `git worktree` is perfectly happy there.
|
|
16
|
+
*
|
|
17
|
+
* - **Trust is inherited by path.** Pi resolves project trust by walking
|
|
18
|
+
* parents (`findNearestTrustEntry`), so a worktree under the repo inherits
|
|
19
|
+
* the repo's trust decision and the child keeps its project skills and
|
|
20
|
+
* AGENTS.md. A worktree in `/tmp` would resolve to "untrusted" and silently
|
|
21
|
+
* strip those, which is why an out-of-repo location is not an option.
|
|
22
|
+
*
|
|
23
|
+
* - **`node_modules` is linked from the PARENT of the checkout, not into it.**
|
|
24
|
+
* A fresh checkout has no dependencies, so an isolated child cannot build or
|
|
25
|
+
* test its own work (verified: `ERR_MODULE_NOT_FOUND: Cannot find package
|
|
26
|
+
* 'effect'`). Node resolves `node_modules` by walking ancestor directories,
|
|
27
|
+
* so a link at `.git/pi-worktrees/node_modules` serves every worktree under
|
|
28
|
+
* it while git never sees the entry at all.
|
|
29
|
+
*
|
|
30
|
+
* Putting it INSIDE the checkout was the original design and was wrong in a
|
|
31
|
+
* way worth recording. A `.gitignore` line of `node_modules/` — with the
|
|
32
|
+
* trailing slash almost every project uses — matches a directory only, not a
|
|
33
|
+
* symlink. So the link showed as `?? node_modules` for the child's whole
|
|
34
|
+
* life, `git add -A` committed it as a `120000` blob, teardown was then
|
|
35
|
+
* permanently blocked ("contains modified or untracked files"), and merging
|
|
36
|
+
* the child's branch REPLACED the parent's real `node_modules` directory
|
|
37
|
+
* with a self-referential symlink — destroying the dependencies of the very
|
|
38
|
+
* repo the session was working in.
|
|
39
|
+
*
|
|
40
|
+
* - **Teardown never uses `--force`.** Git refuses to remove a worktree with
|
|
41
|
+
* modified or untracked files, and that refusal is exactly the policy we
|
|
42
|
+
* want: an isolated child that produced nothing is reclaimed automatically,
|
|
43
|
+
* and one that produced work keeps its directory and branch for the parent
|
|
44
|
+
* to inspect. Committed work is safe either way — the branch outlives the
|
|
45
|
+
* worktree — but uncommitted work would not be, so we let git veto.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { execFile } from "node:child_process";
|
|
49
|
+
import { randomBytes } from "node:crypto";
|
|
50
|
+
import * as fs from "node:fs";
|
|
51
|
+
import * as path from "node:path";
|
|
52
|
+
|
|
53
|
+
/** A hung git call must not stall a spawn or a session teardown. */
|
|
54
|
+
export const WORKTREE_GIT_TIMEOUT_MS = 10_000;
|
|
55
|
+
|
|
56
|
+
/** Kept inside `.git/` so the parent's `git status` never sees these. */
|
|
57
|
+
export const WORKTREE_DIR_SEGMENTS = ["pi-worktrees"] as const;
|
|
58
|
+
|
|
59
|
+
/** Branch/directory prefix, so these are recognizable in `git worktree list`. */
|
|
60
|
+
export const WORKTREE_NAME_PREFIX = "pi/";
|
|
61
|
+
|
|
62
|
+
export interface Worktree {
|
|
63
|
+
/** Absolute path to the isolated checkout; use as the child's cwd. */
|
|
64
|
+
readonly path: string;
|
|
65
|
+
/** Branch created for this worktree. Survives teardown. */
|
|
66
|
+
readonly branch: string;
|
|
67
|
+
/**
|
|
68
|
+
* Commit the worktree was created at. "Did the child produce anything" is
|
|
69
|
+
* measured against THIS, never against the parent's HEAD: the parent moves
|
|
70
|
+
* (it can commit, or merge the child's own branch) while the child works, and
|
|
71
|
+
* a moving baseline makes a productive child look empty and get its branch
|
|
72
|
+
* deleted.
|
|
73
|
+
*/
|
|
74
|
+
readonly baseSha?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WorktreeFailure {
|
|
78
|
+
readonly reason: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type WorktreeResult =
|
|
82
|
+
| { readonly ok: true; readonly worktree: Worktree }
|
|
83
|
+
| { readonly ok: false; readonly reason: string };
|
|
84
|
+
|
|
85
|
+
interface GitResult {
|
|
86
|
+
readonly code: number;
|
|
87
|
+
readonly stdout: string;
|
|
88
|
+
readonly stderr: string;
|
|
89
|
+
/** Spawn/timeout failure rather than a normal numeric Git exit status. */
|
|
90
|
+
readonly exception: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runGit(
|
|
94
|
+
args: readonly string[],
|
|
95
|
+
cwd: string,
|
|
96
|
+
timeoutMs = WORKTREE_GIT_TIMEOUT_MS,
|
|
97
|
+
): Promise<GitResult> {
|
|
98
|
+
if (timeoutMs <= 0) {
|
|
99
|
+
return Promise.resolve({
|
|
100
|
+
code: 1,
|
|
101
|
+
stdout: "",
|
|
102
|
+
stderr: "git operation deadline exceeded",
|
|
103
|
+
exception: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
execFile(
|
|
108
|
+
"git",
|
|
109
|
+
["-c", "core.fsmonitor=false", ...args],
|
|
110
|
+
{
|
|
111
|
+
cwd,
|
|
112
|
+
encoding: "utf8",
|
|
113
|
+
timeout: Math.min(timeoutMs, WORKTREE_GIT_TIMEOUT_MS),
|
|
114
|
+
},
|
|
115
|
+
(error, stdout, stderr) => {
|
|
116
|
+
const code =
|
|
117
|
+
error && typeof (error as { code?: unknown }).code === "number"
|
|
118
|
+
? (error as unknown as { code: number }).code
|
|
119
|
+
: error
|
|
120
|
+
? 1
|
|
121
|
+
: 0;
|
|
122
|
+
resolve({
|
|
123
|
+
code,
|
|
124
|
+
stdout: stdout ?? "",
|
|
125
|
+
stderr: (stderr ?? "") || (error ? error.message : ""),
|
|
126
|
+
exception:
|
|
127
|
+
!!error && typeof (error as { code?: unknown }).code !== "number",
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function firstLine(text: string) {
|
|
135
|
+
return text.trim().split("\n")[0]?.trim() ?? "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sanitize a caller-supplied label into one path/ref segment.
|
|
140
|
+
*
|
|
141
|
+
* The label reaches us from a model-authored tool argument, so it is untrusted
|
|
142
|
+
* input on a path and a ref: `..`, a slash, or a leading dash would let it
|
|
143
|
+
* escape `.git/pi-worktrees/` or be read by git as an option. Only a
|
|
144
|
+
* conservative character set survives, and the result is never empty.
|
|
145
|
+
*
|
|
146
|
+
* The result is NOT unique and must never be the only thing distinguishing two
|
|
147
|
+
* worktrees. It cannot be: a non-Latin label ("中文标签", "🚀") has no surviving
|
|
148
|
+
* characters and collapses to the fallback, and two labels sharing a long
|
|
149
|
+
* prefix collide once `maxLength` cuts them. Uniqueness comes from the random
|
|
150
|
+
* suffix in `createWorktree`.
|
|
151
|
+
*/
|
|
152
|
+
export function worktreeSlug(label: string, fallback: string, maxLength = 32) {
|
|
153
|
+
const slug = label
|
|
154
|
+
.toLowerCase()
|
|
155
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
156
|
+
// `a..b` sanitizes fine as a path but is not a legal ref: git rejects any
|
|
157
|
+
// name containing `..`, which would fail the spawn rather than escape it.
|
|
158
|
+
.replace(/\.{2,}/g, ".")
|
|
159
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
160
|
+
.slice(0, maxLength)
|
|
161
|
+
.replace(/[-.]+$/g, "");
|
|
162
|
+
return slug || fallback;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Common git dir for `cwd`, or undefined when it is not a repository.
|
|
167
|
+
*
|
|
168
|
+
* `--git-common-dir` rather than `--git-dir`: inside an existing worktree the
|
|
169
|
+
* latter points at `.git/worktrees/<name>`, and nesting new worktrees there
|
|
170
|
+
* would bury them. The common dir is the same `.git` for the main checkout and
|
|
171
|
+
* every worktree of it, so isolation composes.
|
|
172
|
+
*/
|
|
173
|
+
export async function resolveGitCommonDir(cwd: string) {
|
|
174
|
+
const result = await runGit(["rev-parse", "--git-common-dir"], cwd);
|
|
175
|
+
if (result.code !== 0) return undefined;
|
|
176
|
+
const value = firstLine(result.stdout);
|
|
177
|
+
if (!value) return undefined;
|
|
178
|
+
return path.resolve(cwd, value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Create an isolated worktree for `cwd`'s repository, branched from HEAD.
|
|
183
|
+
*
|
|
184
|
+
* Never throws and never falls back silently: a non-repository, a git failure,
|
|
185
|
+
* or a name collision comes back as `{ ok: false, reason }` so the caller can
|
|
186
|
+
* decide between reporting it and continuing in the shared directory.
|
|
187
|
+
*/
|
|
188
|
+
export async function createWorktree(options: {
|
|
189
|
+
cwd: string;
|
|
190
|
+
/** Human-ish label (agent title); sanitized into the branch/dir name. */
|
|
191
|
+
label: string;
|
|
192
|
+
/** Disambiguator appended to the slug; must be unique per live worktree. */
|
|
193
|
+
id: string;
|
|
194
|
+
/** Link the repo's node_modules in, so the child can build and test. */
|
|
195
|
+
linkNodeModules?: boolean;
|
|
196
|
+
}): Promise<WorktreeResult> {
|
|
197
|
+
const gitDir = await resolveGitCommonDir(options.cwd);
|
|
198
|
+
if (!gitDir) {
|
|
199
|
+
return { ok: false, reason: `not a git repository: ${options.cwd}` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* The random suffix is what makes the name unique, and it is not optional.
|
|
204
|
+
* A committed branch deliberately OUTLIVES its worktree, so any name derived
|
|
205
|
+
* only from label+id is already taken the next time the same agent title
|
|
206
|
+
* runs — `git worktree add` then fails with "a branch named … already
|
|
207
|
+
* exists" and isolation, which is requested rather than best-effort, turns
|
|
208
|
+
* into a hard spawn failure that tells the model to go run unisolated.
|
|
209
|
+
* Deriving it from a label cannot work: every non-Latin title slugs to the
|
|
210
|
+
* same fallback.
|
|
211
|
+
*/
|
|
212
|
+
const name = [
|
|
213
|
+
worktreeSlug(options.label, "agent"),
|
|
214
|
+
worktreeSlug(options.id, "0", 24),
|
|
215
|
+
randomBytes(3).toString("hex"),
|
|
216
|
+
].join("-");
|
|
217
|
+
const worktreePath = path.join(gitDir, ...WORKTREE_DIR_SEGMENTS, name);
|
|
218
|
+
const branch = `${WORKTREE_NAME_PREFIX}${name}`;
|
|
219
|
+
|
|
220
|
+
// Read before creating: this is the baseline teardown measures against.
|
|
221
|
+
const base = await runGit(["rev-parse", "HEAD"], options.cwd);
|
|
222
|
+
const baseSha = base.code === 0 ? firstLine(base.stdout) : undefined;
|
|
223
|
+
|
|
224
|
+
const added = await runGit(
|
|
225
|
+
["worktree", "add", "--quiet", "-b", branch, worktreePath, "HEAD"],
|
|
226
|
+
options.cwd,
|
|
227
|
+
);
|
|
228
|
+
if (added.code !== 0) {
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
reason: firstLine(added.stderr) || "git worktree add failed",
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (options.linkNodeModules !== false)
|
|
236
|
+
linkNodeModules(options.cwd, worktreePath);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
ok: true,
|
|
240
|
+
worktree: { path: worktreePath, branch, ...(baseSha ? { baseSha } : {}) },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Link the repository's node_modules NEXT TO the worktrees, best effort.
|
|
246
|
+
*
|
|
247
|
+
* The link lives at `.git/pi-worktrees/node_modules`, one level above every
|
|
248
|
+
* checkout, because Node resolves bare imports by walking ancestor
|
|
249
|
+
* directories: a child at `.git/pi-worktrees/impl-1` finds it, and git never
|
|
250
|
+
* sees an entry inside the tree it reports on. See the header for what putting
|
|
251
|
+
* it inside the checkout actually did.
|
|
252
|
+
*
|
|
253
|
+
* The target is absolute so it resolves regardless of depth, and one link
|
|
254
|
+
* serves every worktree, so this is idempotent across concurrent creates. A
|
|
255
|
+
* failure here is not fatal — the child just cannot run builds — so it is
|
|
256
|
+
* swallowed rather than turned into a spawn failure.
|
|
257
|
+
*/
|
|
258
|
+
function linkNodeModules(repoCwd: string, worktreePath: string) {
|
|
259
|
+
const source = path.join(repoCwd, "node_modules");
|
|
260
|
+
const target = path.join(path.dirname(worktreePath), "node_modules");
|
|
261
|
+
try {
|
|
262
|
+
if (!fs.existsSync(source)) return;
|
|
263
|
+
if (
|
|
264
|
+
fs.existsSync(target) ||
|
|
265
|
+
fs.lstatSync(target, { throwIfNoEntry: false })
|
|
266
|
+
)
|
|
267
|
+
return;
|
|
268
|
+
fs.symlinkSync(source, target, "junction");
|
|
269
|
+
} catch {
|
|
270
|
+
// The worktree is still usable for reading and editing without deps.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface WorktreeCleanup {
|
|
275
|
+
/** True when the worktree directory is gone. */
|
|
276
|
+
readonly removed: boolean;
|
|
277
|
+
/** True when the branch was deleted too, because it held no commits. */
|
|
278
|
+
readonly branchDeleted: boolean;
|
|
279
|
+
/** Why it was kept, or why a final cleanup step failed. */
|
|
280
|
+
readonly reason?: string;
|
|
281
|
+
/** Branch observed in the checkout, or the branch created for it. */
|
|
282
|
+
readonly branch: string;
|
|
283
|
+
/** Creation-time baseline used to judge whether the child committed work. */
|
|
284
|
+
readonly baseSha?: string;
|
|
285
|
+
/** HEAD observed before cleanup. */
|
|
286
|
+
readonly headSha?: string;
|
|
287
|
+
/** Commits reachable from HEAD but not from baseSha. */
|
|
288
|
+
readonly commits?: number;
|
|
289
|
+
readonly detached: boolean;
|
|
290
|
+
readonly dirty?: boolean;
|
|
291
|
+
readonly untracked?: boolean;
|
|
292
|
+
readonly ignored?: boolean;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface WorktreeCommitCount {
|
|
296
|
+
readonly ok: boolean;
|
|
297
|
+
readonly count?: number;
|
|
298
|
+
readonly reason?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Reclaim a worktree, keeping anything the child actually produced.
|
|
303
|
+
*
|
|
304
|
+
* Deliberately no `--force`: git's own refusal on a dirty tree is the policy
|
|
305
|
+
* we want. A child that changed nothing costs nothing to discard; a child with
|
|
306
|
+
* uncommitted work keeps its directory so the parent can look at it. The
|
|
307
|
+
* branch is deleted only when it holds no commits, so a child that committed
|
|
308
|
+
* always leaves something to merge, and a child that did nothing leaves no
|
|
309
|
+
* litter behind.
|
|
310
|
+
*
|
|
311
|
+
* "Produced nothing" is measured against the worktree's OWN head and its
|
|
312
|
+
* creation-time base, never against the parent's current HEAD. Both matter:
|
|
313
|
+
* a child is free to `checkout -b` and commit somewhere else, and the parent
|
|
314
|
+
* is free to move on (or merge the child's branch) before teardown runs.
|
|
315
|
+
* Getting either wrong deletes a branch that holds real work.
|
|
316
|
+
*/
|
|
317
|
+
export async function reclaimWorktree(
|
|
318
|
+
repoCwd: string,
|
|
319
|
+
worktree: Worktree,
|
|
320
|
+
options: { timeoutMs?: number } = {},
|
|
321
|
+
): Promise<WorktreeCleanup> {
|
|
322
|
+
const deadline = Date.now() + (options.timeoutMs ?? WORKTREE_GIT_TIMEOUT_MS);
|
|
323
|
+
const run = (args: readonly string[]) =>
|
|
324
|
+
runGit(args, repoCwd, deadline - Date.now());
|
|
325
|
+
const base = {
|
|
326
|
+
removed: false,
|
|
327
|
+
branchDeleted: false,
|
|
328
|
+
branch: worktree.branch,
|
|
329
|
+
...(worktree.baseSha ? { baseSha: worktree.baseSha } : {}),
|
|
330
|
+
detached: false,
|
|
331
|
+
};
|
|
332
|
+
const preserve = (
|
|
333
|
+
reason: string,
|
|
334
|
+
observed: Partial<WorktreeCleanup> = {},
|
|
335
|
+
): WorktreeCleanup => ({ ...base, ...observed, reason });
|
|
336
|
+
|
|
337
|
+
// Inspect everything that automatic removal could destroy before asking Git
|
|
338
|
+
// to remove anything. An unknown state is productive until proven otherwise.
|
|
339
|
+
const head = await run([
|
|
340
|
+
"-C",
|
|
341
|
+
worktree.path,
|
|
342
|
+
"rev-parse",
|
|
343
|
+
"--verify",
|
|
344
|
+
"HEAD",
|
|
345
|
+
]);
|
|
346
|
+
const headSha = head.code === 0 ? firstLine(head.stdout) : "";
|
|
347
|
+
if (!headSha) {
|
|
348
|
+
return preserve(
|
|
349
|
+
firstLine(head.stderr) || "could not inspect worktree HEAD",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const symbolic = await run([
|
|
354
|
+
"-C",
|
|
355
|
+
worktree.path,
|
|
356
|
+
"symbolic-ref",
|
|
357
|
+
"--quiet",
|
|
358
|
+
"--short",
|
|
359
|
+
"HEAD",
|
|
360
|
+
]);
|
|
361
|
+
if (symbolic.code !== 0 && (symbolic.code !== 1 || symbolic.exception)) {
|
|
362
|
+
return preserve(
|
|
363
|
+
firstLine(symbolic.stderr) || "could not inspect worktree branch",
|
|
364
|
+
{ headSha },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const headBranch = symbolic.code === 0 ? firstLine(symbolic.stdout) : "";
|
|
368
|
+
const detached = !headBranch;
|
|
369
|
+
const branch = headBranch || worktree.branch;
|
|
370
|
+
const observed = { branch, headSha, detached };
|
|
371
|
+
|
|
372
|
+
const status = await run([
|
|
373
|
+
"-C",
|
|
374
|
+
worktree.path,
|
|
375
|
+
"status",
|
|
376
|
+
"--porcelain=v1",
|
|
377
|
+
"--untracked-files=all",
|
|
378
|
+
]);
|
|
379
|
+
if (status.code !== 0) {
|
|
380
|
+
return preserve(
|
|
381
|
+
firstLine(status.stderr) || "could not inspect worktree status",
|
|
382
|
+
observed,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const statusLines = status.stdout.split("\n").filter(Boolean);
|
|
386
|
+
const untracked = statusLines.some((line) => line.startsWith("??"));
|
|
387
|
+
const dirty = statusLines.length > 0;
|
|
388
|
+
|
|
389
|
+
const ignoredFiles = await run([
|
|
390
|
+
"-C",
|
|
391
|
+
worktree.path,
|
|
392
|
+
"ls-files",
|
|
393
|
+
"--others",
|
|
394
|
+
"--ignored",
|
|
395
|
+
"--exclude-standard",
|
|
396
|
+
"-z",
|
|
397
|
+
]);
|
|
398
|
+
if (ignoredFiles.code !== 0) {
|
|
399
|
+
return preserve(
|
|
400
|
+
firstLine(ignoredFiles.stderr) || "could not inspect ignored files",
|
|
401
|
+
{ ...observed, dirty, untracked },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const ignored = ignoredFiles.stdout.length > 0;
|
|
405
|
+
|
|
406
|
+
if (!worktree.baseSha) {
|
|
407
|
+
return preserve("worktree creation baseline is unknown", {
|
|
408
|
+
...observed,
|
|
409
|
+
dirty,
|
|
410
|
+
untracked,
|
|
411
|
+
ignored,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const mergeBase = await run(["merge-base", worktree.baseSha, headSha]);
|
|
415
|
+
if (mergeBase.code !== 0) {
|
|
416
|
+
return preserve(
|
|
417
|
+
firstLine(mergeBase.stderr) || "could not compare HEAD with its baseline",
|
|
418
|
+
{ ...observed, dirty, untracked, ignored },
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
if (firstLine(mergeBase.stdout) !== worktree.baseSha) {
|
|
422
|
+
return preserve("worktree HEAD no longer descends from its baseline", {
|
|
423
|
+
...observed,
|
|
424
|
+
dirty,
|
|
425
|
+
untracked,
|
|
426
|
+
ignored,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const commitCount = await worktreeCommitCount(
|
|
430
|
+
repoCwd,
|
|
431
|
+
headSha,
|
|
432
|
+
worktree.baseSha,
|
|
433
|
+
deadline - Date.now(),
|
|
434
|
+
);
|
|
435
|
+
if (!commitCount.ok || commitCount.count === undefined) {
|
|
436
|
+
return preserve(
|
|
437
|
+
commitCount.reason ?? "could not inspect worktree commits",
|
|
438
|
+
{
|
|
439
|
+
...observed,
|
|
440
|
+
dirty,
|
|
441
|
+
untracked,
|
|
442
|
+
ignored,
|
|
443
|
+
},
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
const inspected = {
|
|
447
|
+
...observed,
|
|
448
|
+
commits: commitCount.count,
|
|
449
|
+
dirty,
|
|
450
|
+
untracked,
|
|
451
|
+
ignored,
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
if (dirty || ignored) {
|
|
455
|
+
const contents = [
|
|
456
|
+
dirty ? "modified or untracked files" : undefined,
|
|
457
|
+
ignored ? "ignored files" : undefined,
|
|
458
|
+
].filter(Boolean);
|
|
459
|
+
return preserve(`worktree contains ${contents.join(" and ")}`, inspected);
|
|
460
|
+
}
|
|
461
|
+
// Detachment is itself an intentional checkout-state change, and productive
|
|
462
|
+
// detached commits have no surviving ref. Preserve all detached checkouts
|
|
463
|
+
// rather than guessing that a clean one is disposable.
|
|
464
|
+
if (detached) {
|
|
465
|
+
return preserve(
|
|
466
|
+
commitCount.count > 0
|
|
467
|
+
? "detached HEAD contains commits not reachable from its base"
|
|
468
|
+
: "worktree HEAD is detached",
|
|
469
|
+
{
|
|
470
|
+
...inspected,
|
|
471
|
+
branch: worktree.branch,
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const removed = await run(["worktree", "remove", worktree.path]);
|
|
477
|
+
if (removed.code !== 0) {
|
|
478
|
+
return preserve(
|
|
479
|
+
firstLine(removed.stderr) || "git declined to remove the worktree",
|
|
480
|
+
inspected,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (commitCount.count > 0 || branch !== worktree.branch) {
|
|
484
|
+
return { ...base, ...inspected, removed: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Only delete the branch this module created, after proving it is clean and
|
|
488
|
+
// has no commits beyond its immutable creation baseline.
|
|
489
|
+
const deleted = await run(["branch", "-D", worktree.branch]);
|
|
490
|
+
return {
|
|
491
|
+
...base,
|
|
492
|
+
...inspected,
|
|
493
|
+
removed: true,
|
|
494
|
+
branchDeleted: deleted.code === 0,
|
|
495
|
+
...(deleted.code === 0
|
|
496
|
+
? {}
|
|
497
|
+
: {
|
|
498
|
+
reason: firstLine(deleted.stderr) || "could not delete empty branch",
|
|
499
|
+
}),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Commits on `head` that the immutable creation `base` does not have. */
|
|
504
|
+
export async function worktreeCommitCount(
|
|
505
|
+
repoCwd: string,
|
|
506
|
+
head: string,
|
|
507
|
+
base: string,
|
|
508
|
+
timeoutMs = WORKTREE_GIT_TIMEOUT_MS,
|
|
509
|
+
): Promise<WorktreeCommitCount> {
|
|
510
|
+
const result = await runGit(
|
|
511
|
+
["rev-list", "--count", `${base}..${head}`],
|
|
512
|
+
repoCwd,
|
|
513
|
+
timeoutMs,
|
|
514
|
+
);
|
|
515
|
+
if (result.code !== 0) {
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
reason: firstLine(result.stderr) || "git rev-list failed",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const count = Number.parseInt(firstLine(result.stdout), 10);
|
|
522
|
+
if (!Number.isFinite(count) || count < 0) {
|
|
523
|
+
return { ok: false, reason: "git rev-list returned an invalid count" };
|
|
524
|
+
}
|
|
525
|
+
return { ok: true, count };
|
|
526
|
+
}
|