pim-agent 0.10.0 → 0.11.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/package.json +1 -2
- package/packages/core/src/extensions/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +16 -1
- package/packages/core/src/extensions/system-prompt/prompt.ts +1 -2
- package/packages/core/src/session/SessionHost.ts +35 -9
- package/packages/core/src/session/SessionRegistry.ts +22 -1
- package/packages/core/src/session/SessionUi.ts +127 -0
- package/packages/core/src/shared/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- package/packages/core/src/shared/PiExtensions.ts +228 -0
- package/packages/protocol/src/Command.ts +22 -1
- package/packages/protocol/src/ServerEvent.ts +34 -0
- package/packages/server/src/ProbeClient.ts +21 -0
- package/packages/server/src/SessionStream.ts +228 -2
- package/packages/server/src/WsGateway.ts +153 -16
- package/packages/server/src/probe.ts +16 -0
- package/packages/web/dist/client/assets/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CA0aSzPu.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/index-B9be-i9d.css +0 -1
- package/packages/web/dist/client/assets/index-CpinM5NK.js +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pim-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A batteries-included distro of Pi, accessible in your terminal, browser and Telegram.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -56,7 +56,6 @@
|
|
|
56
56
|
"web:dev": "cd packages/web && vite"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@fontsource/commit-mono": "^5.3.0",
|
|
60
59
|
"@happy-dom/global-registrator": "^20.14.0",
|
|
61
60
|
"@iconify-json/griddy-icons": "^1.2.0",
|
|
62
61
|
"@iconify-json/solar": "^1.2.10",
|
|
@@ -42,7 +42,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
42
42
|
name: "bash",
|
|
43
43
|
label: "bash",
|
|
44
44
|
description:
|
|
45
|
-
"Execute a bash command in the cwd
|
|
45
|
+
"Execute a bash command in the cwd from <environment>. " +
|
|
46
|
+
"Never prefix `cd <cwd> &&`; pass paths relative to cwd or absolute. " +
|
|
46
47
|
"Returns exit code, signal (if any), and stdout/stderr captured separately. " +
|
|
47
48
|
"Stdout that is a png/jpeg/gif/webp is returned as a picture. " +
|
|
48
49
|
"Prefer commands that emit only what you need; keep output as small as possible.",
|
|
@@ -14,6 +14,12 @@ type Reader = ReadableStreamDefaultReader<Uint8Array>;
|
|
|
14
14
|
|
|
15
15
|
const activePids = new Set<number>();
|
|
16
16
|
|
|
17
|
+
const COMMAND_VAR = "PIM_BASH_COMMAND";
|
|
18
|
+
// argv is what `pkill -f`/`pgrep -f` match, so a command naming its own target would
|
|
19
|
+
// signal this shell before its target. The env hands it over out of argv's reach, and
|
|
20
|
+
// the unset keeps it out of every child's environment too.
|
|
21
|
+
const RUNNER = `__pim_command=$${COMMAND_VAR}; unset ${COMMAND_VAR}; eval "$__pim_command"`;
|
|
22
|
+
|
|
17
23
|
export function killAllActiveBashGroups(sig: NodeJS.Signals = "SIGTERM"): void {
|
|
18
24
|
for (const pid of activePids) {
|
|
19
25
|
killGroup(pid, sig);
|
|
@@ -97,11 +103,11 @@ export async function runBashCommand(
|
|
|
97
103
|
|
|
98
104
|
// setsid gives the tree its own process group (pgid == proc.pid) so the whole tree can be signalled.
|
|
99
105
|
const proc = Bun.spawn({
|
|
100
|
-
cmd: ["setsid", "bash", "-lc",
|
|
106
|
+
cmd: ["setsid", "bash", "-lc", RUNNER],
|
|
101
107
|
cwd,
|
|
102
108
|
stdout: "pipe",
|
|
103
109
|
stderr: "pipe",
|
|
104
|
-
env: { ...process.env },
|
|
110
|
+
env: { ...process.env, [COMMAND_VAR]: command },
|
|
105
111
|
});
|
|
106
112
|
if (proc.pid !== undefined) {
|
|
107
113
|
activePids.add(proc.pid);
|
|
@@ -5,10 +5,25 @@ import type {
|
|
|
5
5
|
import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
import type { Surface } from "../../shared/Surface";
|
|
8
|
-
import { buildSystemPrompt } from "./prompt";
|
|
8
|
+
import { buildSystemPrompt, formatDatetime } from "./prompt";
|
|
9
|
+
|
|
10
|
+
const DATETIME_MESSAGE_TYPE = "pim-datetime";
|
|
9
11
|
|
|
10
12
|
export default function (surface?: Surface): ExtensionFactory {
|
|
11
13
|
return (pi: ExtensionAPI): void => {
|
|
14
|
+
// On submit rather than in `before_agent_start`, which can only append
|
|
15
|
+
// behind the user's message.
|
|
16
|
+
pi.on("input", () => {
|
|
17
|
+
pi.sendMessage(
|
|
18
|
+
{
|
|
19
|
+
customType: DATETIME_MESSAGE_TYPE,
|
|
20
|
+
content: `<datetime>${formatDatetime(new Date())}</datetime>`,
|
|
21
|
+
display: false,
|
|
22
|
+
},
|
|
23
|
+
{ triggerTurn: false }
|
|
24
|
+
);
|
|
25
|
+
return { action: "continue" };
|
|
26
|
+
});
|
|
12
27
|
pi.on("before_agent_start", (event, ctx) => {
|
|
13
28
|
const {
|
|
14
29
|
cwd,
|
|
@@ -61,7 +61,6 @@ export function buildSystemPrompt(opts: BuildOptions): string {
|
|
|
61
61
|
`- os: ${opts.os ?? describeOs()}`,
|
|
62
62
|
`- model: ${model}`,
|
|
63
63
|
...(opts.surface ? [`- surface: ${opts.surface}`] : []),
|
|
64
|
-
`- datetime: ${formatDatetime(new Date())}`,
|
|
65
64
|
"</environment>",
|
|
66
65
|
].join("\n")
|
|
67
66
|
);
|
|
@@ -210,7 +209,7 @@ function unquoteValue(value: string): string {
|
|
|
210
209
|
return quote === "'" ? unquoted : unquoted.replace(/\\(["\\$`])/g, "$1");
|
|
211
210
|
}
|
|
212
211
|
|
|
213
|
-
function formatDatetime(d: Date): string {
|
|
212
|
+
export function formatDatetime(d: Date): string {
|
|
214
213
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
215
214
|
const offsetMinutes = -d.getTimezoneOffset();
|
|
216
215
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SettingsManager,
|
|
16
16
|
type AgentSessionEvent,
|
|
17
17
|
type CompactionResult,
|
|
18
|
+
type ExtensionError,
|
|
18
19
|
type ToolDefinition,
|
|
19
20
|
} from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { unlink } from "node:fs/promises";
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
type LeaseRecord,
|
|
31
32
|
} from "./SessionLease";
|
|
32
33
|
import { SessionName } from "./SessionName";
|
|
34
|
+
import { adaptSessionUi, type SessionUi } from "./SessionUi";
|
|
33
35
|
import { WriteMark } from "./WriteMark";
|
|
34
36
|
|
|
35
37
|
/** What the agent is doing right now. */
|
|
@@ -102,6 +104,13 @@ export type SessionHostDeps = {
|
|
|
102
104
|
) => readonly ToolDefinition[];
|
|
103
105
|
/** Runs after the agent is disposed, before the session file is forgotten. */
|
|
104
106
|
readonly onRetire?: (sessionPath: string) => Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Where this host's extensions say things, resolved per call so an agent
|
|
109
|
+
* rebuilt under the host follows its live target. Omit to leave pi's no-op
|
|
110
|
+
* UI in place: pi freezes the mode at `bindExtensions`, so this is a dep
|
|
111
|
+
* rather than something a caller can turn on once a session exists.
|
|
112
|
+
*/
|
|
113
|
+
readonly ui?: () => SessionUi | undefined;
|
|
105
114
|
};
|
|
106
115
|
|
|
107
116
|
/** What a frontend's own tools are built against. */
|
|
@@ -770,16 +779,33 @@ export class SessionHost {
|
|
|
770
779
|
],
|
|
771
780
|
});
|
|
772
781
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
782
|
+
const onError = (err: ExtensionError) => {
|
|
783
|
+
console.warn(
|
|
784
|
+
`[${this.label}] extension ${err.extensionPath} (${err.event}):`,
|
|
785
|
+
err.error
|
|
786
|
+
);
|
|
787
|
+
// Pi answers a command handler that threw as if it had succeeded, so
|
|
788
|
+
// without this the user's message vanishes and only stderr says why.
|
|
789
|
+
this.deps
|
|
790
|
+
.ui?.()
|
|
791
|
+
?.notify(
|
|
792
|
+
`${err.extensionPath} (${err.event}) failed: ${err.error}`,
|
|
793
|
+
"error"
|
|
780
794
|
);
|
|
781
|
-
|
|
782
|
-
|
|
795
|
+
};
|
|
796
|
+
const ui = this.deps.ui;
|
|
797
|
+
// Emits session_start; without it extension tools are registered but never usable.
|
|
798
|
+
await agent.bindExtensions(
|
|
799
|
+
ui
|
|
800
|
+
? // Any context at all makes pi's `hasUI()` true (`uiContext !== noOpUIContext`,
|
|
801
|
+
// runner.js:318); "rpc" is its own name for dialog-capable but not a terminal.
|
|
802
|
+
{
|
|
803
|
+
uiContext: adaptSessionUi(ui),
|
|
804
|
+
mode: "rpc",
|
|
805
|
+
onError,
|
|
806
|
+
}
|
|
807
|
+
: { mode: "print", onError }
|
|
808
|
+
);
|
|
783
809
|
|
|
784
810
|
return { agent, cwd };
|
|
785
811
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "./SessionHost";
|
|
19
19
|
import { SessionLease } from "./SessionLease";
|
|
20
20
|
import { SessionName } from "./SessionName";
|
|
21
|
+
import type { SessionUi } from "./SessionUi";
|
|
21
22
|
|
|
22
23
|
/** A session as pi stores it: one JSONL file under a cwd-encoded directory. */
|
|
23
24
|
export type SessionSummary = {
|
|
@@ -58,6 +59,7 @@ export class SessionRegistry {
|
|
|
58
59
|
private readonly deps: SessionRegistryDeps;
|
|
59
60
|
private readonly runtime: AgentRuntime;
|
|
60
61
|
private readonly hosts: SessionCache<SessionHost>;
|
|
62
|
+
private ui: ((host: SessionHost) => SessionUi | undefined) | undefined;
|
|
61
63
|
|
|
62
64
|
public constructor(deps: SessionRegistryDeps) {
|
|
63
65
|
this.deps = deps;
|
|
@@ -65,10 +67,24 @@ export class SessionRegistry {
|
|
|
65
67
|
this.hosts = new SessionCache(deps.capacity);
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Where the extensions of every host built from here speak. It belongs to the
|
|
72
|
+
* registry rather than each caller because `create` binds an agent eagerly,
|
|
73
|
+
* and pi freezes a session's UI mode at that bind. Resolved per call: a
|
|
74
|
+
* host outlives the stream that speaks for it.
|
|
75
|
+
*/
|
|
76
|
+
public setUi(ui: (host: SessionHost) => SessionUi | undefined): void {
|
|
77
|
+
this.ui = ui;
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
public get sessionsRoot(): string {
|
|
69
81
|
return join(this.runtime.agentDir, "sessions");
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
public get agentDir(): string {
|
|
85
|
+
return this.runtime.agentDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
public async init(): Promise<void> {
|
|
73
89
|
await this.runtime.init();
|
|
74
90
|
}
|
|
@@ -207,7 +223,10 @@ export class SessionRegistry {
|
|
|
207
223
|
}
|
|
208
224
|
|
|
209
225
|
private buildHost(label: string, settings: HostSettings): SessionHost {
|
|
210
|
-
|
|
226
|
+
const ui = this.ui;
|
|
227
|
+
// Named so the sink can reach back for the host it belongs to; the closure
|
|
228
|
+
// only ever runs once construction has returned.
|
|
229
|
+
const host: SessionHost = new SessionHost({
|
|
211
230
|
label: `session ${label}`,
|
|
212
231
|
settings,
|
|
213
232
|
defaults: this.deps.defaults,
|
|
@@ -225,7 +244,9 @@ export class SessionRegistry {
|
|
|
225
244
|
...(this.deps.surface === undefined
|
|
226
245
|
? {}
|
|
227
246
|
: { surface: this.deps.surface }),
|
|
247
|
+
...(ui === undefined ? {} : { ui: () => ui(host) }),
|
|
228
248
|
});
|
|
249
|
+
return host;
|
|
229
250
|
}
|
|
230
251
|
}
|
|
231
252
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionUIContext,
|
|
3
|
+
Theme,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import type { NoticeSeverity } from "../view/ViewBlock";
|
|
7
|
+
|
|
8
|
+
/** The slice of pi's dialog options a surface without a terminal can honour; the rest is countdown chrome. */
|
|
9
|
+
export type UiAsk = {
|
|
10
|
+
readonly timeout?: number;
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Where an extension's own words go. Four of pi's thirty methods: the ones that address a human rather than a terminal. */
|
|
15
|
+
export type SessionUi = {
|
|
16
|
+
readonly notify: (text: string, severity: NoticeSeverity) => void;
|
|
17
|
+
readonly select: (
|
|
18
|
+
title: string,
|
|
19
|
+
options: readonly string[],
|
|
20
|
+
opts?: UiAsk
|
|
21
|
+
) => Promise<string | undefined>;
|
|
22
|
+
readonly confirm: (
|
|
23
|
+
title: string,
|
|
24
|
+
message: string,
|
|
25
|
+
opts?: UiAsk
|
|
26
|
+
) => Promise<boolean>;
|
|
27
|
+
readonly input: (
|
|
28
|
+
title: string,
|
|
29
|
+
placeholder?: string,
|
|
30
|
+
opts?: UiAsk
|
|
31
|
+
) => Promise<string | undefined>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Everything off the terminal paints with; also what pi's border getters hand back. */
|
|
35
|
+
const IDENTITY = (text: string): string => text;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* pi keeps its own `theme` instance to itself, and nothing off the terminal has
|
|
39
|
+
* colours to paint with. `satisfies` covers the whole public surface — `keyof`
|
|
40
|
+
* skips the private fields the cast is for — so a method pi adds is a type
|
|
41
|
+
* error here rather than a throw inside somebody's event handler.
|
|
42
|
+
*/
|
|
43
|
+
const PLAIN_THEME = {
|
|
44
|
+
fg: (_color: string, text: string) => text,
|
|
45
|
+
bg: (_color: string, text: string) => text,
|
|
46
|
+
bold: IDENTITY,
|
|
47
|
+
italic: IDENTITY,
|
|
48
|
+
underline: IDENTITY,
|
|
49
|
+
inverse: IDENTITY,
|
|
50
|
+
strikethrough: IDENTITY,
|
|
51
|
+
getFgAnsi: () => "",
|
|
52
|
+
getBgAnsi: () => "",
|
|
53
|
+
getColorMode: () => "truecolor",
|
|
54
|
+
getThinkingBorderColor: () => IDENTITY,
|
|
55
|
+
getBashModeBorderColor: () => IDENTITY,
|
|
56
|
+
} satisfies { [K in keyof Theme]: Theme[K] } as unknown as Theme;
|
|
57
|
+
|
|
58
|
+
function severityOf(
|
|
59
|
+
kind: Parameters<ExtensionUIContext["notify"]>[1]
|
|
60
|
+
): NoticeSeverity {
|
|
61
|
+
return kind === "warning" ? "warn" : (kind ?? "info");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A dialog an extension awaits goes unanswered rather than ever throwing at it. */
|
|
65
|
+
async function settle<T>(
|
|
66
|
+
ask: () => Promise<T> | undefined
|
|
67
|
+
): Promise<T | undefined> {
|
|
68
|
+
try {
|
|
69
|
+
return await ask();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.warn("[SessionUi] dialog sink failed:", err);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Widens the sink onto pi's `ExtensionUIContext`, no-opping everything a
|
|
78
|
+
* terminal owns exactly as pi's own RPC mode does. `sink` is read per call, so
|
|
79
|
+
* an agent rebuilt under a host keeps whatever its host was last handed.
|
|
80
|
+
*
|
|
81
|
+
* Every method has to be an *own* property: pi re-wraps what it is given with
|
|
82
|
+
* `{ ...ui }` (`runner.js:274`), which copies own enumerable keys only. A class
|
|
83
|
+
* instance would arrive with `notify` and the rest of the prototype missing.
|
|
84
|
+
*/
|
|
85
|
+
export function adaptSessionUi(
|
|
86
|
+
sink: () => SessionUi | undefined
|
|
87
|
+
): ExtensionUIContext {
|
|
88
|
+
return {
|
|
89
|
+
select: (title, options, opts) =>
|
|
90
|
+
settle(() => sink()?.select(title, options, opts)),
|
|
91
|
+
confirm: async (title, message, opts) =>
|
|
92
|
+
(await settle(() => sink()?.confirm(title, message, opts))) === true,
|
|
93
|
+
input: (title, placeholder, opts) =>
|
|
94
|
+
settle(() => sink()?.input(title, placeholder, opts)),
|
|
95
|
+
notify: (message, kind) => {
|
|
96
|
+
try {
|
|
97
|
+
sink()?.notify(message, severityOf(kind));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.warn("[SessionUi] notify sink failed:", err);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onTerminalInput: () => () => {},
|
|
103
|
+
setStatus: () => {},
|
|
104
|
+
setWorkingMessage: () => {},
|
|
105
|
+
setWorkingVisible: () => {},
|
|
106
|
+
setWorkingIndicator: () => {},
|
|
107
|
+
setHiddenThinkingLabel: () => {},
|
|
108
|
+
setWidget: () => {},
|
|
109
|
+
setFooter: () => {},
|
|
110
|
+
setHeader: () => {},
|
|
111
|
+
setTitle: () => {},
|
|
112
|
+
custom: async <T>() => undefined as T,
|
|
113
|
+
pasteToEditor: () => {},
|
|
114
|
+
setEditorText: () => {},
|
|
115
|
+
getEditorText: () => "",
|
|
116
|
+
editor: async () => undefined,
|
|
117
|
+
addAutocompleteProvider: () => {},
|
|
118
|
+
setEditorComponent: () => {},
|
|
119
|
+
getEditorComponent: () => undefined,
|
|
120
|
+
theme: PLAIN_THEME,
|
|
121
|
+
getAllThemes: () => [],
|
|
122
|
+
getTheme: () => undefined,
|
|
123
|
+
setTheme: () => ({ success: false, error: "UI not available" }),
|
|
124
|
+
getToolsExpanded: () => false,
|
|
125
|
+
setToolsExpanded: () => {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
1
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { FsErrors } from "./FsErrors";
|
|
5
5
|
import { Paths } from "./Paths";
|
|
@@ -29,12 +29,16 @@ async function check(path: string): Promise<string | undefined> {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
function rooted(path: string): string {
|
|
33
33
|
const expanded = Paths.expandHome(path);
|
|
34
34
|
if (!isAbsolute(expanded)) {
|
|
35
35
|
throw new Error(`not an absolute path: ${path}`);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
return resolve(expanded);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function list(path: string): Promise<DirectoryListing> {
|
|
41
|
+
const resolved = rooted(path);
|
|
38
42
|
const reason = await check(resolved);
|
|
39
43
|
if (reason !== undefined) {
|
|
40
44
|
throw new Error(reason);
|
|
@@ -58,4 +62,34 @@ async function list(path: string): Promise<DirectoryListing> {
|
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
function refusal(error: unknown, path: string): string {
|
|
66
|
+
switch (FsErrors.code(error)) {
|
|
67
|
+
case "ENOENT":
|
|
68
|
+
return `path does not exist: ${dirname(path)}`;
|
|
69
|
+
case "EEXIST":
|
|
70
|
+
return `already exists: ${path}`;
|
|
71
|
+
case "ENOTDIR":
|
|
72
|
+
return `not a directory: ${dirname(path)}`;
|
|
73
|
+
case "EACCES":
|
|
74
|
+
case "EPERM":
|
|
75
|
+
return `permission denied: ${path}`;
|
|
76
|
+
default:
|
|
77
|
+
return `mkdir failed: ${(error as Error).message}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Makes one directory inside an existing one. Never recursive: a parent that is missing is said so, not invented. */
|
|
82
|
+
async function create(path: string): Promise<void> {
|
|
83
|
+
const name = basename(Paths.expandHome(path));
|
|
84
|
+
if (name === "" || name === "." || name === "..") {
|
|
85
|
+
throw new Error(`not a directory to create: ${path}`);
|
|
86
|
+
}
|
|
87
|
+
const resolved = rooted(path);
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(resolved);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error(refusal(error, resolved));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const Directories = { check, list, create };
|
|
@@ -34,6 +34,13 @@ const DEFAULT_DISABLED: readonly PimExtensionName[] = ["todo", "tps"];
|
|
|
34
34
|
|
|
35
35
|
const REQUIRED: readonly PimExtensionName[] = ["_init", "pim", "session-lease"];
|
|
36
36
|
|
|
37
|
+
// Hardcoded, never derived: the browser switches these and nothing else, under
|
|
38
|
+
// the label it gives them — the terminal menu names them by key, this does not.
|
|
39
|
+
const WEB = [{ name: "todo", label: "Todo Tool" }] as const satisfies readonly {
|
|
40
|
+
name: PimExtensionName;
|
|
41
|
+
label: string;
|
|
42
|
+
}[];
|
|
43
|
+
|
|
37
44
|
function isRequired(name: string): boolean {
|
|
38
45
|
return (REQUIRED as readonly string[]).includes(name);
|
|
39
46
|
}
|
|
@@ -119,6 +126,7 @@ function enabled(
|
|
|
119
126
|
export const ExtensionToggles = {
|
|
120
127
|
REQUIRED,
|
|
121
128
|
NAMES,
|
|
129
|
+
WEB,
|
|
122
130
|
isRequired,
|
|
123
131
|
isKnown,
|
|
124
132
|
describe,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultPackageManager,
|
|
3
|
+
type PackageSource,
|
|
4
|
+
type ResolvedResource,
|
|
5
|
+
SettingsManager,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { basename, dirname, extname, relative } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { ExtensionToggles } from "./ExtensionToggles";
|
|
10
|
+
import { Fs } from "./Fs";
|
|
11
|
+
import { Paths } from "./Paths";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every origin an entry comes from, in the order a reader meets them: the
|
|
15
|
+
* roster is sorted by it, and a pane groups what it is handed without
|
|
16
|
+
* restating it.
|
|
17
|
+
*/
|
|
18
|
+
const EXTENSION_GROUPS = ["pim", "package", "user", "project"] as const;
|
|
19
|
+
|
|
20
|
+
export type ExtensionGroup = (typeof EXTENSION_GROUPS)[number];
|
|
21
|
+
|
|
22
|
+
export type ExtensionEntry = {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly label: string;
|
|
25
|
+
readonly group: ExtensionGroup;
|
|
26
|
+
readonly enabled: boolean;
|
|
27
|
+
/** Whether this process will write the toggle; a project-scoped entry is listed but not switched. */
|
|
28
|
+
readonly writable: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ExtensionScope = {
|
|
32
|
+
readonly cwd: string;
|
|
33
|
+
readonly agentDir: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Resolution = {
|
|
37
|
+
readonly extensions: readonly ResolvedResource[];
|
|
38
|
+
readonly settingsManager: SettingsManager;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type PackageFilter = Exclude<PackageSource, string>;
|
|
42
|
+
|
|
43
|
+
const FILTER_KEYS = ["extensions", "skills", "prompts", "themes"] as const;
|
|
44
|
+
|
|
45
|
+
const writes = Fs.serialised();
|
|
46
|
+
|
|
47
|
+
async function list(scope: ExtensionScope): Promise<readonly ExtensionEntry[]> {
|
|
48
|
+
const [off, { extensions }] = await Promise.all([
|
|
49
|
+
ExtensionToggles.disabled(),
|
|
50
|
+
resolve(scope),
|
|
51
|
+
]);
|
|
52
|
+
const disabled = new Set(off);
|
|
53
|
+
const pim = ExtensionToggles.WEB.map((allowed) =>
|
|
54
|
+
pimEntry(allowed, !disabled.has(allowed.name))
|
|
55
|
+
);
|
|
56
|
+
const pi = extensions
|
|
57
|
+
.filter((resource) => resource.metadata.scope !== "temporary")
|
|
58
|
+
.map(piEntry)
|
|
59
|
+
.sort(byGroupThenLabel);
|
|
60
|
+
return [...pim, ...pi];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function setEnabled(
|
|
64
|
+
id: string,
|
|
65
|
+
enabled: boolean,
|
|
66
|
+
scope: ExtensionScope
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const pim = ExtensionToggles.WEB.find((entry) => `pim:${entry.name}` === id);
|
|
69
|
+
if (pim) {
|
|
70
|
+
await ExtensionToggles.setDisabled(pim.name, !enabled);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!id.startsWith("pi:")) {
|
|
74
|
+
throw new Error(`Unknown extension "${id}"`);
|
|
75
|
+
}
|
|
76
|
+
await writes.run(() => writePiToggle(id.slice("pi:".length), enabled, scope));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A fresh SettingsManager per write: `setExtensionPaths` persists its in-memory
|
|
80
|
+
// copy, which another process may have superseded since we last read it.
|
|
81
|
+
async function writePiToggle(
|
|
82
|
+
path: string,
|
|
83
|
+
enabled: boolean,
|
|
84
|
+
scope: ExtensionScope
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const { extensions, settingsManager } = await resolve(scope);
|
|
87
|
+
const resource = extensions.find((candidate) => candidate.path === path);
|
|
88
|
+
if (!resource) {
|
|
89
|
+
throw new Error(`Unknown extension "pi:${path}"`);
|
|
90
|
+
}
|
|
91
|
+
const { metadata } = resource;
|
|
92
|
+
if (metadata.scope !== "user") {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`"${path}" is ${metadata.scope}-scoped and cannot be switched from here`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (metadata.origin === "package") {
|
|
98
|
+
togglePackage(
|
|
99
|
+
settingsManager,
|
|
100
|
+
metadata.source,
|
|
101
|
+
relative(metadata.baseDir ?? dirname(path), path),
|
|
102
|
+
enabled
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
toggleTopLevel(
|
|
106
|
+
settingsManager,
|
|
107
|
+
relative(metadata.baseDir ?? scope.agentDir, path),
|
|
108
|
+
enabled
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
await settingsManager.flush();
|
|
112
|
+
const failure = settingsManager.drainErrors()[0];
|
|
113
|
+
if (failure) {
|
|
114
|
+
throw new Error(`Failed to write pi settings: ${failure.error.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toggleTopLevel(
|
|
119
|
+
settingsManager: SettingsManager,
|
|
120
|
+
pattern: string,
|
|
121
|
+
enabled: boolean
|
|
122
|
+
): void {
|
|
123
|
+
const current = settingsManager.getGlobalSettings().extensions ?? [];
|
|
124
|
+
settingsManager.setExtensionPaths(withPattern(current, pattern, enabled));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function togglePackage(
|
|
128
|
+
settingsManager: SettingsManager,
|
|
129
|
+
source: string,
|
|
130
|
+
pattern: string,
|
|
131
|
+
enabled: boolean
|
|
132
|
+
): void {
|
|
133
|
+
const packages = [...(settingsManager.getGlobalSettings().packages ?? [])];
|
|
134
|
+
const index = packages.findIndex((entry) => sourceOf(entry) === source);
|
|
135
|
+
const entry = packages[index];
|
|
136
|
+
if (entry === undefined) {
|
|
137
|
+
throw new Error(`No configured package provides "${source}"`);
|
|
138
|
+
}
|
|
139
|
+
const pkg: PackageFilter =
|
|
140
|
+
typeof entry === "string" ? { source: entry } : { ...entry };
|
|
141
|
+
const updated = withPattern(pkg.extensions ?? [], pattern, enabled);
|
|
142
|
+
pkg.extensions = updated.length > 0 ? updated : undefined;
|
|
143
|
+
packages[index] = FILTER_KEYS.some((key) => pkg[key] !== undefined)
|
|
144
|
+
? pkg
|
|
145
|
+
: pkg.source;
|
|
146
|
+
settingsManager.setPackages(packages);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function withPattern(
|
|
150
|
+
current: readonly string[],
|
|
151
|
+
pattern: string,
|
|
152
|
+
enabled: boolean
|
|
153
|
+
): string[] {
|
|
154
|
+
const kept = current.filter((entry) => stripMarker(entry) !== pattern);
|
|
155
|
+
return [...kept, `${enabled ? "+" : "-"}${pattern}`];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stripMarker(entry: string): string {
|
|
159
|
+
return /^[!+-]/.test(entry) ? entry.slice(1) : entry;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sourceOf(entry: PackageSource): string {
|
|
163
|
+
return typeof entry === "string" ? entry : entry.source;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function resolve(scope: ExtensionScope): Promise<Resolution> {
|
|
167
|
+
const { cwd, agentDir } = scope;
|
|
168
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
169
|
+
const manager = new DefaultPackageManager({
|
|
170
|
+
cwd,
|
|
171
|
+
agentDir,
|
|
172
|
+
settingsManager,
|
|
173
|
+
});
|
|
174
|
+
// Without an answer, pi npm-installs every missing or drifted package, so a
|
|
175
|
+
// bare listing request would fire off an install.
|
|
176
|
+
const resolved = await manager.resolve(async () => "skip");
|
|
177
|
+
return { extensions: resolved.extensions, settingsManager };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pimEntry(
|
|
181
|
+
allowed: (typeof ExtensionToggles.WEB)[number],
|
|
182
|
+
enabled: boolean
|
|
183
|
+
): ExtensionEntry {
|
|
184
|
+
return {
|
|
185
|
+
id: `pim:${allowed.name}`,
|
|
186
|
+
label: allowed.label,
|
|
187
|
+
group: "pim",
|
|
188
|
+
enabled,
|
|
189
|
+
writable: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function piEntry(resource: ResolvedResource): ExtensionEntry {
|
|
194
|
+
const { path, metadata } = resource;
|
|
195
|
+
const fromPackage = metadata.origin === "package";
|
|
196
|
+
const group = fromPackage
|
|
197
|
+
? "package"
|
|
198
|
+
: metadata.scope === "project"
|
|
199
|
+
? "project"
|
|
200
|
+
: "user";
|
|
201
|
+
return {
|
|
202
|
+
id: `pi:${path}`,
|
|
203
|
+
label: fromPackage
|
|
204
|
+
? metadata.source.replace(/^(?:npm|git):/, "")
|
|
205
|
+
: fileLabel(path),
|
|
206
|
+
group,
|
|
207
|
+
enabled: resource.enabled,
|
|
208
|
+
// Scope, not group: a package a project configures is written to the repo's
|
|
209
|
+
// settings, which a browser toggle never touches.
|
|
210
|
+
writable: metadata.scope === "user",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fileLabel(path: string): string {
|
|
215
|
+
const base = basename(path, extname(path));
|
|
216
|
+
return base === "index" ? basename(dirname(path)) : base;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function byGroupThenLabel(left: ExtensionEntry, right: ExtensionEntry): number {
|
|
220
|
+
const byGroup =
|
|
221
|
+
EXTENSION_GROUPS.indexOf(left.group) -
|
|
222
|
+
EXTENSION_GROUPS.indexOf(right.group);
|
|
223
|
+
return byGroup === 0
|
|
224
|
+
? Paths.compare(left.label.toLowerCase(), right.label.toLowerCase())
|
|
225
|
+
: byGroup;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const PiExtensions = { list, setEnabled };
|