pim-agent 0.10.0 → 0.12.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 +3 -4
- package/packages/core/src/extensions/apply-patch/model.ts +1 -12
- 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/edit/index.ts +1 -1
- package/packages/core/src/extensions/subagent/index.ts +2 -1
- package/packages/core/src/extensions/subagent/subagent.ts +10 -1
- package/packages/core/src/extensions/system-prompt/index.ts +33 -24
- package/packages/core/src/extensions/system-prompt/prompt.ts +43 -67
- 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/core/src/shared/Tools.ts +3 -3
- 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-DKI-ZG3V.js} +1 -1
- package/packages/web/dist/client/assets/index-BCSESkyh.css +1 -0
- package/packages/web/dist/client/assets/index-BWUZzdeu.js +45 -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
|
@@ -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 };
|
|
@@ -3,7 +3,7 @@ import type {
|
|
|
3
3
|
ExtensionAPI,
|
|
4
4
|
ToolDefinition,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
6
|
+
import { validateToolArguments, type JsonObject } from "@earendil-works/pi-ai";
|
|
7
7
|
import type { Static, TSchema } from "typebox";
|
|
8
8
|
import { Levenshtein } from "./Levenshtein";
|
|
9
9
|
import { Renderer } from "./Renderer";
|
|
@@ -169,7 +169,7 @@ function wrap<TParams extends TSchema, TDetails = unknown, TState = unknown>(
|
|
|
169
169
|
type: "toolCall",
|
|
170
170
|
id: "",
|
|
171
171
|
name: def.name,
|
|
172
|
-
arguments: cleaned as
|
|
172
|
+
arguments: cleaned as JsonObject,
|
|
173
173
|
}
|
|
174
174
|
) as Static<TParams>;
|
|
175
175
|
} catch (err) {
|
|
@@ -461,7 +461,7 @@ function revalidateBranch(branch: JsonSchema, value: unknown): Issue[] {
|
|
|
461
461
|
type: "toolCall",
|
|
462
462
|
id: "",
|
|
463
463
|
name: "_branch",
|
|
464
|
-
arguments: (value ?? {}) as
|
|
464
|
+
arguments: (value ?? {}) as JsonObject,
|
|
465
465
|
}
|
|
466
466
|
);
|
|
467
467
|
return [];
|
|
@@ -118,8 +118,19 @@ export type Command =
|
|
|
118
118
|
}
|
|
119
119
|
/** The models this server can switch to, plus the current model's thinking levels; answers without a session. */
|
|
120
120
|
| { readonly id: string; readonly type: "list_models" }
|
|
121
|
+
/** The extensions this server can switch on and off, read against the connection's cwd when it has one. */
|
|
122
|
+
| { readonly id: string; readonly type: "list_extensions" }
|
|
123
|
+
/** Switch one extension on or off; the sessions built from here pick it up as each rebuilds its agent. */
|
|
124
|
+
| {
|
|
125
|
+
readonly id: string;
|
|
126
|
+
readonly type: "set_extension";
|
|
127
|
+
readonly extensionId: string;
|
|
128
|
+
readonly value: boolean;
|
|
129
|
+
}
|
|
121
130
|
/** Subdirectories of `path` on the server's filesystem; errors rather than answering empty when it is not a readable directory. */
|
|
122
131
|
| { readonly id: string; readonly type: "list_dirs"; readonly path: string }
|
|
132
|
+
/** Makes the directory `path` names, one level inside an existing one; answers empty, and the caller re-lists. */
|
|
133
|
+
| { readonly id: string; readonly type: "create_dir"; readonly path: string }
|
|
123
134
|
/** Re-read the cwd's git state now; `fetch` asks the remote first, which is the only thing that moves ahead and behind. */
|
|
124
135
|
| {
|
|
125
136
|
readonly id: string;
|
|
@@ -195,7 +206,17 @@ export type Command =
|
|
|
195
206
|
readonly callId: string;
|
|
196
207
|
}
|
|
197
208
|
/** Update this install and restart it unconditionally; refused while any session is mid-turn unless `force`. */
|
|
198
|
-
| { readonly id: string; readonly type: "reload"; readonly force?: boolean }
|
|
209
|
+
| { readonly id: string; readonly type: "reload"; readonly force?: boolean }
|
|
210
|
+
/** Answers one `ui_request`; dismissal is `cancelled`, and a late answer to a settled request is refused. */
|
|
211
|
+
| {
|
|
212
|
+
readonly id: string;
|
|
213
|
+
readonly type: "ui_response";
|
|
214
|
+
readonly sessionId: string;
|
|
215
|
+
readonly requestId: string;
|
|
216
|
+
readonly value?: string;
|
|
217
|
+
readonly confirmed?: boolean;
|
|
218
|
+
readonly cancelled?: boolean;
|
|
219
|
+
};
|
|
199
220
|
|
|
200
221
|
export type CommandType = Command["type"];
|
|
201
222
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DirectoryListing } from "#core/shared/Directories";
|
|
2
2
|
import type { CommitResult, GitBranch } from "#core/shared/Git";
|
|
3
3
|
import type { PickerItem } from "#core/picker/PickerItem";
|
|
4
|
+
import type { ExtensionEntry } from "#core/shared/PiExtensions";
|
|
4
5
|
import type { SearchRange, SearchSnippet } from "#core/session/SearchIndex";
|
|
5
6
|
import type { LeaseFrontend } from "#core/session/SessionLease";
|
|
6
7
|
import type { UpdateSkip } from "#core/shared/Updater";
|
|
@@ -154,6 +155,29 @@ export type EphemeralEvent =
|
|
|
154
155
|
readonly events: readonly StreamEvent[];
|
|
155
156
|
}
|
|
156
157
|
| { readonly type: "turn_end"; readonly stats: TurnStats }
|
|
158
|
+
/** Something an extension said, in Markdown; shown and then forgotten, never written to the session. */
|
|
159
|
+
| {
|
|
160
|
+
readonly type: "ui_notice";
|
|
161
|
+
readonly id: string;
|
|
162
|
+
readonly severity: NoticeSeverity;
|
|
163
|
+
readonly text: string;
|
|
164
|
+
/** The command being dispatched when it was said, like `/login`; absent means nobody asked for it. */
|
|
165
|
+
readonly command?: string;
|
|
166
|
+
}
|
|
167
|
+
/** An extension is waiting on a human; the first `ui_response` wins and the rest are refused. */
|
|
168
|
+
| {
|
|
169
|
+
readonly type: "ui_request";
|
|
170
|
+
readonly requestId: string;
|
|
171
|
+
readonly method: "select" | "confirm" | "input";
|
|
172
|
+
readonly title: string;
|
|
173
|
+
readonly message?: string;
|
|
174
|
+
readonly options?: readonly string[];
|
|
175
|
+
readonly placeholder?: string;
|
|
176
|
+
/** The command that asked, like `/login`; absent where an extension asked unprompted. */
|
|
177
|
+
readonly command?: string;
|
|
178
|
+
}
|
|
179
|
+
/** The request is settled, by whoever answered it or by the server answering for them; drop its control. */
|
|
180
|
+
| { readonly type: "ui_request_done"; readonly requestId: string }
|
|
157
181
|
/** Sent to every connection, not just those attached; only sessions this server holds open are reported. */
|
|
158
182
|
| {
|
|
159
183
|
readonly type: "session_activity";
|
|
@@ -188,6 +212,8 @@ export type EphemeralEvent =
|
|
|
188
212
|
| { readonly type: "pins_changed"; readonly order: readonly string[] }
|
|
189
213
|
/** Sent to every connection: the sessions on disk changed, so any listing a client holds is stale. */
|
|
190
214
|
| { readonly type: "sessions_changed" }
|
|
215
|
+
/** Sent to every connection: an extension was switched, so any roster a client holds is stale. */
|
|
216
|
+
| { readonly type: "extensions_changed" }
|
|
191
217
|
/** Sent to every connection; the restart it ends in drops every socket. */
|
|
192
218
|
| UpdateStateEvent
|
|
193
219
|
| {
|
|
@@ -327,6 +353,8 @@ export type ResponseEvent = {
|
|
|
327
353
|
readonly models?: readonly ModelView[];
|
|
328
354
|
/** What the *current* model supports, on the same answer. */
|
|
329
355
|
readonly thinkingLevels?: readonly string[];
|
|
356
|
+
/** The extension roster, for `list_extensions`. */
|
|
357
|
+
readonly extensions?: readonly ExtensionEntry[];
|
|
330
358
|
/** One directory's subdirectories, for `list_dirs`. */
|
|
331
359
|
readonly directory?: DirectoryListing;
|
|
332
360
|
/** The cwd's local branches, for `list_branches`. */
|
|
@@ -341,6 +369,8 @@ export type ResponseEvent = {
|
|
|
341
369
|
readonly fileLines?: FileLines;
|
|
342
370
|
/** For `cancel` and `dequeue`: queued messages pi gave back, now owned by the client that asked. */
|
|
343
371
|
readonly restored?: readonly string[];
|
|
372
|
+
/** For `user_message`: it named an extension command, so no turn started and no entry was written. */
|
|
373
|
+
readonly dispatched?: boolean;
|
|
344
374
|
};
|
|
345
375
|
|
|
346
376
|
export type ServerEvent = DurableEvent | EphemeralEvent | ResponseEvent;
|
|
@@ -382,6 +412,9 @@ export function isAttachScoped(event: ServerEvent): boolean {
|
|
|
382
412
|
case "subagent_events":
|
|
383
413
|
case "turn_end":
|
|
384
414
|
case "session_state":
|
|
415
|
+
case "ui_notice":
|
|
416
|
+
case "ui_request":
|
|
417
|
+
case "ui_request_done":
|
|
385
418
|
// Names a cwd, but only ever reaches a client down the session stream it
|
|
386
419
|
// is attached to, so the old session's is the only one that can arrive
|
|
387
420
|
// mid-attach — and re-querying for it would warm the wrong cache.
|
|
@@ -393,6 +426,7 @@ export function isAttachScoped(event: ServerEvent): boolean {
|
|
|
393
426
|
case "project_meta":
|
|
394
427
|
case "pins_changed":
|
|
395
428
|
case "sessions_changed":
|
|
429
|
+
case "extensions_changed":
|
|
396
430
|
case "update_state":
|
|
397
431
|
// Sent for a frame the server could not read at all, which is likeliest
|
|
398
432
|
// before an attach has settled: gating it swallows the diagnostic.
|
|
@@ -2,6 +2,7 @@ import { basename } from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import type { PickerItem } from "#core/picker/PickerItem";
|
|
4
4
|
import { RemoteFilePickerSuggestionEngine } from "#core/picker/RemoteFilePickerSuggestionEngine";
|
|
5
|
+
import type { ExtensionEntry } from "#core/shared/PiExtensions";
|
|
5
6
|
import type {
|
|
6
7
|
AttachmentRef,
|
|
7
8
|
CommandDraft,
|
|
@@ -265,6 +266,26 @@ export class ProbeClient {
|
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/** Every extension this server can switch, scoped to the attached session's cwd. */
|
|
270
|
+
public async listExtensions(): Promise<readonly ExtensionEntry[]> {
|
|
271
|
+
const response = await this.send({ type: "list_extensions" });
|
|
272
|
+
if (!response.success) {
|
|
273
|
+
throw new Error(response.error ?? "list_extensions failed");
|
|
274
|
+
}
|
|
275
|
+
return response.extensions ?? [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
public async setExtension(id: string, value: boolean): Promise<void> {
|
|
279
|
+
const response = await this.send({
|
|
280
|
+
type: "set_extension",
|
|
281
|
+
extensionId: id,
|
|
282
|
+
value,
|
|
283
|
+
});
|
|
284
|
+
if (!response.success) {
|
|
285
|
+
throw new Error(response.error ?? "set_extension failed");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
268
289
|
/** Transfers a client-local file into the server's world; only the bytes and bare filename are sent. */
|
|
269
290
|
public async upload(localPath: string): Promise<UploadedFile> {
|
|
270
291
|
const file = Bun.file(localPath);
|