pi-roundtable-coding 0.32.1 → 0.33.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 +7 -0
- package/README.md +9 -6
- package/package.json +3 -3
- package/src/coding-plugin.ts +19 -1
- package/src/index.ts +3 -2
- package/src/pi-coding-worker.ts +64 -4
- package/src/repo-shelf.ts +107 -27
- package/src/worker-entry.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.33.0] - 2026-11-04
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- The worker process no longer inherits the host's environment: it gets the shell's whitelist (`SHELL_ENV_NAMES`), `PI_CODING_AGENT_DIR`, `TMPDIR`, the variables its model may read, found by the new `modelEnvNames(agentDir)` (each `$VAR` a `models.json` in Pi's agent dir names, and `CLAUDE_*` and `ANTHROPIC_*`), and the names the new option `workerEnv: { pass? }` lists (type `WorkerEnv`). Its `bash` gets only the whitelist and the host's `shellEnv`, never the model's credentials, and its tool results are masked like the host's. **Migration:** a worker on Pi's `auth.json` login, a `models.json` `$VAR` key, or a Claude Code or Anthropic variable needs nothing; a model that reads another variable needs it named, as in `workerEnv: { pass: ["OPENAI_API_KEY"] }`.
|
|
10
|
+
- The shelf's git and gh calls (clone, fetch, push, `repo_list` fetch) get the whitelist, git's config, identity and ssh variables, and a GitHub token as `GH_TOKEN` from the new option `githubToken` (a string or a function; type `GithubToken`), by default the host's `GH_TOKEN` read when the plugin sets up, so existing hosts keep working. No other host secret reaches them.
|
|
11
|
+
|
|
5
12
|
## [0.32.1] - 2026-11-04
|
|
6
13
|
|
|
7
14
|
- Release in lockstep with pi-roundtable 0.32.1 (optimistic mark-as-read, a Home button at the foot of the room drawer, Discord's -# subtext and line breaks in messages, the shared prompt's description in zh-TW, and the push device list centred); no package-specific behavior changes.
|
package/README.md
CHANGED
|
@@ -7,18 +7,19 @@ This package is a reference for the plugin guide's [Helpers for a Pi session of
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
9
9
|
- Bun 1.4.4 or newer on a POSIX host (Linux or macOS).
|
|
10
|
-
- pi-roundtable `>=0.
|
|
10
|
+
- pi-roundtable `>=0.33.0 <0.34.0` as a peer dependency; this package uses only its public main, kit and testing entries.
|
|
11
11
|
- Pi `>=1.0.0 <2`, shared with the host's core dependencies.
|
|
12
12
|
A core-only host still pinned to Pi 0.99.x must update its Pi dependencies before adding coding, so cross-package extension and session types resolve to one Pi version.
|
|
13
13
|
- Git, plus GitHub CLI (`gh`) for the default clone implementation.
|
|
14
14
|
- A Git host token with read access to repositories being cloned and write access to those being pushed, or a host login holding that token.
|
|
15
|
-
- Pi model credentials available through the host's Pi login or
|
|
15
|
+
- Pi model credentials available through the host's Pi login, a `models.json` `$VAR`, a `CLAUDE_*` or `ANTHROPIC_*` variable, or another variable named in `workerEnv.pass`.
|
|
16
16
|
|
|
17
|
-
The default clone uses `gh repo clone owner/repo`
|
|
18
|
-
Configure Git's credential helper for later `git fetch` and `git push` (for GitHub, `gh auth setup-git`)
|
|
19
|
-
|
|
17
|
+
The default clone uses `gh repo clone owner/repo`, which authenticates with `githubToken` given to it as `GH_TOKEN` (by default the host's `GH_TOKEN`, read when the plugin sets up) or with the host's `gh auth login` credentials.
|
|
18
|
+
Configure Git's credential helper for later `git fetch` and `git push` (for GitHub, `gh auth setup-git`); those git calls get the same `GH_TOKEN`.
|
|
19
|
+
The shelf's git and gh calls get the shell's environment whitelist (`SHELL_ENV_NAMES`), git's own config, identity and ssh variables, and that token, and no other host secret.
|
|
20
|
+
Use a least-privilege token and restrict its repositories; tokens are never tool arguments or remote URLs, and prefer a `githubToken` function that reads it from where you keep it to a literal string.
|
|
20
21
|
The package does not print subprocess output or Git authentication diagnostics and never logs credentials.
|
|
21
|
-
The coding worker
|
|
22
|
+
The coding worker process gets the shell's whitelist, `PI_CODING_AGENT_DIR`, the variables its model may read (`modelEnvNames`: each `$VAR` its agent dir's `models.json` names, `CLAUDE_*` and `ANTHROPIC_*`) and the names in `workerEnv.pass`; its `bash` gets only the whitelist and the host's `shellEnv`, never the model's credentials. It can still read files accessible to that host user: do not give it credentials you would not give a trusted coding agent.
|
|
22
23
|
A custom `clone(repo, destination)` can use another Git host's CLI, credential helper or SSH login, and must create a Git clone with an `origin` remote.
|
|
23
24
|
No network calls are needed by the offline test suite.
|
|
24
25
|
|
|
@@ -218,6 +219,8 @@ The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trust
|
|
|
218
219
|
| `skipUnavailableCarriedSkills` | `false` | Skip and disclose unavailable implicit skills; explicit skill requests still refuse them. |
|
|
219
220
|
| `workerPackages` | `[]` | Absolute installed extension paths. |
|
|
220
221
|
| `agentDir` | Pi host directory | Pi credentials and model configuration. |
|
|
222
|
+
| `workerEnv` | `{}` | `{ pass? }`: more host variables the worker process gets for its model, beyond its `models.json` `$VAR`s, `CLAUDE_*` and `ANTHROPIC_*`; the worker's shell never gets them. |
|
|
223
|
+
| `githubToken` | host `GH_TOKEN`, read at setup | A string, or a function read at each call, given as `GH_TOKEN` to the shelf's git and gh calls only (clone, fetch, push); a function returning undefined leaves gh to its own login. |
|
|
221
224
|
| `timeoutMs` | `3600000` | Work-time limit in ms, excluding owner wait; 0 < value <= 2147483647. |
|
|
222
225
|
| `maxWorkers` | `4` | Workers running at once on the host; a whole number of at least 1. |
|
|
223
226
|
| `maxQueued` | `16` | Tasks waiting for a worker slot; a whole number of at least 0. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-coding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Repository shelves and owner-approved Pi coding workers for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"typebox": "1.3.37"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"pi-roundtable": ">=0.
|
|
41
|
+
"pi-roundtable": ">=0.33.0 <0.34.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
45
45
|
"@biomejs/biome": "2.5.15",
|
|
46
46
|
"@types/bun": "1.4.3",
|
|
47
|
-
"pi-roundtable": "0.
|
|
47
|
+
"pi-roundtable": "0.33.0",
|
|
48
48
|
"typescript": "7.0.2"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/coding-plugin.ts
CHANGED
|
@@ -34,10 +34,11 @@ import {
|
|
|
34
34
|
codingReport,
|
|
35
35
|
MAX_CODING_TASK_CHARS,
|
|
36
36
|
} from "./coding-desk.ts";
|
|
37
|
-
import { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
37
|
+
import { PiCodingWorker, type WorkerEnv } from "./pi-coding-worker.ts";
|
|
38
38
|
import {
|
|
39
39
|
type ChangeReport,
|
|
40
40
|
type CloneCommand,
|
|
41
|
+
type GithubToken,
|
|
41
42
|
RepoShelf,
|
|
42
43
|
type RepoSummary,
|
|
43
44
|
reportPost,
|
|
@@ -129,6 +130,18 @@ export interface CodingOptions {
|
|
|
129
130
|
/** Absolute installed extension package paths. Default: none. */
|
|
130
131
|
workerPackages?: string[];
|
|
131
132
|
agentDir?: string;
|
|
133
|
+
/**
|
|
134
|
+
* More host variables the worker process gets for its model, by name; its shell never gets them.
|
|
135
|
+
* Without it the worker already gets the shell's whitelist, `PI_CODING_AGENT_DIR`, the `$VAR`s
|
|
136
|
+
* its agent dir's `models.json` names, and `CLAUDE_*` and `ANTHROPIC_*`.
|
|
137
|
+
*/
|
|
138
|
+
workerEnv?: WorkerEnv;
|
|
139
|
+
/**
|
|
140
|
+
* The token git and gh calls of the shelf (clone, fetch, push) get as GH_TOKEN, or a function
|
|
141
|
+
* read at each call; no other host secret reaches them. Default: the host's GH_TOKEN, read when
|
|
142
|
+
* the plugin sets up.
|
|
143
|
+
*/
|
|
144
|
+
githubToken?: GithubToken;
|
|
132
145
|
timeoutMs?: number;
|
|
133
146
|
/** Workers running at once on the host; default 4. More tasks wait in a queue. */
|
|
134
147
|
maxWorkers?: number;
|
|
@@ -230,6 +243,9 @@ export function coding(options: CodingOptions) {
|
|
|
230
243
|
setup(context) {
|
|
231
244
|
const shelf = new RepoShelf(options.shelfDir, options.clone, {
|
|
232
245
|
diagnosticChars: options.diagnosticChars,
|
|
246
|
+
...(options.githubToken !== undefined
|
|
247
|
+
? { githubToken: options.githubToken }
|
|
248
|
+
: {}),
|
|
233
249
|
});
|
|
234
250
|
for (const adoption of options.adoptClones ?? [])
|
|
235
251
|
shelf.adopt(adoption.from, adoption.repo);
|
|
@@ -243,6 +259,8 @@ export function coding(options: CodingOptions) {
|
|
|
243
259
|
...(context.env.scratchDir
|
|
244
260
|
? { scratchDir: context.env.scratchDir }
|
|
245
261
|
: {}),
|
|
262
|
+
...(options.workerEnv ? { workerEnv: options.workerEnv } : {}),
|
|
263
|
+
...(context.env.shellEnv ? { shellEnv: context.env.shellEnv } : {}),
|
|
246
264
|
prompt: options.workerPrompt,
|
|
247
265
|
blockText: options.workerBlockText,
|
|
248
266
|
diagnosticChars: options.diagnosticChars,
|
package/src/index.ts
CHANGED
|
@@ -23,11 +23,12 @@ export type {
|
|
|
23
23
|
RepoToolName,
|
|
24
24
|
} from "./coding-plugin.ts";
|
|
25
25
|
export { CODING, coding } from "./coding-plugin.ts";
|
|
26
|
-
export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
|
|
27
|
-
export { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
26
|
+
export type { PiCodingWorkerOptions, WorkerEnv } from "./pi-coding-worker.ts";
|
|
27
|
+
export { modelEnvNames, PiCodingWorker } from "./pi-coding-worker.ts";
|
|
28
28
|
export type {
|
|
29
29
|
ChangeReport,
|
|
30
30
|
CloneCommand,
|
|
31
|
+
GithubToken,
|
|
31
32
|
Integration,
|
|
32
33
|
RepoState,
|
|
33
34
|
RepoSummary,
|
package/src/pi-coding-worker.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type { HeldCall, HoldCheck } from "pi-roundtable";
|
|
5
|
+
import type { HeldCall, HoldCheck, ShellEnv } from "pi-roundtable";
|
|
4
6
|
import {
|
|
5
7
|
AgentError,
|
|
6
8
|
canonicalJson,
|
|
9
|
+
childEnv,
|
|
7
10
|
holdChain,
|
|
8
11
|
shellHoldRule,
|
|
9
12
|
} from "pi-roundtable/kit";
|
|
@@ -38,6 +41,57 @@ export interface PiCodingWorkerOptions {
|
|
|
38
41
|
blockText?: (answer: "declined" | "held", action: string) => string;
|
|
39
42
|
/** Longest worker error text kept for the report, in characters; default 600. */
|
|
40
43
|
diagnosticChars?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Host variables the worker process gets besides `SHELL_ENV_NAMES`, `PI_CODING_AGENT_DIR` and
|
|
46
|
+
* `modelEnvNames` (its `models.json` `$VAR`s, `CLAUDE_*`, `ANTHROPIC_*`), for a model reading
|
|
47
|
+
* another name. Its shell never gets them.
|
|
48
|
+
*/
|
|
49
|
+
workerEnv?: WorkerEnv;
|
|
50
|
+
/** What the worker's shell gets besides `SHELL_ENV_NAMES`, as the agents' shell; default the host's `env.shellEnv`. */
|
|
51
|
+
shellEnv?: ShellEnv;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Host variables a coding worker's process gets for its model and Pi runtime, by name. */
|
|
55
|
+
export interface WorkerEnv {
|
|
56
|
+
pass?: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The host variables a worker's model may read, found without the host listing them: the names a
|
|
61
|
+
* `models.json` in Pi's agent dir writes as `$NAME` (an API key a custom provider resolves), and
|
|
62
|
+
* the Claude Code and Anthropic families (`CLAUDE_*`, `ANTHROPIC_*`) that Anthropic providers and
|
|
63
|
+
* Claude Code bridges read. A missing or unreadable `models.json` adds nothing.
|
|
64
|
+
*/
|
|
65
|
+
export function modelEnvNames(agentDir: string): string[] {
|
|
66
|
+
const names = ["CLAUDE_*", "ANTHROPIC_*"];
|
|
67
|
+
try {
|
|
68
|
+
const text = readFileSync(join(agentDir, "models.json"), "utf8");
|
|
69
|
+
for (const match of text.matchAll(/"\$([A-Za-z_][A-Za-z0-9_]*)"/g))
|
|
70
|
+
if (match[1] && !names.includes(match[1])) names.push(match[1]);
|
|
71
|
+
} catch {
|
|
72
|
+
// No models.json: the providers' own variables above are all.
|
|
73
|
+
}
|
|
74
|
+
return names;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The worker process's environment: the shell's whitelist, Pi's agent dir, the variables its model
|
|
79
|
+
* may read (`modelEnvNames`), `workerEnv.pass`, and TMPDIR. Its bash cuts this again to the shell's.
|
|
80
|
+
*/
|
|
81
|
+
export function workerProcessEnv(
|
|
82
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
83
|
+
workerEnv: WorkerEnv = {},
|
|
84
|
+
scratchDir?: string,
|
|
85
|
+
agentDir: string = getAgentDir(),
|
|
86
|
+
): Record<string, string> {
|
|
87
|
+
return childEnv(source, {
|
|
88
|
+
pass: [
|
|
89
|
+
"PI_CODING_AGENT_DIR",
|
|
90
|
+
...modelEnvNames(agentDir),
|
|
91
|
+
...(workerEnv.pass ?? []),
|
|
92
|
+
],
|
|
93
|
+
...(scratchDir ? { set: { TMPDIR: scratchDir } } : {}),
|
|
94
|
+
});
|
|
41
95
|
}
|
|
42
96
|
interface WorkerMessage {
|
|
43
97
|
type: string;
|
|
@@ -107,9 +161,12 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
107
161
|
],
|
|
108
162
|
{
|
|
109
163
|
cwd: job.dir,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
164
|
+
env: workerProcessEnv(
|
|
165
|
+
process.env,
|
|
166
|
+
this.#options.workerEnv,
|
|
167
|
+
this.#options.scratchDir,
|
|
168
|
+
this.#options.agentDir ?? getAgentDir(),
|
|
169
|
+
),
|
|
113
170
|
stdin: "ignore",
|
|
114
171
|
stdout: "ignore",
|
|
115
172
|
stderr: "ignore",
|
|
@@ -128,6 +185,9 @@ export class PiCodingWorker implements CodingWorker {
|
|
|
128
185
|
...(this.#options.scratchDir
|
|
129
186
|
? { scratchDir: this.#options.scratchDir }
|
|
130
187
|
: {}),
|
|
188
|
+
...(this.#options.shellEnv
|
|
189
|
+
? { shellEnv: this.#options.shellEnv }
|
|
190
|
+
: {}),
|
|
131
191
|
});
|
|
132
192
|
} else if (
|
|
133
193
|
value.type === "report" &&
|
package/src/repo-shelf.ts
CHANGED
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
rmSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
AgentError,
|
|
18
|
+
checkRepoName,
|
|
19
|
+
childEnv,
|
|
20
|
+
scrubDiagnostic,
|
|
21
|
+
} from "pi-roundtable/kit";
|
|
17
22
|
|
|
18
23
|
/** What a clone would push to its default branch (repos-and-skills spec behavior 10). */
|
|
19
24
|
export interface ChangeReport {
|
|
@@ -53,6 +58,12 @@ export interface RepoState {
|
|
|
53
58
|
uncommitted: string[];
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The GitHub token git and gh calls get as GH_TOKEN, or a function read at each call; undefined
|
|
63
|
+
* leaves them to gh's own login.
|
|
64
|
+
*/
|
|
65
|
+
export type GithubToken = string | undefined | (() => string | undefined);
|
|
66
|
+
|
|
56
67
|
export type CloneCommand = (repo: string, dir: string) => Promise<void>;
|
|
57
68
|
|
|
58
69
|
/** A coding worker's own checkout: a linked worktree on its own branch, cut from the clone's HEAD. */
|
|
@@ -89,9 +100,39 @@ const GIT_TIMEOUT_MS = 60_000;
|
|
|
89
100
|
const MAX_STAT_FILES = 40;
|
|
90
101
|
const MAX_SUMMARY_CHARS = 120;
|
|
91
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Host variables git and gh need besides the shell's: their config files and homes, the identity a
|
|
105
|
+
* rebase commits as, ssh agent and ssh command, and gh's host; never a token, which comes from
|
|
106
|
+
* `githubToken`.
|
|
107
|
+
*/
|
|
108
|
+
const GIT_ENV_NAMES = [
|
|
109
|
+
"XDG_CONFIG_HOME",
|
|
110
|
+
"GIT_CONFIG_GLOBAL",
|
|
111
|
+
"GIT_CONFIG_SYSTEM",
|
|
112
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
113
|
+
"GIT_AUTHOR_NAME",
|
|
114
|
+
"GIT_AUTHOR_EMAIL",
|
|
115
|
+
"GIT_COMMITTER_NAME",
|
|
116
|
+
"GIT_COMMITTER_EMAIL",
|
|
117
|
+
"GH_CONFIG_DIR",
|
|
118
|
+
"GH_HOST",
|
|
119
|
+
"SSH_AUTH_SOCK",
|
|
120
|
+
"GIT_SSH",
|
|
121
|
+
"GIT_SSH_COMMAND",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
/** The environment of a git or gh call: the shell's whitelist, git's own names, and the token as GH_TOKEN. */
|
|
125
|
+
function gitEnv(token: string | undefined): Record<string, string> {
|
|
126
|
+
return childEnv(process.env, {
|
|
127
|
+
pass: GIT_ENV_NAMES,
|
|
128
|
+
...(token ? { set: { GH_TOKEN: token } } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
92
132
|
async function run(
|
|
93
133
|
command: string[],
|
|
94
|
-
cwd
|
|
134
|
+
cwd: string | undefined,
|
|
135
|
+
env: Record<string, string>,
|
|
95
136
|
): Promise<{ out: string; err: string; code: number }> {
|
|
96
137
|
const safeCommand =
|
|
97
138
|
command[0] === "git"
|
|
@@ -106,6 +147,7 @@ async function run(
|
|
|
106
147
|
: command;
|
|
107
148
|
const child = Bun.spawn(safeCommand, {
|
|
108
149
|
...(cwd ? { cwd } : {}),
|
|
150
|
+
env,
|
|
109
151
|
stdout: "pipe",
|
|
110
152
|
stderr: "pipe",
|
|
111
153
|
timeout: GIT_TIMEOUT_MS,
|
|
@@ -118,22 +160,21 @@ async function run(
|
|
|
118
160
|
return { out: out.trimEnd(), err: err.trim(), code };
|
|
119
161
|
}
|
|
120
162
|
|
|
121
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Clones with gh, which authenticates with `token` as GH_TOKEN (default the host's GH_TOKEN, read
|
|
165
|
+
* at each clone) or its own login; a failure's stderr is cut at `diagnosticChars`.
|
|
166
|
+
*/
|
|
122
167
|
const ghCloneWith =
|
|
123
|
-
(diagnosticChars?: number): CloneCommand =>
|
|
168
|
+
(diagnosticChars?: number, token?: () => string | undefined): CloneCommand =>
|
|
124
169
|
async (repo, dir) => {
|
|
125
170
|
checkRepoName(repo);
|
|
126
171
|
if (repo.startsWith("-"))
|
|
127
172
|
throw new AgentError("Repository owner cannot start with a dash.");
|
|
128
|
-
const { err, code } = await run(
|
|
129
|
-
"gh",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
dir,
|
|
134
|
-
"--",
|
|
135
|
-
"--quiet",
|
|
136
|
-
]);
|
|
173
|
+
const { err, code } = await run(
|
|
174
|
+
["gh", "repo", "clone", repo, dir, "--", "--quiet"],
|
|
175
|
+
undefined,
|
|
176
|
+
gitEnv(token ? token() : process.env.GH_TOKEN),
|
|
177
|
+
);
|
|
137
178
|
if (code !== 0)
|
|
138
179
|
throw new AgentError(
|
|
139
180
|
`Cloning ${repo} failed: ${scrubDiagnostic(err, diagnosticChars) || `exit ${code}`}`,
|
|
@@ -211,14 +252,30 @@ export class RepoShelf {
|
|
|
211
252
|
|
|
212
253
|
readonly #diagnosticChars: number | undefined;
|
|
213
254
|
|
|
255
|
+
readonly #token: () => string | undefined;
|
|
256
|
+
|
|
214
257
|
constructor(
|
|
215
258
|
dir: string,
|
|
216
259
|
clone?: CloneCommand,
|
|
217
|
-
options: {
|
|
260
|
+
options: {
|
|
261
|
+
diagnosticChars?: number | undefined;
|
|
262
|
+
githubToken?: GithubToken | undefined;
|
|
263
|
+
} = {},
|
|
218
264
|
) {
|
|
219
265
|
this.dir = resolve(dir);
|
|
220
266
|
this.#diagnosticChars = options.diagnosticChars;
|
|
221
|
-
|
|
267
|
+
const { githubToken = process.env.GH_TOKEN } = options;
|
|
268
|
+
this.#token =
|
|
269
|
+
typeof githubToken === "function" ? githubToken : () => githubToken;
|
|
270
|
+
this.#clone = clone ?? ghCloneWith(options.diagnosticChars, this.#token);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A git or gh call with the shelf's token and no other host secret. */
|
|
274
|
+
#run(
|
|
275
|
+
command: string[],
|
|
276
|
+
cwd?: string,
|
|
277
|
+
): Promise<{ out: string; err: string; code: number }> {
|
|
278
|
+
return run(command, cwd, gitEnv(this.#token()));
|
|
222
279
|
}
|
|
223
280
|
|
|
224
281
|
/** Adopt an existing standalone clone at startup; an existing destination is never replaced. */
|
|
@@ -450,7 +507,7 @@ export class RepoShelf {
|
|
|
450
507
|
const taken =
|
|
451
508
|
existsSync(dir) ||
|
|
452
509
|
(
|
|
453
|
-
await run([
|
|
510
|
+
await this.#run([
|
|
454
511
|
"git",
|
|
455
512
|
"-C",
|
|
456
513
|
clone,
|
|
@@ -507,9 +564,16 @@ export class RepoShelf {
|
|
|
507
564
|
...kept,
|
|
508
565
|
reason: `the worker left uncommitted changes in its worktree:\n${treeDirty}`,
|
|
509
566
|
};
|
|
510
|
-
const rebase = await run([
|
|
567
|
+
const rebase = await this.#run([
|
|
568
|
+
"git",
|
|
569
|
+
"-C",
|
|
570
|
+
treeDir,
|
|
571
|
+
"rebase",
|
|
572
|
+
"--quiet",
|
|
573
|
+
head,
|
|
574
|
+
]);
|
|
511
575
|
if (rebase.code !== 0) {
|
|
512
|
-
const conflicted = await run([
|
|
576
|
+
const conflicted = await this.#run([
|
|
513
577
|
"git",
|
|
514
578
|
"-C",
|
|
515
579
|
treeDir,
|
|
@@ -517,14 +581,14 @@ export class RepoShelf {
|
|
|
517
581
|
"--name-only",
|
|
518
582
|
"--diff-filter=U",
|
|
519
583
|
]);
|
|
520
|
-
await run(["git", "-C", treeDir, "rebase", "--abort"]);
|
|
584
|
+
await this.#run(["git", "-C", treeDir, "rebase", "--abort"]);
|
|
521
585
|
return {
|
|
522
586
|
status: "conflict",
|
|
523
587
|
...kept,
|
|
524
588
|
files: conflicted.out ? conflicted.out.split("\n") : [],
|
|
525
589
|
};
|
|
526
590
|
}
|
|
527
|
-
const merge = await run([
|
|
591
|
+
const merge = await this.#run([
|
|
528
592
|
"git",
|
|
529
593
|
"-C",
|
|
530
594
|
clone,
|
|
@@ -581,11 +645,27 @@ export class RepoShelf {
|
|
|
581
645
|
async removeWorktree(tree: WorkerTree): Promise<void> {
|
|
582
646
|
const clone = this.dirOf(tree.repo);
|
|
583
647
|
this.#treeDir(tree, false);
|
|
584
|
-
await run([
|
|
648
|
+
await this.#run([
|
|
649
|
+
"git",
|
|
650
|
+
"-C",
|
|
651
|
+
clone,
|
|
652
|
+
"worktree",
|
|
653
|
+
"remove",
|
|
654
|
+
"--force",
|
|
655
|
+
tree.dir,
|
|
656
|
+
]);
|
|
585
657
|
if (existsSync(tree.dir))
|
|
586
658
|
rmSync(tree.dir, { recursive: true, force: true });
|
|
587
|
-
await run(["git", "-C", clone, "worktree", "prune"]);
|
|
588
|
-
await run([
|
|
659
|
+
await this.#run(["git", "-C", clone, "worktree", "prune"]);
|
|
660
|
+
await this.#run([
|
|
661
|
+
"git",
|
|
662
|
+
"-C",
|
|
663
|
+
clone,
|
|
664
|
+
"branch",
|
|
665
|
+
"-D",
|
|
666
|
+
"--quiet",
|
|
667
|
+
tree.branch,
|
|
668
|
+
]);
|
|
589
669
|
}
|
|
590
670
|
|
|
591
671
|
/** A worktree path, refused unless it lies in this repository's worktree folder. */
|
|
@@ -614,7 +694,7 @@ export class RepoShelf {
|
|
|
614
694
|
if (fetch) await this.#git(dir, "fetch", "--quiet", "origin");
|
|
615
695
|
const branch = await this.#git(dir, "rev-parse", "--abbrev-ref", "HEAD");
|
|
616
696
|
const status = await this.#git(dir, "status", "--porcelain");
|
|
617
|
-
const last = await run([
|
|
697
|
+
const last = await this.#run([
|
|
618
698
|
"git",
|
|
619
699
|
"-C",
|
|
620
700
|
dir,
|
|
@@ -623,7 +703,7 @@ export class RepoShelf {
|
|
|
623
703
|
"--format=%h %s (%cs)",
|
|
624
704
|
"--abbrev=12",
|
|
625
705
|
]);
|
|
626
|
-
const counts = await run([
|
|
706
|
+
const counts = await this.#run([
|
|
627
707
|
"git",
|
|
628
708
|
"-C",
|
|
629
709
|
dir,
|
|
@@ -678,7 +758,7 @@ export class RepoShelf {
|
|
|
678
758
|
async #defaultBranch(dir: string): Promise<string> {
|
|
679
759
|
await this.#git(dir, "remote", "set-head", "origin", "--auto");
|
|
680
760
|
const read = () =>
|
|
681
|
-
run([
|
|
761
|
+
this.#run([
|
|
682
762
|
"git",
|
|
683
763
|
"-C",
|
|
684
764
|
dir,
|
|
@@ -693,7 +773,7 @@ export class RepoShelf {
|
|
|
693
773
|
}
|
|
694
774
|
|
|
695
775
|
async #git(dir: string, ...args: string[]): Promise<string> {
|
|
696
|
-
const { out, err, code } = await run(["git", "-C", dir, ...args]);
|
|
776
|
+
const { out, err, code } = await this.#run(["git", "-C", dir, ...args]);
|
|
697
777
|
if (code !== 0)
|
|
698
778
|
throw new AgentError(
|
|
699
779
|
`git ${args[0]} failed in ${dir}: ${scrubDiagnostic(err, this.#diagnosticChars) || `exit ${code}`}`,
|
package/src/worker-entry.ts
CHANGED
|
@@ -2,15 +2,20 @@ import { lstatSync, realpathSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
createAgentSession,
|
|
5
|
+
createBashToolDefinition,
|
|
5
6
|
DefaultResourceLoader,
|
|
6
7
|
ModelRuntime,
|
|
7
8
|
SessionManager,
|
|
8
9
|
SettingsManager,
|
|
10
|
+
type ToolDefinition,
|
|
9
11
|
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { ShellEnv } from "pi-roundtable";
|
|
10
13
|
import {
|
|
11
14
|
activeToolsExtension,
|
|
12
15
|
runWorkerTask,
|
|
13
16
|
SHELL_TOOLS,
|
|
17
|
+
secretMaskExtension,
|
|
18
|
+
shellChildEnv,
|
|
14
19
|
textOf,
|
|
15
20
|
} from "pi-roundtable/kit";
|
|
16
21
|
import type { CodingJob, HeldCallAnswer } from "./coding-desk.ts";
|
|
@@ -25,6 +30,29 @@ interface StartMessage {
|
|
|
25
30
|
progress?: boolean;
|
|
26
31
|
/** The host's scratch dir, the worker's TMPDIR, where writes and removals need no approval. */
|
|
27
32
|
scratchDir?: string;
|
|
33
|
+
/** What the worker's shell gets besides `SHELL_ENV_NAMES`. */
|
|
34
|
+
shellEnv?: ShellEnv;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pi's bash with the worker's environment cut to the shell's whitelist and `shellEnv`: the model's
|
|
39
|
+
* credentials the worker process holds never reach a command.
|
|
40
|
+
*/
|
|
41
|
+
export function workerBash(
|
|
42
|
+
cwd: string,
|
|
43
|
+
scratchDir: string | undefined,
|
|
44
|
+
shellEnv: ShellEnv = {},
|
|
45
|
+
): ToolDefinition {
|
|
46
|
+
// SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
|
|
47
|
+
return createBashToolDefinition(cwd, {
|
|
48
|
+
spawnHook: (context) => ({
|
|
49
|
+
...context,
|
|
50
|
+
env: {
|
|
51
|
+
...shellChildEnv(context.env, shellEnv),
|
|
52
|
+
...(scratchDir === undefined ? {} : { TMPDIR: scratchDir }),
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
}) as unknown as ToolDefinition;
|
|
28
56
|
}
|
|
29
57
|
/** Said to every worker whose host has a scratch dir, after the host's own prompt or the default. */
|
|
30
58
|
export function scratchDirPrompt(scratchDir: string): string {
|
|
@@ -54,6 +82,7 @@ async function run({
|
|
|
54
82
|
prompt,
|
|
55
83
|
progress,
|
|
56
84
|
scratchDir,
|
|
85
|
+
shellEnv,
|
|
57
86
|
}: StartMessage): Promise<string> {
|
|
58
87
|
const modelRuntime = await ModelRuntime.create({
|
|
59
88
|
authPath: join(agentDir, "auth.json"),
|
|
@@ -86,6 +115,7 @@ async function run({
|
|
|
86
115
|
name: "coding-shell",
|
|
87
116
|
factory: activeToolsExtension(() => SHELL_TOOLS),
|
|
88
117
|
},
|
|
118
|
+
{ name: "secret-mask", factory: secretMaskExtension() },
|
|
89
119
|
{
|
|
90
120
|
name: "coding-approvals",
|
|
91
121
|
factory: (pi) => {
|
|
@@ -127,6 +157,7 @@ async function run({
|
|
|
127
157
|
modelRuntime,
|
|
128
158
|
thinkingLevel: job.thinking,
|
|
129
159
|
resourceLoader: loader,
|
|
160
|
+
customTools: [workerBash(job.dir, scratchDir, shellEnv)],
|
|
130
161
|
sessionManager: SessionManager.inMemory(job.dir),
|
|
131
162
|
settingsManager: SettingsManager.inMemory({}),
|
|
132
163
|
});
|