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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.7.0] - Unreleased
|
|
6
|
+
|
|
7
|
+
Prepared for the first npm publication.
|
|
8
|
+
The earlier `0.1.0` was local-only and was never published on npm.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Move into the pi-roundtable workspace with preserved Git history and lockstep version `0.7.0`.
|
|
13
|
+
CI checks the core and all packages; the shared `publish.yml` releases them from one `v*` tag.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Add a managed repository shelf with clone, list, change-report and exact-commit push tools.
|
|
18
|
+
- Add background out-of-process Pi coding workers with host-side owner cards and a work timeout that excludes approval waits.
|
|
19
|
+
- Hold every repository push by default, with an explicit owner-repository allowlist.
|
|
20
|
+
- Add configurable Git host cloning, model, Pi extension packages, skill selection and result delivery.
|
|
21
|
+
- Document the host security boundary and provide offline integration tests and release workflows.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wayne930242
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# pi-roundtable-coding
|
|
2
|
+
|
|
3
|
+
A repository shelf and an out-of-process Pi coding desk for [pi-roundtable][roundtable].
|
|
4
|
+
Agents and owner sessions get five repository tools; coding jobs commit locally and report back, while shipping stays behind the owner's approval.
|
|
5
|
+
This package is a reference for the plugin guide's [Helpers for a Pi session of your own][helpers].
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Bun 1.4.2 or newer on a POSIX host (Linux or macOS).
|
|
10
|
+
- pi-roundtable `>=0.7.0 <0.8.0` as a peer dependency; this package uses only its public main, kit and testing entries.
|
|
11
|
+
- Pi `>=1.0.0 <2`, shared with the host's core dependencies.
|
|
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
|
+
- Git, plus GitHub CLI (`gh`) for the default clone implementation.
|
|
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 the provider's environment variables.
|
|
16
|
+
|
|
17
|
+
The default clone uses `gh repo clone owner/repo` and inherits `GH_TOKEN` or 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`).
|
|
19
|
+
Use a least-privilege token and restrict its repositories; tokens are never package options, tool arguments or remote URLs.
|
|
20
|
+
The package does not print subprocess output or Git authentication diagnostics and never logs credentials.
|
|
21
|
+
The coding worker inherits the host environment and can read files accessible to that host user: do not give it credentials you would not give a trusted coding agent.
|
|
22
|
+
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
|
+
No network calls are needed by the offline test suite.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bun add pi-roundtable-coding pi-roundtable
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// roundtable.config.ts
|
|
33
|
+
import { coding } from "pi-roundtable-coding";
|
|
34
|
+
|
|
35
|
+
export default {
|
|
36
|
+
// ...the settings created by pi-roundtable init...
|
|
37
|
+
plugins: [
|
|
38
|
+
coding({
|
|
39
|
+
shelfDir: "/srv/roundtable/repos",
|
|
40
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
41
|
+
thinking: "medium",
|
|
42
|
+
timeoutMs: 60 * 60_000,
|
|
43
|
+
// Default []: every repo_push is held for approval.
|
|
44
|
+
ownerRepos: [],
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The model is an explicit `provider/model-id` available on this host, not selected by this package.
|
|
51
|
+
`agentDir` defaults to Pi's host login directory and controls its `auth.json` and `models.json` locations.
|
|
52
|
+
No automatic extension, skill, prompt-template or theme discovery is enabled for workers.
|
|
53
|
+
Only repository context files within the clone are included as standing instructions.
|
|
54
|
+
|
|
55
|
+
To load installed Pi extension packages (for example a model provider), pass their absolute package directories in `workerPackages`.
|
|
56
|
+
Resolve them from your configuration rather than from a core checkout:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { packageDir } from "pi-roundtable/kit";
|
|
60
|
+
|
|
61
|
+
const workerPackages = [packageDir("your-provider-package", import.meta.url)];
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Install the provider package on the host first and pass `workerPackages` to `coding`.
|
|
65
|
+
Workers activate only `SHELL_TOOLS` (`bash`, `read`, `edit`, `write`); extension packages are trusted executable code, not a way to grant extra model tools.
|
|
66
|
+
|
|
67
|
+
## Tools and flow
|
|
68
|
+
|
|
69
|
+
| Tool | Parameters | Behavior |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `repo_add` | `repo` | Clone `owner/repo` into `shelfDir/owner/repo`; refuse invalid names and existing clones. |
|
|
72
|
+
| `repo_list` | `fetch?` | List paths, branches, upstream ahead/behind counts, dirty files, last commits, prose summaries, CI hints and linked skills. |
|
|
73
|
+
| `repo_task` | `repo`, `task`, `skills?` | Start a background worker, returning its job number immediately. |
|
|
74
|
+
| `repo_change_report` | `repo` | Fetch and post the commits and diffstat ahead of the default branch; return its full SHA. |
|
|
75
|
+
| `repo_push` | `repo`, `sha` | Push the exact reported SHA to the reported default branch, without force, after the hold gate permits it. |
|
|
76
|
+
|
|
77
|
+
All five tools have `minTier: "owner"` and are contributed through `defineTool` for agent and owner session selections.
|
|
78
|
+
The operator remains responsible for any tool-tier overrides.
|
|
79
|
+
A custom owner claim with a restricted `ToolSelection` must include the exported `REPO_TOOLS` names; include `skill_list` separately when the host skills addon is enabled.
|
|
80
|
+
The host's skills addon resolves explicit skills, or the calling agent's carried skills when omitted; an owner call carries none unless it names them.
|
|
81
|
+
Missing or unknown skills refuse the task; requesting skills while the addon is off also refuses it.
|
|
82
|
+
Sessions with kind `owner` get the public `skillListExtension`; agent sessions keep the core's existing `skill_list` instead of registering it twice.
|
|
83
|
+
|
|
84
|
+
A worker reads the repository's instructions, edits and checks the work, and commits using repository conventions.
|
|
85
|
+
One worker may use a repository at a time, with up to three per channel.
|
|
86
|
+
Reports and pushes refuse a repository while its coding worker is still running.
|
|
87
|
+
The plugin also reserves repositories during report and push operations, refusing concurrent shipping calls and new coding tasks.
|
|
88
|
+
The worker's report includes its final answer, declined or unanswered actions, branch, HEAD, new commits and uncommitted files.
|
|
89
|
+
Failures and timeouts also report the final Git state when it can be read.
|
|
90
|
+
By default reports are sent to the caller's surface; `onResult(result)` can instead enqueue a background conversation turn through your host integration.
|
|
91
|
+
Dates are formatted using `context.env.timeZone`, not a process-wide zone.
|
|
92
|
+
|
|
93
|
+
Shipping is separate from coding:
|
|
94
|
+
|
|
95
|
+
1. Review the worker's changes and verification evidence.
|
|
96
|
+
2. Call `repo_change_report`; the clone must be clean, ahead of `origin/HEAD`, and missing none of its commits.
|
|
97
|
+
3. Call `repo_push` with the full SHA from that report.
|
|
98
|
+
4. The host holds the push and asks the owner before running it.
|
|
99
|
+
|
|
100
|
+
`ownerRepos` is an exact list of `owner/repo` names, not a prefix or wildcard; its default is empty.
|
|
101
|
+
Listed repositories skip the plugin's `repo_push` hold, though other host hold rules can still hold the call.
|
|
102
|
+
They do not exempt shell pushes or risky worker actions.
|
|
103
|
+
The report and approval card name the full SHA, current default branch and credential-free push destination.
|
|
104
|
+
The clone must have exactly one push destination; HTTP remote URLs with user information, query strings or fragments are refused before fetching.
|
|
105
|
+
A changed HEAD, dirty clone, changed remote default branch, changed push destination, missing report or non-fast-forward remote causes refusal.
|
|
106
|
+
Report receipts live in memory, so request a fresh report after restarting the host.
|
|
107
|
+
|
|
108
|
+
## Approvals and work time
|
|
109
|
+
|
|
110
|
+
Every worker tool call crosses a private Bun IPC channel to the parent process before execution.
|
|
111
|
+
The parent consults `shellHoldRule`, additional `holds`, and the host's linked hold rules; a recognized risky action asks the owner's surface prompts using `approvalCard`.
|
|
112
|
+
`promptSlot` tracks these cards and `workTimeout` excludes their waiting time from the worker's budget (one hour by default).
|
|
113
|
+
`timeoutMs` must be positive and finite, and at most 2,147,483,647 ms to fit the host timer.
|
|
114
|
+
An approved call runs; a declined, expired, missing or failed card blocks it and instructs the worker not to retry or work around the refusal.
|
|
115
|
+
Unapproved actions appear in the report rather than running later automatically.
|
|
116
|
+
Worker answers are capped at 20,000 characters; the Held list keeps ten entries of at most 1,000 characters each, with explicit truncation and omission notices.
|
|
117
|
+
Worker failures expose only a structured exit category and numeric code, never stderr or provider diagnostics.
|
|
118
|
+
Card expiration is determined by the host's surface implementation, not this package.
|
|
119
|
+
|
|
120
|
+
The worker runs in a fresh Bun child process with an unsaved Pi session and uses the public `runWorkerTask` helper.
|
|
121
|
+
Timeout and shutdown kill its POSIX process group and wait for exit; normal completion also removes shell descendants left in that group.
|
|
122
|
+
The service advertises running jobs through `busy()` so the host can drain them before shutdown.
|
|
123
|
+
Jobs are in memory; they do not resume after a crash.
|
|
124
|
+
The injectable `worker` runner must observe aborts and settle its run; this is a trusted test/integration seam.
|
|
125
|
+
|
|
126
|
+
## Security model
|
|
127
|
+
|
|
128
|
+
This is a host coding desk, **not a sandbox**.
|
|
129
|
+
The worker can run local checks, read the host's accessible files, use the network and commit changes.
|
|
130
|
+
Its standing instructions delegate shipping to the calling agent; repository tools and extra package tools are not activated inside the worker.
|
|
131
|
+
`repo_push` is the supported shipping route and always uses a previously reported full SHA and a non-force push.
|
|
132
|
+
The public shell rule holds recognized shell pushes, destructive commands, privileged commands and recognized writes outside the clone, even for `ownerRepos`.
|
|
133
|
+
It is a heuristic guard: scripts, interpreters, symlinks, Git hooks, package extensions and detached grandchildren are not an OS isolation boundary.
|
|
134
|
+
A trusted package or arbitrary shell program can bypass heuristic detection; only run code and repositories you trust, under a dedicated low-privilege host user.
|
|
135
|
+
Use an isolated container or VM when host access is unacceptable.
|
|
136
|
+
|
|
137
|
+
The shelf validates names with the public `checkRepoName` helper and checks real paths before operating on a clone, refusing paths that escape the shelf or Git directories that escape a clone.
|
|
138
|
+
Metadata discovery skips symlinks, directories and special files, reads at most 64 KiB per file, and refuses symlinked workflow directories.
|
|
139
|
+
Git commands use argument arrays, not interpolated shell commands.
|
|
140
|
+
Host-side Git operations disable repository hooks and fsmonitor; the coding worker's own Git operations retain repository conventions.
|
|
141
|
+
Local Git credential helpers and other executable Git configuration still run as the host user and can be written by a worker, so repository configuration is a trusted boundary, not an approval boundary.
|
|
142
|
+
Keep external processes from changing shelf paths or Git refs during approval and push; these checks do not serialize unrelated host writers.
|
|
143
|
+
Model output, diffs and tool inputs can contain sensitive repository content, so report channels and approval cards must remain owner-trusted.
|
|
144
|
+
|
|
145
|
+
## API and options
|
|
146
|
+
|
|
147
|
+
`coding(options)` is the plugin factory.
|
|
148
|
+
The `CODING` service exposes `shelf: RepoShelf` and `desk: CodingDesk` for trusted owner integrations; direct service calls are not the host tool approval gate.
|
|
149
|
+
|
|
150
|
+
| Option | Default | Purpose |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `shelfDir` | required | Managed clone directory. |
|
|
153
|
+
| `model` | required | Worker provider/model ID. |
|
|
154
|
+
| `thinking` | `medium` | Pi thinking level. |
|
|
155
|
+
| `ownerRepos` | `[]` | Exact push-hold exemptions. |
|
|
156
|
+
| `workerPackages` | `[]` | Absolute installed extension paths. |
|
|
157
|
+
| `agentDir` | Pi host directory | Pi credentials and model configuration. |
|
|
158
|
+
| `timeoutMs` | `3600000` | Work-time limit in ms, excluding owner wait; 0 < value <= 2147483647. |
|
|
159
|
+
| `holds` | none | Additional worker hold check. |
|
|
160
|
+
| `clone` | `ghClone` | Git-host clone implementation. |
|
|
161
|
+
| `worker` | `PiCodingWorker` | Trusted worker runner replacement. |
|
|
162
|
+
| `onResult` | surface reply | Report delivery integration. |
|
|
163
|
+
|
|
164
|
+
`RepoShelf`, `CodingDesk`, `PiCodingWorker`, `CodingWorkerFailure`, `REPO_TOOLS`, `codingReport`, `reportPost` and their option/result types are exported for testing and host integrations.
|
|
165
|
+
No database or Discord-specific types are required.
|
|
166
|
+
|
|
167
|
+
## Development and publishing
|
|
168
|
+
|
|
169
|
+
This package lives in `packages/coding` in the pi-roundtable workspace.
|
|
170
|
+
Run these commands from the repository root:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
bun install --frozen-lockfile
|
|
174
|
+
bun run --cwd packages/coding test
|
|
175
|
+
bun run --cwd packages/coding typecheck
|
|
176
|
+
bun run --cwd packages/coding lint
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Tests use temporary bare Git origins, `testPlugin`, fake workers and a real child Pi session with an offline faux provider.
|
|
180
|
+
They cover cloning/listing, reports, push holds and approved pushes, owner exceptions, work-time exclusion, bad names, closed approval paths and child-process exit.
|
|
181
|
+
CI runs those checks.
|
|
182
|
+
The shared `publish.yml` workflow repeats them, checks that every workspace version and core peer range match the single `v*` tag, then uses npm trusted publishing with provenance.
|
|
183
|
+
The owner performs the initial npm publication and configures this package's trust against `publish.yml`; no separate repository or package-specific tag is needed.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT; see [LICENSE](LICENSE).
|
|
188
|
+
|
|
189
|
+
[roundtable]: https://www.npmjs.com/package/pi-roundtable
|
|
190
|
+
[helpers]: https://github.com/wayne930242/pi-roundtable/blob/master/docs/plugins.md#helpers-for-a-pi-session-of-your-own
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { coding } from "../src/index.ts";
|
|
2
|
+
|
|
3
|
+
/** Add this plugin to the host's roundtable.config.ts plugins list. */
|
|
4
|
+
export const codingDesk = coding({
|
|
5
|
+
shelfDir: "/srv/roundtable/repos",
|
|
6
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
7
|
+
thinking: "medium",
|
|
8
|
+
timeoutMs: 3_600_000,
|
|
9
|
+
ownerRepos: [],
|
|
10
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-roundtable-coding",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Repository shelves and owner-approved Pi coding workers for pi-roundtable",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/wayne930242/pi-roundtable.git",
|
|
10
|
+
"directory": "packages/coding"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"bun": ">=1.4.2"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"!src/**/*.test.ts",
|
|
21
|
+
"!src/testing",
|
|
22
|
+
"examples",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"README.md",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"lint": "biome check ."
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
38
|
+
"typebox": "1.3.34"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"pi-roundtable": ">=0.7.0 <0.8.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-ai": ">=1.0.0 <2",
|
|
45
|
+
"@biomejs/biome": "2.5.15",
|
|
46
|
+
"@types/bun": "1.4.2",
|
|
47
|
+
"pi-roundtable": "0.7.0",
|
|
48
|
+
"typescript": "7.0.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelKey,
|
|
3
|
+
HeldCall,
|
|
4
|
+
Logger,
|
|
5
|
+
OwnerPrompts,
|
|
6
|
+
ThinkingLevel,
|
|
7
|
+
} from "pi-roundtable";
|
|
8
|
+
import {
|
|
9
|
+
AgentError,
|
|
10
|
+
approvalCard,
|
|
11
|
+
promptSlot,
|
|
12
|
+
workTimeout,
|
|
13
|
+
} from "pi-roundtable/kit";
|
|
14
|
+
import type { RepoShelf, RepoState } from "./repo-shelf.ts";
|
|
15
|
+
import { CodingWorkerFailure } from "./worker-failure.ts";
|
|
16
|
+
|
|
17
|
+
export type HeldCallAnswer = "approved" | "declined" | "held";
|
|
18
|
+
export interface CodingJob {
|
|
19
|
+
id: number;
|
|
20
|
+
repo: string;
|
|
21
|
+
task: string;
|
|
22
|
+
channel: ChannelKey;
|
|
23
|
+
model: string;
|
|
24
|
+
thinking: ThinkingLevel;
|
|
25
|
+
skillFiles: string[];
|
|
26
|
+
startedAt: Date;
|
|
27
|
+
startHead: string;
|
|
28
|
+
}
|
|
29
|
+
export interface CodingWorker {
|
|
30
|
+
run(
|
|
31
|
+
job: CodingJob & { dir: string },
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
review: (call: HeldCall) => Promise<HeldCallAnswer>,
|
|
34
|
+
): Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
export interface CodingResult {
|
|
37
|
+
job: CodingJob;
|
|
38
|
+
outcome: { ok: true; report: string } | { ok: false; error: string };
|
|
39
|
+
held: string[];
|
|
40
|
+
state?: RepoState;
|
|
41
|
+
commits: string[];
|
|
42
|
+
}
|
|
43
|
+
export interface CodingDeskOptions {
|
|
44
|
+
shelf: Pick<RepoShelf, "dirOf" | "state" | "commitsSince">;
|
|
45
|
+
worker: CodingWorker;
|
|
46
|
+
prompts(channel: ChannelKey): OwnerPrompts | undefined;
|
|
47
|
+
deliver(result: CodingResult): Promise<void>;
|
|
48
|
+
logger: Logger;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
export const MAX_CODING_TASK_CHARS = 8_000;
|
|
52
|
+
const MAX_REPORT_CHARS = 20_000;
|
|
53
|
+
const MAX_HELD_ENTRIES = 10;
|
|
54
|
+
const MAX_HELD_CHARS = 1_000;
|
|
55
|
+
function bounded(text: string, max: number): string {
|
|
56
|
+
const suffix = "\n[truncated]";
|
|
57
|
+
return text.length > max
|
|
58
|
+
? `${text.slice(0, max - suffix.length)}${suffix}`
|
|
59
|
+
: text;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One worker per repository, at most three per channel; results and jobs are in memory. */
|
|
63
|
+
export class CodingDesk {
|
|
64
|
+
readonly #options: CodingDeskOptions;
|
|
65
|
+
readonly #running = new Map<
|
|
66
|
+
number,
|
|
67
|
+
{ job: CodingJob; controller: AbortController; done?: Promise<void> }
|
|
68
|
+
>();
|
|
69
|
+
#nextId = 1;
|
|
70
|
+
#stopped = false;
|
|
71
|
+
constructor(options: CodingDeskOptions) {
|
|
72
|
+
if (
|
|
73
|
+
!Number.isFinite(options.timeoutMs ?? 3_600_000) ||
|
|
74
|
+
(options.timeoutMs ?? 3_600_000) <= 0 ||
|
|
75
|
+
(options.timeoutMs ?? 3_600_000) > 2_147_483_647
|
|
76
|
+
)
|
|
77
|
+
throw new AgentError(
|
|
78
|
+
"timeoutMs must be positive, finite and at most 2147483647.",
|
|
79
|
+
);
|
|
80
|
+
this.#options = options;
|
|
81
|
+
}
|
|
82
|
+
busy(): string[] {
|
|
83
|
+
return [...this.#running.values()].map(
|
|
84
|
+
({ job }) => `Coding #${job.id} in ${job.repo}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
checkIdle(repo: string): void {
|
|
88
|
+
if ([...this.#running.values()].some(({ job }) => job.repo === repo))
|
|
89
|
+
throw new AgentError(`A coding worker is still using ${repo}.`);
|
|
90
|
+
}
|
|
91
|
+
async idle(): Promise<void> {
|
|
92
|
+
await Promise.all([...this.#running.values()].map((run) => run.done));
|
|
93
|
+
}
|
|
94
|
+
async stop(): Promise<void> {
|
|
95
|
+
this.#stopped = true;
|
|
96
|
+
for (const run of this.#running.values()) run.controller.abort();
|
|
97
|
+
await this.idle();
|
|
98
|
+
}
|
|
99
|
+
async start(
|
|
100
|
+
request: Omit<CodingJob, "id" | "startedAt" | "startHead">,
|
|
101
|
+
): Promise<CodingJob> {
|
|
102
|
+
if (this.#stopped) throw new AgentError("The coding desk is stopped.");
|
|
103
|
+
const task = request.task.trim();
|
|
104
|
+
if (!task || task.length > MAX_CODING_TASK_CHARS)
|
|
105
|
+
throw new AgentError(
|
|
106
|
+
`The task must contain 1–${MAX_CODING_TASK_CHARS} characters.`,
|
|
107
|
+
);
|
|
108
|
+
const dir = this.#options.shelf.dirOf(request.repo);
|
|
109
|
+
this.checkIdle(request.repo);
|
|
110
|
+
if (
|
|
111
|
+
[...this.#running.values()].filter(
|
|
112
|
+
({ job }) => job.channel === request.channel,
|
|
113
|
+
).length >= 3
|
|
114
|
+
)
|
|
115
|
+
throw new AgentError("This channel already has three coding workers.");
|
|
116
|
+
const job: CodingJob = {
|
|
117
|
+
...request,
|
|
118
|
+
task,
|
|
119
|
+
id: this.#nextId++,
|
|
120
|
+
startedAt: new Date(),
|
|
121
|
+
startHead: "",
|
|
122
|
+
};
|
|
123
|
+
const run = {
|
|
124
|
+
job,
|
|
125
|
+
controller: new AbortController(),
|
|
126
|
+
done: undefined as Promise<void> | undefined,
|
|
127
|
+
};
|
|
128
|
+
this.#running.set(job.id, run);
|
|
129
|
+
// Reserve before the first await, including startup state reads.
|
|
130
|
+
run.done = this.#run(job, dir, run.controller)
|
|
131
|
+
.catch(() => {
|
|
132
|
+
this.#options.logger.error(
|
|
133
|
+
{ job: job.id },
|
|
134
|
+
"Coding report delivery failed.",
|
|
135
|
+
);
|
|
136
|
+
})
|
|
137
|
+
.finally(() => this.#running.delete(job.id));
|
|
138
|
+
return job;
|
|
139
|
+
}
|
|
140
|
+
async #run(
|
|
141
|
+
job: CodingJob,
|
|
142
|
+
dir: string,
|
|
143
|
+
controller: AbortController,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const {
|
|
146
|
+
shelf,
|
|
147
|
+
worker,
|
|
148
|
+
prompts,
|
|
149
|
+
deliver,
|
|
150
|
+
timeoutMs = 3_600_000,
|
|
151
|
+
} = this.#options;
|
|
152
|
+
const slot = promptSlot();
|
|
153
|
+
const held: string[] = [];
|
|
154
|
+
let omittedHeld = 0;
|
|
155
|
+
let cancel = () => {};
|
|
156
|
+
let timedOut = false;
|
|
157
|
+
let outcome: CodingResult["outcome"];
|
|
158
|
+
try {
|
|
159
|
+
job.startHead = (await shelf.state(job.repo)).head;
|
|
160
|
+
if (controller.signal.aborted)
|
|
161
|
+
throw new AgentError("The worker was stopped.");
|
|
162
|
+
slot.bind(prompts(job.channel), `Coding worker #${job.id}`);
|
|
163
|
+
cancel = workTimeout(timeoutMs, slot, () => {
|
|
164
|
+
timedOut = true;
|
|
165
|
+
controller.abort();
|
|
166
|
+
});
|
|
167
|
+
const review = async (call: HeldCall): Promise<HeldCallAnswer> => {
|
|
168
|
+
let answer: HeldCallAnswer = "held";
|
|
169
|
+
try {
|
|
170
|
+
const decision = await slot.prompts?.confirm(
|
|
171
|
+
`${slot.asker} requests approval`,
|
|
172
|
+
approvalCard(call),
|
|
173
|
+
controller.signal,
|
|
174
|
+
);
|
|
175
|
+
if (decision === "approved" || decision === "declined")
|
|
176
|
+
answer = decision;
|
|
177
|
+
} catch {
|
|
178
|
+
/* A failed card never authorizes the call. */
|
|
179
|
+
}
|
|
180
|
+
if (answer !== "approved") {
|
|
181
|
+
if (held.length < MAX_HELD_ENTRIES)
|
|
182
|
+
held.push(
|
|
183
|
+
bounded(
|
|
184
|
+
`${answer}: ${call.action}: ${call.tool} ${call.input}`,
|
|
185
|
+
MAX_HELD_CHARS,
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
else omittedHeld++;
|
|
189
|
+
}
|
|
190
|
+
return answer;
|
|
191
|
+
};
|
|
192
|
+
const report = await worker.run(
|
|
193
|
+
{ ...job, dir },
|
|
194
|
+
controller.signal,
|
|
195
|
+
review,
|
|
196
|
+
);
|
|
197
|
+
if (controller.signal.aborted)
|
|
198
|
+
throw new AgentError("The worker was stopped.");
|
|
199
|
+
outcome = { ok: true, report: bounded(report, MAX_REPORT_CHARS) };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// Only structured, fixed diagnostics can cross the worker boundary.
|
|
202
|
+
let message =
|
|
203
|
+
"The worker failed; inspect the clone and worker configuration on the host.";
|
|
204
|
+
if (error instanceof CodingWorkerFailure) {
|
|
205
|
+
message = error.message;
|
|
206
|
+
this.#options.logger.warn(
|
|
207
|
+
{ job: job.id, category: error.category, exitCode: error.exitCode },
|
|
208
|
+
"Coding worker failed.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (controller.signal.aborted) message = "The worker was stopped.";
|
|
212
|
+
if (timedOut)
|
|
213
|
+
message = `Work timeout after ${timeoutMs} ms (owner wait excluded).`;
|
|
214
|
+
outcome = { ok: false, error: message };
|
|
215
|
+
} finally {
|
|
216
|
+
cancel();
|
|
217
|
+
slot.unbind();
|
|
218
|
+
}
|
|
219
|
+
let state: RepoState | undefined;
|
|
220
|
+
let commits: string[] = [];
|
|
221
|
+
try {
|
|
222
|
+
state = await shelf.state(job.repo);
|
|
223
|
+
if (job.startHead)
|
|
224
|
+
commits = await shelf.commitsSince(job.repo, job.startHead);
|
|
225
|
+
} catch {
|
|
226
|
+
this.#options.logger.warn(
|
|
227
|
+
{ job: job.id },
|
|
228
|
+
"Repository state unreadable.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (omittedHeld)
|
|
232
|
+
held.push(`[${omittedHeld} more unapproved actions omitted]`);
|
|
233
|
+
await deliver({ job, outcome, held, ...(state ? { state } : {}), commits });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function codingReport(result: CodingResult, started: string): string {
|
|
238
|
+
const { job, outcome, state, commits, held } = result;
|
|
239
|
+
return [
|
|
240
|
+
`## Coding task #${job.id}: ${job.repo}`,
|
|
241
|
+
`Started: ${started}`,
|
|
242
|
+
outcome.ok ? outcome.report : outcome.error,
|
|
243
|
+
"### Held",
|
|
244
|
+
held.length ? held.join("\n") : "None.",
|
|
245
|
+
"### Repository",
|
|
246
|
+
state
|
|
247
|
+
? `Branch ${state.branch}, HEAD ${state.head} (was ${job.startHead}).\nNew commits:\n${commits.join("\n") || "None."}\nUncommitted:\n${state.uncommitted.join("\n") || "None."}`
|
|
248
|
+
: "Unreadable.",
|
|
249
|
+
"Review the changes and checks, then use repo_change_report before repo_push.",
|
|
250
|
+
].join("\n\n");
|
|
251
|
+
}
|