pi-scout 0.1.3 → 0.2.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 CHANGED
@@ -6,6 +6,31 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-09
10
+
11
+ ### Added
12
+
13
+ - Return declared `{ repo }` / `{ removed, deletedClone }` structured results with public repo fields, `scout` namespace discovery, behavior annotations and sequential tool dispatch.
14
+
15
+ ### Changed
16
+
17
+ - Store reference context in the named `scout_repos` prompt section using Pi's structured API. Remove empty sections, preserve unrelated prompt changes, and leave deliberate full-prompt overrides intact.
18
+ - Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
19
+ - Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
20
+
21
+ ### Fixed
22
+
23
+ - Keep origin credentials out of tool text/details/results, inferred URL-based names and Git failure diagnostics; terminate Git options before source arguments.
24
+ - Preserve removal-tool deactivation across prompts/reload and respect explicit default-tool exclusions during conditional registration.
25
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
26
+
27
+ ## [0.1.4] - 2026-07-17
28
+
29
+ ### Fixed
30
+
31
+ - Store temporary clones in a private per-user directory and reject unsafe clone roots.
32
+ - Serialize repository state mutations and persist state through atomic file replacement.
33
+
9
34
  ## [0.1.3] - 2026-07-01
10
35
 
11
36
  ### Changed
@@ -18,13 +43,13 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
18
43
 
19
44
  - Update `author` field to full name for monorepo consistency.
20
45
 
21
- ## [0.1.1] - TBD
46
+ ## [0.1.1] - 2026-05-22
22
47
 
23
48
  ### Added
24
49
 
25
50
  - Add install/update telemetry ping to `mocito.dev`, gated by Pi telemetry/offline settings and disabled in CI.
26
51
 
27
- ## [0.1.0] - TBD
52
+ ## [0.1.0] - 2026-05-21
28
53
 
29
54
  ### Added
30
55
 
package/CONTRIBUTING.md CHANGED
@@ -30,6 +30,14 @@ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
30
30
 
31
31
  ## Pull request checklist
32
32
 
33
+ For prompt changes, also run the shared real-session contracts from the monorepo root:
34
+
35
+ ```bash
36
+ node --import tsx --test tests/structured-prompts.test.mjs
37
+ ```
38
+
39
+ These load both Scout and Skillful through Pi's SDK and inspect serialized provider requests and session history. They use disposable profiles, synthetic credentials, temporary reference directories, and mocked provider responses; they do not contact Git remotes, use a live model, or change existing Scout records. Coverage includes stale-reference pruning, section removal, extension load order, explicit prompt overrides, codemode, reload, resume, fork/tree navigation, and providers that collapse system updates.
40
+
33
41
  Before opening a pull request:
34
42
 
35
43
  - Run `npm run check`.
package/README.md CHANGED
@@ -1,19 +1,21 @@
1
1
  # pi-scout
2
2
 
