@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4
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/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +116 -18
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/acp/session-snapshot.ts +260 -0
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/oauth/seed-providers.ts +11 -0
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/acp-routes.ts +17 -161
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -17,6 +17,10 @@ import { dirname, join } from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
19
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
20
|
+
import {
|
|
21
|
+
LEGACY_PLUGIN_MANIFEST,
|
|
22
|
+
readPluginManifest,
|
|
23
|
+
} from "../../util/plugin-manifest.js";
|
|
20
24
|
import { parsePluginIcon } from "./plugin-artifact.js";
|
|
21
25
|
import { readValidatedPluginIcon } from "./plugin-icon-file.js";
|
|
22
26
|
|
|
@@ -51,10 +55,13 @@ export interface InstalledPluginInfo {
|
|
|
51
55
|
readonly name: string;
|
|
52
56
|
/** Absolute path to the plugin directory. */
|
|
53
57
|
readonly target: string;
|
|
54
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Metadata from the selected manifest. The API exposes this field as
|
|
60
|
+
* `packageJson`.
|
|
61
|
+
*/
|
|
55
62
|
readonly packageJson: PluginPackageMetadata | null;
|
|
56
63
|
/**
|
|
57
|
-
* Non-fatal issues with this entry (missing
|
|
64
|
+
* Non-fatal issues with this entry (missing or malformed selected manifest,
|
|
58
65
|
* JSON, unexpected type, etc.). Empty when the entry parses cleanly.
|
|
59
66
|
*/
|
|
60
67
|
readonly issues: readonly string[];
|
|
@@ -143,51 +150,30 @@ function readPluginEntry(
|
|
|
143
150
|
name: string,
|
|
144
151
|
): InstalledPluginInfo {
|
|
145
152
|
const target = join(pluginsDir, name);
|
|
146
|
-
const pkgJsonPath = join(target, "package.json");
|
|
147
153
|
const issues: string[] = [];
|
|
148
154
|
|
|
149
155
|
// Icon validation is independent of package.json parsing, so resolve it
|
|
150
156
|
// once and attach to every return below (including error paths).
|
|
151
157
|
const iconFields = pluginIconFields(target);
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
issues.push("missing package.json");
|
|
155
|
-
return { name, target, packageJson: null, issues, ...iconFields };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
let raw: string;
|
|
159
|
-
try {
|
|
160
|
-
raw = readFileSync(pkgJsonPath, "utf8");
|
|
161
|
-
} catch (err) {
|
|
162
|
-
issues.push(
|
|
163
|
-
`package.json unreadable: ${err instanceof Error ? err.message : String(err)}`,
|
|
164
|
-
);
|
|
165
|
-
return { name, target, packageJson: null, issues, ...iconFields };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
let parsed: unknown;
|
|
159
|
+
let manifest: ReturnType<typeof readPluginManifest>;
|
|
169
160
|
try {
|
|
170
|
-
|
|
161
|
+
manifest = readPluginManifest(target);
|
|
171
162
|
} catch (err) {
|
|
172
|
-
issues.push(
|
|
173
|
-
`package.json invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
174
|
-
);
|
|
163
|
+
issues.push(err instanceof Error ? err.message : String(err));
|
|
175
164
|
return { name, target, packageJson: null, issues, ...iconFields };
|
|
176
165
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const meta = parsed as Record<string, unknown>;
|
|
184
|
-
const icon = parsePluginIcon(meta);
|
|
166
|
+
const meta = manifest.raw;
|
|
167
|
+
const icon =
|
|
168
|
+
manifest.source === LEGACY_PLUGIN_MANIFEST
|
|
169
|
+
? parsePluginIcon(meta)
|
|
170
|
+
: undefined;
|
|
185
171
|
const packageJson: PluginPackageMetadata = {
|
|
186
|
-
name:
|
|
187
|
-
version:
|
|
188
|
-
description:
|
|
189
|
-
typeof meta.description === "string" ? meta.description : undefined,
|
|
172
|
+
name: manifest.name,
|
|
173
|
+
version: manifest.version,
|
|
174
|
+
description: manifest.description,
|
|
190
175
|
peerDependencies:
|
|
176
|
+
manifest.source === LEGACY_PLUGIN_MANIFEST &&
|
|
191
177
|
typeof meta.peerDependencies === "object" &&
|
|
192
178
|
meta.peerDependencies !== null &&
|
|
193
179
|
!Array.isArray(meta.peerDependencies)
|
|
@@ -33,6 +33,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
35
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
36
|
+
import { readPluginManifest } from "../../util/plugin-manifest.js";
|
|
36
37
|
import type { FetchLike } from "./fetch-like.js";
|
|
37
38
|
import { sanitizePluginName } from "./install-from-github.js";
|
|
38
39
|
import {
|
|
@@ -230,22 +231,34 @@ interface LocalPlugin {
|
|
|
230
231
|
readonly readme: string | null;
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
/** Read an installed copy's
|
|
234
|
+
/** Read an installed copy's selected manifest and README off disk, if present. */
|
|
234
235
|
function readLocalPlugin(pluginsDir: string, name: string): LocalPlugin {
|
|
235
236
|
const target = join(pluginsDir, name);
|
|
236
237
|
if (!existsSync(target)) {
|
|
237
238
|
return { installed: false, manifest: emptyManifest(), readme: null };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
const pkgPath = join(target, "package.json");
|
|
241
241
|
let manifest = emptyManifest();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
242
|
+
try {
|
|
243
|
+
const packagePath = join(target, "package.json");
|
|
244
|
+
if (existsSync(packagePath)) {
|
|
245
|
+
// Detail metadata is intentionally lenient. An installed legacy package
|
|
246
|
+
// may omit the loader-required name while still carrying useful fields.
|
|
247
|
+
manifest = parseManifest(readFileSync(packagePath, "utf8"));
|
|
248
|
+
} else {
|
|
249
|
+
const selected = readPluginManifest(target);
|
|
250
|
+
manifest = {
|
|
251
|
+
version: selected.version ?? null,
|
|
252
|
+
description: selected.description ?? null,
|
|
253
|
+
homepage: selected.homepage ?? null,
|
|
254
|
+
license: normalizeLicense(selected.license),
|
|
255
|
+
artifact: null,
|
|
256
|
+
icon: null,
|
|
257
|
+
};
|
|
248
258
|
}
|
|
259
|
+
} catch {
|
|
260
|
+
// A malformed local manifest degrades to empty fields. The entry is still
|
|
261
|
+
// installed, but it has no manifest metadata to surface.
|
|
249
262
|
}
|
|
250
263
|
|
|
251
264
|
return { installed: true, manifest, readme: readLocalReadme(target) };
|
|
@@ -15,10 +15,13 @@ import { existsSync, rmSync, statSync } from "node:fs";
|
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
|
|
17
17
|
import { runShutdownHook } from "../../hooks/hook-loader.js";
|
|
18
|
+
import { MESSAGE_KEYS, t } from "../../i18n/index.js";
|
|
18
19
|
import { isPluginDisabled } from "../../plugins/disabled-state.js";
|
|
20
|
+
import { PLUGIN_MCP_MANIFEST } from "../../plugins/mcp-servers.js";
|
|
19
21
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
20
22
|
import {
|
|
21
23
|
InvalidPluginNameError,
|
|
24
|
+
readInstallMeta,
|
|
22
25
|
sanitizePluginName,
|
|
23
26
|
} from "./install-from-github.js";
|
|
24
27
|
|
|
@@ -40,11 +43,27 @@ export interface UninstallPluginOptions {
|
|
|
40
43
|
readonly workspacePluginsDir?: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
export const PLUGIN_UNINSTALL_WARNING_KEYS = {
|
|
47
|
+
MCP_OAUTH_CREDENTIALS_UNCHECKED:
|
|
48
|
+
MESSAGE_KEYS.PLUGIN_MCP_OAUTH_CREDENTIALS_UNCHECKED,
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
export type PluginUninstallWarningKey =
|
|
52
|
+
(typeof PLUGIN_UNINSTALL_WARNING_KEYS)[keyof typeof PLUGIN_UNINSTALL_WARNING_KEYS];
|
|
53
|
+
|
|
54
|
+
export function resolvePluginUninstallWarning(
|
|
55
|
+
key: PluginUninstallWarningKey,
|
|
56
|
+
): string {
|
|
57
|
+
return t(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
/** Result of a successful uninstall. */
|
|
44
61
|
export interface UninstallPluginResult {
|
|
45
62
|
readonly name: string;
|
|
46
63
|
/** Absolute path that was removed. */
|
|
47
64
|
readonly target: string;
|
|
65
|
+
/** Stable keys for non-fatal cleanup limitations. */
|
|
66
|
+
readonly warnings?: PluginUninstallWarningKey[];
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
/**
|
|
@@ -87,6 +106,41 @@ export async function uninstallPlugin(
|
|
|
87
106
|
throw new PluginNotInstalledError(name, target);
|
|
88
107
|
}
|
|
89
108
|
|
|
109
|
+
const hasCurrentMcpManifest = existsSync(join(target, PLUGIN_MCP_MANIFEST));
|
|
110
|
+
const recordedFiles = readInstallMeta(target)?.fingerprint?.files;
|
|
111
|
+
const hasRecordedMcpManifest =
|
|
112
|
+
recordedFiles !== undefined &&
|
|
113
|
+
Object.hasOwn(recordedFiles, PLUGIN_MCP_MANIFEST);
|
|
114
|
+
const warnings: PluginUninstallWarningKey[] = [];
|
|
115
|
+
const { deletePluginMcpOAuthCredentials } =
|
|
116
|
+
await import("../../mcp/mcp-oauth-provider.js");
|
|
117
|
+
let credentialsReachable = true;
|
|
118
|
+
let cleanup:
|
|
119
|
+
| Awaited<ReturnType<typeof deletePluginMcpOAuthCredentials>>
|
|
120
|
+
| undefined;
|
|
121
|
+
try {
|
|
122
|
+
cleanup = await deletePluginMcpOAuthCredentials(name);
|
|
123
|
+
credentialsReachable = !cleanup.unreachable;
|
|
124
|
+
} catch {
|
|
125
|
+
credentialsReachable = false;
|
|
126
|
+
}
|
|
127
|
+
if (cleanup && !cleanup.unreachable && !cleanup.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Plugin "${name}" was not removed because its MCP OAuth credentials could not be deleted.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!credentialsReachable) {
|
|
134
|
+
if (hasCurrentMcpManifest || hasRecordedMcpManifest) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Plugin "${name}" was not removed because credential storage is unavailable and its MCP OAuth credentials could not be checked.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
warnings.push(
|
|
140
|
+
PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
90
144
|
// Skip the shutdown hook when the plugin is disabled. A `.disabled` plugin
|
|
91
145
|
// is never loaded — no hooks, tools, or init — so its shutdown was never
|
|
92
146
|
// paired with an init. Running it on uninstall would be the first and only
|
|
@@ -98,7 +152,11 @@ export async function uninstallPlugin(
|
|
|
98
152
|
}
|
|
99
153
|
|
|
100
154
|
rmSync(target, { recursive: true, force: true });
|
|
101
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
name,
|
|
157
|
+
target,
|
|
158
|
+
...(warnings.length > 0 && { warnings }),
|
|
159
|
+
};
|
|
102
160
|
}
|
|
103
161
|
|
|
104
162
|
export { InvalidPluginNameError };
|
|
@@ -94,6 +94,27 @@ describe("discoverPluginResidentSkills (via loadSkillCatalog)", () => {
|
|
|
94
94
|
expect(skill!.owner).toEqual({ kind: "plugin", id: "demo-matched" });
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
test("surfaces a skill from a standard-only plugin", () => {
|
|
98
|
+
const pluginDir = join(getWorkspacePluginsDir(), "standard-plugin");
|
|
99
|
+
const skillDir = join(pluginDir, "skills", "qa-standard-skill");
|
|
100
|
+
mkdirSync(skillDir, { recursive: true });
|
|
101
|
+
writeFileSync(
|
|
102
|
+
join(pluginDir, "plugin.json"),
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
105
|
+
name: "standard-plugin",
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
writeFileSync(
|
|
109
|
+
join(skillDir, "SKILL.md"),
|
|
110
|
+
"---\nname: qa-standard-skill\ndescription: Standard skill.\n---\n\nBody.\n",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const skill = skillById("qa-standard-skill");
|
|
114
|
+
|
|
115
|
+
expect(skill?.owner).toEqual({ kind: "plugin", id: "standard-plugin" });
|
|
116
|
+
});
|
|
117
|
+
|
|
97
118
|
test("skips a plugin directory that has no package.json", () => {
|
|
98
119
|
writePlugin(
|
|
99
120
|
"no-manifest",
|
|
@@ -125,5 +125,5 @@ Default to the conversation's current working directory when spawning an agent.
|
|
|
125
125
|
|
|
126
126
|
- The spawned agent runs autonomously with its own tools, file editing, and terminal access.
|
|
127
127
|
- Results are streamed back and injected into the conversation when the agent completes.
|
|
128
|
-
- Use `acp_status` to
|
|
128
|
+
- Use `acp_status` to inspect running and idle agents. Use `acp_steer` to attempt follow-up work on an idle session; do not replace it with a new `acp_spawn`.
|
|
129
129
|
- The `cwd` parameter controls where the agent works - set it to the project root the user wants the agent to operate in.
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"name": "acp_status",
|
|
36
|
-
"description": "Get the status of a specific ACP session or list
|
|
36
|
+
"description": "Get the status of a specific ACP session or list recent ACP sessions. Cleanly completed sessions with durable resume metadata are returned as idle; use `acp_steer` to attempt follow-up work on them instead of starting over with `acp_spawn`. Only use this when the user explicitly asks about ACP session status - do NOT poll automatically, as you will be notified when sessions complete.",
|
|
37
37
|
"category": "orchestration",
|
|
38
38
|
"risk": "low",
|
|
39
39
|
"input_schema": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"properties": {
|
|
42
42
|
"acp_session_id": {
|
|
43
43
|
"type": "string",
|
|
44
|
-
"description": "Optional ACP session ID to query. If omitted, returns
|
|
44
|
+
"description": "Optional ACP session ID to query. If omitted, returns recent ACP sessions, including active and resumable idle sessions."
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
47
|
"required": []
|
|
@@ -37,6 +37,36 @@ The tree is walked to a limited depth to keep steps fast, and says when it was
|
|
|
37
37
|
cut off. If the element you need is not in it, call `computer_use_observe` with
|
|
38
38
|
`full_tree: true`.
|
|
39
39
|
|
|
40
|
+
## Scripting apps (macOS)
|
|
41
|
+
|
|
42
|
+
Reach for `computer_use_run_applescript` first when an app can be driven by
|
|
43
|
+
script. It does not take the cursor.
|
|
44
|
+
|
|
45
|
+
**Try the app's own scripting dictionary before anything else.** Ask it for the
|
|
46
|
+
state you want, not for the clicks that would produce that state. Finder, Mail,
|
|
47
|
+
Music, Notes, Safari, Terminal and many third-party apps have one, and where
|
|
48
|
+
there is a dictionary the whole task is usually a sentence with no window to
|
|
49
|
+
open, no tree to read and nothing to click:
|
|
50
|
+
|
|
51
|
+
```applescript
|
|
52
|
+
tell application "Finder"
|
|
53
|
+
set target of front window to desktop
|
|
54
|
+
set current view of front window to list view
|
|
55
|
+
set sort column of list view options of front window to name column
|
|
56
|
+
end tell
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**System Events menu clicking is the fallback**, for apps with no dictionary
|
|
60
|
+
entry for what you need: `click menu item "Split Clip" of menu "Modify" of menu
|
|
61
|
+
bar 1` inside `tell application "System Events" to tell process "iMovie"`. It is
|
|
62
|
+
UI automation in a script's clothes: it still depends on the menu sitting where
|
|
63
|
+
you expect and on the app being frontmost. A menu item that needs a selection or
|
|
64
|
+
a playhead position does nothing when that context is missing, so set it up
|
|
65
|
+
first, and read `enabled of menu item` when unsure.
|
|
66
|
+
|
|
67
|
+
Click and type for everything a script cannot reach. `host_bash` is for shell
|
|
68
|
+
commands, not for driving apps.
|
|
69
|
+
|
|
40
70
|
## Typing is not sending
|
|
41
71
|
|
|
42
72
|
Pressing enter in a chat, email or form usually sends or submits it, and that
|
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
},
|
|
270
270
|
{
|
|
271
271
|
"name": "computer_use_run_applescript",
|
|
272
|
-
"description": "Run an AppleScript
|
|
272
|
+
"description": "Run an AppleScript on the Mac. Try the target app's own scripting dictionary first, asking it for the state you want rather than for the clicks that would produce it; fall back to System Events menu clicking for apps with no dictionary entry for what you need. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
|
|
273
273
|
"category": "computer-use",
|
|
274
274
|
"risk": "medium",
|
|
275
275
|
"input_schema": {
|
|
@@ -281,7 +281,7 @@
|
|
|
281
281
|
},
|
|
282
282
|
"reasoning": {
|
|
283
283
|
"type": "string",
|
|
284
|
-
"description": "
|
|
284
|
+
"description": "What this script does"
|
|
285
285
|
},
|
|
286
286
|
"target_client_id": {
|
|
287
287
|
"type": "string",
|
|
@@ -8,11 +8,11 @@ metadata:
|
|
|
8
8
|
display-name: "Screen Annotation"
|
|
9
9
|
category: "system"
|
|
10
10
|
activation-hints:
|
|
11
|
-
- "User asks where
|
|
11
|
+
- "User asks where a control is, or to be walked through doing something themselves, in an app they share"
|
|
12
12
|
- "User wants to be shown, not have it done for them"
|
|
13
13
|
- "The answer is a place on the user's screen"
|
|
14
14
|
avoid-when:
|
|
15
|
-
- "User
|
|
15
|
+
- "User asks the assistant to do it (click, type, edit), even on a call: use computer-use"
|
|
16
16
|
- "Nothing is being shared, so there is no surface to point at"
|
|
17
17
|
---
|
|
18
18
|
|
|
@@ -540,8 +540,8 @@
|
|
|
540
540
|
"id": "assistant-desktop",
|
|
541
541
|
"scope": "assistant",
|
|
542
542
|
"key": "assistant-desktop",
|
|
543
|
-
"label": "
|
|
544
|
-
"description": "Streams an interactive desktop from a containerized assistant
|
|
543
|
+
"label": "Virtual desktop",
|
|
544
|
+
"description": "Streams an interactive virtual desktop from a platform-hosted, containerized assistant over /v1/desktop/stream. Requires platform hosting; self-hosted assistants are excluded. Off: the virtual desktop is hidden. On: the chat header offers its viewer and controls.",
|
|
545
545
|
"defaultEnabled": false
|
|
546
546
|
}
|
|
547
547
|
]
|
|
@@ -9,7 +9,7 @@ const PROGRESS_DEFAULTS = {
|
|
|
9
9
|
maxSilenceMs: 35_000,
|
|
10
10
|
longOpMs: 15_000,
|
|
11
11
|
minGapMs: 6_000,
|
|
12
|
-
generationTimeoutMs:
|
|
12
|
+
generationTimeoutMs: 5_000,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
const FRONT_MODEL_DEFAULTS = {
|
|
@@ -75,7 +75,7 @@ describe("VoiceFrontModelConfigSchema", () => {
|
|
|
75
75
|
expect(parsed.progress.maxSilenceMs).toBe(35_000);
|
|
76
76
|
expect(parsed.progress.longOpMs).toBe(15_000);
|
|
77
77
|
expect(parsed.progress.minGapMs).toBe(6_000);
|
|
78
|
-
expect(parsed.progress.generationTimeoutMs).toBe(
|
|
78
|
+
expect(parsed.progress.generationTimeoutMs).toBe(5_000);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
test("a stale maxPerTurn key is stripped, not rejected", () => {
|
|
@@ -55,9 +55,7 @@ const McpSseTransportSchema = z
|
|
|
55
55
|
.optional()
|
|
56
56
|
.describe("Custom HTTP headers sent with SSE requests"),
|
|
57
57
|
})
|
|
58
|
-
.describe(
|
|
59
|
-
"SSE transport: connects to an MCP server over Server-Sent Events",
|
|
60
|
-
);
|
|
58
|
+
.describe("SSE transport: connects to an MCP server over Server-Sent Events");
|
|
61
59
|
|
|
62
60
|
const McpStreamableHttpTransportSchema = z
|
|
63
61
|
.object({
|
|
@@ -114,10 +112,14 @@ export type McpConfig = z.infer<typeof McpConfigSchema>;
|
|
|
114
112
|
*/
|
|
115
113
|
export type McpServerSource = "workspace" | "plugin";
|
|
116
114
|
|
|
117
|
-
/** A server config with its origin
|
|
118
|
-
export
|
|
119
|
-
readonly source:
|
|
120
|
-
|
|
115
|
+
/** A server config with its runtime-only origin and credential identity. */
|
|
116
|
+
export type ResolvedMcpServerConfig =
|
|
117
|
+
| (McpServerConfig & { readonly source: "workspace" })
|
|
118
|
+
| (McpServerConfig & {
|
|
119
|
+
readonly source: "plugin";
|
|
120
|
+
readonly pluginName: string;
|
|
121
|
+
readonly serverKey: string;
|
|
122
|
+
});
|
|
121
123
|
|
|
122
124
|
/** The MCP config the daemon runs: both origins, every server attributed. */
|
|
123
125
|
export interface ResolvedMcpConfig {
|
|
@@ -75,6 +75,15 @@ export const MonitoringConfigSchema = z
|
|
|
75
75
|
.describe(
|
|
76
76
|
"Minimum interval between file-descriptor warnings while usage stays over the threshold, in milliseconds. Crossing the threshold always warns immediately; this only throttles the repeats.",
|
|
77
77
|
),
|
|
78
|
+
mountWatchIntervalMs: z
|
|
79
|
+
.number({ error: "monitoring.mountWatchIntervalMs must be a number" })
|
|
80
|
+
.int("monitoring.mountWatchIntervalMs must be an integer")
|
|
81
|
+
.min(1_000, "monitoring.mountWatchIntervalMs must be at least 1000ms")
|
|
82
|
+
.max(60_000, "monitoring.mountWatchIntervalMs must be <= 60000ms")
|
|
83
|
+
.default(10_000)
|
|
84
|
+
.describe(
|
|
85
|
+
"How often the monitor diffs the assistant container's mount table (/proc/self/mountinfo plus statfs of /workspace and /data), in milliseconds. Logs only when the watched mounts change. Default 10 seconds so a virtiofs bind drop is timestamped without a line per poll.",
|
|
86
|
+
),
|
|
78
87
|
pluginSourceScanIntervalMs: z
|
|
79
88
|
.number({
|
|
80
89
|
error: "monitoring.pluginSourceScanIntervalMs must be a number",
|
|
@@ -74,9 +74,9 @@ export const VoiceProgressConfigSchema = z
|
|
|
74
74
|
.positive(
|
|
75
75
|
"voice.frontModel.progress.generationTimeoutMs must be a positive integer",
|
|
76
76
|
)
|
|
77
|
-
.default(
|
|
77
|
+
.default(5_000)
|
|
78
78
|
.describe(
|
|
79
|
-
"Budget (ms) for LLM-generated progress text. Not latency-critical: it speaks into dead air",
|
|
79
|
+
"Budget (ms) for LLM-generated progress text. Not latency-critical: it speaks into dead air, so the budget is set to cover the narrator's real roundtrip rather than to be tight. A budget that only sometimes covers that roundtrip thins narration out rather than speeding it up, and it thins out exactly where the updates matter most, since the slow beats are the ones with the longest silence behind them (see endpointDecisionTimeoutMs for the same failure mode). Kept under progress.minGapMs so an update that spends the whole budget still lands before the next one is due",
|
|
80
80
|
),
|
|
81
81
|
})
|
|
82
82
|
// The heartbeat is checked when the idle tick finds the turn silent, so a
|
package/src/config/skills.ts
CHANGED
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
getWorkspacePluginsDir,
|
|
40
40
|
getWorkspaceSkillsDir,
|
|
41
41
|
} from "../util/platform.js";
|
|
42
|
+
import {
|
|
43
|
+
hasPluginManifest,
|
|
44
|
+
readPluginManifest,
|
|
45
|
+
} from "../util/plugin-manifest.js";
|
|
42
46
|
import { stripCommentLines } from "../util/strip-comment-lines.js";
|
|
43
47
|
import { isAssistantFeatureFlagEnabled } from "./assistant-feature-flags.js";
|
|
44
48
|
import { getConfig } from "./loader.js";
|
|
@@ -734,8 +738,7 @@ function discoverSkillDirectories(skillsDir: string): string[] {
|
|
|
734
738
|
}
|
|
735
739
|
|
|
736
740
|
/**
|
|
737
|
-
* Whether `pluginDir` carries a plugin manifest the runtime can load
|
|
738
|
-
* parseable `package.json` with a non-empty string `name`. This mirrors the
|
|
741
|
+
* Whether `pluginDir` carries a plugin manifest the runtime can load. This mirrors the
|
|
739
742
|
* external plugin loader (`buildPluginFromDir`), which builds a plugin from
|
|
740
743
|
* any such directory and derives the plugin's identity from `package.json`
|
|
741
744
|
* `name` — it imposes no match between that `name` and the directory name.
|
|
@@ -745,31 +748,20 @@ function discoverSkillDirectories(skillsDir: string): string[] {
|
|
|
745
748
|
* requiring the two to match would silently drop the resident skills of every
|
|
746
749
|
* such plugin even though the runtime loads its hooks and tools fine.
|
|
747
750
|
*
|
|
748
|
-
* The caller is responsible for the missing
|
|
751
|
+
* The caller is responsible for the missing-manifest case (it emits a
|
|
749
752
|
* diagnostic warning); this function only judges a manifest that is present.
|
|
750
753
|
*/
|
|
751
754
|
function hasLoadablePluginManifest(pluginDir: string): boolean {
|
|
752
|
-
const manifestPath = join(pluginDir, "package.json");
|
|
753
|
-
if (!existsSync(manifestPath)) {
|
|
754
|
-
return false;
|
|
755
|
-
}
|
|
756
|
-
let parsed: unknown;
|
|
757
755
|
try {
|
|
758
|
-
|
|
756
|
+
readPluginManifest(pluginDir);
|
|
757
|
+
return true;
|
|
759
758
|
} catch (err) {
|
|
760
759
|
log.warn(
|
|
761
|
-
{ err,
|
|
762
|
-
"Skipping plugin dir with
|
|
760
|
+
{ err, pluginDir },
|
|
761
|
+
"Skipping plugin dir with an invalid manifest for resident skills",
|
|
763
762
|
);
|
|
764
763
|
return false;
|
|
765
764
|
}
|
|
766
|
-
return (
|
|
767
|
-
typeof parsed === "object" &&
|
|
768
|
-
parsed !== null &&
|
|
769
|
-
"name" in parsed &&
|
|
770
|
-
typeof (parsed as { name: unknown }).name === "string" &&
|
|
771
|
-
(parsed as { name: string }).name.length > 0
|
|
772
|
-
);
|
|
773
765
|
}
|
|
774
766
|
|
|
775
767
|
/**
|
|
@@ -885,15 +877,15 @@ function discoverInstalledPluginResidentSkills(): SkillSummary[] {
|
|
|
885
877
|
}
|
|
886
878
|
const pluginDir = join(pluginsDir, entry.name);
|
|
887
879
|
|
|
888
|
-
// A directory under `plugins/` with no
|
|
880
|
+
// A directory under `plugins/` with no supported manifest is not a plugin the
|
|
889
881
|
// runtime can load, so its skills are never surfaced. This is an easy
|
|
890
882
|
// footgun — a plugin dropped in without its manifest looks installed but
|
|
891
883
|
// silently contributes nothing — so warn loudly with the path rather than
|
|
892
884
|
// skipping in silence, to make the misconfiguration diagnosable.
|
|
893
|
-
if (!
|
|
885
|
+
if (!hasPluginManifest(pluginDir)) {
|
|
894
886
|
log.warn(
|
|
895
887
|
{ pluginDir },
|
|
896
|
-
"Plugin directory is missing package.json
|
|
888
|
+
"Plugin directory is missing package.json and plugin.json; its skills will not be available.",
|
|
897
889
|
);
|
|
898
890
|
continue;
|
|
899
891
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
8
8
|
|
|
9
|
+
import { setOverridesForTesting } from "../../__tests__/feature-flag-test-helpers.js";
|
|
9
10
|
import * as configLoader from "../../config/loader.js";
|
|
10
11
|
import type { AssistantConfig } from "../../config/schema.js";
|
|
11
12
|
import * as disabledState from "../../plugins/disabled-state.js";
|
|
@@ -245,3 +246,49 @@ describe("createResolveToolsCallback — config.tools.exclude", () => {
|
|
|
245
246
|
expect(ctx.allowedToolNames?.has("bash")).toBe(false);
|
|
246
247
|
});
|
|
247
248
|
});
|
|
249
|
+
|
|
250
|
+
test("managed browser guidance follows each turn without changing native or shared tool definitions", () => {
|
|
251
|
+
const platform = process.env.IS_PLATFORM;
|
|
252
|
+
const containerized = process.env.IS_CONTAINERIZED;
|
|
253
|
+
process.env.IS_PLATFORM = "true";
|
|
254
|
+
process.env.IS_CONTAINERIZED = "true";
|
|
255
|
+
setOverridesForTesting({ "assistant-desktop": true });
|
|
256
|
+
const bash = def("bash");
|
|
257
|
+
const ctx = makeCtx({
|
|
258
|
+
conversationId: "conv-browser-guidance",
|
|
259
|
+
transportInterface: "web",
|
|
260
|
+
currentTurnClientOs: "web",
|
|
261
|
+
trustContext: { trustClass: "guardian" } as Conversation["trustContext"],
|
|
262
|
+
getTurnActorPrincipalId: () => "user-123",
|
|
263
|
+
});
|
|
264
|
+
const resolve = createResolveToolsCallback([bash], ctx)!;
|
|
265
|
+
try {
|
|
266
|
+
expect(
|
|
267
|
+
resolve([]).find((tool) => tool.name === "bash")?.description,
|
|
268
|
+
).toContain("assistant browser navigate");
|
|
269
|
+
expect(bash.description).toBe("bash");
|
|
270
|
+
for (const clientOs of ["macos", "windows", "linux"] as const) {
|
|
271
|
+
ctx.currentTurnClientOs = clientOs;
|
|
272
|
+
expect(
|
|
273
|
+
resolve([]).find((tool) => tool.name === "bash")?.description,
|
|
274
|
+
).toBe("bash");
|
|
275
|
+
}
|
|
276
|
+
ctx.currentTurnClientOs = "web";
|
|
277
|
+
setOverridesForTesting({ "assistant-desktop": false });
|
|
278
|
+
expect(resolve([]).find((tool) => tool.name === "bash")?.description).toBe(
|
|
279
|
+
"bash",
|
|
280
|
+
);
|
|
281
|
+
} finally {
|
|
282
|
+
setOverridesForTesting({});
|
|
283
|
+
if (platform === undefined) {
|
|
284
|
+
delete process.env.IS_PLATFORM;
|
|
285
|
+
} else {
|
|
286
|
+
process.env.IS_PLATFORM = platform;
|
|
287
|
+
}
|
|
288
|
+
if (containerized === undefined) {
|
|
289
|
+
delete process.env.IS_CONTAINERIZED;
|
|
290
|
+
} else {
|
|
291
|
+
process.env.IS_CONTAINERIZED = containerized;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ClientOs, parseClientOs } from "../channels/types.js";
|
|
2
|
+
import type { Conversation } from "./conversation.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Host OS of the client driving this turn. The Electron renderer reports
|
|
6
|
+
* `interface: "web"` and carries the real OS in `clientOs`, so this prefers
|
|
7
|
+
* the frozen per-turn value and only falls back to a desktop transport.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveTurnClientOs(ctx: Conversation): {
|
|
10
|
+
clientOs: ClientOs | undefined;
|
|
11
|
+
transportInterface: Conversation["transportInterface"];
|
|
12
|
+
} {
|
|
13
|
+
const pin = ctx.toolContextPin;
|
|
14
|
+
const transportInterface = pin
|
|
15
|
+
? pin.transportInterface
|
|
16
|
+
: ctx.transportInterface;
|
|
17
|
+
const clientOs = pin
|
|
18
|
+
? pin.clientOs
|
|
19
|
+
: (parseClientOs(ctx.currentTurnClientOs ?? ctx.clientOs) ??
|
|
20
|
+
(transportInterface === "macos" ||
|
|
21
|
+
transportInterface === "windows" ||
|
|
22
|
+
transportInterface === "linux"
|
|
23
|
+
? transportInterface
|
|
24
|
+
: undefined));
|
|
25
|
+
return { clientOs, transportInterface };
|
|
26
|
+
}
|
|
@@ -854,7 +854,7 @@ export function buildChannelCapabilityBlock(
|
|
|
854
854
|
if (clientOs === "macos") {
|
|
855
855
|
lines.push("");
|
|
856
856
|
lines.push(
|
|
857
|
-
"On macOS,
|
|
857
|
+
"On macOS, drive apps with the computer-use skill; `host_bash` is for shell commands.",
|
|
858
858
|
);
|
|
859
859
|
}
|
|
860
860
|
|