pi-microsandbox 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/LICENSE +21 -0
- package/README.md +120 -0
- package/SECURITY.md +58 -0
- package/docs/commands.md +38 -0
- package/docs/configuration.md +80 -0
- package/docs/development.md +119 -0
- package/docs/getting-started.md +66 -0
- package/docs/images.md +190 -0
- package/docs/safety.md +39 -0
- package/docs/storage.md +57 -0
- package/docs/troubleshooting.md +20 -0
- package/extensions/pi-msb/command.ts +532 -0
- package/extensions/pi-msb/config.ts +771 -0
- package/extensions/pi-msb/control.ts +803 -0
- package/extensions/pi-msb/footer.ts +191 -0
- package/extensions/pi-msb/git.ts +256 -0
- package/extensions/pi-msb/index.ts +156 -0
- package/extensions/pi-msb/labels.ts +321 -0
- package/extensions/pi-msb/locks.ts +292 -0
- package/extensions/pi-msb/operations-exec.ts +434 -0
- package/extensions/pi-msb/operations.ts +321 -0
- package/extensions/pi-msb/prune.ts +232 -0
- package/extensions/pi-msb/sandbox-manager.ts +702 -0
- package/extensions/pi-msb/skill-access.ts +164 -0
- package/extensions/pi-msb/storage.ts +332 -0
- package/extensions/pi-msb/tools.ts +417 -0
- package/extensions/pi-msb/transport.ts +518 -0
- package/extensions/pi-msb/types.ts +436 -0
- package/package.json +74 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ContextUsage,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
type ReadonlyFooterDataProvider,
|
|
5
|
+
type Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
type Component,
|
|
11
|
+
type TUI,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "pi-msb";
|
|
16
|
+
const MIN_GAP = 2;
|
|
17
|
+
|
|
18
|
+
type UsageLike = {
|
|
19
|
+
input?: number;
|
|
20
|
+
output?: number;
|
|
21
|
+
cacheRead?: number;
|
|
22
|
+
cacheWrite?: number;
|
|
23
|
+
cost?: { total?: number };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type UsageTotals = Required<Omit<UsageLike, "cost">> & { cost: number };
|
|
27
|
+
|
|
28
|
+
function formatTokens(count: number): string {
|
|
29
|
+
if (count < 1_000) return count.toString();
|
|
30
|
+
if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
31
|
+
if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
|
|
32
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
33
|
+
return `${Math.round(count / 1_000_000)}M`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatCwd(cwd: string, home: string | undefined): string {
|
|
37
|
+
if (!home) return cwd;
|
|
38
|
+
const resolvedCwd = resolve(cwd);
|
|
39
|
+
const resolvedHome = resolve(home);
|
|
40
|
+
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
41
|
+
const insideHome = relativeToHome === "" || (
|
|
42
|
+
relativeToHome !== ".." &&
|
|
43
|
+
!relativeToHome.startsWith(`..${sep}`) &&
|
|
44
|
+
!isAbsolute(relativeToHome)
|
|
45
|
+
);
|
|
46
|
+
if (!insideHome) return cwd;
|
|
47
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usageFromEntry(entry: unknown): UsageLike | undefined {
|
|
51
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
52
|
+
const value = entry as {
|
|
53
|
+
type?: string;
|
|
54
|
+
usage?: UsageLike;
|
|
55
|
+
message?: { role?: string; usage?: UsageLike };
|
|
56
|
+
};
|
|
57
|
+
if (value.type === "message" && (value.message?.role === "assistant" || value.message?.role === "toolResult")) {
|
|
58
|
+
return value.message.usage;
|
|
59
|
+
}
|
|
60
|
+
if (value.type === "branch_summary" || value.type === "compaction") return value.usage;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function totalUsage(entries: readonly unknown[]): { totals: UsageTotals; latestCacheHitRate?: number } {
|
|
65
|
+
const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
66
|
+
let latestCacheHitRate: number | undefined;
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const usage = usageFromEntry(entry);
|
|
69
|
+
if (!usage) continue;
|
|
70
|
+
totals.input += usage.input ?? 0;
|
|
71
|
+
totals.output += usage.output ?? 0;
|
|
72
|
+
totals.cacheRead += usage.cacheRead ?? 0;
|
|
73
|
+
totals.cacheWrite += usage.cacheWrite ?? 0;
|
|
74
|
+
totals.cost += usage.cost?.total ?? 0;
|
|
75
|
+
|
|
76
|
+
const value = entry as { type?: string; message?: { role?: string } };
|
|
77
|
+
if (value.type === "message" && value.message?.role === "assistant") {
|
|
78
|
+
const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
|
|
79
|
+
latestCacheHitRate = promptTokens > 0 ? ((usage.cacheRead ?? 0) / promptTokens) * 100 : undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { totals, latestCacheHitRate };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Align two ANSI-styled values while keeping the right value visible. */
|
|
86
|
+
export function alignFooterRow(left: string, right: string, width: number, ellipsis = "..."): string {
|
|
87
|
+
if (width <= 0) return "";
|
|
88
|
+
const rightWidth = visibleWidth(right);
|
|
89
|
+
if (rightWidth >= width) return truncateToWidth(right, width, "");
|
|
90
|
+
const maxLeftWidth = Math.max(0, width - rightWidth - MIN_GAP);
|
|
91
|
+
const fittedLeft = truncateToWidth(left, maxLeftWidth, ellipsis);
|
|
92
|
+
const padding = " ".repeat(Math.max(0, width - visibleWidth(fittedLeft) - rightWidth));
|
|
93
|
+
return fittedLeft + padding + right;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function sanitizeStatus(text: string): string {
|
|
97
|
+
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isUsingSubscription(ctx: ExtensionContext): boolean {
|
|
101
|
+
const model = ctx.model;
|
|
102
|
+
if (!model) return false;
|
|
103
|
+
if (model.provider === "kimi-coding") return true;
|
|
104
|
+
const provider = ctx.modelRegistry.getProvider(model.provider);
|
|
105
|
+
return ctx.modelRegistry.isUsingOAuth(model) && provider?.auth.oauth?.isSubscription === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderContextUsage(theme: Theme, usage: ContextUsage | undefined, contextWindow: number): string {
|
|
109
|
+
const percentValue = usage?.percent ?? 0;
|
|
110
|
+
const percent = usage?.percent === null ? "?" : (usage?.percent ?? 0).toFixed(1);
|
|
111
|
+
const text = `${percent}%/${formatTokens(usage?.contextWindow ?? contextWindow)}`;
|
|
112
|
+
if (percentValue > 90) return theme.fg("error", text);
|
|
113
|
+
if (percentValue > 70) return theme.fg("warning", text);
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build a two-row Pi footer with the MSB status in the upper-right corner. */
|
|
118
|
+
export function createMsbFooter(
|
|
119
|
+
tui: TUI,
|
|
120
|
+
theme: Theme,
|
|
121
|
+
ctx: ExtensionContext,
|
|
122
|
+
footerData: ReadonlyFooterDataProvider,
|
|
123
|
+
): Component & { dispose(): void } {
|
|
124
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
invalidate() {},
|
|
128
|
+
dispose: unsubscribe,
|
|
129
|
+
render(width: number): string[] {
|
|
130
|
+
const statuses = footerData.getExtensionStatuses();
|
|
131
|
+
const msbStatus = sanitizeStatus(statuses.get(STATUS_KEY) ?? "");
|
|
132
|
+
|
|
133
|
+
let location = formatCwd(
|
|
134
|
+
ctx.sessionManager.getCwd(),
|
|
135
|
+
process.env.HOME || process.env.USERPROFILE,
|
|
136
|
+
);
|
|
137
|
+
const branch = footerData.getGitBranch();
|
|
138
|
+
if (branch) location += ` (${branch})`;
|
|
139
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
140
|
+
if (sessionName) location += ` • ${sessionName}`;
|
|
141
|
+
|
|
142
|
+
const dimEllipsis = theme.fg("dim", "...");
|
|
143
|
+
const locationText = theme.fg("dim", location);
|
|
144
|
+
const locationLine = msbStatus
|
|
145
|
+
? alignFooterRow(locationText, msbStatus, width, dimEllipsis)
|
|
146
|
+
: truncateToWidth(locationText, width, dimEllipsis);
|
|
147
|
+
|
|
148
|
+
const { totals, latestCacheHitRate } = totalUsage(ctx.sessionManager.getEntries());
|
|
149
|
+
const stats: string[] = [];
|
|
150
|
+
if (totals.input) stats.push(`↑${formatTokens(totals.input)}`);
|
|
151
|
+
if (totals.output) stats.push(`↓${formatTokens(totals.output)}`);
|
|
152
|
+
if (totals.cacheRead) stats.push(`R${formatTokens(totals.cacheRead)}`);
|
|
153
|
+
if (totals.cacheWrite) stats.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
154
|
+
if ((totals.cacheRead || totals.cacheWrite) && latestCacheHitRate !== undefined) {
|
|
155
|
+
stats.push(`CH${latestCacheHitRate.toFixed(1)}%`);
|
|
156
|
+
}
|
|
157
|
+
const usingSubscription = isUsingSubscription(ctx);
|
|
158
|
+
if (totals.cost || usingSubscription) {
|
|
159
|
+
stats.push(`$${totals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`);
|
|
160
|
+
}
|
|
161
|
+
stats.push(renderContextUsage(theme, ctx.getContextUsage(), ctx.model?.contextWindow ?? 0));
|
|
162
|
+
|
|
163
|
+
const modelName = ctx.model?.id ?? "no-model";
|
|
164
|
+
const thinkingLevel = ctx.thinkingLevel ?? "off";
|
|
165
|
+
const modelAndThinking = ctx.model?.reasoning
|
|
166
|
+
? `${modelName} • ${thinkingLevel === "off" ? "thinking off" : thinkingLevel}`
|
|
167
|
+
: modelName;
|
|
168
|
+
let modelText = modelAndThinking;
|
|
169
|
+
if (ctx.model && footerData.getAvailableProviderCount() > 1) {
|
|
170
|
+
const withProvider = `(${ctx.model.provider}) ${modelAndThinking}`;
|
|
171
|
+
if (visibleWidth(stats.join(" ")) + MIN_GAP + visibleWidth(withProvider) <= width) modelText = withProvider;
|
|
172
|
+
}
|
|
173
|
+
const statsLine = alignFooterRow(
|
|
174
|
+
theme.fg("dim", stats.join(" ")),
|
|
175
|
+
theme.fg("dim", modelText),
|
|
176
|
+
width,
|
|
177
|
+
dimEllipsis,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const lines = [locationLine, statsLine];
|
|
181
|
+
const otherStatuses = Array.from(statuses.entries())
|
|
182
|
+
.filter(([key]) => key !== STATUS_KEY)
|
|
183
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
184
|
+
.map(([, text]) => sanitizeStatus(text));
|
|
185
|
+
if (otherStatuses.length > 0) {
|
|
186
|
+
lines.push(truncateToWidth(otherStatuses.join(" "), width, dimEllipsis));
|
|
187
|
+
}
|
|
188
|
+
return lines;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
import type { ExecFn, GitRepoInfo, GitSeedBundle } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const SEED_REF = "refs/pi-msb/seed";
|
|
12
|
+
|
|
13
|
+
interface ExecFileFailure {
|
|
14
|
+
code?: number | string;
|
|
15
|
+
killed?: boolean;
|
|
16
|
+
signal?: string | null;
|
|
17
|
+
stdout?: string | Buffer;
|
|
18
|
+
stderr?: string | Buffer;
|
|
19
|
+
message?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Execute a program without giving it a shell or interpolating its arguments. */
|
|
23
|
+
const defaultExec: ExecFn = async (command, args, options = {}) => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await execFileAsync(command, args, {
|
|
26
|
+
cwd: options.cwd,
|
|
27
|
+
env: options.env,
|
|
28
|
+
timeout: options.timeout === undefined ? undefined : options.timeout * 1000,
|
|
29
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
stdout: String(result.stdout),
|
|
34
|
+
stderr: String(result.stderr),
|
|
35
|
+
code: 0,
|
|
36
|
+
};
|
|
37
|
+
} catch (error: unknown) {
|
|
38
|
+
const failure = error as ExecFileFailure;
|
|
39
|
+
const numericCode = typeof failure.code === "number" ? failure.code : 127;
|
|
40
|
+
return {
|
|
41
|
+
stdout: String(failure.stdout ?? ""),
|
|
42
|
+
stderr: String(failure.stderr ?? failure.message ?? ""),
|
|
43
|
+
code: numericCode,
|
|
44
|
+
killed: Boolean(failure.killed || failure.signal),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const emptyRepo = (hostCwd?: string): GitRepoInfo => ({
|
|
50
|
+
isGitRepo: false,
|
|
51
|
+
...(hostCwd ? { hostCwd } : {}),
|
|
52
|
+
repoRoot: null,
|
|
53
|
+
branch: null,
|
|
54
|
+
headSha: null,
|
|
55
|
+
unborn: false,
|
|
56
|
+
isLinkedWorktree: false,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function output(result: Awaited<ReturnType<ExecFn>>): string {
|
|
60
|
+
return result.stdout.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function failureMessage(context: string, result: Awaited<ReturnType<ExecFn>>): Error {
|
|
64
|
+
const detail = result.stderr.trim() || `git exited with code ${result.code}`;
|
|
65
|
+
return new Error(`${context}: ${detail}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function canonicalPath(value: string): Promise<string> {
|
|
69
|
+
try {
|
|
70
|
+
return await realpath(value);
|
|
71
|
+
} catch {
|
|
72
|
+
return resolve(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pathFromGitResult(value: string, base: string): string {
|
|
77
|
+
return isAbsolute(value) ? value : resolve(base, value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function lexicalRepoRoot(requestedCwd: string, canonicalRepoRoot: string): Promise<string | null> {
|
|
81
|
+
let candidate = requestedCwd;
|
|
82
|
+
while (true) {
|
|
83
|
+
if (await canonicalPath(candidate) === canonicalRepoRoot) return candidate;
|
|
84
|
+
const parent = dirname(candidate);
|
|
85
|
+
if (parent === candidate) return null;
|
|
86
|
+
candidate = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the immutable source state of cwd. A directory outside Git is an ordinary
|
|
92
|
+
* result rather than an error, which lets storage mode `auto` remain a direct alias.
|
|
93
|
+
*/
|
|
94
|
+
export async function detectGitRepo(cwd: string, exec: ExecFn = defaultExec): Promise<GitRepoInfo> {
|
|
95
|
+
const requestedCwd = resolve(cwd);
|
|
96
|
+
const hostCwd = await canonicalPath(requestedCwd);
|
|
97
|
+
const topLevel = await exec("git", ["-C", requestedCwd, "rev-parse", "--show-toplevel"]);
|
|
98
|
+
if (topLevel.code !== 0 || topLevel.killed || !output(topLevel)) return emptyRepo(hostCwd);
|
|
99
|
+
|
|
100
|
+
const repoRoot = await canonicalPath(output(topLevel));
|
|
101
|
+
const guestRepoRoot = await lexicalRepoRoot(requestedCwd, repoRoot);
|
|
102
|
+
const branchResult = await exec("git", [
|
|
103
|
+
"-C",
|
|
104
|
+
repoRoot,
|
|
105
|
+
"symbolic-ref",
|
|
106
|
+
"--quiet",
|
|
107
|
+
"--short",
|
|
108
|
+
"HEAD",
|
|
109
|
+
]);
|
|
110
|
+
const branch = branchResult.code === 0 && !branchResult.killed && output(branchResult)
|
|
111
|
+
? output(branchResult)
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
const headResult = await exec("git", ["-C", repoRoot, "rev-parse", "--verify", "HEAD"]);
|
|
115
|
+
const headSha = headResult.code === 0 && !headResult.killed && output(headResult)
|
|
116
|
+
? output(headResult)
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
// In a linked worktree --git-dir points at .git/worktrees/<name>, while
|
|
120
|
+
// --git-common-dir points at the shared .git directory.
|
|
121
|
+
const gitDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-dir"]);
|
|
122
|
+
const commonDirResult = await exec("git", ["-C", repoRoot, "rev-parse", "--git-common-dir"]);
|
|
123
|
+
let isLinkedWorktree = false;
|
|
124
|
+
if (gitDirResult.code === 0 && commonDirResult.code === 0) {
|
|
125
|
+
const gitDir = await canonicalPath(pathFromGitResult(output(gitDirResult), repoRoot));
|
|
126
|
+
const commonDir = await canonicalPath(pathFromGitResult(output(commonDirResult), repoRoot));
|
|
127
|
+
isLinkedWorktree = gitDir !== commonDir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
isGitRepo: true,
|
|
132
|
+
hostCwd,
|
|
133
|
+
repoRoot,
|
|
134
|
+
guestRepoRoot,
|
|
135
|
+
branch,
|
|
136
|
+
headSha,
|
|
137
|
+
unborn: headSha === null && branch !== null,
|
|
138
|
+
isLinkedWorktree,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function bundleFailure(context: string, result: Awaited<ReturnType<ExecFn>>): never {
|
|
143
|
+
throw failureMessage(context, result);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function checkedGit(
|
|
147
|
+
exec: ExecFn,
|
|
148
|
+
args: string[],
|
|
149
|
+
context: string,
|
|
150
|
+
): Promise<Awaited<ReturnType<ExecFn>>> {
|
|
151
|
+
const result = await exec("git", args);
|
|
152
|
+
if (result.code !== 0 || result.killed) bundleFailure(context, result);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Make a disposable bundle from committed objects only. The temporary bare clone
|
|
158
|
+
* deliberately has no worktree, so ignored, modified, and untracked files cannot
|
|
159
|
+
* enter the seed.
|
|
160
|
+
*/
|
|
161
|
+
export async function createSeedBundle(
|
|
162
|
+
info: GitRepoInfo,
|
|
163
|
+
options: {
|
|
164
|
+
branch: "current" | string;
|
|
165
|
+
depth: number | "unlimited";
|
|
166
|
+
tempRoot?: string;
|
|
167
|
+
exec?: ExecFn;
|
|
168
|
+
},
|
|
169
|
+
): Promise<GitSeedBundle | null> {
|
|
170
|
+
if (!info.isGitRepo || info.unborn || !info.repoRoot || !info.headSha) return null;
|
|
171
|
+
if (options.depth !== "unlimited" && (!Number.isInteger(options.depth) || options.depth < 1)) {
|
|
172
|
+
throw new Error(`invalid Git bundle depth: ${String(options.depth)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const exec = options.exec ?? defaultExec;
|
|
176
|
+
const parent = resolve(options.tempRoot ?? tmpdir());
|
|
177
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
178
|
+
const tempDir = await mkdtemp(join(parent, "pi-msb-git-"));
|
|
179
|
+
await chmod(tempDir, 0o700);
|
|
180
|
+
let cleaned = false;
|
|
181
|
+
const cleanup = async (): Promise<void> => {
|
|
182
|
+
if (cleaned) return;
|
|
183
|
+
cleaned = true;
|
|
184
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const bareName = "source.git";
|
|
189
|
+
const barePath = join(tempDir, bareName);
|
|
190
|
+
const bundlePath = join(tempDir, "seed.bundle");
|
|
191
|
+
const source = fileUrl(info.repoRoot);
|
|
192
|
+
const depthArgs = options.depth === "unlimited" ? [] : ["--depth", String(options.depth)];
|
|
193
|
+
|
|
194
|
+
// A file:// URL forces Git's local transport while keeping the source repo
|
|
195
|
+
// read-only. --bare means no checkout, filters, or worktree are involved.
|
|
196
|
+
await checkedGit(
|
|
197
|
+
exec,
|
|
198
|
+
["-C", tempDir, "clone", "--bare", "--no-tags", ...depthArgs, source, bareName],
|
|
199
|
+
"creating temporary bare clone",
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const objectCheck = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
203
|
+
if (objectCheck.code !== 0 || objectCheck.killed) {
|
|
204
|
+
// A branch may have moved between detection and clone. Fetch the captured
|
|
205
|
+
// object explicitly; never fall back to whatever HEAD the clone obtained.
|
|
206
|
+
await checkedGit(
|
|
207
|
+
exec,
|
|
208
|
+
["-C", barePath, "fetch", "--no-tags", ...depthArgs, source, info.headSha],
|
|
209
|
+
`fetching captured HEAD ${info.headSha}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const verifiedObject = await exec("git", ["-C", barePath, "cat-file", "-e", `${info.headSha}^{commit}`]);
|
|
214
|
+
if (verifiedObject.code !== 0 || verifiedObject.killed) {
|
|
215
|
+
bundleFailure(`captured HEAD ${info.headSha} is not present in temporary clone`, verifiedObject);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await checkedGit(
|
|
219
|
+
exec,
|
|
220
|
+
["-C", barePath, "update-ref", SEED_REF, info.headSha],
|
|
221
|
+
"updating temporary seed ref",
|
|
222
|
+
);
|
|
223
|
+
await checkedGit(
|
|
224
|
+
exec,
|
|
225
|
+
["-C", barePath, "bundle", "create", bundlePath, SEED_REF],
|
|
226
|
+
"creating Git bundle",
|
|
227
|
+
);
|
|
228
|
+
await checkedGit(exec, ["-C", barePath, "bundle", "verify", bundlePath], "verifying Git bundle");
|
|
229
|
+
const heads = await checkedGit(
|
|
230
|
+
exec,
|
|
231
|
+
["-C", barePath, "bundle", "list-heads", bundlePath],
|
|
232
|
+
"listing Git bundle heads",
|
|
233
|
+
);
|
|
234
|
+
const expectedHead = heads.stdout
|
|
235
|
+
.split(/\r?\n/)
|
|
236
|
+
.map((line) => line.trim().split(/\s+/))
|
|
237
|
+
.find(([sha, ref]) => sha === info.headSha && ref === SEED_REF);
|
|
238
|
+
if (!expectedHead) {
|
|
239
|
+
throw new Error(`Git bundle did not contain captured HEAD ${info.headSha} at ${SEED_REF}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
hostPath: bundlePath,
|
|
244
|
+
branch: options.branch === "current" ? info.branch : options.branch,
|
|
245
|
+
headSha: info.headSha,
|
|
246
|
+
cleanup,
|
|
247
|
+
};
|
|
248
|
+
} catch (error: unknown) {
|
|
249
|
+
await cleanup().catch(() => undefined);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function fileUrl(path: string): string {
|
|
255
|
+
return pathToFileURL(path).href;
|
|
256
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONFIG_DIR_NAME,
|
|
3
|
+
DEFAULT_MAX_BYTES,
|
|
4
|
+
DEFAULT_MAX_LINES,
|
|
5
|
+
formatSize,
|
|
6
|
+
truncateHead,
|
|
7
|
+
truncateLine,
|
|
8
|
+
type ExtensionAPI,
|
|
9
|
+
type ExtensionContext,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { createHostReadAccess } from "./skill-access.ts";
|
|
12
|
+
import { createSandboxGrepExecute } from "./operations-exec.ts";
|
|
13
|
+
import { registerMsbCommand, systemPromptNote } from "./command.ts";
|
|
14
|
+
import { createMsbIntegration } from "./control.ts";
|
|
15
|
+
import { createMsbFooter } from "./footer.ts";
|
|
16
|
+
import { registerSandboxTools } from "./tools.ts";
|
|
17
|
+
import type { ResolvedConfig, RuntimeState } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export function footerStatus(state: RuntimeState, visible: boolean): string | undefined {
|
|
20
|
+
if (!visible) return undefined;
|
|
21
|
+
switch (state.status) {
|
|
22
|
+
case "active": return `msb-${state.info?.displayId ?? "active"}`;
|
|
23
|
+
case "off": return "MSB host";
|
|
24
|
+
case "host-fallback": return "MSB host fallback";
|
|
25
|
+
case "unavailable": return "MSB blocked";
|
|
26
|
+
case "booting": return "(msb) preparing sandbox...";
|
|
27
|
+
case "stopping": return "MSB stopping";
|
|
28
|
+
default: return "MSB disabled";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The extension entry point intentionally imports no native SDK. Loading this
|
|
34
|
+
* module must remain safe on hosts without KVM or the microsandbox binary;
|
|
35
|
+
* control.ts calls import("microsandbox") only when a session actually boots.
|
|
36
|
+
*/
|
|
37
|
+
export default function registerPiMsb(pi: ExtensionAPI): void {
|
|
38
|
+
const hostReads = createHostReadAccess();
|
|
39
|
+
const integration = createMsbIntegration({
|
|
40
|
+
sessionId: "uninitialized",
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
configDirName: CONFIG_DIR_NAME,
|
|
43
|
+
env: process.env,
|
|
44
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
45
|
+
entries: () => currentContext?.sessionManager.getEntries() ?? [],
|
|
46
|
+
onState: (state) => {
|
|
47
|
+
if (currentContext) updateStatus(currentContext, state);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const currentProvider = integration.provider;
|
|
51
|
+
let currentContext: ExtensionContext | undefined;
|
|
52
|
+
let lastConfig: ResolvedConfig = integration.control.getEffectiveConfig();
|
|
53
|
+
let bootAnimation: ReturnType<typeof setInterval> | undefined;
|
|
54
|
+
let customFooterInstalled = false;
|
|
55
|
+
|
|
56
|
+
const styleFooterStatus = (ctx: ExtensionContext, text: string): string =>
|
|
57
|
+
`\x1b[22m${ctx.ui.theme.fg("dim", text)}\x1b[22m`;
|
|
58
|
+
|
|
59
|
+
const stopBootAnimation = (): void => {
|
|
60
|
+
if (bootAnimation !== undefined) {
|
|
61
|
+
clearInterval(bootAnimation);
|
|
62
|
+
bootAnimation = undefined;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const startBootAnimation = (ctx: ExtensionContext): void => {
|
|
67
|
+
if (bootAnimation !== undefined) return;
|
|
68
|
+
const frames = [
|
|
69
|
+
"(msb) preparing sandbox.",
|
|
70
|
+
"(msb) preparing sandbox..",
|
|
71
|
+
"(msb) preparing sandbox...",
|
|
72
|
+
];
|
|
73
|
+
let frame = 0;
|
|
74
|
+
const render = (): void => {
|
|
75
|
+
ctx.ui.setStatus("pi-msb", styleFooterStatus(ctx, frames[frame]));
|
|
76
|
+
frame = (frame + 1) % frames.length;
|
|
77
|
+
};
|
|
78
|
+
render();
|
|
79
|
+
bootAnimation = setInterval(render, 400);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const ensureCustomFooter = (ctx: ExtensionContext): void => {
|
|
83
|
+
if (customFooterInstalled) return;
|
|
84
|
+
ctx.ui.setFooter((tui, theme, footerData) => createMsbFooter(tui, theme, ctx, footerData));
|
|
85
|
+
customFooterInstalled = true;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const updateStatus = (ctx: ExtensionContext, state: RuntimeState): void => {
|
|
89
|
+
const text = footerStatus(state, integration.configRef.value.showFooter);
|
|
90
|
+
if (text !== undefined) ensureCustomFooter(ctx);
|
|
91
|
+
if (text === undefined) {
|
|
92
|
+
stopBootAnimation();
|
|
93
|
+
ctx.ui.setStatus("pi-msb", undefined);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (state.status === "booting") {
|
|
97
|
+
startBootAnimation(ctx);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
stopBootAnimation();
|
|
101
|
+
ctx.ui.setStatus("pi-msb", styleFooterStatus(ctx, text));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
registerSandboxTools(pi, {
|
|
105
|
+
provider: currentProvider,
|
|
106
|
+
config: integration.configRef.value,
|
|
107
|
+
cwd: process.cwd(),
|
|
108
|
+
hostReads,
|
|
109
|
+
createGrepExecute: ({ provider, cwd, grepHelpers }) =>
|
|
110
|
+
createSandboxGrepExecute({ provider, cwd, helpers: grepHelpers }),
|
|
111
|
+
grepHelpers: { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead: truncateHead as (content: string, options?: unknown) => any, truncateLine, formatSize },
|
|
112
|
+
systemPromptNote,
|
|
113
|
+
});
|
|
114
|
+
registerMsbCommand(pi, integration.control);
|
|
115
|
+
|
|
116
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
117
|
+
currentContext = ctx;
|
|
118
|
+
updateStatus(ctx, { status: "booting", info: null });
|
|
119
|
+
try {
|
|
120
|
+
// configureSession performs Git discovery before project config resolution,
|
|
121
|
+
// so trust and the nearest project file are evaluated against the real root.
|
|
122
|
+
const state = await integration.configureSession({
|
|
123
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
124
|
+
cwd: ctx.cwd,
|
|
125
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
126
|
+
restored: undefined,
|
|
127
|
+
});
|
|
128
|
+
lastConfig = integration.control.getEffectiveConfig();
|
|
129
|
+
updateStatus(ctx, state);
|
|
130
|
+
for (const warning of lastConfig.warnings) ctx.ui.notify(`pi-microsandbox: ${warning}`, "warning");
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Invalid config and native boot failures are fail-closed. Do not throw from
|
|
133
|
+
// the lifecycle hook: Pi remains usable and routed tools remain blocked.
|
|
134
|
+
updateStatus(ctx, { status: "unavailable", info: null });
|
|
135
|
+
ctx.ui.notify(`pi-microsandbox unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
140
|
+
stopBootAnimation();
|
|
141
|
+
await integration.manager.shutdown();
|
|
142
|
+
hostReads.clear();
|
|
143
|
+
ctx.ui.setStatus("pi-msb", undefined);
|
|
144
|
+
// Pi tears down extension-owned UI after this hook. Do not call
|
|
145
|
+
// setFooter(undefined): another extension may have replaced our footer.
|
|
146
|
+
currentContext = undefined;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The tools module owns the skill capture hook. This handler only keeps the
|
|
150
|
+
// footer in sync after transitions initiated by commands or reload.
|
|
151
|
+
pi.on("session_info_changed", () => {
|
|
152
|
+
if (currentContext) updateStatus(currentContext, integration.control.getState());
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
void lastConfig;
|
|
156
|
+
}
|