@openforge-app/plugin-sdk 0.1.0 → 0.2.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/backend.d.ts +2 -2
  4. package/dist/browserSurfaces.d.ts +78 -0
  5. package/dist/browserSurfaces.js +17 -0
  6. package/dist/browserSurfacesTesting.d.ts +60 -0
  7. package/dist/browserSurfacesTesting.js +240 -0
  8. package/dist/domain.d.ts +74 -15
  9. package/dist/domain.js +5 -1
  10. package/dist/fileIcons.d.ts +6 -0
  11. package/dist/fileIcons.js +84 -0
  12. package/dist/frontend.d.ts +5 -2
  13. package/dist/frontend.js +1 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +2 -1
  16. package/dist/manifest.d.ts +80 -2
  17. package/dist/manifest.js +55 -1
  18. package/dist/markdown.d.ts +5 -1
  19. package/dist/markdown.js +67 -18
  20. package/dist/openforgePackageMetadataSchema.json +80 -4
  21. package/dist/pluginIconContract.d.ts +3 -0
  22. package/dist/pluginIconContract.js +12 -0
  23. package/dist/pluginIcons.d.ts +7 -0
  24. package/dist/pluginIcons.js +99 -0
  25. package/dist/publicUiExports.d.mts +16 -0
  26. package/dist/publicUiExports.mjs +60 -0
  27. package/dist/svelteHostRuntimeContract.d.mts +1 -0
  28. package/dist/svelteHostRuntimeContract.mjs +16 -0
  29. package/dist/testing/backendServicesFake.d.ts +19 -0
  30. package/dist/testing/backendServicesFake.js +87 -0
  31. package/dist/testing/commonApiFake.d.ts +23 -0
  32. package/dist/testing/commonApiFake.js +306 -0
  33. package/dist/testing/contracts.d.ts +236 -0
  34. package/dist/testing/contracts.js +1 -0
  35. package/dist/testing/frontendContributionFake.d.ts +36 -0
  36. package/dist/testing/frontendContributionFake.js +204 -0
  37. package/dist/testing/registryFake.d.ts +40 -0
  38. package/dist/testing/registryFake.js +124 -0
  39. package/dist/testing/support.d.ts +40 -0
  40. package/dist/testing/support.js +247 -0
  41. package/dist/testing.d.ts +3 -223
  42. package/dist/testing.js +2 -625
  43. package/dist/types.d.ts +251 -9
  44. package/dist/types.js +47 -0
  45. package/dist/ui/Button.svelte +49 -0
  46. package/dist/ui/Checkbox.svelte +137 -0
  47. package/dist/ui/FileTypeIcon.svelte +37 -0
  48. package/dist/ui/MarkdownContent.svelte +71 -11
  49. package/dist/ui/Modal.svelte +157 -0
  50. package/dist/ui/PluginPageHeader.svelte +26 -0
  51. package/dist/ui/PluginSidebarLink.svelte +54 -0
  52. package/dist/ui/PluginViewState.svelte +76 -0
  53. package/dist/ui/ResizablePanel.svelte +10 -0
  54. package/dist/ui/icons/README.md +9 -0
  55. package/dist/ui/icons/c.svg +1 -0
  56. package/dist/ui/icons/console.svg +1 -0
  57. package/dist/ui/icons/cpp.svg +1 -0
  58. package/dist/ui/icons/csharp.svg +1 -0
  59. package/dist/ui/icons/css.svg +3 -0
  60. package/dist/ui/icons/database.svg +1 -0
  61. package/dist/ui/icons/docker.svg +1 -0
  62. package/dist/ui/icons/document.svg +4 -0
  63. package/dist/ui/icons/file.svg +1 -0
  64. package/dist/ui/icons/folder-open.svg +1 -0
  65. package/dist/ui/icons/folder.svg +1 -0
  66. package/dist/ui/icons/git.svg +3 -0
  67. package/dist/ui/icons/go.svg +1 -0
  68. package/dist/ui/icons/graphql.svg +20 -0
  69. package/dist/ui/icons/h.svg +3 -0
  70. package/dist/ui/icons/html.svg +3 -0
  71. package/dist/ui/icons/image.svg +3 -0
  72. package/dist/ui/icons/java.svg +6 -0
  73. package/dist/ui/icons/javascript.svg +3 -0
  74. package/dist/ui/icons/json.svg +3 -0
  75. package/dist/ui/icons/kotlin.svg +1 -0
  76. package/dist/ui/icons/lock.svg +3 -0
  77. package/dist/ui/icons/markdown.svg +6 -0
  78. package/dist/ui/icons/nodejs.svg +6 -0
  79. package/dist/ui/icons/npm.svg +3 -0
  80. package/dist/ui/icons/pdf.svg +1 -0
  81. package/dist/ui/icons/php.svg +1 -0
  82. package/dist/ui/icons/python.svg +1 -0
  83. package/dist/ui/icons/react.svg +8 -0
  84. package/dist/ui/icons/react_ts.svg +8 -0
  85. package/dist/ui/icons/readme.svg +4 -0
  86. package/dist/ui/icons/ruby.svg +1 -0
  87. package/dist/ui/icons/rust.svg +1 -0
  88. package/dist/ui/icons/sass.svg +3 -0
  89. package/dist/ui/icons/settings.svg +4 -0
  90. package/dist/ui/icons/svelte.svg +1 -0
  91. package/dist/ui/icons/svg.svg +3 -0
  92. package/dist/ui/icons/swift.svg +1 -0
  93. package/dist/ui/icons/tune.svg +11 -0
  94. package/dist/ui/icons/typescript-def.svg +6 -0
  95. package/dist/ui/icons/typescript.svg +3 -0
  96. package/dist/ui/icons/vue.svg +1 -0
  97. package/dist/ui/icons/xml.svg +1 -0
  98. package/dist/ui/icons/yaml.svg +3 -0
  99. package/dist/ui/icons/zip.svg +3 -0
  100. package/dist/vite.js +5 -2
  101. package/package.json +35 -7
