pi-scout 0.1.2 → 0.1.4
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 +13 -0
- package/README.md +3 -1
- package/package.json +6 -4
- package/src/repo.ts +37 -16
- package/src/state.ts +29 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 - 2026-07-17
|
|
4
|
+
|
|
3
5
|
All notable changes to this project will be documented in this file.
|
|
4
6
|
|
|
5
7
|
This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning for releases.
|
|
6
8
|
|
|
7
9
|
## [Unreleased]
|
|
8
10
|
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Store temporary clones in a private per-user directory and reject unsafe clone roots.
|
|
14
|
+
- Serialize repository state mutations and persist state through atomic file replacement.
|
|
15
|
+
|
|
16
|
+
## [0.1.3] - 2026-07-01
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Update Pi core development dependency for Pi 0.80 compatibility.
|
|
21
|
+
|
|
9
22
|
## [0.1.2] - 2026-06-06
|
|
10
23
|
|
|
11
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A source-distributed [Pi](https://pi.dev) package for registering local referenc
|
|
|
13
13
|
- Compact per-turn system prompt guidance with registered repo names and local clone paths.
|
|
14
14
|
- Automatic pruning: if the OS cleans a temporary clone, Pi Scout removes that stale entry before adding prompt context.
|
|
15
15
|
|
|
16
|
-
Registered repositories are cloned under
|
|
16
|
+
Registered repositories are cloned in a private, per-user directory under the OS temp directory (`<temp>/pi-scout-<uid>` on Unix-like systems). Root and clone permissions are restricted to the current user on Unix. Set `PI_SCOUT_TMPDIR` to override the parent temp directory. Pi Scout uses shallow clones with depth `1` by default because it is for code exploration, not history exploration. Pi Scout keeps records in Pi's agent directory and reuses them across sessions while the cloned directories still exist.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -87,6 +87,7 @@ After a repository is registered, the agent sees its local path in the system pr
|
|
|
87
87
|
- Pi Scout uses local file access for exploration. It does not provide web search or remote content-fetching tools.
|
|
88
88
|
- Registering a Git URL still uses `git clone`, so Git may contact the configured remote.
|
|
89
89
|
- Registered repositories are intended as read-only references unless the user explicitly asks otherwise.
|
|
90
|
+
- Repository state changes are serialized and persisted with atomic file replacement.
|
|
90
91
|
- The system prompt includes only registered repo names and local paths, not origin URLs or branch metadata.
|
|
91
92
|
|
|
92
93
|
## Development
|
|
@@ -94,5 +95,6 @@ After a repository is registered, the agent sees its local path in the system pr
|
|
|
94
95
|
```bash
|
|
95
96
|
npm install
|
|
96
97
|
npm run check
|
|
98
|
+
npm test
|
|
97
99
|
npm run pack:dry-run
|
|
98
100
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-scout",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Register local reference codebases for Pi agent exploration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"scripts": {
|
|
45
45
|
"check": "tsc --noEmit",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "node --import tsx --test tests/*.test.mjs",
|
|
47
48
|
"pack:dry-run": "npm pack --dry-run"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
@@ -51,9 +52,10 @@
|
|
|
51
52
|
"typebox": "*"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
55
|
-
"@types/node": "^
|
|
56
|
-
"
|
|
55
|
+
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
56
|
+
"@types/node": "^26.1.0",
|
|
57
|
+
"tsx": "^4.23.0",
|
|
58
|
+
"typebox": "^1.3.4",
|
|
57
59
|
"typescript": "^6.0.3"
|
|
58
60
|
},
|
|
59
61
|
"publishConfig": {
|
package/src/repo.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { chmod, lstat, mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { platform, tmpdir } from "node:os";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import { mutateState, type ScoutRepo } from "./state.js";
|
|
7
7
|
|
|
8
8
|
export interface RegisterRepoOptions {
|
|
9
9
|
source: string;
|
|
@@ -21,7 +21,8 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
|
|
|
21
21
|
const name = sanitizeName(options.name?.trim() || inferName(source) || `repo-${id}`);
|
|
22
22
|
const root = getScoutCloneRoot();
|
|
23
23
|
const destination = join(root, `${name}-${id}`);
|
|
24
|
-
await
|
|
24
|
+
await ensurePrivateCloneRoot(root);
|
|
25
|
+
await mkdir(destination, { mode: 0o700 });
|
|
25
26
|
|
|
26
27
|
const args = ["clone"];
|
|
27
28
|
if (options.branch?.trim()) args.push("--branch", options.branch.trim());
|
|
@@ -31,9 +32,11 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
|
|
|
31
32
|
|
|
32
33
|
const result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
|
|
33
34
|
if (result.code !== 0) {
|
|
35
|
+
await rm(destination, { recursive: true, force: true });
|
|
34
36
|
const stderr = result.stderr?.trim() || result.stdout?.trim() || "git clone failed";
|
|
35
37
|
throw new Error(stderr);
|
|
36
38
|
}
|
|
39
|
+
if (platform() !== "win32") await chmod(destination, 0o700);
|
|
37
40
|
|
|
38
41
|
const now = new Date().toISOString();
|
|
39
42
|
const repo: ScoutRepo = {
|
|
@@ -46,9 +49,9 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
|
|
|
46
49
|
lastSeenAt: now,
|
|
47
50
|
};
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
await mutateState((state) => {
|
|
53
|
+
state.repos.push(repo);
|
|
54
|
+
});
|
|
52
55
|
return repo;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -56,12 +59,11 @@ export async function removeRepo(idOrName: string, options: { deleteClone?: bool
|
|
|
56
59
|
const needle = idOrName.trim();
|
|
57
60
|
if (!needle) return undefined;
|
|
58
61
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await saveState(state);
|
|
62
|
+
const removed = await mutateState((state) => {
|
|
63
|
+
const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
|
|
64
|
+
if (index === -1) return undefined;
|
|
65
|
+
return state.repos.splice(index, 1)[0];
|
|
66
|
+
});
|
|
65
67
|
|
|
66
68
|
if (options.deleteClone && removed) {
|
|
67
69
|
await rm(removed.path, { recursive: true, force: true });
|
|
@@ -75,10 +77,29 @@ export function formatRepo(repo: ScoutRepo): string {
|
|
|
75
77
|
return `${repo.name}${branch}\n id: ${repo.id}\n source: ${repo.source}\n path: ${repo.path}`;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
function getScoutCloneRoot(): string {
|
|
79
|
-
|
|
80
|
-
if (platform()
|
|
81
|
-
return join(
|
|
80
|
+
export function getScoutCloneRoot(): string {
|
|
81
|
+
const parent = process.env.PI_SCOUT_TMPDIR || tmpdir();
|
|
82
|
+
if (platform() === "win32") return join(parent, "pi-scout");
|
|
83
|
+
return join(parent, `pi-scout-${getCurrentUid()}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function ensurePrivateCloneRoot(root: string): Promise<void> {
|
|
87
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
88
|
+
if (platform() === "win32") return;
|
|
89
|
+
|
|
90
|
+
const info = await lstat(root);
|
|
91
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
92
|
+
throw new Error(`Unsafe Pi Scout clone root: ${root} is not a real directory.`);
|
|
93
|
+
}
|
|
94
|
+
if (info.uid !== getCurrentUid()) {
|
|
95
|
+
throw new Error(`Unsafe Pi Scout clone root: ${root} is not owned by current user.`);
|
|
96
|
+
}
|
|
97
|
+
await chmod(root, 0o700);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getCurrentUid(): number {
|
|
101
|
+
if (!process.getuid) throw new Error("Pi Scout cannot determine current user ID.");
|
|
102
|
+
return process.getuid();
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
function inferName(source: string): string {
|
package/src/state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
export interface ScoutRepo {
|
|
6
7
|
id: string;
|
|
@@ -36,7 +37,25 @@ export async function loadState(): Promise<ScoutState> {
|
|
|
36
37
|
|
|
37
38
|
export async function saveState(state: ScoutState): Promise<void> {
|
|
38
39
|
await mkdir(STATE_DIR, { recursive: true });
|
|
39
|
-
|
|
40
|
+
const temporaryPath = join(dirname(STATE_PATH), `.${basename(STATE_PATH)}.${randomUUID()}.tmp`);
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
|
+
await rename(temporaryPath, STATE_PATH);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function mutateState<T>(
|
|
51
|
+
mutation: (state: ScoutState) => Promise<T> | T,
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
return withFileMutationQueue(STATE_PATH, async () => {
|
|
54
|
+
const state = (await pruneMissingRepos(await loadState())).state;
|
|
55
|
+
const result = await mutation(state);
|
|
56
|
+
await saveState(state);
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
export async function pruneMissingRepos(state: ScoutState): Promise<{ state: ScoutState; removed: ScoutRepo[] }> {
|
|
@@ -52,13 +71,15 @@ export async function pruneMissingRepos(state: ScoutState): Promise<{ state: Sco
|
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
if (removed.length > 0) await saveState(next);
|
|
57
|
-
return { state: next, removed };
|
|
74
|
+
return { state: { repos }, removed };
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
export async function loadPrunedState(): Promise<ScoutState> {
|
|
61
|
-
return (
|
|
78
|
+
return withFileMutationQueue(STATE_PATH, async () => {
|
|
79
|
+
const { state, removed } = await pruneMissingRepos(await loadState());
|
|
80
|
+
if (removed.length > 0) await saveState(state);
|
|
81
|
+
return state;
|
|
82
|
+
});
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
async function pathExists(path: string): Promise<boolean> {
|