pi-roundtable-coding 0.7.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 +21 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/examples/plugin.ts +10 -0
- package/package.json +50 -0
- package/src/coding-desk.ts +251 -0
- package/src/coding-plugin.ts +266 -0
- package/src/index.ts +21 -0
- package/src/pi-coding-worker.ts +126 -0
- package/src/repo-shelf.ts +477 -0
- package/src/repo-tools.ts +8 -0
- package/src/worker-entry.ts +138 -0
- package/src/worker-failure.ts +16 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin,
|
|
3
|
+
defineTool,
|
|
4
|
+
type HoldCheck,
|
|
5
|
+
PluginError,
|
|
6
|
+
SKILLS,
|
|
7
|
+
serviceKey,
|
|
8
|
+
type ThinkingLevel,
|
|
9
|
+
ToolRefusal,
|
|
10
|
+
} from "pi-roundtable";
|
|
11
|
+
import {
|
|
12
|
+
AgentError,
|
|
13
|
+
checkRepoName,
|
|
14
|
+
skillListExtension,
|
|
15
|
+
zonedStamp,
|
|
16
|
+
} from "pi-roundtable/kit";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import {
|
|
19
|
+
CodingDesk,
|
|
20
|
+
type CodingResult,
|
|
21
|
+
type CodingWorker,
|
|
22
|
+
codingReport,
|
|
23
|
+
MAX_CODING_TASK_CHARS,
|
|
24
|
+
} from "./coding-desk.ts";
|
|
25
|
+
import { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
26
|
+
import { type CloneCommand, RepoShelf, reportPost } from "./repo-shelf.ts";
|
|
27
|
+
|
|
28
|
+
export interface CodingService {
|
|
29
|
+
readonly shelf: RepoShelf;
|
|
30
|
+
readonly desk: CodingDesk;
|
|
31
|
+
}
|
|
32
|
+
export const CODING = serviceKey<CodingService>("coding.workbench");
|
|
33
|
+
export interface CodingOptions {
|
|
34
|
+
shelfDir: string;
|
|
35
|
+
/** Provider/model-id, resolved with the worker's Pi host login. */
|
|
36
|
+
model: string;
|
|
37
|
+
thinking?: ThinkingLevel;
|
|
38
|
+
/** Exact owner/repo names allowed to use repo_push without a hold. Default: none. */
|
|
39
|
+
ownerRepos?: string[];
|
|
40
|
+
/** Absolute installed extension package paths. Default: none. */
|
|
41
|
+
workerPackages?: string[];
|
|
42
|
+
agentDir?: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
/** Optional additional worker rules; linked host rules also apply. */
|
|
45
|
+
holds?: HoldCheck;
|
|
46
|
+
/** A different Git host or an offline clone implementation. */
|
|
47
|
+
clone?: CloneCommand;
|
|
48
|
+
/** Replace the process runner for deterministic tests. */
|
|
49
|
+
worker?: CodingWorker;
|
|
50
|
+
/** Override report delivery, for example to schedule a background conversation turn. */
|
|
51
|
+
onResult?: (result: CodingResult) => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
async function refusal<T>(operation: () => Promise<T>): Promise<T> {
|
|
54
|
+
try {
|
|
55
|
+
return await operation();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof AgentError) throw new ToolRefusal(error.message);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Repository tools for agent and owner sessions, with coding work outside the host process. */
|
|
63
|
+
export function coding(options: CodingOptions) {
|
|
64
|
+
if (!options.shelfDir.trim()) throw new PluginError("shelfDir is required.");
|
|
65
|
+
if (!/^[^/\s]+\/\S+$/.test(options.model))
|
|
66
|
+
throw new PluginError("model must be provider/model-id.");
|
|
67
|
+
const ownerRepos = new Set(options.ownerRepos ?? []);
|
|
68
|
+
for (const repo of ownerRepos) checkRepoName(repo);
|
|
69
|
+
return definePlugin({
|
|
70
|
+
name: "coding",
|
|
71
|
+
provides: [CODING],
|
|
72
|
+
setup(context) {
|
|
73
|
+
const shelf = new RepoShelf(options.shelfDir, options.clone);
|
|
74
|
+
const skills = context.services.find(SKILLS);
|
|
75
|
+
const worker =
|
|
76
|
+
options.worker ??
|
|
77
|
+
new PiCodingWorker({
|
|
78
|
+
packages: options.workerPackages,
|
|
79
|
+
agentDir: options.agentDir,
|
|
80
|
+
holds: (tool, input, scope) =>
|
|
81
|
+
options.holds?.(tool, input, scope) ??
|
|
82
|
+
context.sessions().holds(tool, input, scope),
|
|
83
|
+
});
|
|
84
|
+
const desk = new CodingDesk({
|
|
85
|
+
shelf,
|
|
86
|
+
worker,
|
|
87
|
+
timeoutMs: options.timeoutMs,
|
|
88
|
+
logger: context.logger,
|
|
89
|
+
prompts: (channel) => context.surfaces.prompts(channel),
|
|
90
|
+
deliver:
|
|
91
|
+
options.onResult ??
|
|
92
|
+
(async (result) => {
|
|
93
|
+
await context.surfaces.sendReply(result.job.channel, {
|
|
94
|
+
chunks: [
|
|
95
|
+
codingReport(
|
|
96
|
+
result,
|
|
97
|
+
zonedStamp(result.job.startedAt, context.env.timeZone),
|
|
98
|
+
),
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const shipping = new Set<string>();
|
|
104
|
+
async function ship<T>(
|
|
105
|
+
repo: string,
|
|
106
|
+
operation: () => Promise<T>,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
desk.checkIdle(repo);
|
|
109
|
+
if (shipping.has(repo))
|
|
110
|
+
throw new AgentError(
|
|
111
|
+
"A report or push is already in progress for this repository.",
|
|
112
|
+
);
|
|
113
|
+
shipping.add(repo);
|
|
114
|
+
try {
|
|
115
|
+
return await operation();
|
|
116
|
+
} finally {
|
|
117
|
+
shipping.delete(repo);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
context.services.provide(CODING, { shelf, desk });
|
|
121
|
+
const repoSchema = Type.String({
|
|
122
|
+
description: "A managed repository, owner/repo.",
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
services: [
|
|
126
|
+
{
|
|
127
|
+
name: "coding-desk",
|
|
128
|
+
busy: () => desk.busy(),
|
|
129
|
+
stop: () => desk.stop(),
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
sessionTools: skills
|
|
133
|
+
? [
|
|
134
|
+
{
|
|
135
|
+
name: "coding-owner-skill-list",
|
|
136
|
+
phase: "tools" as const,
|
|
137
|
+
snapshot: () => ({
|
|
138
|
+
revision: 0,
|
|
139
|
+
factory: (session) => {
|
|
140
|
+
if (session.kind !== "owner") return null;
|
|
141
|
+
return skillListExtension(skills);
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
: [],
|
|
147
|
+
tools: [
|
|
148
|
+
defineTool({
|
|
149
|
+
name: "repo_list",
|
|
150
|
+
minTier: "owner",
|
|
151
|
+
description:
|
|
152
|
+
"List managed clones, branch, upstream counts, dirty files, last commit, summary, CI hints and linked skills. Fetch first only when requested.",
|
|
153
|
+
parameters: Type.Object({ fetch: Type.Optional(Type.Boolean()) }),
|
|
154
|
+
run: ({ fetch }) =>
|
|
155
|
+
refusal(async () => {
|
|
156
|
+
const repos = await shelf.list(fetch === true);
|
|
157
|
+
return repos.length
|
|
158
|
+
? JSON.stringify(
|
|
159
|
+
repos.map((repo) => ({
|
|
160
|
+
...repo,
|
|
161
|
+
skills: skills?.linkedFrom(repo.repo) ?? [],
|
|
162
|
+
})),
|
|
163
|
+
null,
|
|
164
|
+
2,
|
|
165
|
+
)
|
|
166
|
+
: `No managed repositories in ${shelf.dir}; use repo_add.`;
|
|
167
|
+
}),
|
|
168
|
+
}),
|
|
169
|
+
defineTool({
|
|
170
|
+
name: "repo_add",
|
|
171
|
+
minTier: "owner",
|
|
172
|
+
description:
|
|
173
|
+
"Clone owner/repo into the shelf using the host's Git login (GitHub CLI by default).",
|
|
174
|
+
parameters: Type.Object({ repo: repoSchema }),
|
|
175
|
+
run: ({ repo }) =>
|
|
176
|
+
refusal(
|
|
177
|
+
async () => `Cloned ${repo} into ${await shelf.add(repo)}.`,
|
|
178
|
+
),
|
|
179
|
+
}),
|
|
180
|
+
defineTool({
|
|
181
|
+
name: "repo_change_report",
|
|
182
|
+
minTier: "owner",
|
|
183
|
+
description:
|
|
184
|
+
"Fetch and report commits and changed files for the default branch. Requires a clean, ahead-only clone. Review checks before requesting this report.",
|
|
185
|
+
parameters: Type.Object({ repo: repoSchema }),
|
|
186
|
+
run: ({ repo }, turn) =>
|
|
187
|
+
refusal(() =>
|
|
188
|
+
ship(repo, async () => {
|
|
189
|
+
const report = await shelf.report(repo);
|
|
190
|
+
const text = reportPost(report, ownerRepos.has(repo));
|
|
191
|
+
await context.surfaces.sendReply(turn.channel, {
|
|
192
|
+
chunks: [text],
|
|
193
|
+
});
|
|
194
|
+
return text;
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
}),
|
|
198
|
+
defineTool({
|
|
199
|
+
name: "repo_push",
|
|
200
|
+
minTier: "owner",
|
|
201
|
+
description:
|
|
202
|
+
"Push the exact full SHA from the latest change report to its default branch, without force. Held for owner approval unless ownerRepos explicitly lists the clone.",
|
|
203
|
+
parameters: Type.Object({
|
|
204
|
+
repo: repoSchema,
|
|
205
|
+
sha: Type.String({ pattern: "^[0-9a-f]{40}$" }),
|
|
206
|
+
}),
|
|
207
|
+
hold: ({ repo, sha }) =>
|
|
208
|
+
ownerRepos.has(repo)
|
|
209
|
+
? undefined
|
|
210
|
+
: shelf.pushDescription(repo, sha),
|
|
211
|
+
run: ({ repo, sha }) =>
|
|
212
|
+
refusal(() =>
|
|
213
|
+
ship(
|
|
214
|
+
repo,
|
|
215
|
+
async () =>
|
|
216
|
+
`Pushed ${sha} to ${repo} ${await shelf.push(repo, sha)}.`,
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
}),
|
|
220
|
+
defineTool({
|
|
221
|
+
name: "repo_task",
|
|
222
|
+
minTier: "owner",
|
|
223
|
+
description:
|
|
224
|
+
"Start one background Pi coding worker in a clone. The worker reads repository instructions, edits, checks and commits; shipping uses a separate report and approval. Report returns to this channel.",
|
|
225
|
+
parameters: Type.Object({
|
|
226
|
+
repo: repoSchema,
|
|
227
|
+
task: Type.String({
|
|
228
|
+
minLength: 1,
|
|
229
|
+
maxLength: MAX_CODING_TASK_CHARS,
|
|
230
|
+
}),
|
|
231
|
+
skills: Type.Optional(Type.Array(Type.String())),
|
|
232
|
+
}),
|
|
233
|
+
run: ({ repo, task, skills: names }, turn) =>
|
|
234
|
+
refusal(async () => {
|
|
235
|
+
if (shipping.has(repo))
|
|
236
|
+
throw new AgentError(
|
|
237
|
+
"A report or push is in progress for this repository.",
|
|
238
|
+
);
|
|
239
|
+
if (names?.length && !skills)
|
|
240
|
+
throw new AgentError("The host's skills addon is off.");
|
|
241
|
+
const carried =
|
|
242
|
+
names ??
|
|
243
|
+
(turn.agent ? skills?.carriedNames(turn.agent.name) : []) ??
|
|
244
|
+
[];
|
|
245
|
+
skills?.checkRegistered(carried);
|
|
246
|
+
const set = skills?.resolve(carried);
|
|
247
|
+
if (set?.missing.length)
|
|
248
|
+
throw new AgentError(
|
|
249
|
+
"Some carried skills cannot load; inspect skill_list.",
|
|
250
|
+
);
|
|
251
|
+
const job = await desk.start({
|
|
252
|
+
repo,
|
|
253
|
+
task,
|
|
254
|
+
channel: turn.channel,
|
|
255
|
+
model: options.model,
|
|
256
|
+
thinking: options.thinking ?? "medium",
|
|
257
|
+
skillFiles: set?.skills.map((skill) => skill.file) ?? [],
|
|
258
|
+
});
|
|
259
|
+
return `Started coding task #${job.id} in ${repo} on ${job.model}; its report returns to this channel.`;
|
|
260
|
+
}),
|
|
261
|
+
}),
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CodingDeskOptions,
|
|
3
|
+
CodingJob,
|
|
4
|
+
CodingResult,
|
|
5
|
+
CodingWorker,
|
|
6
|
+
HeldCallAnswer,
|
|
7
|
+
} from "./coding-desk.ts";
|
|
8
|
+
export { CodingDesk, codingReport } from "./coding-desk.ts";
|
|
9
|
+
export type { CodingOptions, CodingService } from "./coding-plugin.ts";
|
|
10
|
+
export { CODING, coding } from "./coding-plugin.ts";
|
|
11
|
+
export type { PiCodingWorkerOptions } from "./pi-coding-worker.ts";
|
|
12
|
+
export { PiCodingWorker } from "./pi-coding-worker.ts";
|
|
13
|
+
export type {
|
|
14
|
+
ChangeReport,
|
|
15
|
+
CloneCommand,
|
|
16
|
+
RepoState,
|
|
17
|
+
RepoSummary,
|
|
18
|
+
} from "./repo-shelf.ts";
|
|
19
|
+
export { ghClone, RepoShelf, reportPost } from "./repo-shelf.ts";
|
|
20
|
+
export { REPO_TOOLS } from "./repo-tools.ts";
|
|
21
|
+
export { CodingWorkerFailure } from "./worker-failure.ts";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { HeldCall, HoldCheck } from "pi-roundtable";
|
|
4
|
+
import { AgentError, canonicalJson, shellHoldRule } from "pi-roundtable/kit";
|
|
5
|
+
import type { CodingJob, CodingWorker, HeldCallAnswer } from "./coding-desk.ts";
|
|
6
|
+
import { CodingWorkerFailure } from "./worker-failure.ts";
|
|
7
|
+
|
|
8
|
+
export interface PiCodingWorkerOptions {
|
|
9
|
+
/** Installed Pi extension package paths; their extra tools stay inactive. */
|
|
10
|
+
packages?: string[];
|
|
11
|
+
/** Pi credentials and models live here, defaulting to Pi's host login directory. */
|
|
12
|
+
agentDir?: string;
|
|
13
|
+
/** Additional host rules, consulted in the parent process. */
|
|
14
|
+
holds?: HoldCheck;
|
|
15
|
+
}
|
|
16
|
+
interface WorkerMessage {
|
|
17
|
+
type: string;
|
|
18
|
+
id?: number;
|
|
19
|
+
tool?: string;
|
|
20
|
+
input?: Record<string, unknown>;
|
|
21
|
+
report?: string;
|
|
22
|
+
}
|
|
23
|
+
function isMessage(value: unknown): value is WorkerMessage {
|
|
24
|
+
return (
|
|
25
|
+
typeof value === "object" &&
|
|
26
|
+
value !== null &&
|
|
27
|
+
"type" in value &&
|
|
28
|
+
typeof value.type === "string"
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fresh Bun process per task; only the host decides whether a held call may run. */
|
|
33
|
+
export class PiCodingWorker implements CodingWorker {
|
|
34
|
+
readonly #options: PiCodingWorkerOptions;
|
|
35
|
+
constructor(options: PiCodingWorkerOptions = {}) {
|
|
36
|
+
this.#options = options;
|
|
37
|
+
}
|
|
38
|
+
async run(
|
|
39
|
+
job: CodingJob & { dir: string },
|
|
40
|
+
signal: AbortSignal,
|
|
41
|
+
review: (call: HeldCall) => Promise<HeldCallAnswer>,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
if (process.platform === "win32")
|
|
44
|
+
throw new AgentError("Coding workers require a POSIX host.");
|
|
45
|
+
if (signal.aborted) throw new AgentError("The worker was stopped.");
|
|
46
|
+
let report: string | undefined;
|
|
47
|
+
const child = Bun.spawn(
|
|
48
|
+
[
|
|
49
|
+
process.execPath,
|
|
50
|
+
fileURLToPath(new URL("./worker-entry.ts", import.meta.url)),
|
|
51
|
+
],
|
|
52
|
+
{
|
|
53
|
+
cwd: job.dir,
|
|
54
|
+
stdin: "ignore",
|
|
55
|
+
stdout: "ignore",
|
|
56
|
+
stderr: "ignore",
|
|
57
|
+
detached: true,
|
|
58
|
+
ipc: (value, proc) => {
|
|
59
|
+
if (!isMessage(value)) return;
|
|
60
|
+
if (value.type === "ready") {
|
|
61
|
+
proc.send({
|
|
62
|
+
type: "start",
|
|
63
|
+
job,
|
|
64
|
+
packages: this.#options.packages ?? [],
|
|
65
|
+
agentDir: this.#options.agentDir ?? getAgentDir(),
|
|
66
|
+
});
|
|
67
|
+
} else if (
|
|
68
|
+
value.type === "report" &&
|
|
69
|
+
typeof value.report === "string"
|
|
70
|
+
) {
|
|
71
|
+
report = value.report;
|
|
72
|
+
} else if (
|
|
73
|
+
value.type === "call" &&
|
|
74
|
+
Number.isSafeInteger(value.id) &&
|
|
75
|
+
typeof value.tool === "string" &&
|
|
76
|
+
typeof value.input === "object" &&
|
|
77
|
+
value.input !== null
|
|
78
|
+
) {
|
|
79
|
+
const { id, tool, input } = value;
|
|
80
|
+
void (async () => {
|
|
81
|
+
let answer: HeldCallAnswer = "held";
|
|
82
|
+
try {
|
|
83
|
+
const context = { workspace: job.dir };
|
|
84
|
+
const action =
|
|
85
|
+
shellHoldRule.describe(tool, input, context) ??
|
|
86
|
+
this.#options.holds?.(tool, input, context);
|
|
87
|
+
if (!signal.aborted) {
|
|
88
|
+
answer = action
|
|
89
|
+
? await review({
|
|
90
|
+
tool,
|
|
91
|
+
input: canonicalJson(input),
|
|
92
|
+
action,
|
|
93
|
+
})
|
|
94
|
+
: "approved";
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* Fail closed. */
|
|
98
|
+
}
|
|
99
|
+
if (!signal.aborted && child.exitCode === null)
|
|
100
|
+
proc.send({ type: "answer", id, answer });
|
|
101
|
+
})();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
const kill = () => {
|
|
107
|
+
try {
|
|
108
|
+
process.kill(-child.pid, "SIGKILL");
|
|
109
|
+
} catch {
|
|
110
|
+
/* The group has already exited. */
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
signal.addEventListener("abort", kill, { once: true });
|
|
114
|
+
try {
|
|
115
|
+
if (signal.aborted) kill();
|
|
116
|
+
const code = await child.exited;
|
|
117
|
+
if (signal.aborted) throw new CodingWorkerFailure("stopped");
|
|
118
|
+
if (code !== 0) throw new CodingWorkerFailure("exit", code);
|
|
119
|
+
if (!report?.trim()) throw new CodingWorkerFailure("missing-report");
|
|
120
|
+
return report;
|
|
121
|
+
} finally {
|
|
122
|
+
signal.removeEventListener("abort", kill);
|
|
123
|
+
kill(); // Also remove shell descendants the task left running.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|