@@ -0,0 +1,99 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { isPluginSvgIcon } from './pluginIconContract';
3
+ export const MAX_PLUGIN_SVG_ICON_CHARACTERS = 10_000;
4
+ const ALLOWED_SVG_TAGS = ['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'];
5
+ const ALLOWED_SVG_ATTRIBUTES = [
6
+ 'viewBox',
7
+ 'd',
8
+ 'fill',
9
+ 'fill-rule',
10
+ 'fill-opacity',
11
+ 'stroke',
12
+ 'stroke-width',
13
+ 'stroke-linecap',
14
+ 'stroke-linejoin',
15
+ 'stroke-miterlimit',
16
+ 'stroke-dasharray',
17
+ 'stroke-dashoffset',
18
+ 'stroke-opacity',
19
+ 'opacity',
20
+ 'transform',
21
+ 'x',
22
+ 'y',
23
+ 'x1',
24
+ 'y1',
25
+ 'x2',
26
+ 'y2',
27
+ 'cx',
28
+ 'cy',
29
+ 'r',
30
+ 'rx',
31
+ 'ry',
32
+ 'width',
33
+ 'height',
34
+ 'points',
35
+ ];
36
+ const SAFE_PAINT_VALUE = /^(?:none|currentColor|transparent|#[0-9a-f]{3,8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([\d\s.,%+\-/]+\))$/iu;
37
+ function parseSvgRoot(markup) {
38
+ const template = document.createElement('template');
39
+ template.innerHTML = markup.trim();
40
+ const children = template.content.children;
41
+ const root = children.item(0);
42
+ if (children.length !== 1 || !(root instanceof SVGSVGElement)) {
43
+ throw new TypeError('Custom plugin icons require exactly one <svg> root');
44
+ }
45
+ return root;
46
+ }
47
+ function hasValidViewBox(root) {
48
+ const values = root.getAttribute('viewBox')?.trim().split(/[\s,]+/u).map(Number) ?? [];
49
+ return values.length === 4
50
+ && values.every(Number.isFinite)
51
+ && (values[2] ?? 0) > 0
52
+ && (values[3] ?? 0) > 0;
53
+ }
54
+ function removeUnsafePaint(root) {
55
+ for (const element of [root, ...root.querySelectorAll('*')]) {
56
+ for (const attribute of ['fill', 'stroke']) {
57
+ const value = element.getAttribute(attribute);
58
+ if (value !== null && !SAFE_PAINT_VALUE.test(value.trim())) {
59
+ element.removeAttribute(attribute);
60
+ }
61
+ }
62
+ }
63
+ }
64
+ /**
65
+ * Validates and sanitizes a plugin icon before it crosses a host contribution boundary.
66
+ * Named icons are preserved so unsupported names can retain the host's existing fallback.
67
+ */
68
+ export function sanitizePluginIcon(icon) {
69
+ if (typeof icon === 'string') {
70
+ if (icon.trim().length === 0) {
71
+ throw new TypeError('Plugin icon names must be non-empty');
72
+ }
73
+ return icon;
74
+ }
75
+ if (!isPluginSvgIcon(icon) || icon.svg.trim().length === 0) {
76
+ throw new TypeError('Plugin icons must be a non-empty name or { type: "svg", svg }');
77
+ }
78
+ if (icon.svg.length > MAX_PLUGIN_SVG_ICON_CHARACTERS) {
79
+ throw new TypeError(`Custom plugin icon SVG must not exceed ${MAX_PLUGIN_SVG_ICON_CHARACTERS.toLocaleString('en-US')} characters`);
80
+ }
81
+ const sanitizedMarkup = DOMPurify.sanitize(icon.svg, {
82
+ ALLOWED_TAGS: ALLOWED_SVG_TAGS,
83
+ ALLOWED_ATTR: ALLOWED_SVG_ATTRIBUTES,
84
+ ALLOW_ARIA_ATTR: false,
85
+ ALLOW_DATA_ATTR: false,
86
+ KEEP_CONTENT: false,
87
+ });
88
+ const root = parseSvgRoot(sanitizedMarkup);
89
+ if (!hasValidViewBox(root)) {
90
+ throw new TypeError('Custom plugin icons require a viewBox with positive width and height');
91
+ }
92
+ if (!root.querySelector('path, rect, circle, ellipse, line, polyline, polygon')) {
93
+ throw new TypeError('Custom plugin icons require visible geometry');
94
+ }
95
+ root.removeAttribute('width');
96
+ root.removeAttribute('height');
97
+ removeUnsafePaint(root);
98
+ return { type: 'svg', svg: root.outerHTML };
99
+ }
@@ -0,0 +1,16 @@
1
+ export type OpenForgePluginSdkPublicUiExport = Readonly<{
2
+ componentName: string
3
+ packageSubpath: `./ui/${string}.svelte`
4
+ importSpecifier: `@openforge-app/plugin-sdk/ui/${string}.svelte`
5
+ sourcePath: `src/ui/${string}.svelte`
6
+ workspaceSourcePath: `packages/plugin-sdk/src/ui/${string}.svelte`
7
+ distPath: `./dist/ui/${string}.svelte`
8
+ }>
9
+
10
+ export const OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS: readonly OpenForgePluginSdkPublicUiExport[]
11
+
12
+ export function createOpenForgePluginSdkPublicUiPackageExports(): Record<string, string>
13
+
14
+ export function assertOpenForgePluginSdkPublicUiPackageExports(
15
+ packageExports: unknown,
16
+ ): asserts packageExports is Record<string, unknown>
@@ -0,0 +1,60 @@
1
+ const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
2
+
3
+ const PUBLIC_UI_COMPONENT_NAMES = Object.freeze([
4
+ 'Button',
5
+ 'Checkbox',
6
+ 'MarkdownContent',
7
+ 'ResizablePanel',
8
+ 'Modal',
9
+ 'PluginPageHeader',
10
+ 'PluginViewState',
11
+ 'PluginSidebarLink',
12
+ 'FileTypeIcon',
13
+ ])
14
+
15
+ /**
16
+ * Canonical registrations for public Svelte components shipped by the plugin SDK.
17
+ * Package exports are validated against this list; asset copying, source aliases,
18
+ * root Vite aliases, and import-boundary fallbacks are derived from it.
19
+ */
20
+ export const OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS = Object.freeze(
21
+ PUBLIC_UI_COMPONENT_NAMES.map((componentName) => Object.freeze({
22
+ componentName,
23
+ packageSubpath: `./ui/${componentName}.svelte`,
24
+ importSpecifier: `${PLUGIN_SDK_PACKAGE_NAME}/ui/${componentName}.svelte`,
25
+ sourcePath: `src/ui/${componentName}.svelte`,
26
+ workspaceSourcePath: `packages/plugin-sdk/src/ui/${componentName}.svelte`,
27
+ distPath: `./dist/ui/${componentName}.svelte`,
28
+ })),
29
+ )
30
+
31
+ export function createOpenForgePluginSdkPublicUiPackageExports() {
32
+ return Object.fromEntries(
33
+ OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS.map(({ packageSubpath, distPath }) => [packageSubpath, distPath]),
34
+ )
35
+ }
36
+
37
+ export function assertOpenForgePluginSdkPublicUiPackageExports(packageExports) {
38
+ if (!packageExports || typeof packageExports !== 'object' || Array.isArray(packageExports)) {
39
+ throw new Error('Plugin SDK package.json must define an exports object')
40
+ }
41
+
42
+ const expected = createOpenForgePluginSdkPublicUiPackageExports()
43
+ const actual = Object.fromEntries(
44
+ Object.entries(packageExports).filter(([subpath]) => subpath.startsWith('./ui/')),
45
+ )
46
+ const expectedEntries = Object.entries(expected)
47
+ const missingOrMismatched = expectedEntries
48
+ .filter(([subpath, distPath]) => actual[subpath] !== distPath)
49
+ .map(([subpath, distPath]) => `${subpath} -> ${distPath}`)
50
+ const unexpected = Object.keys(actual).filter((subpath) => !(subpath in expected))
51
+
52
+ if (missingOrMismatched.length === 0 && unexpected.length === 0) return
53
+
54
+ const details = [
55
+ missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
56
+ unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
57
+ ].filter(Boolean)
58
+
59
+ throw new Error(`Plugin SDK public UI exports drifted from the canonical manifest (${details.join('; ')})`)
60
+ }
@@ -6,6 +6,7 @@ export type SvelteHostRuntimeModule = Readonly<{
6
6
 
7
7
  export const SVELTE_HOST_RUNTIME_MODULES: readonly SvelteHostRuntimeModule[]
8
8
  export const OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS: readonly string[]
9
+ export function isOpenForgeHostRuntimeSvelteExternal(id: string): boolean
9
10
  export const SVELTE_HOST_RUNTIME_IMPORTS: Readonly<Record<string, string>>
10
11
  export function svelteHostRuntimeImportUrl(specifier: string): string | null
11
12
  export function svelteHostRuntimeBuildEntries(): Record<string, string>
@@ -26,6 +26,22 @@ export const OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS = Object.freeze(
26
26
  SVELTE_HOST_RUNTIME_MODULES.map(module => module.specifier),
27
27
  )
28
28
 
29
+ const OPENFORGE_HOST_RUNTIME_SVELTE_MODULE_SET = new Set(OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS)
30
+
31
+ /**
32
+ * True for the Svelte host-runtime specifiers the HOST renderer build must externalize.
33
+ *
34
+ * Externalizing these makes the host emit bare `svelte` / `svelte/internal/client` imports
35
+ * that resolve through the injected renderer import map to `plugin://host-runtime/svelte`
36
+ * — the same Svelte instance external plugins load. Host + plugins then share ONE Svelte
37
+ * instance, so mounting an external plugin component from the host tree no longer throws
38
+ * `effect_orphan`. Deliberately Svelte-only: once Svelte is external, host-bundled
39
+ * terminal-runtime resolves Svelte to the shared instance too, so nothing wider is needed.
40
+ */
41
+ export function isOpenForgeHostRuntimeSvelteExternal(id) {
42
+ return OPENFORGE_HOST_RUNTIME_SVELTE_MODULE_SET.has(id)
43
+ }
44
+
29
45
  export const SVELTE_HOST_RUNTIME_IMPORTS = Object.freeze(Object.fromEntries(
30
46
  SVELTE_HOST_RUNTIME_MODULES.map(module => [module.specifier, `${HOST_RUNTIME_SVELTE_BASE_URL}${module.assetPath}`]),
31
47
  ))
@@ -0,0 +1,19 @@
1
+ import type { BackendOpenForgeAPI } from '../types';
2
+ import { type TestingRegistryServices } from './support';
3
+ import type { TestingBackendMethodContribution, TestingBackgroundServiceContribution } from './contracts';
4
+ export declare class TestingBackendServicesFake {
5
+ private readonly services;
6
+ private readonly backendMethods;
7
+ private readonly backgroundServices;
8
+ constructor(services: TestingRegistryServices);
9
+ createApi(): Pick<BackendOpenForgeAPI, 'backend' | 'background'>;
10
+ getSnapshot(): {
11
+ backendMethods: TestingBackendMethodContribution[];
12
+ backgroundServices: TestingBackgroundServiceContribution[];
13
+ };
14
+ captureBackgroundServiceIds(): Set<string>;
15
+ startNewBackgroundServices(existingServices: Set<string>): Promise<void>;
16
+ invokeMethod<TOutput>(method: string, payload?: unknown): Promise<TOutput>;
17
+ private registerBackendMethod;
18
+ private registerBackgroundService;
19
+ }
@@ -0,0 +1,87 @@
1
+ import { assertFunction, createDisposable } from './support';
2
+ export class TestingBackendServicesFake {
3
+ services;
4
+ backendMethods = new Map();
5
+ backgroundServices = new Map();
6
+ constructor(services) {
7
+ this.services = services;
8
+ }
9
+ createApi() {
10
+ return {
11
+ backend: {
12
+ registerMethod: (method, registration) => this.registerBackendMethod(method, registration),
13
+ },
14
+ background: {
15
+ register: (registration) => this.registerBackgroundService(registration),
16
+ },
17
+ };
18
+ }
19
+ getSnapshot() {
20
+ return {
21
+ backendMethods: Array.from(this.backendMethods.values()),
22
+ backgroundServices: Array.from(this.backgroundServices.values()),
23
+ };
24
+ }
25
+ captureBackgroundServiceIds() {
26
+ return new Set(this.backgroundServices.keys());
27
+ }
28
+ async startNewBackgroundServices(existingServices) {
29
+ for (const [key, service] of this.backgroundServices.entries()) {
30
+ if (existingServices.has(key) || service.started)
31
+ continue;
32
+ await service.start();
33
+ service.started = true;
34
+ }
35
+ }
36
+ async invokeMethod(method, payload) {
37
+ const qualifiedId = this.services.localQualifiedId('backend', method);
38
+ this.services.calls.backendInvocations.push({ method, qualifiedId, payload });
39
+ const contribution = this.backendMethods.get(qualifiedId);
40
+ if (!contribution) {
41
+ throw new Error(`Backend method is not registered: ${qualifiedId}`);
42
+ }
43
+ return await contribution.registration.handler(payload);
44
+ }
45
+ registerBackendMethod(method, registration) {
46
+ const qualifiedId = this.services.localQualifiedId('backend', method);
47
+ assertFunction('backend', 'handler', registration.handler);
48
+ this.services.claims.claim('backend', qualifiedId);
49
+ const contribution = {
50
+ id: method.trim(),
51
+ qualifiedId,
52
+ pluginId: this.services.pluginId,
53
+ projectId: this.services.projectId,
54
+ registration,
55
+ };
56
+ this.backendMethods.set(qualifiedId, contribution);
57
+ return createDisposable(() => {
58
+ this.backendMethods.delete(qualifiedId);
59
+ this.services.claims.release('backend', qualifiedId);
60
+ });
61
+ }
62
+ registerBackgroundService(registration) {
63
+ const qualifiedId = this.services.localQualifiedId('background', registration.id);
64
+ if (registration.scope !== 'global' && registration.scope !== 'project' && registration.scope !== 'task') {
65
+ throw new Error('background registration requires scope to be global, project, or task');
66
+ }
67
+ assertFunction('background', 'start', registration.start);
68
+ this.services.claims.claim('background', qualifiedId);
69
+ const contribution = {
70
+ ...registration,
71
+ id: registration.id.trim(),
72
+ qualifiedId,
73
+ pluginId: this.services.pluginId,
74
+ projectId: this.services.projectId,
75
+ started: false,
76
+ };
77
+ this.backgroundServices.set(qualifiedId, contribution);
78
+ return createDisposable(async () => {
79
+ this.backgroundServices.delete(qualifiedId);
80
+ this.services.claims.release('background', qualifiedId);
81
+ if (contribution.started) {
82
+ await contribution.stop?.();
83
+ contribution.started = false;
84
+ }
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,23 @@
1
+ import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, OpenForgeCommonAPI } from '../types';
2
+ import { type TestingRegistryServices } from './support';
3
+ import type { TestingCommandContribution, TestingEventListenerContribution } from './contracts';
4
+ export type TestingCommonApi = OpenForgeCommonAPI & Pick<FrontendOpenForgeAPI, 'navigation'>;
5
+ export declare class TestingCommonApiFake {
6
+ private readonly services;
7
+ private readonly commands;
8
+ private readonly eventListeners;
9
+ private readonly eventHandlers;
10
+ private eventListenerSequence;
11
+ constructor(services: TestingRegistryServices);
12
+ createApi(): TestingCommonApi;
13
+ createBackendApi(): TestingCommonApi & Pick<BackendOpenForgeAPI, 'fs'>;
14
+ getSnapshot(): {
15
+ commands: TestingCommandContribution[];
16
+ eventListeners: TestingEventListenerContribution[];
17
+ };
18
+ private registerCommand;
19
+ private registerEventListener;
20
+ private invokeCommand;
21
+ private invokeGlobalCommand;
22
+ private emitEvent;
23
+ }
@@ -0,0 +1,306 @@
1
+ import { assertFunction, assertTitle, commandDescriptor, createDisposable, isJsonValue, normalizeAgentCommandMetadata, } from './support';
2
+ export class TestingCommonApiFake {
3
+ services;
4
+ commands = new Map();
5
+ eventListeners = new Map();
6
+ eventHandlers = new Map();
7
+ eventListenerSequence = 0;
8
+ constructor(services) {
9
+ this.services = services;
10
+ }
11
+ createApi() {
12
+ const api = {
13
+ commands: {
14
+ register: (registration) => this.registerCommand(registration),
15
+ invoke: async (id, payload) => this.invokeCommand(id, payload),
16
+ invokeGlobal: async (qualifiedId, payload) => this.invokeGlobalCommand(qualifiedId, payload),
17
+ list: async () => Array.from(this.commands.values()).map(commandDescriptor),
18
+ listCatalog: async () => [],
19
+ },
20
+ events: {
21
+ on: (event, handler) => this.registerEventListener(event, handler, false),
22
+ onGlobal: (qualifiedEvent, handler) => this.registerEventListener(qualifiedEvent, handler, true),
23
+ emit: async (event, payload) => this.emitEvent(event, payload, false),
24
+ emitGlobal: async (qualifiedEvent, payload) => this.emitEvent(qualifiedEvent, payload, true),
25
+ },
26
+ storage: this.services.storage,
27
+ context: {
28
+ getSnapshot: () => this.services.getContextSnapshot(),
29
+ },
30
+ tasks: {
31
+ list: async (request) => {
32
+ const projectId = request?.projectId ?? null;
33
+ const includeDone = request?.includeDone ?? false;
34
+ this.services.calls.taskListRequests.push({ projectId, includeDone });
35
+ return this.services.seededTasks.filter((task) => {
36
+ if (projectId !== null && task.project_id !== projectId)
37
+ return false;
38
+ if (!includeDone && task.status === 'done')
39
+ return false;
40
+ return true;
41
+ });
42
+ },
43
+ get: async () => null,
44
+ create: async (request) => {
45
+ this.services.calls.taskCreations.push(request);
46
+ return {
47
+ id: `mock-task-${this.services.calls.taskCreations.length}`,
48
+ initial_prompt: request.initialPrompt,
49
+ status: 'backlog',
50
+ prompt: null,
51
+ title: null,
52
+ title_source: null,
53
+ title_generated_at: null,
54
+ agent: null,
55
+ permission_mode: null,
56
+ worktree_source: null,
57
+ worktree_branch: null,
58
+ source_ticket_url: null,
59
+ depends_on: request.dependsOn ?? [],
60
+ project_id: request.projectId,
61
+ created_at: 0,
62
+ updated_at: 0,
63
+ };
64
+ },
65
+ // The fake stands in for the host dialog: it records the request and
66
+ // reports a created-but-not-started task, so consumers can assert what
67
+ // they asked for without a UI. Override per test for the other outcomes.
68
+ compose: async (request) => {
69
+ this.services.calls.taskComposes.push(request);
70
+ const task = await api.tasks.create({
71
+ projectId: request.projectId,
72
+ initialPrompt: request.initialPrompt,
73
+ });
74
+ return { task, started: false };
75
+ },
76
+ updateStatus: async (taskId, status) => {
77
+ this.services.calls.taskStatusUpdates.push({ taskId, status });
78
+ },
79
+ listStartPromptContributions: async (projectId) => this.services.startPromptContributions(projectId),
80
+ configureStartPromptContribution: async (request) => {
81
+ this.services.calls.startPromptContributionConfigurations.push(request);
82
+ const contribution = { ...request, ownerPluginId: this.services.pluginId };
83
+ const existing = this.services.startPromptContributions(request.projectId)
84
+ .filter((entry) => entry.id !== request.id
85
+ || (entry.ownerPluginId !== undefined && entry.ownerPluginId !== contribution.ownerPluginId));
86
+ const next = [...existing, contribution].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)
87
+ || a.id.localeCompare(b.id)
88
+ || (a.ownerPluginId ?? '').localeCompare(b.ownerPluginId ?? ''));
89
+ this.services.config.set(`project:${request.projectId}:start_prompt_contributions`, next);
90
+ return next;
91
+ },
92
+ startImplementation: async (request) => {
93
+ this.services.calls.taskImplementationStarts.push(request);
94
+ return {
95
+ taskId: request.taskId,
96
+ workspacePath: '/mock-workspace',
97
+ sessionId: 'mock-session',
98
+ };
99
+ },
100
+ sendFollowUp: async (request) => {
101
+ this.services.calls.taskFollowUps.push(request);
102
+ return {
103
+ taskId: request.taskId,
104
+ sessionId: 'mock-session',
105
+ disposition: 'delivered',
106
+ };
107
+ },
108
+ getWorkspace: async () => null,
109
+ getLatestSession: async () => null,
110
+ },
111
+ projects: {
112
+ list: async () => [],
113
+ get: async () => null,
114
+ },
115
+ fs: {
116
+ readDir: async () => [],
117
+ readFile: async () => ({ type: 'text', content: '', mimeType: null, size: 0 }),
118
+ writeFile: async (request) => {
119
+ this.services.calls.fsWrites.push(request);
120
+ },
121
+ searchFiles: async () => [],
122
+ },
123
+ shell: {
124
+ spawn: async (request) => {
125
+ this.services.calls.shellSpawns.push(request);
126
+ return 0;
127
+ },
128
+ write: async (request) => {
129
+ this.services.calls.shellWrites.push(request);
130
+ },
131
+ resize: async (request) => {
132
+ this.services.calls.shellResizes.push(request);
133
+ },
134
+ kill: async (request) => {
135
+ this.services.calls.shellKills.push(request);
136
+ },
137
+ getBuffer: async (request) => {
138
+ this.services.calls.shellBuffers.push(request);
139
+ return { buffer: null, isLive: false };
140
+ },
141
+ },
142
+ notifications: {
143
+ notify: async (request) => {
144
+ this.services.calls.notify.push(request);
145
+ },
146
+ },
147
+ attention: {
148
+ listProjects: async () => [],
149
+ },
150
+ system: {
151
+ openUrl: async (url) => {
152
+ this.services.calls.openUrl.push(url);
153
+ },
154
+ writeClipboardText: async (text) => {
155
+ this.services.calls.clipboardWrites.push(text);
156
+ },
157
+ },
158
+ navigation: {
159
+ get: () => this.services.getNavigationSnapshot(),
160
+ navigate: async (request) => {
161
+ this.services.calls.navigationRequests.push(request);
162
+ return this.services.getNavigationSnapshot(request);
163
+ },
164
+ },
165
+ config: {
166
+ get: async (key) => this.services.config.has(`global:${key}`)
167
+ ? this.services.config.get(`global:${key}`)
168
+ : null,
169
+ set: async (key, value) => {
170
+ this.services.config.set(`global:${key}`, value);
171
+ this.services.calls.configWrites.push({ key, value, projectId: null });
172
+ },
173
+ },
174
+ projectConfig: {
175
+ get: async (key, projectId = this.services.projectId ?? '') => this.services.config.has(`project:${projectId}:${key}`)
176
+ ? this.services.config.get(`project:${projectId}:${key}`)
177
+ : null,
178
+ set: async (key, value, projectId = this.services.projectId ?? '') => {
179
+ this.services.config.set(`project:${projectId}:${key}`, value);
180
+ this.services.calls.configWrites.push({ key, value, projectId });
181
+ },
182
+ },
183
+ };
184
+ return api;
185
+ }
186
+ createBackendApi() {
187
+ const api = this.createApi();
188
+ return {
189
+ ...api,
190
+ fs: {
191
+ ...api.fs,
192
+ userData: {
193
+ readDir: async (request = {}) => {
194
+ this.services.calls.fsUserDataReadDirs.push(request);
195
+ return [];
196
+ },
197
+ readTextFile: async (request) => {
198
+ this.services.calls.fsUserDataReads.push(request);
199
+ return '';
200
+ },
201
+ writeTextFile: async (request) => {
202
+ this.services.calls.fsUserDataWrites.push(request);
203
+ },
204
+ },
205
+ external: {
206
+ readDir: async (request) => {
207
+ this.services.calls.fsExternalReadDirs.push(request);
208
+ return [];
209
+ },
210
+ readTextFile: async (request) => {
211
+ this.services.calls.fsExternalReads.push(request);
212
+ return '';
213
+ },
214
+ },
215
+ },
216
+ };
217
+ }
218
+ getSnapshot() {
219
+ return {
220
+ commands: Array.from(this.commands.values()),
221
+ eventListeners: Array.from(this.eventListeners.values()),
222
+ };
223
+ }
224
+ registerCommand(registration) {
225
+ const qualifiedId = this.services.localQualifiedId('commands', registration.id);
226
+ assertTitle('commands', registration.title);
227
+ assertFunction('commands', 'handler', registration.handler);
228
+ const agent = normalizeAgentCommandMetadata(registration.agent);
229
+ if (agent && registration.input !== undefined && !isJsonValue(registration.input)) {
230
+ throw new Error('commands registration agent-facing input schema must be a JSON value');
231
+ }
232
+ if (agent && registration.output !== undefined && !isJsonValue(registration.output)) {
233
+ throw new Error('commands registration agent-facing output schema must be a JSON value');
234
+ }
235
+ this.services.claims.claim('commands', qualifiedId);
236
+ const contribution = {
237
+ ...registration,
238
+ agent,
239
+ id: registration.id.trim(),
240
+ title: registration.title.trim(),
241
+ qualifiedId,
242
+ pluginId: this.services.pluginId,
243
+ projectId: this.services.projectId,
244
+ handler: registration.handler,
245
+ };
246
+ this.commands.set(qualifiedId, contribution);
247
+ return createDisposable(() => {
248
+ this.commands.delete(qualifiedId);
249
+ this.services.claims.release('commands', qualifiedId);
250
+ });
251
+ }
252
+ registerEventListener(event, handler, global) {
253
+ const qualifiedId = global ? event : this.services.localQualifiedId('events', event);
254
+ if (qualifiedId.trim().length === 0) {
255
+ throw new Error('events registration requires a non-empty id');
256
+ }
257
+ assertFunction('events', 'handler', handler);
258
+ const handlers = this.eventHandlers.get(qualifiedId) ?? new Set();
259
+ handlers.add(handler);
260
+ this.eventHandlers.set(qualifiedId, handlers);
261
+ const listenerKey = `${qualifiedId}#${++this.eventListenerSequence}`;
262
+ const contribution = {
263
+ id: event,
264
+ qualifiedId,
265
+ pluginId: this.services.pluginId,
266
+ projectId: this.services.projectId,
267
+ handler,
268
+ global,
269
+ };
270
+ this.eventListeners.set(listenerKey, contribution);
271
+ return createDisposable(() => {
272
+ handlers.delete(handler);
273
+ if (handlers.size === 0)
274
+ this.eventHandlers.delete(qualifiedId);
275
+ this.eventListeners.delete(listenerKey);
276
+ });
277
+ }
278
+ async invokeCommand(id, payload) {
279
+ const qualifiedId = this.services.localQualifiedId('commands', id);
280
+ this.services.calls.commandInvocations.push({ id, qualifiedId, payload });
281
+ return this.invokeGlobalCommand(qualifiedId, payload);
282
+ }
283
+ async invokeGlobalCommand(qualifiedId, payload) {
284
+ this.services.calls.globalCommandInvocations.push({ qualifiedId, payload });
285
+ const command = this.commands.get(qualifiedId);
286
+ if (!command)
287
+ throw new Error(`Unknown command: ${qualifiedId}`);
288
+ return await command.handler(payload, {
289
+ taskId: null,
290
+ projectId: command.projectId,
291
+ source: 'plugin',
292
+ });
293
+ }
294
+ async emitEvent(event, payload, global) {
295
+ const qualifiedEvent = global ? event : this.services.localQualifiedId('events', event);
296
+ if (global) {
297
+ this.services.calls.emittedGlobalEvents.push({ qualifiedEvent, payload });
298
+ }
299
+ else {
300
+ this.services.calls.emittedEvents.push({ event, qualifiedEvent, payload });
301
+ }
302
+ for (const handler of Array.from(this.eventHandlers.get(qualifiedEvent) ?? [])) {
303
+ handler(payload);
304
+ }
305
+ }
306
+ }