pi-t3-bridge 0.7.2 → 0.8.1
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 +12 -0
- package/README.md +5 -63
- package/bridge.ts +0 -2
- package/package.json +2 -4
- package/pstack/skills/poteto-mode/brief.md +2 -1
- package/src/pstack/child.ts +1 -1
- package/src/pstack/sandbox.ts +97 -23
- package/amplike.ts +0 -58
- package/src/amplike/btw.ts +0 -89
- package/src/amplike/handoff.ts +0 -200
- package/src/amplike/package.ts +0 -136
- package/src/amplike/selection.ts +0 -144
- package/src/amplike/t3-view.ts +0 -88
- package/src/bridge/bridge-trim.ts +0 -104
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Change log
|
|
2
2
|
|
|
3
|
+
## 0.8.1, 6 November 2026
|
|
4
|
+
|
|
5
|
+
Version 0.8.1 lets a delegated child run `pnpm install` and `git commit` in a pnpm and lefthook repo, under `bwrap` on Linux and `sandbox-exec` on macOS. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm 12 takes its store lock. The sandbox can now write to the store that `pnpm store path` names for the workdir, and the child uses that store, so it shares the parent's store and does not build a second one in the workdir.
|
|
6
|
+
|
|
7
|
+
Children now skip install scripts, so a root `prepare` such as `lefthook install` no longer fails on the read-only git hooks. With this setting npm also skips the pre and post scripts of `npm run`, while pnpm still runs them. A brief that needs a postinstall step, such as `next typegen`, should name the build or typecheck that runs it.
|
|
8
|
+
|
|
9
|
+
## 0.8.0, 5 November 2026
|
|
10
|
+
|
|
11
|
+
Version 0.8.0 removes the pi-amplike adapter (`amplike.ts`). The package no longer loads pi-amplike or changes its `/mode`, `/btw`, `/handoff`, and `subagent` for T3. Use T3's own `/btw`, `/side`, model picker, and `delegate_task` instead. If you set `{"source":"npm:pi-amplike","extensions":[]}` for the adapter, remove that entry from `~/.pi/agent/settings.json`.
|
|
12
|
+
|
|
13
|
+
The bridge no longer rewrites the "- Subagents:" bullet in T3's "## T3 Code orchestration" section, so Pi sends T3's system prompt unchanged. The rewrite broke pi-cached-compaction, which rebuilds the last request and compares it byte for byte before it reuses the prompt cache. Every cached compaction in T3 failed with `request field "system" differs`. Pi now sees T3's original bullet, which names Claude's `Agent` and Codex's `spawn_agent`. Pi has neither tool.
|
|
14
|
+
|
|
3
15
|
## 0.7.2, 5 November 2026
|
|
4
16
|
|
|
5
17
|
Version 0.7.2 stops pi-amplike's permission prompt in T3 threads. T3 has its own permission system, so the bridge no longer loads amplike's `permissions` extension inside T3, and Pi no longer asks "Permission required ... Allow?" before bash commands there. Outside T3, amplike's `/permissions` and its prompts work as before.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-t3-bridge
|
|
2
2
|
|
|
3
|
-
pi-t3-bridge is a [Pi](https://pi.dev) package that lets Pi run as an agent inside [T3 Code](https://github.com/pingdotgg/t3code). It also ships the pstack and tstack skill sets
|
|
3
|
+
pi-t3-bridge is a [Pi](https://pi.dev) package that lets Pi run as an agent inside [T3 Code](https://github.com/pingdotgg/t3code). It also ships the pstack and tstack skill sets.
|
|
4
4
|
|
|
5
5
|
```mermaid
|
|
6
6
|
flowchart LR
|
|
@@ -10,15 +10,12 @@ flowchart LR
|
|
|
10
10
|
Bridge[bridge.ts<br/>T3 tools, cards, limits]
|
|
11
11
|
Pstack[pstack.ts<br/>pstack skills, model routing]
|
|
12
12
|
Tstack[tstack.ts<br/>tstack skills]
|
|
13
|
-
Amplike[amplike.ts<br/>pi-amplike in T3]
|
|
14
13
|
end
|
|
15
14
|
Pi --> Bridge
|
|
16
15
|
Pi --> Pstack
|
|
17
16
|
Pi --> Tstack
|
|
18
|
-
Pi --> Amplike
|
|
19
17
|
Bridge -->|MCP| T3
|
|
20
18
|
Pstack -->|delegate_task| T3
|
|
21
|
-
Amplike -->|t3_thread_launch, delegate_task| T3
|
|
22
19
|
T3 -->|child threads| Children[Claude, GPT, and other models]
|
|
23
20
|
```
|
|
24
21
|
|
|
@@ -32,6 +29,8 @@ You need the following programs:
|
|
|
32
29
|
|
|
33
30
|
A delegated child runs its shell commands in a sandbox, which is `sandbox-exec` on macOS and `bwrap` (bubblewrap) on Linux and WSL2. On Linux you need the `bubblewrap` package and unprivileged user namespaces. The Ubuntu `bubblewrap` package ships an AppArmor profile that lets `bwrap` run (checked on 26.04). If AppArmor still blocks `bwrap`, run `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`.
|
|
34
31
|
|
|
32
|
+
Inside the sandbox a child can write only to its workdir, its git directory, the tool caches, the pnpm store, and the system temp folder. The git hooks and git config stay read-only, because the parent runs them later outside the sandbox. Each child gets its own `XDG_RUNTIME_DIR` in the temp folder, where pnpm takes its store lock. The child also gets `pnpm_config_store_dir` set to the store that `pnpm store path` names for the workdir outside the sandbox, so it shares the parent's store. Under `bwrap`, pnpm copies packages from the store, because Linux refuses hardlinks across the sandbox's mounts. Children skip install scripts (`npm_config_ignore_scripts` and `pnpm_config_ignore_scripts` are `true`), so a root `prepare` such as `lefthook install` does not run. With this setting npm also skips the pre and post scripts of `npm run` and `npm test`, while pnpm still runs them. Steps that only a postinstall runs must run through the build or typecheck the brief names.
|
|
33
|
+
|
|
35
34
|
Native Windows and WSL1 have no sandbox. Where no sandbox can run, the bridge blocks a child's shell commands, unless you set `childSandbox` to `"off"` in the settings file.
|
|
36
35
|
|
|
37
36
|
## Install
|
|
@@ -46,18 +45,16 @@ Pi loads every extension in the package on the next start. To try the package fo
|
|
|
46
45
|
|
|
47
46
|
## What is in the package
|
|
48
47
|
|
|
49
|
-
The package has
|
|
48
|
+
The package has three Pi extensions, and you can turn off any of them. None of the extensions needs another one to be loaded, although `pstack.ts` reuses code from `src/bridge`.
|
|
50
49
|
|
|
51
50
|
```text
|
|
52
51
|
pi-t3-bridge/
|
|
53
52
|
├── bridge.ts # connects Pi to T3 Code
|
|
54
53
|
├── pstack.ts # serves pstack skills and picks models for subagents
|
|
55
54
|
├── tstack.ts # serves tstack skills
|
|
56
|
-
├── amplike.ts # loads pi-amplike and adapts it for T3 threads
|
|
57
55
|
├── src/
|
|
58
56
|
│ ├── bridge/ # code for bridge.ts
|
|
59
|
-
│
|
|
60
|
-
│ └── amplike/ # code for amplike.ts
|
|
57
|
+
│ └── pstack/ # code for pstack.ts
|
|
61
58
|
├── pstack/ # upstream pstack skills, agents, and model defaults
|
|
62
59
|
├── overlay/ # the changes Pi needs on top of pstack/
|
|
63
60
|
└── tstack/ # Theo Browne's tstack skills
|
|
@@ -255,61 +252,6 @@ sequenceDiagram
|
|
|
255
252
|
|
|
256
253
|
The tstack extension serves Theo Browne's tstack skills from `tstack/skills`, which are babysit-pr, council, file-pr, full-send, full-stop, leaving-pr-comments, modernize, sol-loop, and takeover. It imports no other code from the package, so the skills stay available when you turn the other extensions off.
|
|
257
254
|
|
|
258
|
-
## pi-amplike in T3 threads
|
|
259
|
-
|
|
260
|
-
pi-amplike adds handoff, `/btw`, subagents, and prompt modes to Pi, but T3 can't show most of them. T3 shows Pi's `notify`, `select`, `confirm`, `input`, and `editor` calls, and it drops widgets, status lines, custom UI, and key presses. T3 also resumes a thread from the session file it first recorded, so a handoff that moves Pi to a new session file breaks the thread.
|
|
261
|
-
|
|
262
|
-
To use the adapter, keep pi-amplike installed and turn off its own extensions in `~/.pi/agent/settings.json`. The adapter then loads amplike's extension files itself.
|
|
263
|
-
|
|
264
|
-
```diff
|
|
265
|
-
"packages": [
|
|
266
|
-
"npm:pi-t3-bridge",
|
|
267
|
-
- "npm:pi-amplike"
|
|
268
|
-
+ { "source": "npm:pi-amplike", "extensions": [] }
|
|
269
|
-
]
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
```text
|
|
273
|
-
on(Pi start)
|
|
274
|
-
find pi-amplike with Pi's package manager
|
|
275
|
-
if amplike still loads its own extensions
|
|
276
|
-
do nothing, and in a T3 thread show a notice with the settings change
|
|
277
|
-
else if Pi runs inside a T3 thread
|
|
278
|
-
load amplike's extensions with the T3 changes below
|
|
279
|
-
else
|
|
280
|
-
load amplike's extensions unchanged
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
In a T3 thread, the adapter changes the following parts of amplike:
|
|
284
|
-
|
|
285
|
-
| amplike | In a T3 thread |
|
|
286
|
-
| --- | --- |
|
|
287
|
-
| `subagent` tool | The adapter turns it off, so subagents run through T3's `delegate_task` and show up in T3. |
|
|
288
|
-
| `/btw <prompt>` | Starts a `delegate_task` child that reads the parent thread's session with `session_query`. The child's answer wakes the parent thread. |
|
|
289
|
-
| `handoff` tool and `/handoff <goal>` | Writes a summary with the session model, then starts a new T3 thread. The current thread keeps its own session file. |
|
|
290
|
-
| `/mode` | Works as in the terminal, and sends each model and thinking change to T3, so T3's model picker shows the same model. |
|
|
291
|
-
| Shortcuts | The adapter turns them off, because T3 sends Pi no key presses. |
|
|
292
|
-
| `/permissions` and its bash prompts | The adapter doesn't load them, because T3 has its own permission system and amplike's prompt would ask a second time. |
|
|
293
|
-
|
|
294
|
-
`session_query` and amplike's skills work unchanged. The diagram below shows the three commands that talk to T3.
|
|
295
|
-
|
|
296
|
-
```mermaid
|
|
297
|
-
sequenceDiagram
|
|
298
|
-
participant You
|
|
299
|
-
participant Pi as Pi (amplike.ts)
|
|
300
|
-
participant T3
|
|
301
|
-
You->>Pi: /handoff fix the failing tests
|
|
302
|
-
Pi->>Pi: summarize the chat with the session model
|
|
303
|
-
Pi->>T3: t3_thread_launch(goal, summary, parent session file)
|
|
304
|
-
T3-->>You: link to the new thread
|
|
305
|
-
You->>Pi: /btw which file holds the parser?
|
|
306
|
-
Pi->>T3: delegate_task(question, parent session file)
|
|
307
|
-
T3-->>Pi: child's answer wakes the thread
|
|
308
|
-
You->>Pi: /mode rush
|
|
309
|
-
Pi->>Pi: set model and thinking level
|
|
310
|
-
Pi->>T3: t3_thread_configure(modelSelection)
|
|
311
|
-
```
|
|
312
|
-
|
|
313
255
|
## Settings
|
|
314
256
|
|
|
315
257
|
The bridge reads one JSON file per machine, at `~/.config/pi-t3/config.json`. Every key is optional. The bridge reads the file again on each prompt, so an edit applies on the next prompt. When a value is invalid, only the invalid key falls back to its default, and the bridge tells you once.
|
package/bridge.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { registerAccounts } from "./src/bridge/accounts.ts";
|
|
4
4
|
import { registerAsk } from "./src/bridge/ask.ts";
|
|
5
5
|
import { registerBashTimeout } from "./src/bridge/bash-timeout.ts";
|
|
6
|
-
import { registerBridgeTrim } from "./src/bridge/bridge-trim.ts";
|
|
7
6
|
import { registerUnlessTaken } from "./src/bridge/claim.ts";
|
|
8
7
|
import { registerConfig } from "./src/bridge/config.ts";
|
|
9
8
|
import { registerPlanGate } from "./src/bridge/plan.ts";
|
|
@@ -30,7 +29,6 @@ const piT3Bridge = (pi: ExtensionAPI): void => {
|
|
|
30
29
|
registerPlanGate(pi);
|
|
31
30
|
registerAsk(pi);
|
|
32
31
|
registerT3Mcp(pi);
|
|
33
|
-
registerBridgeTrim(pi);
|
|
34
32
|
registerUsage(pi);
|
|
35
33
|
registerSteer(pi);
|
|
36
34
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-t3-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Pi extension adapter for t3-pstack inside T3 Code.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-extension",
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
"LICENSE",
|
|
40
40
|
"tstack.ts",
|
|
41
41
|
"tstack",
|
|
42
|
-
"amplike.ts",
|
|
43
42
|
"CHANGELOG.md"
|
|
44
43
|
],
|
|
45
44
|
"type": "module",
|
|
@@ -82,8 +81,7 @@
|
|
|
82
81
|
"extensions": [
|
|
83
82
|
"./bridge.ts",
|
|
84
83
|
"./tstack.ts",
|
|
85
|
-
"./pstack.ts"
|
|
86
|
-
"./amplike.ts"
|
|
84
|
+
"./pstack.ts"
|
|
87
85
|
]
|
|
88
86
|
},
|
|
89
87
|
"upstreams": {
|
|
@@ -60,7 +60,8 @@ ISSUES or BLOCKED: one line each with file:line and the command that shows it
|
|
|
60
60
|
|
|
61
61
|
The child's own pstack extension reads this brief from its first message and enforces it, whatever the model does:
|
|
62
62
|
|
|
63
|
-
- Every shell command runs in a
|
|
63
|
+
- Every shell command runs in a sandbox (`sandbox-exec` on macOS, `bwrap` on Linux) that can write only to the workdir, its git directory, the tool caches, the pnpm store and the system temp folder. Git hooks and git config stay read-only.
|
|
64
|
+
- Install scripts are skipped, so `pnpm install` or `npm install` runs no `prepare` or `postinstall`. Don't ask for `prepare` to pass. If a postinstall generates something, such as `next typegen`, name the build or typecheck that runs it in the acceptance command. npm also skips `pre` and `post` scripts of `npm run` and `npm test`, so name those too, as in `npm run prebuild && npm test`.
|
|
64
65
|
- `write` and `edit` only touch the listed files and `.scratch/`.
|
|
65
66
|
- Shell timeouts stop at the time limit. After it, or past the disk limit, every tool is refused and the child can only report.
|
|
66
67
|
- When the child ends its turn, the extension reruns the acceptance command, `git status` and `du`, and appends a "Checked by t3-pstack" block with the real exit codes and STATUS. It also writes a ledger line to `~/.local/state/t3-pstack/ledger.jsonl`.
|
package/src/pstack/child.ts
CHANGED
|
@@ -297,7 +297,7 @@ export const registerChild = (pi: ExtensionAPI): void => {
|
|
|
297
297
|
state.gitDir = undefined;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
state.sandbox = await chooseSandbox(state.gitDir);
|
|
300
|
+
state.sandbox = await chooseSandbox(ctx.cwd, state.gitDir);
|
|
301
301
|
|
|
302
302
|
state.baselineKb = await duKb(ctx.cwd);
|
|
303
303
|
|
package/src/pstack/sandbox.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -34,10 +34,17 @@ const isProbeFailure = compile(
|
|
|
34
34
|
)
|
|
35
35
|
).check;
|
|
36
36
|
|
|
37
|
+
// Settled once per child: its own XDG_RUNTIME_DIR, and the pnpm store it
|
|
38
|
+
// shares with the parent.
|
|
39
|
+
interface ChildEnv {
|
|
40
|
+
readonly pnpmStore: string | undefined;
|
|
41
|
+
readonly runtimeDir: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
export type Sandbox =
|
|
38
|
-
| { kind: "bubblewrap"; bwrap: string }
|
|
39
|
-
| { kind: "off" }
|
|
40
|
-
| { kind: "sandbox-exec" }
|
|
45
|
+
| { kind: "bubblewrap"; bwrap: string; env: ChildEnv }
|
|
46
|
+
| { kind: "off"; env: ChildEnv }
|
|
47
|
+
| { kind: "sandbox-exec"; env: ChildEnv }
|
|
41
48
|
| { kind: "unavailable"; reason: string };
|
|
42
49
|
|
|
43
50
|
const quote = (text: string): string => `'${text.replaceAll("'", `'\\''`)}'`;
|
|
@@ -50,8 +57,30 @@ export const real = (target: string): string => {
|
|
|
50
57
|
}
|
|
51
58
|
};
|
|
52
59
|
|
|
53
|
-
//
|
|
54
|
-
|
|
60
|
+
// The store pnpm picks for this workdir outside the sandbox, from project and
|
|
61
|
+
// user config alike. Undefined when pnpm isn't installed. A store at or above
|
|
62
|
+
// home would open all of home, so it stays unbound.
|
|
63
|
+
const pnpmStore = async (workdir: string): Promise<string | undefined> => {
|
|
64
|
+
try {
|
|
65
|
+
const { stdout } = await run("pnpm", ["store", "path"], {
|
|
66
|
+
cwd: workdir,
|
|
67
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const store = path.dirname(stdout.trim());
|
|
71
|
+
const fromHome = path.relative(store, homedir());
|
|
72
|
+
|
|
73
|
+
return path.isAbsolute(store) && fromHome.startsWith("..")
|
|
74
|
+
? store
|
|
75
|
+
: undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Tool caches, installs and the pnpm store stay writable, so uv, npm, pnpm
|
|
82
|
+
// and mise work.
|
|
83
|
+
const cacheRoots = (store: string | undefined): string[] => {
|
|
55
84
|
const home = homedir();
|
|
56
85
|
|
|
57
86
|
return [
|
|
@@ -59,14 +88,19 @@ const cacheRoots = (): string[] => {
|
|
|
59
88
|
path.join(home, ".npm"),
|
|
60
89
|
path.join(home, ".local", "share", "uv"),
|
|
61
90
|
path.join(home, ".local", "share", "mise"),
|
|
91
|
+
...(store === undefined ? [] : [store]),
|
|
62
92
|
];
|
|
63
93
|
};
|
|
64
94
|
|
|
65
|
-
const writableRoots = (
|
|
95
|
+
const writableRoots = (
|
|
96
|
+
workdir: string,
|
|
97
|
+
gitDir: string | undefined,
|
|
98
|
+
store: string | undefined
|
|
99
|
+
) =>
|
|
66
100
|
[
|
|
67
101
|
workdir,
|
|
68
102
|
gitDir,
|
|
69
|
-
...cacheRoots(),
|
|
103
|
+
...cacheRoots(store),
|
|
70
104
|
path.join(homedir(), "Library", "Caches"),
|
|
71
105
|
"/private/var/folders",
|
|
72
106
|
tmpdir(),
|
|
@@ -114,7 +148,8 @@ const bwrapArgs = (
|
|
|
114
148
|
bwrap: string,
|
|
115
149
|
command: string,
|
|
116
150
|
workdir: string,
|
|
117
|
-
gitDir: string | undefined
|
|
151
|
+
gitDir: string | undefined,
|
|
152
|
+
store: string | undefined
|
|
118
153
|
): string[] => [
|
|
119
154
|
bwrap,
|
|
120
155
|
"--ro-bind",
|
|
@@ -125,7 +160,7 @@ const bwrapArgs = (
|
|
|
125
160
|
"/dev",
|
|
126
161
|
"--proc",
|
|
127
162
|
"/proc",
|
|
128
|
-
...existing(writableRoots(workdir, gitDir)).flatMap((root) =>
|
|
163
|
+
...existing(writableRoots(workdir, gitDir, store)).flatMap((root) =>
|
|
129
164
|
root === "/dev" ? [] : ["--bind", root, root]
|
|
130
165
|
),
|
|
131
166
|
...existing(
|
|
@@ -158,10 +193,18 @@ const probeBwrap = async (bwrap: string): Promise<string | undefined> => {
|
|
|
158
193
|
|
|
159
194
|
// A missing root can't be bound, and a missing hooks directory could be
|
|
160
195
|
// created by the child, so both are made on the host first. They are empty
|
|
161
|
-
// directories, so git and the tools see no change.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
// directories, so git and the tools see no change. A root the user can't
|
|
197
|
+
// create stays unbound.
|
|
198
|
+
const prepareBinds = (
|
|
199
|
+
gitDir: string | undefined,
|
|
200
|
+
store: string | undefined
|
|
201
|
+
): void => {
|
|
202
|
+
for (const root of cacheRoots(store)) {
|
|
203
|
+
try {
|
|
204
|
+
mkdirSync(root, { recursive: true });
|
|
205
|
+
} catch {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
165
208
|
}
|
|
166
209
|
|
|
167
210
|
if (gitDir !== undefined) {
|
|
@@ -169,17 +212,23 @@ const prepareBinds = (gitDir: string | undefined): void => {
|
|
|
169
212
|
}
|
|
170
213
|
};
|
|
171
214
|
|
|
215
|
+
const childEnv = async (workdir: string): Promise<ChildEnv> => ({
|
|
216
|
+
pnpmStore: await pnpmStore(workdir),
|
|
217
|
+
runtimeDir: mkdtempSync(path.join(tmpdir(), "pi-t3-child-")),
|
|
218
|
+
});
|
|
219
|
+
|
|
172
220
|
// Run once per child, before its first command. "childSandbox": "off" in the
|
|
173
221
|
// config wins on every platform.
|
|
174
222
|
export const chooseSandbox = async (
|
|
223
|
+
workdir: string,
|
|
175
224
|
gitDir: string | undefined
|
|
176
225
|
): Promise<Sandbox> => {
|
|
177
226
|
if (readConfig().childSandbox === "off") {
|
|
178
|
-
return { kind: "off" };
|
|
227
|
+
return { env: await childEnv(workdir), kind: "off" };
|
|
179
228
|
}
|
|
180
229
|
|
|
181
230
|
if (process.platform === "darwin" && existsSync(SANDBOX)) {
|
|
182
|
-
return { kind: "sandbox-exec" };
|
|
231
|
+
return { env: await childEnv(workdir), kind: "sandbox-exec" };
|
|
183
232
|
}
|
|
184
233
|
|
|
185
234
|
if (process.platform !== "linux") {
|
|
@@ -204,11 +253,24 @@ export const chooseSandbox = async (
|
|
|
204
253
|
return { kind: "unavailable", reason: problem };
|
|
205
254
|
}
|
|
206
255
|
|
|
207
|
-
|
|
256
|
+
const env = await childEnv(workdir);
|
|
208
257
|
|
|
209
|
-
|
|
258
|
+
prepareBinds(gitDir, env.pnpmStore);
|
|
259
|
+
|
|
260
|
+
return { bwrap, env, kind: "bubblewrap" };
|
|
210
261
|
};
|
|
211
262
|
|
|
263
|
+
// pnpm 12 takes its store lock in $XDG_RUNTIME_DIR, which the sandbox keeps
|
|
264
|
+
// read-only, so each child gets its own. Each bwrap bind is its own mount, and
|
|
265
|
+
// Linux refuses a hardlink between mounts, so pnpm would build a second store
|
|
266
|
+
// in the workdir. Naming the store keeps the one pnpm picked for the parent,
|
|
267
|
+
// and pnpm copies from it. Install scripts stay off, so a `prepare` such as `lefthook install`
|
|
268
|
+
// doesn't fail on the read-only hooks. npm and yarn read npm_config_*, pnpm 11
|
|
269
|
+
// and later read pnpm_config_*. npm also skips pre and post scripts of
|
|
270
|
+
// `npm run` then.
|
|
271
|
+
const exportEnv = (env: ChildEnv, command: string): string =>
|
|
272
|
+
`export XDG_RUNTIME_DIR=${quote(env.runtimeDir)}${env.pnpmStore === undefined ? "" : ` pnpm_config_store_dir=${quote(env.pnpmStore)}`} npm_config_ignore_scripts=true pnpm_config_ignore_scripts=true; ${command}`;
|
|
273
|
+
|
|
212
274
|
// Undefined when no sandbox runs: the caller must not run the command.
|
|
213
275
|
export const wrap = (
|
|
214
276
|
sandbox: Sandbox | undefined,
|
|
@@ -218,17 +280,29 @@ export const wrap = (
|
|
|
218
280
|
): string | undefined => {
|
|
219
281
|
switch (sandbox?.kind) {
|
|
220
282
|
case "sandbox-exec": {
|
|
221
|
-
return
|
|
283
|
+
return exportEnv(
|
|
284
|
+
sandbox.env,
|
|
285
|
+
`${SANDBOX} -p ${quote(sandboxProfile(writableRoots(workdir, gitDir, sandbox.env.pnpmStore), gitDenies(gitDir)))} /bin/bash -c ${quote(command)}`
|
|
286
|
+
);
|
|
222
287
|
}
|
|
223
288
|
|
|
224
289
|
case "bubblewrap": {
|
|
225
|
-
return
|
|
226
|
-
.
|
|
227
|
-
|
|
290
|
+
return exportEnv(
|
|
291
|
+
sandbox.env,
|
|
292
|
+
bwrapArgs(
|
|
293
|
+
sandbox.bwrap,
|
|
294
|
+
command,
|
|
295
|
+
workdir,
|
|
296
|
+
gitDir,
|
|
297
|
+
sandbox.env.pnpmStore
|
|
298
|
+
)
|
|
299
|
+
.map(quote)
|
|
300
|
+
.join(" ")
|
|
301
|
+
);
|
|
228
302
|
}
|
|
229
303
|
|
|
230
304
|
case "off": {
|
|
231
|
-
return command;
|
|
305
|
+
return exportEnv(sandbox.env, command);
|
|
232
306
|
}
|
|
233
307
|
|
|
234
308
|
default: {
|
package/amplike.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
|
|
3
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
findAmplike,
|
|
7
|
-
loadFactory,
|
|
8
|
-
loadModeSpecFrom,
|
|
9
|
-
} from "./src/amplike/package.ts";
|
|
10
|
-
import { t3View } from "./src/amplike/t3-view.ts";
|
|
11
|
-
import { insideT3 } from "./src/bridge/t3-http.ts";
|
|
12
|
-
|
|
13
|
-
// T3 asks the user before risky tool calls itself, so amplike's bash prompt
|
|
14
|
-
// would ask a second time.
|
|
15
|
-
const T3_SKIPPED_EXTENSIONS: ReadonlySet<string> = new Set(["permissions"]);
|
|
16
|
-
|
|
17
|
-
// Loads pi-amplike's extensions in place of Pi, so a T3 thread gets T3's
|
|
18
|
-
// versions of the parts T3 can't show. Outside T3 amplike loads unchanged.
|
|
19
|
-
// It runs only when the settings entry for amplike turns its extensions off.
|
|
20
|
-
const amplikeExtension = async (pi: ExtensionAPI): Promise<void> => {
|
|
21
|
-
const amplike = await findAmplike(process.cwd());
|
|
22
|
-
|
|
23
|
-
if (amplike === undefined) {
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (amplike.selfLoaded) {
|
|
28
|
-
if (insideT3()) {
|
|
29
|
-
pi.on("session_start", (_event, ctx) => {
|
|
30
|
-
if (ctx.hasUI) {
|
|
31
|
-
ctx.ui.notify(
|
|
32
|
-
`pi-t3 can't adapt pi-amplike for T3 while pi-amplike loads its own extensions. In ~/.pi/agent/settings.json, change "${amplike.source}" to {"source":"${amplike.source}","extensions":[]}.`,
|
|
33
|
-
"warning"
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const inT3 = insideT3();
|
|
43
|
-
|
|
44
|
-
const host = inT3 ? t3View(pi, await loadModeSpecFrom(amplike.root)) : pi;
|
|
45
|
-
|
|
46
|
-
const files = inT3
|
|
47
|
-
? amplike.extensions.filter(
|
|
48
|
-
(file) => !T3_SKIPPED_EXTENSIONS.has(path.parse(file).name)
|
|
49
|
-
)
|
|
50
|
-
: amplike.extensions;
|
|
51
|
-
|
|
52
|
-
const factories = await Promise.all(files.map(loadFactory));
|
|
53
|
-
|
|
54
|
-
// Each call starts in file order, as when Pi loads the files itself.
|
|
55
|
-
await Promise.all(factories.map((factory) => factory(host)));
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export default amplikeExtension;
|
package/src/amplike/btw.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { compile, nullable, obj, opt, str } from "@earendil-works/pi-schema";
|
|
3
|
-
|
|
4
|
-
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
5
|
-
import type { LoadModeSpec } from "./package.ts";
|
|
6
|
-
import {
|
|
7
|
-
changeFromFlags,
|
|
8
|
-
currentSelection,
|
|
9
|
-
isEmptyChange,
|
|
10
|
-
parseFlags,
|
|
11
|
-
} from "./selection.ts";
|
|
12
|
-
import type { SelectionChange } from "./selection.ts";
|
|
13
|
-
|
|
14
|
-
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
15
|
-
|
|
16
|
-
const isDelegated = compile(
|
|
17
|
-
obj({ link: opt(nullable(str())), taskId: str() }, { open: true })
|
|
18
|
-
).check;
|
|
19
|
-
|
|
20
|
-
const TITLE_LENGTH = 80;
|
|
21
|
-
|
|
22
|
-
const USAGE = "Usage: /btw [-mode <name>] [-model <provider/id>] <prompt>";
|
|
23
|
-
|
|
24
|
-
// delegate_task's target for a Pi child: model as "provider/modelId", and the
|
|
25
|
-
// thinking level as an option. A flag that names only one keeps the other.
|
|
26
|
-
const targetFor = async (change: SelectionChange) => {
|
|
27
|
-
const current = await currentSelection();
|
|
28
|
-
const options: Record<string, string> = {};
|
|
29
|
-
|
|
30
|
-
if (change.thinking !== undefined) {
|
|
31
|
-
options.thinking = change.thinking;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return { model: change.model ?? current.model, options };
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
// In T3, /btw is a delegate_task child. The child shows up in T3, and its
|
|
38
|
-
// result wakes this thread when it finishes.
|
|
39
|
-
export const btwCommand = (loadModeSpec: LoadModeSpec): Command => ({
|
|
40
|
-
description:
|
|
41
|
-
"Ask a side question in a T3 child thread: /btw [-mode <name>] [-model <provider/id>] <prompt>",
|
|
42
|
-
handler: async (args, ctx) => {
|
|
43
|
-
try {
|
|
44
|
-
const { flags, text } = parseFlags(args);
|
|
45
|
-
|
|
46
|
-
if (text.length === 0) {
|
|
47
|
-
throw new Error(USAGE);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const change = await changeFromFlags(flags, ctx.cwd, loadModeSpec);
|
|
51
|
-
const parent = ctx.sessionManager.getSessionFile();
|
|
52
|
-
|
|
53
|
-
const task = [
|
|
54
|
-
"The user asked this side question while working in another thread. Answer it. Inspect only, change no file.",
|
|
55
|
-
text,
|
|
56
|
-
...(parent === undefined
|
|
57
|
-
? []
|
|
58
|
-
: [
|
|
59
|
-
`The other thread's Pi session file is \`${parent}\`. Call session_query on it for any context the question needs.`,
|
|
60
|
-
]),
|
|
61
|
-
].join("\n\n");
|
|
62
|
-
|
|
63
|
-
const delegate = {
|
|
64
|
-
mode: "async",
|
|
65
|
-
role: "research",
|
|
66
|
-
task,
|
|
67
|
-
title: `btw: ${text}`.slice(0, TITLE_LENGTH),
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const delegated = isEmptyChange(change)
|
|
71
|
-
? await callT3Tool("delegate_task", delegate)
|
|
72
|
-
: await callT3Tool("delegate_task", {
|
|
73
|
-
...delegate,
|
|
74
|
-
target: await targetFor(change),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
if (!isDelegated(delegated)) {
|
|
78
|
-
throw new Error("delegate_task returned no task.");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
ctx.ui.notify(
|
|
82
|
-
`btw started: ${delegated.link ?? delegated.taskId}`,
|
|
83
|
-
"info"
|
|
84
|
-
);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
ctx.ui.notify(`btw failed: ${String(error)}`, "error");
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
});
|
package/src/amplike/handoff.ts
DELETED
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
3
|
-
convertToLlm,
|
|
4
|
-
defineTool,
|
|
5
|
-
serializeConversation,
|
|
6
|
-
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type {
|
|
8
|
-
ExtensionAPI,
|
|
9
|
-
ExtensionContext,
|
|
10
|
-
} from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import { compile, nullable, obj, opt, str } from "@earendil-works/pi-schema";
|
|
12
|
-
import type { Static } from "@earendil-works/pi-schema";
|
|
13
|
-
|
|
14
|
-
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
15
|
-
import type { LoadModeSpec } from "./package.ts";
|
|
16
|
-
import {
|
|
17
|
-
applyChange,
|
|
18
|
-
changeFromFlags,
|
|
19
|
-
currentSelection,
|
|
20
|
-
isEmptyChange,
|
|
21
|
-
parseFlags,
|
|
22
|
-
} from "./selection.ts";
|
|
23
|
-
import type { Flags } from "./selection.ts";
|
|
24
|
-
|
|
25
|
-
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
26
|
-
|
|
27
|
-
const SUMMARY_PROMPT = `You write the first message of a new thread that continues work from a conversation. Given the conversation and the user's goal for the new thread, write a self-contained prompt with:
|
|
28
|
-
|
|
29
|
-
## Context
|
|
30
|
-
The decisions made, approaches taken, and key findings that matter for the goal.
|
|
31
|
-
|
|
32
|
-
Files involved:
|
|
33
|
-
- each relevant file that was discussed or changed
|
|
34
|
-
|
|
35
|
-
## Task
|
|
36
|
-
What the new thread should do next, based on the goal.
|
|
37
|
-
|
|
38
|
-
Output only the prompt, with no preamble.`;
|
|
39
|
-
|
|
40
|
-
const TITLE_LENGTH = 80;
|
|
41
|
-
|
|
42
|
-
const isLaunched = compile(
|
|
43
|
-
obj({ link: opt(nullable(str())), threadId: str() }, { open: true })
|
|
44
|
-
).check;
|
|
45
|
-
|
|
46
|
-
const HandoffSchema = obj(
|
|
47
|
-
{
|
|
48
|
-
goal: str({ description: "The goal/task for the new thread" }),
|
|
49
|
-
mode: opt(
|
|
50
|
-
str({
|
|
51
|
-
description:
|
|
52
|
-
"Amplike mode name to start the new thread with (e.g. 'rush', 'smart', 'deep')",
|
|
53
|
-
})
|
|
54
|
-
),
|
|
55
|
-
model: opt(
|
|
56
|
-
str({
|
|
57
|
-
description:
|
|
58
|
-
"Model to start the new thread with, as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')",
|
|
59
|
-
})
|
|
60
|
-
),
|
|
61
|
-
},
|
|
62
|
-
{ open: true }
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
const summarize = async (
|
|
66
|
-
ctx: ExtensionContext,
|
|
67
|
-
goal: string,
|
|
68
|
-
signal: AbortSignal | undefined
|
|
69
|
-
): Promise<string> => {
|
|
70
|
-
const { model } = ctx;
|
|
71
|
-
|
|
72
|
-
if (model === undefined) {
|
|
73
|
-
throw new Error("No model selected.");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const messages = ctx.sessionManager
|
|
77
|
-
.getBranch()
|
|
78
|
-
.flatMap((entry) => (entry.type === "message" ? [entry.message] : []));
|
|
79
|
-
|
|
80
|
-
if (messages.length === 0) {
|
|
81
|
-
throw new Error("No conversation to hand off.");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const request: Message = {
|
|
85
|
-
content: [
|
|
86
|
-
{
|
|
87
|
-
text: `## Conversation\n\n${serializeConversation(convertToLlm(messages))}\n\n## Goal for the new thread\n\n${goal}`,
|
|
88
|
-
type: "text",
|
|
89
|
-
},
|
|
90
|
-
],
|
|
91
|
-
role: "user",
|
|
92
|
-
timestamp: Date.now(),
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const response = await ctx.modelRegistry
|
|
96
|
-
.streamSimple(
|
|
97
|
-
model,
|
|
98
|
-
{ messages: [request], systemPrompt: SUMMARY_PROMPT },
|
|
99
|
-
{ signal }
|
|
100
|
-
)
|
|
101
|
-
.result();
|
|
102
|
-
|
|
103
|
-
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
104
|
-
throw new Error(
|
|
105
|
-
response.errorMessage ?? `The summary ${response.stopReason}.`
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return response.content
|
|
110
|
-
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
111
|
-
.join("\n");
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
// In T3 a handoff is a new T3 thread. Pi keeps its own session file, which is
|
|
115
|
-
// the one T3 resumes this thread from.
|
|
116
|
-
const handoff = async (
|
|
117
|
-
ctx: ExtensionContext,
|
|
118
|
-
goal: string,
|
|
119
|
-
flags: Flags,
|
|
120
|
-
loadModeSpec: LoadModeSpec,
|
|
121
|
-
signal: AbortSignal | undefined
|
|
122
|
-
): Promise<string> => {
|
|
123
|
-
const change = await changeFromFlags(flags, ctx.cwd, loadModeSpec);
|
|
124
|
-
const summary = await summarize(ctx, goal, signal);
|
|
125
|
-
const parent = ctx.sessionManager.getSessionFile();
|
|
126
|
-
|
|
127
|
-
const message = [
|
|
128
|
-
goal,
|
|
129
|
-
...(parent === undefined
|
|
130
|
-
? []
|
|
131
|
-
: ["/skill:session-query", `**Parent session:** \`${parent}\``]),
|
|
132
|
-
summary,
|
|
133
|
-
].join("\n\n");
|
|
134
|
-
|
|
135
|
-
const launch = { message, title: goal.slice(0, TITLE_LENGTH) };
|
|
136
|
-
|
|
137
|
-
const launched = isEmptyChange(change)
|
|
138
|
-
? await callT3Tool("t3_thread_launch", launch)
|
|
139
|
-
: await callT3Tool("t3_thread_launch", {
|
|
140
|
-
...launch,
|
|
141
|
-
modelSelection: { ...applyChange(await currentSelection(), change) },
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
if (!isLaunched(launched)) {
|
|
145
|
-
throw new Error("t3_thread_launch returned no thread.");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return launched.link ?? launched.threadId;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
export const handoffTool = (loadModeSpec: LoadModeSpec) =>
|
|
152
|
-
defineTool({
|
|
153
|
-
description:
|
|
154
|
-
"Transfer context to a new focused T3 thread. ONLY use this when the user explicitly asks for a handoff. Provide a goal describing what the new thread should focus on. The result is the new thread's link, which you show the user verbatim.",
|
|
155
|
-
execute: async (
|
|
156
|
-
_toolCallId,
|
|
157
|
-
params: Static<typeof HandoffSchema>,
|
|
158
|
-
signal,
|
|
159
|
-
_onUpdate,
|
|
160
|
-
ctx
|
|
161
|
-
) => {
|
|
162
|
-
const link = await handoff(
|
|
163
|
-
ctx,
|
|
164
|
-
params.goal,
|
|
165
|
-
{ mode: params.mode, model: params.model },
|
|
166
|
-
loadModeSpec,
|
|
167
|
-
signal
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
return {
|
|
171
|
-
content: [{ text: `Handoff thread started: ${link}`, type: "text" }],
|
|
172
|
-
details: { link },
|
|
173
|
-
};
|
|
174
|
-
},
|
|
175
|
-
label: "Handoff",
|
|
176
|
-
name: "handoff",
|
|
177
|
-
parameters: HandoffSchema,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
export const handoffCommand = (loadModeSpec: LoadModeSpec): Command => ({
|
|
181
|
-
description:
|
|
182
|
-
"Start a new T3 thread with a summary of this one: /handoff [-mode <name>] [-model <provider/id>] <goal>",
|
|
183
|
-
handler: async (args, ctx) => {
|
|
184
|
-
try {
|
|
185
|
-
const { flags, text } = parseFlags(args);
|
|
186
|
-
|
|
187
|
-
if (text.length === 0) {
|
|
188
|
-
throw new Error(
|
|
189
|
-
"Usage: /handoff [-mode <name>] [-model <provider/id>] <goal>"
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const link = await handoff(ctx, text, flags, loadModeSpec, ctx.signal);
|
|
194
|
-
|
|
195
|
-
ctx.ui.notify(`Handoff thread started: ${link}`, "info");
|
|
196
|
-
} catch (error) {
|
|
197
|
-
ctx.ui.notify(`Handoff failed: ${String(error)}`, "error");
|
|
198
|
-
}
|
|
199
|
-
},
|
|
200
|
-
});
|
package/src/amplike/package.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
DefaultPackageManager,
|
|
6
|
-
getAgentDir,
|
|
7
|
-
SettingsManager,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type {
|
|
10
|
-
ExtensionFactory,
|
|
11
|
-
ResolvedResource,
|
|
12
|
-
} from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { compile, obj, opt, str, unknown } from "@earendil-works/pi-schema";
|
|
14
|
-
import type { Static } from "@earendil-works/pi-schema";
|
|
15
|
-
|
|
16
|
-
const PACKAGE_NAME = "pi-amplike";
|
|
17
|
-
|
|
18
|
-
const isManifest = compile(obj({ name: str() }, { open: true })).check;
|
|
19
|
-
|
|
20
|
-
// JSON Schema has no function type, so these check that the export exists and
|
|
21
|
-
// isFunction checks that it is a function. Like TypeBox's Type.Function, the
|
|
22
|
-
// check can't see the function's parameters, so the caller names them.
|
|
23
|
-
const isFunction = <F extends (...args: never[]) => void>(
|
|
24
|
-
value: unknown
|
|
25
|
-
): value is F => typeof value === "function";
|
|
26
|
-
|
|
27
|
-
const isFactoryModule = compile(
|
|
28
|
-
obj({ default: unknown() }, { open: true })
|
|
29
|
-
).check;
|
|
30
|
-
|
|
31
|
-
const isModeUtils = compile(
|
|
32
|
-
obj({ loadModeSpec: unknown() }, { open: true })
|
|
33
|
-
).check;
|
|
34
|
-
|
|
35
|
-
const ModeSpecSchema = obj(
|
|
36
|
-
{
|
|
37
|
-
modelId: opt(str()),
|
|
38
|
-
provider: opt(str()),
|
|
39
|
-
thinkingLevel: opt(str()),
|
|
40
|
-
},
|
|
41
|
-
{ open: true }
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
const isModeSpec = compile(ModeSpecSchema).check;
|
|
45
|
-
|
|
46
|
-
export type ModeSpec = Static<typeof ModeSpecSchema>;
|
|
47
|
-
|
|
48
|
-
export type LoadModeSpec = (
|
|
49
|
-
cwd: string,
|
|
50
|
-
name: string
|
|
51
|
-
) => Promise<ModeSpec | undefined>;
|
|
52
|
-
|
|
53
|
-
export interface Amplike {
|
|
54
|
-
readonly extensions: readonly string[];
|
|
55
|
-
readonly root: string;
|
|
56
|
-
// True when Pi loads amplike's extensions itself, so this adapter must not.
|
|
57
|
-
readonly selfLoaded: boolean;
|
|
58
|
-
readonly source: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const isAmplikeRoot = (root: string): boolean => {
|
|
62
|
-
try {
|
|
63
|
-
const manifest: unknown = JSON.parse(
|
|
64
|
-
readFileSync(path.join(root, "package.json"), "utf-8")
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
return isManifest(manifest) && manifest.name === PACKAGE_NAME;
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const isAmplike = (resource: ResolvedResource): boolean =>
|
|
74
|
-
resource.metadata.packageRoot !== undefined &&
|
|
75
|
-
isAmplikeRoot(resource.metadata.packageRoot);
|
|
76
|
-
|
|
77
|
-
// Pi's own package manager gives the extension files amplike's manifest names,
|
|
78
|
-
// and whether the settings entry for amplike turns them off.
|
|
79
|
-
export const findAmplike = async (
|
|
80
|
-
cwd: string
|
|
81
|
-
): Promise<Amplike | undefined> => {
|
|
82
|
-
const agentDir = getAgentDir();
|
|
83
|
-
|
|
84
|
-
const manager = new DefaultPackageManager({
|
|
85
|
-
agentDir,
|
|
86
|
-
cwd,
|
|
87
|
-
settingsManager: SettingsManager.create(cwd, agentDir),
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
const resolved = await manager.resolve(async () => "skip");
|
|
91
|
-
const own = resolved.extensions.filter(isAmplike);
|
|
92
|
-
const [first] = own;
|
|
93
|
-
|
|
94
|
-
if (first?.metadata.packageRoot === undefined) {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
extensions: own.map((resource) => resource.path),
|
|
100
|
-
root: first.metadata.packageRoot,
|
|
101
|
-
selfLoaded: own.some((resource) => resource.enabled),
|
|
102
|
-
source: first.metadata.source,
|
|
103
|
-
};
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
export const loadFactory = async (file: string): Promise<ExtensionFactory> => {
|
|
107
|
-
const module: unknown = await import(file);
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
!isFactoryModule(module) ||
|
|
111
|
-
!isFunction<ExtensionFactory>(module.default)
|
|
112
|
-
) {
|
|
113
|
-
throw new TypeError(`${file} has no default extension factory.`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return module.default;
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// amplike's handoff and subagent read -mode through this helper, so the T3
|
|
120
|
-
// replacements read it the same way.
|
|
121
|
-
export const loadModeSpecFrom = async (root: string): Promise<LoadModeSpec> => {
|
|
122
|
-
const file = path.join(root, "extensions", "lib", "mode-utils.ts");
|
|
123
|
-
const module: unknown = await import(file);
|
|
124
|
-
|
|
125
|
-
if (!isModeUtils(module) || !isFunction<LoadModeSpec>(module.loadModeSpec)) {
|
|
126
|
-
throw new TypeError(`${file} has no loadModeSpec export.`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const { loadModeSpec } = module;
|
|
130
|
-
|
|
131
|
-
return async (cwd, name) => {
|
|
132
|
-
const spec: unknown = await loadModeSpec(cwd, name);
|
|
133
|
-
|
|
134
|
-
return isModeSpec(spec) ? spec : undefined;
|
|
135
|
-
};
|
|
136
|
-
};
|
package/src/amplike/selection.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
arr,
|
|
3
|
-
bool,
|
|
4
|
-
compile,
|
|
5
|
-
obj,
|
|
6
|
-
opt,
|
|
7
|
-
str,
|
|
8
|
-
union,
|
|
9
|
-
} from "@earendil-works/pi-schema";
|
|
10
|
-
import type { Static } from "@earendil-works/pi-schema";
|
|
11
|
-
|
|
12
|
-
import { callT3Tool } from "../bridge/t3-http.ts";
|
|
13
|
-
import type { LoadModeSpec } from "./package.ts";
|
|
14
|
-
|
|
15
|
-
const OptionSchema = obj(
|
|
16
|
-
{ id: str(), value: union([str(), bool()]) },
|
|
17
|
-
{ open: true }
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const SelectionSchema = obj(
|
|
21
|
-
{
|
|
22
|
-
instanceId: opt(str()),
|
|
23
|
-
model: str(),
|
|
24
|
-
options: opt(arr(OptionSchema)),
|
|
25
|
-
},
|
|
26
|
-
{ open: true }
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const isConfigured = compile(
|
|
30
|
-
obj({ modelSelection: SelectionSchema }, { open: true })
|
|
31
|
-
).check;
|
|
32
|
-
|
|
33
|
-
// T3's modelSelection for a Pi thread: model is "provider/modelId", and the
|
|
34
|
-
// thinking level is the "thinking" option.
|
|
35
|
-
export type Selection = Static<typeof SelectionSchema>;
|
|
36
|
-
|
|
37
|
-
export interface SelectionChange {
|
|
38
|
-
model?: string;
|
|
39
|
-
thinking?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export interface Flags {
|
|
43
|
-
mode?: string;
|
|
44
|
-
model?: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ParsedArgs {
|
|
48
|
-
readonly flags: Flags;
|
|
49
|
-
readonly text: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const THINKING = "thinking";
|
|
53
|
-
|
|
54
|
-
export const isEmptyChange = (change: SelectionChange): boolean =>
|
|
55
|
-
change.model === undefined && change.thinking === undefined;
|
|
56
|
-
|
|
57
|
-
export const currentSelection = async (): Promise<Selection> => {
|
|
58
|
-
const result = await callT3Tool("t3_thread_configure", {});
|
|
59
|
-
|
|
60
|
-
if (!isConfigured(result)) {
|
|
61
|
-
throw new Error("t3_thread_configure returned no modelSelection.");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return result.modelSelection;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export const applyChange = (
|
|
68
|
-
current: Selection,
|
|
69
|
-
change: SelectionChange
|
|
70
|
-
): Selection => {
|
|
71
|
-
const next: Selection = { ...current, model: change.model ?? current.model };
|
|
72
|
-
|
|
73
|
-
if (change.thinking !== undefined) {
|
|
74
|
-
next.options = [
|
|
75
|
-
...(current.options ?? []).filter((option) => option.id !== THINKING),
|
|
76
|
-
{ id: THINKING, value: change.thinking },
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return next;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export const configureThread = async (selection: Selection): Promise<void> => {
|
|
84
|
-
await callT3Tool("t3_thread_configure", { modelSelection: { ...selection } });
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// amplike's commands take `-mode <name>` and `-model <provider/id>` before the
|
|
88
|
-
// text, in either order.
|
|
89
|
-
export const parseFlags = (args: string): ParsedArgs => {
|
|
90
|
-
const words = args.trim().split(/\s+/u);
|
|
91
|
-
const flags: Flags = {};
|
|
92
|
-
|
|
93
|
-
while (words[0] === "-mode" || words[0] === "-model") {
|
|
94
|
-
const [flag, value] = words.splice(0, 2);
|
|
95
|
-
|
|
96
|
-
if (value === undefined) {
|
|
97
|
-
throw new Error(`${flag} needs a value.`);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (flag === "-mode") {
|
|
101
|
-
flags.mode = value;
|
|
102
|
-
} else {
|
|
103
|
-
flags.model = value;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return { flags, text: words.join(" ") };
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// Same order as amplike: the mode sets model and thinking, then -model
|
|
111
|
-
// overrides the model.
|
|
112
|
-
export const changeFromFlags = async (
|
|
113
|
-
flags: Flags,
|
|
114
|
-
cwd: string,
|
|
115
|
-
loadModeSpec: LoadModeSpec
|
|
116
|
-
): Promise<SelectionChange> => {
|
|
117
|
-
const change: SelectionChange = {};
|
|
118
|
-
|
|
119
|
-
if (flags.mode !== undefined) {
|
|
120
|
-
const spec = await loadModeSpec(cwd, flags.mode);
|
|
121
|
-
|
|
122
|
-
if (spec === undefined) {
|
|
123
|
-
throw new Error(`Unknown mode "${flags.mode}".`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (spec.provider !== undefined && spec.modelId !== undefined) {
|
|
127
|
-
change.model = `${spec.provider}/${spec.modelId}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
change.thinking = spec.thinkingLevel;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (flags.model !== undefined) {
|
|
134
|
-
if (!flags.model.includes("/")) {
|
|
135
|
-
throw new Error(
|
|
136
|
-
`Model "${flags.model}" must be written as provider/modelId.`
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
change.model = flags.model;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return change;
|
|
144
|
-
};
|
package/src/amplike/t3-view.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import { btwCommand } from "./btw.ts";
|
|
4
|
-
import { handoffCommand, handoffTool } from "./handoff.ts";
|
|
5
|
-
import type { LoadModeSpec } from "./package.ts";
|
|
6
|
-
import { applyChange, configureThread, currentSelection } from "./selection.ts";
|
|
7
|
-
import type { SelectionChange } from "./selection.ts";
|
|
8
|
-
|
|
9
|
-
type Tool = Parameters<ExtensionAPI["registerTool"]>[0];
|
|
10
|
-
|
|
11
|
-
type Command = Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
12
|
-
|
|
13
|
-
// What a T3 thread gets for each name amplike registers. A missing name is
|
|
14
|
-
// registered as amplike wrote it. null drops it.
|
|
15
|
-
interface Rules {
|
|
16
|
-
readonly commands: Readonly<Record<string, Command | null>>;
|
|
17
|
-
readonly tools: Readonly<Record<string, Tool | null>>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const t3Rules = (loadModeSpec: LoadModeSpec): Rules => ({
|
|
21
|
-
commands: {
|
|
22
|
-
btw: btwCommand(loadModeSpec),
|
|
23
|
-
handoff: handoffCommand(loadModeSpec),
|
|
24
|
-
},
|
|
25
|
-
tools: {
|
|
26
|
-
handoff: handoffTool(loadModeSpec),
|
|
27
|
-
// T3's delegate_task runs subagents as T3 threads.
|
|
28
|
-
subagent: null,
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// T3 owns the thread's model. When amplike's /mode switches Pi's model or
|
|
33
|
-
// thinking level, the same change goes to T3, so T3's model picker matches and
|
|
34
|
-
// T3's next set_model keeps it. amplike awaits setModel before it sets the
|
|
35
|
-
// thinking level, so the two calls reach T3 in order.
|
|
36
|
-
const mirror = async (change: SelectionChange): Promise<void> => {
|
|
37
|
-
await configureThread(applyChange(await currentSelection(), change));
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const mirrorThinking = async (thinking: string): Promise<void> => {
|
|
41
|
-
try {
|
|
42
|
-
await mirror({ thinking });
|
|
43
|
-
} catch (error) {
|
|
44
|
-
console.error(`pi-t3: T3 kept its thinking level: ${String(error)}`);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Pi's extension API is a plain object of closures, so a copy with some
|
|
49
|
-
// methods replaced behaves like the original for everything else.
|
|
50
|
-
export const t3View = (
|
|
51
|
-
pi: ExtensionAPI,
|
|
52
|
-
loadModeSpec: LoadModeSpec
|
|
53
|
-
): ExtensionAPI => {
|
|
54
|
-
const rules = t3Rules(loadModeSpec);
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
...pi,
|
|
58
|
-
registerCommand: (name, options) => {
|
|
59
|
-
const rule = rules.commands[name];
|
|
60
|
-
|
|
61
|
-
if (rule !== null) {
|
|
62
|
-
pi.registerCommand(name, rule ?? options);
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
// T3 sends Pi no key presses.
|
|
66
|
-
registerShortcut: () => null,
|
|
67
|
-
registerTool: (tool) => {
|
|
68
|
-
const rule = rules.tools[tool.name];
|
|
69
|
-
|
|
70
|
-
if (rule !== null) {
|
|
71
|
-
pi.registerTool(rule ?? tool);
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
setModel: async (model) => {
|
|
75
|
-
const ok = await pi.setModel(model);
|
|
76
|
-
|
|
77
|
-
if (ok) {
|
|
78
|
-
await mirror({ model: `${model.provider}/${model.id}` });
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return ok;
|
|
82
|
-
},
|
|
83
|
-
setThinkingLevel: (level) => {
|
|
84
|
-
pi.setThinkingLevel(level);
|
|
85
|
-
void mirrorThinking(level);
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
};
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getCurrentSystemMessage,
|
|
3
|
-
getCurrentSystemPrompt,
|
|
4
|
-
} from "@earendil-works/pi-ai";
|
|
5
|
-
import type {
|
|
6
|
-
BeforeAgentStartEvent,
|
|
7
|
-
ExtensionAPI,
|
|
8
|
-
ExtensionContext,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
|
|
11
|
-
// T3 shares its orchestration instructions across providers. Pi has neither
|
|
12
|
-
// Claude's Agent tool nor Codex's spawn_agent, so route its subagents through T3.
|
|
13
|
-
// Match the bullet by its label, so a reworded bullet still matches.
|
|
14
|
-
const T3_MARKER = "## T3 Code orchestration";
|
|
15
|
-
|
|
16
|
-
// The section ends at the next h2 heading or closing tag.
|
|
17
|
-
const SECTION = /## T3 Code orchestration[\s\S]*?(?=\n## |\n<\/|$)/gu;
|
|
18
|
-
|
|
19
|
-
const SUBAGENTS_BULLET = /^- Subagents:.*$/gmu;
|
|
20
|
-
|
|
21
|
-
const SUBAGENTS_LABEL = /^- Subagents:/mu;
|
|
22
|
-
|
|
23
|
-
const REPLACEMENT =
|
|
24
|
-
"- Subagents: use `delegate_task` for subagent work. Workflow only when asked.";
|
|
25
|
-
|
|
26
|
-
const trim = (text: string): string =>
|
|
27
|
-
text.replaceAll(SECTION, (section) =>
|
|
28
|
-
section.replaceAll(SUBAGENTS_BULLET, REPLACEMENT)
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
export const registerBridgeTrim = (pi: ExtensionAPI): void => {
|
|
32
|
-
let warned = false;
|
|
33
|
-
let runOptions: BeforeAgentStartEvent["systemPromptOptions"] | undefined;
|
|
34
|
-
|
|
35
|
-
const warnOnDrift = (text: string, ctx: ExtensionContext): void => {
|
|
36
|
-
if (!(text.includes(T3_MARKER) && ctx.hasUI) || warned) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const found = (text.match(SECTION) ?? []).some((section) =>
|
|
41
|
-
SUBAGENTS_LABEL.test(section)
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
if (!found) {
|
|
45
|
-
warned = true;
|
|
46
|
-
ctx.ui.notify(
|
|
47
|
-
"pi-t3: T3's orchestration text has no '- Subagents:' bullet, so Pi cannot route subagents through T3. Update src/bridge/bridge-trim.ts.",
|
|
48
|
-
"warning"
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
pi.on("before_agent_start", (event) => {
|
|
54
|
-
runOptions = event.systemPromptOptions;
|
|
55
|
-
|
|
56
|
-
if (runOptions.forceSystemPrompt !== undefined) {
|
|
57
|
-
runOptions.forceSystemPrompt = trim(runOptions.forceSystemPrompt);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
pi.on("context_with_system", (event, ctx) => {
|
|
62
|
-
// Pi projects a forced prompt after this hook, including one set by a
|
|
63
|
-
// later before_agent_start handler such as T3's bridge.
|
|
64
|
-
const forced = runOptions?.forceSystemPrompt;
|
|
65
|
-
|
|
66
|
-
const section = getCurrentSystemMessage(event.messages)?.sections
|
|
67
|
-
?.t3_code_orchestration;
|
|
68
|
-
|
|
69
|
-
warnOnDrift(
|
|
70
|
-
forced ?? section ?? getCurrentSystemPrompt(event.messages),
|
|
71
|
-
ctx
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
if (runOptions !== undefined && forced !== undefined) {
|
|
75
|
-
runOptions.forceSystemPrompt = trim(forced);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
messages: event.messages.map((message) => {
|
|
80
|
-
if (message.role !== "system") {
|
|
81
|
-
return message;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const content = Array.isArray(message.content)
|
|
85
|
-
? message.content.map((part) => ({
|
|
86
|
-
...part,
|
|
87
|
-
text: trim(part.text),
|
|
88
|
-
}))
|
|
89
|
-
: trim(message.content);
|
|
90
|
-
|
|
91
|
-
const sections =
|
|
92
|
-
message.sections &&
|
|
93
|
-
Object.fromEntries(
|
|
94
|
-
Object.entries(message.sections).map(([key, text]) => [
|
|
95
|
-
key,
|
|
96
|
-
text === null ? null : trim(text),
|
|
97
|
-
])
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
return { ...message, content, sections };
|
|
101
|
-
}),
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
};
|