3
- A source-distributed [Pi](https://pi.dev) package for registering local reference codebases for agent exploration.
3
+ Give [Pi](https://pi.dev) proven codebases to learn from before it changes yours.
4
+
5
+ `pi-scout` clones and registers reference repositories, then exposes their local paths to the agent for fast, tool-native exploration across sessions.
4
6
 
5
7
  > [!WARNING]
6
8
  > Pi packages can execute arbitrary code through extensions. Review package source before installing any third-party Pi package.
7
9
 
8
10
  ## Features
9
11
 
10
- - `/scout` slash command with a simple TUI flow for registering, listing, and removing reference repositories.
11
- - `scout_add` tool for cloning a Git repository into a local temporary cache.
12
- - `scout_rm` tool for agent-driven removal once repositories exist.
13
- - Compact per-turn system prompt guidance with registered repo names and local clone paths.
14
- - Automatic pruning: if the OS cleans a temporary clone, Pi Scout removes that stale entry before adding prompt context.
12
+ - **Reference-driven coding** — let Pi inspect real implementations, conventions, and patterns instead of guessing.
13
+ - **One-step registration** — add Git URLs, local paths, or GitHub `owner/repo` shorthand from `/scout` or natural-language requests.
14
+ - **Fast local exploration** — shallow-clone references into a reusable private cache compatible with Pi's normal file tools.
15
+ - **Cross-session memory** — keep registered references available while their cached clones exist.
16
+ - **Clean context** — tell the agent only which references exist and where to inspect them; stale clones are pruned automatically.
15
17
 
16
- Registered repositories are cloned under `/tmp/pi-scout/<name>-<id>` on Unix-like systems, or the OS temp directory on Windows. 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.
18
+ 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
19
 
18
20
  ## Installation
19
21
 
@@ -42,6 +44,7 @@ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
42
44
  ```
43
45
 
44
46
  This is an npm-compatible TypeScript Pi package. There is no runtime build step.
47
+ Use Pi 1.1.0 or newer and Node.js >=22.19.0 for the structured tool contracts.
45
48
 
46
49
  ## Configuration
47
50
 
@@ -74,6 +77,16 @@ Register https://github.com/owner/repo.git with Pi Scout, then inspect how it im
74
77
 
75
78
  After a repository is registered, the agent sees its local path in the system prompt and can inspect it with local file tools.
76
79
 
80
+ ### Prompt updates
81
+
82
+ Pi Scout uses the structured prompt API in Pi 1.1.0 or newer. It owns the `scout_repos` section and updates that section without replacing the full system prompt or other extensions' sections. When the last reference is removed or its directory disappears, the section is removed on the next prompt.
83
+
84
+ The section contains only repository names, local paths, and read-only usage guidance. Origin URLs, credentials, branch names, and other record metadata are not included.
85
+
86
+ A deliberate full-prompt override from another extension (`systemPrompt` or `forceSystemPrompt`) takes precedence. Scout does not append to or rewrite that override, so its author controls whether reference context is included. Scout tools remain available according to their normal registration rules.
87
+
88
+ Pi records section updates in the session transcript. Providers that do not support mid-conversation system changes may require a full prompt checkpoint; this does not guarantee cache savings.
89
+
77
90
  ## Tools
78
91
 
79
92
  | Tool | Purpose |
@@ -81,12 +94,59 @@ After a repository is registered, the agent sees its local path in the system pr
81
94
  | `scout_add` | Clone and register a Git repository as a local reference codebase. Takes only `source`: Git URL, local path, or GitHub `owner/repo` shorthand. |
82
95
  | `scout_rm` | Remove a repository from Pi Scout records, optionally deleting the temporary clone. Available to the model only while repos are registered. |
83
96
 
97
+ ### Script results and discovery
98
+
99
+ Both tools declare output schemas and return objects, not strings, in codemode:
100
+
101
+ - `scout_add`: `{ repo }`.
102
+ - `scout_rm`: `{ removed: repo | null, deletedClone: boolean }`.
103
+ - `repo`: `{ id, name, path, branch?, createdAt, lastSeenAt }`. Origin/source
104
+ metadata is deliberately excluded from text, structured results, and renderer
105
+ details. Pi still records caller arguments; use Git's credential mechanisms,
106
+ not credentials embedded in `source`.
107
+
108
+ After an explicit registration request:
109
+
110
+ ```js
111
+ const { repo } = await tools.scout_add({ source: "owner/repo" });
112
+ text({ id: repo.id, path: repo.path });
113
+ ```
114
+
115
+ The namespace is `scout`; tool names are unchanged. Await
116
+ `describeNamespace("scout")`, `describeTool("scout_add")`, or
117
+ `searchTools("reference repository", { namespace: "scout" })` for discovery,
118
+ including with zero inline budget. When adding the first reference, start a
119
+ **new codemode call** before discovering/calling `scout_rm`: Pi snapshots the
120
+ callable tools at script start.
121
+
122
+ Clone/process/storage failures throw and reject scripted calls. A missing
123
+ removal target is successful `{ removed: null, deletedClone: false }` data.
124
+ `deletedClone: true` means deletion was requested and the removal completed;
125
+ filesystem errors throw, and state removal may already have happened. These tools
126
+ do not return a structured success object with `isError: true`.
127
+ Human-readable direct results and the `/scout` menu remain available without
128
+ codemode.
129
+
130
+ Both tools run sequentially within Pi's dispatch queue. Await dependent calls;
131
+ this is not a cross-process transaction. Addition is a non-idempotent local
132
+ mutation that may contact a Git host. Removal is destructive, non-idempotent
133
+ (repeated names can match different records), and local-only. These advisory
134
+ hints do not authorize cloning or deletion; existing approval hooks still run.
135
+
136
+ No optional deferred/codemode exposure setting is added. Direct activation,
137
+ CLI exclusions, and conditional removal availability remain in force. Explicit
138
+ `defaultTools: ["-scout_rm"]` suppresses automatic removal-tool activation.
139
+ Manually disabling it is retained across prompts and reload, while repos remain.
140
+ If the repo set becomes empty and later gains a reference, the normal availability
141
+ transition can activate it again. Use a CLI exclusion for a lasting prohibition.
142
+
84
143
  ## Notes
85
144
 
86
145
  - On startup, Pi Scout sends a best-effort install/update telemetry ping once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI.
87
146
  - Pi Scout uses local file access for exploration. It does not provide web search or remote content-fetching tools.
88
147
  - Registering a Git URL still uses `git clone`, so Git may contact the configured remote.
89
148
  - Registered repositories are intended as read-only references unless the user explicitly asks otherwise.
149
+ - Repository state changes are serialized and persisted with atomic file replacement.
90
150
  - The system prompt includes only registered repo names and local paths, not origin URLs or branch metadata.
91
151
 
92
152
  ## Development
@@ -94,5 +154,6 @@ After a repository is registered, the agent sees its local path in the system pr
94
154
  ```bash
95
155
  npm install
96
156
  npm run check
157
+ npm test
97
158
  npm run pack:dry-run
98
159
  ```
package/SECURITY.md CHANGED
@@ -23,6 +23,21 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
23
23
 
24
24
  Do not commit API keys, tokens, credentials, local settings, or machine-specific paths.
25
25
 
26
- On startup, the extension sends a best-effort install/update telemetry ping to `mocito.dev` once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, repository sources, clone paths, config values, or API keys.
26
+ On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, repository sources, clone paths, config values, or API keys.
27
27
 
28
28
  `pi-scout` stores repository records under Pi's agent directory and clones registered repositories into the OS temporary directory. It does not provide web search or content-fetching tools, but registering a Git URL uses `git clone`, which may contact the configured remote. Registered repository paths are appended to the system prompt so the agent can inspect them with local file tools.
29
+
30
+ Direct tool text, structured results and renderer details expose only repository
31
+ IDs, names, local paths, optional branches and timestamps—not origin/source
32
+ metadata. URL userinfo/query/fragment are not used to infer public clone names.
33
+ Git receives arguments without a shell and with an option terminator; clone
34
+ failures do not echo stderr or remote response bodies. This does not remove
35
+ caller-supplied origins from Pi's tool-call transcript, Scout's private records,
36
+ the local `/scout` listing, or Git configuration. Prefer Git's credential
37
+ mechanisms to embedding credentials in URLs.
38
+
39
+ Scout tool calls run sequentially within one Pi dispatcher; atomic state writes
40
+ remain separate from clone creation/deletion and are not a cross-process
41
+ transaction. Namespaces and behavior hints are not authorization. Registration
42
+ can contact remote hosts; removal can delete a clone when explicitly requested.
43
+ Normal approval hooks and tool exclusions remain effective.
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
2
2
  import { Type } from "typebox";
3
3
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
4
4
  import { buildScoutPrompt, formatRepo, loadPrunedState, registerRepo, removeRepo } from "../src/index.js";
5
+ import { ADD_REPO_OUTPUT, REMOVE_REPO_OUTPUT, SCOUT_NAMESPACE, formatPublicRepo, publicRepo } from "../src/tool-contracts.js";
5
6
 
6
7
  const RegisterRepoParams = Type.Object({
7
8
  source: Type.String({ description: "Git URL/path or owner/repo." }),
@@ -16,6 +17,7 @@ export default function piScout(pi: ExtensionAPI) {
16
17
  reportInstallTelemetry();
17
18
 
18
19
  let scoutRmRegistered = false;
20
+ let hadRepos = false;
19
21
 
20
22
  function setToolActive(name: string, active: boolean): void {
21
23
  const activeTools = pi.getActiveTools();
@@ -24,11 +26,20 @@ export default function piScout(pi: ExtensionAPI) {
24
26
  if (!active && hasTool) pi.setActiveTools(activeTools.filter((tool) => tool !== name));
25
27
  }
26
28
 
27
- async function syncScoutRmTool(): Promise<void> {
29
+ async function syncScoutRmTool(restoring = false): Promise<void> {
28
30
  const hasRepos = (await loadPrunedState()).repos.length > 0;
31
+ const selection = pi.getSettings?.().defaultTools;
32
+ const disabled = Array.isArray(selection)
33
+ && selection.filter(entry => entry === "+scout_rm" || entry === "-scout_rm").at(-1) === "-scout_rm";
29
34
  if (hasRepos && !scoutRmRegistered) {
30
35
  pi.registerTool({
31
36
  name: "scout_rm",
37
+ namespace: SCOUT_NAMESPACE,
38
+ outputSchema: REMOVE_REPO_OUTPUT,
39
+ executionMode: "sequential",
40
+ defaultActive: !restoring && !disabled,
41
+ // Repeating a name may remove another clone with that name.
42
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
32
43
  label: "Scout Remove",
33
44
  description: "Remove a Scout repo record.",
34
45
  promptSnippet: "Remove Scout repo records.",
@@ -40,19 +51,26 @@ export default function piScout(pi: ExtensionAPI) {
40
51
  const params = rawParams as { idOrName: string; deleteClone?: boolean };
41
52
  const removed = await removeRepo(params.idOrName, { deleteClone: params.deleteClone });
42
53
  await syncScoutRmTool();
54
+ const payload = { removed: removed ? publicRepo(removed) : null, deletedClone: Boolean(params.deleteClone && removed) };
43
55
  const text = removed
44
- ? `Removed Pi Scout repository from records:\n${formatRepo(removed)}\n\nLocal clone ${params.deleteClone ? "deleted" : "was not deleted"}.`
45
- : `No Pi Scout repository matched "${params.idOrName}".`;
46
- return { content: [{ type: "text", text }], details: { removed, deletedClone: Boolean(params.deleteClone && removed) } };
56
+ ? `Removed Pi Scout repository from records:\n${formatPublicRepo(removed)}\n\nLocal clone ${params.deleteClone ? "deleted" : "was not deleted"}.`
57
+ : "No Pi Scout repository matched.";
58
+ return { content: [{ type: "text", text }], structuredContent: payload, details: payload };
47
59
  },
48
60
  });
49
61
  scoutRmRegistered = true;
50
62
  }
51
- if (scoutRmRegistered) setToolActive("scout_rm", hasRepos);
63
+ if (scoutRmRegistered) {
64
+ if (!hasRepos) setToolActive("scout_rm", false);
65
+ // Do not reactivate a manually disabled tool on every prompt. On reload,
66
+ // Pi restores pending active names; this extension only restores availability.
67
+ else if (!hadRepos && !restoring && !disabled) setToolActive("scout_rm", true);
68
+ }
69
+ hadRepos = hasRepos;
52
70
  }
53
71
 
54
- pi.on("session_start", async () => {
55
- await syncScoutRmTool();
72
+ pi.on("session_start", async (event) => {
73
+ await syncScoutRmTool(event.reason === "reload");
56
74
  });
57
75
 
58
76
  pi.registerCommand("scout", {
@@ -64,6 +82,10 @@ export default function piScout(pi: ExtensionAPI) {
64
82
 
65
83
  pi.registerTool({
66
84
  name: "scout_add",
85
+ namespace: SCOUT_NAMESPACE,
86
+ outputSchema: ADD_REPO_OUTPUT,
87
+ executionMode: "sequential",
88
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
67
89
  label: "Scout Add",
68
90
  description: "Clone/register a reference repo.",
69
91
  promptSnippet: "Add Scout reference repos.",
@@ -76,19 +98,20 @@ export default function piScout(pi: ExtensionAPI) {
76
98
  const repo = await registerRepo(pi, { source: params.source, signal });
77
99
  await syncScoutRmTool();
78
100
  return {
79
- content: [{ type: "text", text: `Registered Pi Scout repository:\n${formatRepo(repo)}` }],
80
- details: { repo },
101
+ content: [{ type: "text", text: `Registered Pi Scout repository:\n${formatPublicRepo(repo)}` }],
102
+ structuredContent: { repo: publicRepo(repo) },
103
+ details: { repo: publicRepo(repo) },
81
104
  };
82
105
  },
83
106
  });
84
107
 
85
-
86
108
  pi.on("before_agent_start", async (event) => {
87
109
  await syncScoutRmTool();
88
110
  const state = await loadPrunedState();
89
111
  const scoutPrompt = buildScoutPrompt(state.repos);
90
- if (!scoutPrompt) return;
91
- return { systemPrompt: `${event.systemPrompt}\n\n${scoutPrompt}` };
112
+ // Change only our section; a deliberate forceSystemPrompt remains authoritative.
113
+ if (scoutPrompt) event.systemPromptOptions.sections.scout_repos = scoutPrompt;
114
+ else delete event.systemPromptOptions.sections.scout_repos;
92
115
  });
93
116
  }
94
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-scout",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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,15 +52,19 @@
51
52
  "typebox": "*"
52
53
  },
53
54
  "devDependencies": {
54
- "@earendil-works/pi-coding-agent": "^0.80.0",
55
- "@types/node": "^25.9.3",
56
- "typebox": "^1.2.10",
57
- "typescript": "^6.0.3"
55
+ "@earendil-works/pi-coding-agent": "1.1.0",
56
+ "@types/node": "^26.6.3",
57
+ "tsx": "^4.23.15",
58
+ "typebox": "^1.3.34",
59
+ "typescript": "^7.0.2"
58
60
  },
59
61
  "publishConfig": {
60
62
  "access": "public"
61
63
  },
62
64
  "engines": {
63
- "node": ">=20.6.0"
65
+ "node": ">=22.19.0"
66
+ },
67
+ "dependencies": {
68
+ "@mocito/install-telemetry": "0.1.1"
64
69
  }
65
70
  }
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const PACKAGE_NAME = "pi-scout";
8
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
9
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
10
9
  const CI_ENVIRONMENT_VARIABLES = [
11
10
  "APPVEYOR",
12
11
  "BITBUCKET_BUILD_NUMBER",
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
24
23
  "VERCEL",
25
24
  ];
26
25
 
27
- interface InstallTelemetryState {
28
- lastReportedVersion?: string;
29
- }
30
-
31
26
  interface PiSettingsDocument {
32
27
  enableInstallTelemetry?: unknown;
33
28
  }
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
51
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
52
47
  }
53
48
 
54
- function isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
57
- }
58
-
59
- function isInstallTelemetryEnabled(): boolean {
60
- if (isCiEnvironment()) return false;
61
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
62
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
63
53
 
64
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
66
58
  }
67
59
 
68
60
  function getPackageVersion(): string {
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
70
62
  return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
71
63
  }
72
64
 
73
- function getInstallTelemetryUserAgent(version: string): string {
74
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
75
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
76
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
77
- }
78
-
79
- async function reportInstallTelemetryAsync(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
80
66
  try {
81
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "pi-scout-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
88
-
89
- await mkdir(extensionsDir, { recursive: true });
90
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
91
-
92
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
93
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
94
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
95
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
96
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "pi-scout-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
97
74
  } catch {
98
- // Best-effort telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }
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 { loadPrunedState, saveState, type ScoutRepo } from "./state.js";
6
+ import { mutateState, type ScoutRepo } from "./state.js";
7
7
 
8
8
  export interface RegisterRepoOptions {
9
9
  source: string;
@@ -21,19 +21,28 @@ 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 mkdir(root, { recursive: true });
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());
28
29
  const depth = options.depth && Number.isInteger(options.depth) && options.depth > 0 ? options.depth : 1;
29
30
  args.push("--depth", String(depth));
30
- args.push(source, destination);
31
-
32
- const result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
31
+ args.push("--", source, destination);
32
+
33
+ let result;
34
+ try {
35
+ result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
36
+ } catch {
37
+ await rm(destination, { recursive: true, force: true });
38
+ throw new Error(options.signal?.aborted ? "Git clone cancelled." : "Git clone could not run. Check Git and authentication.");
39
+ }
33
40
  if (result.code !== 0) {
34
- const stderr = result.stderr?.trim() || result.stdout?.trim() || "git clone failed";
35
- throw new Error(stderr);
41
+ await rm(destination, { recursive: true, force: true });
42
+ // Git can echo origin URLs, credentials or arbitrary remote output.
43
+ throw new Error(`Git clone failed (exit code ${result.code}). Check the source and Git authentication.`);
36
44
  }
45
+ if (platform() !== "win32") await chmod(destination, 0o700);
37
46
 
38
47
  const now = new Date().toISOString();
39
48
  const repo: ScoutRepo = {
@@ -46,9 +55,9 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
46
55
  lastSeenAt: now,
47
56
  };
48
57
 
49
- const state = await loadPrunedState();
50
- state.repos.push(repo);
51
- await saveState(state);
58
+ await mutateState((state) => {
59
+ state.repos.push(repo);
60
+ });
52
61
  return repo;
53
62
  }
54
63
 
@@ -56,12 +65,11 @@ export async function removeRepo(idOrName: string, options: { deleteClone?: bool
56
65
  const needle = idOrName.trim();
57
66
  if (!needle) return undefined;
58
67
 
59
- const state = await loadPrunedState();
60
- const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
61
- if (index === -1) return undefined;
62
-
63
- const [removed] = state.repos.splice(index, 1);
64
- await saveState(state);
68
+ const removed = await mutateState((state) => {
69
+ const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
70
+ if (index === -1) return undefined;
71
+ return state.repos.splice(index, 1)[0];
72
+ });
65
73
 
66
74
  if (options.deleteClone && removed) {
67
75
  await rm(removed.path, { recursive: true, force: true });
@@ -75,16 +83,41 @@ export function formatRepo(repo: ScoutRepo): string {
75
83
  return `${repo.name}${branch}\n id: ${repo.id}\n source: ${repo.source}\n path: ${repo.path}`;
76
84
  }
77
85
 
78
- function getScoutCloneRoot(): string {
79
- if (process.env.PI_SCOUT_TMPDIR) return join(process.env.PI_SCOUT_TMPDIR, "pi-scout");
80
- if (platform() !== "win32") return "/tmp/pi-scout";
81
- return join(tmpdir(), "pi-scout");
86
+ export function getScoutCloneRoot(): string {
87
+ const parent = process.env.PI_SCOUT_TMPDIR || tmpdir();
88
+ if (platform() === "win32") return join(parent, "pi-scout");
89
+ return join(parent, `pi-scout-${getCurrentUid()}`);
90
+ }
91
+
92
+ export async function ensurePrivateCloneRoot(root: string): Promise<void> {
93
+ await mkdir(root, { recursive: true, mode: 0o700 });
94
+ if (platform() === "win32") return;
95
+
96
+ const info = await lstat(root);
97
+ if (!info.isDirectory() || info.isSymbolicLink()) {
98
+ throw new Error(`Unsafe Pi Scout clone root: ${root} is not a real directory.`);
99
+ }
100
+ if (info.uid !== getCurrentUid()) {
101
+ throw new Error(`Unsafe Pi Scout clone root: ${root} is not owned by current user.`);
102
+ }
103
+ await chmod(root, 0o700);
104
+ }
105
+
106
+ function getCurrentUid(): number {
107
+ if (!process.getuid) throw new Error("Pi Scout cannot determine current user ID.");
108
+ return process.getuid();
82
109
  }
83
110
 
84
111
  function inferName(source: string): string {
85
112
  const shorthand = parseGitHubShorthand(source);
86
113
  if (shorthand) return shorthand.repo;
87
114
 
115
+ // URL userinfo/query/fragment may contain credentials. Never use them as a
116
+ // public name or destination basename, including origins without a path.
117
+ if (source.includes("://")) {
118
+ try { source = new URL(source).pathname; }
119
+ catch { return "repo"; }
120
+ }
88
121
  const withoutTrailingSlash = source.replace(/[\\/]+$/, "");
89
122
  const last = basename(withoutTrailingSlash).replace(/\.git$/i, "");
90
123
  return last || "repo";
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 { getAgentDir } from "@earendil-works/pi-coding-agent";
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
- await writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, "utf8");
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
- const next = { repos };
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 (await pruneMissingRepos(await loadState())).state;
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> {
@@ -0,0 +1,30 @@
1
+ import { Type } from "typebox";
2
+ import type { ScoutRepo } from "./state.js";
3
+
4
+ export const SCOUT_NAMESPACE = {
5
+ name: "scout",
6
+ description: "Register and remove local read-only reference clones.",
7
+ instructions: "scout_add returns { repo }; scout_rm returns { removed: repo|null, deletedClone: boolean }. Public repo fields exclude origin/source metadata and credentials. Clone/process/storage failures reject; a missing removal target is successful data with removed:null. Both operations mutate state and run sequentially. Await dependent operations. After the first registration, start a new codemode call to discover/use scout_rm: the running script has a snapshot of callable tools. scout_add may access remote Git hosts. scout_rm can delete a clone when requested; use it only with user authorization. Repositories remain read-only references unless the user requests edits. Availability follows the active tool set; scout_rm is present only while references exist.",
8
+ };
9
+
10
+ const repo = Type.Object({
11
+ id: Type.String(), name: Type.String(), path: Type.String(),
12
+ branch: Type.Optional(Type.String()), createdAt: Type.String(), lastSeenAt: Type.String(),
13
+ }, { additionalProperties: false });
14
+ export const ADD_REPO_OUTPUT = Type.Object({ repo }, { additionalProperties: false });
15
+ export const REMOVE_REPO_OUTPUT = Type.Object({
16
+ removed: Type.Union([repo, Type.Null()]), deletedClone: Type.Boolean(),
17
+ }, { additionalProperties: false });
18
+
19
+ export function publicRepo(value: ScoutRepo) {
20
+ return {
21
+ id: value.id, name: value.name, path: value.path,
22
+ ...(value.branch ? { branch: value.branch } : {}),
23
+ createdAt: value.createdAt, lastSeenAt: value.lastSeenAt,
24
+ };
25
+ }
26
+
27
+ export function formatPublicRepo(value: ScoutRepo): string {
28
+ const repo = publicRepo(value);
29
+ return `${repo.name}${repo.branch ? ` (${repo.branch})` : ""}\n id: ${repo.id}\n path: ${repo.path}`;
30
+ }