@openforge-app/plugin-sdk 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @openforge-app/plugin-sdk
2
2
 
3
- Public SDK for building OpenForge plugins. See [`docs/plugin-authoring.md`](../../docs/plugin-authoring.md) for the complete package, runtime, Svelte-sharing, and CSS-loading contract.
3
+ Public SDK for building OpenForge plugins. [`docs/plugin-authoring.md`](../../docs/plugin-authoring.md) is the canonical guide for the package, runtime, Svelte-sharing, and CSS-loading contract.
4
4
 
5
5
  Styled frontend plugins must declare Vite/Svelte's emitted CSS artifacts in `package.json#openforge.frontendStyles`:
6
6
 
@@ -24,9 +24,3 @@ Automated releases use npm trusted publishing rather than a long-lived npm token
24
24
  `@openforge-app/plugin-sdk` is licensed under the MIT License. Plugin authors may use it for personal, internal, open source, or commercial plugin development, including redistribution and modification under the MIT terms.
25
25
 
26
26
  This MIT license applies to the SDK package only. The OpenForge desktop application is licensed separately under the repository root `LICENSE` and is source-available/proprietary; the app may not be commercially resold or redistributed without permission.
27
-
28
- ## Current contract
29
-
30
- Version `0.2.1` publishes the API-v1 contract used by the OpenForge host, including agent-facing Plugin Command metadata, `PluginCommandInvocationContext`, and the `@openforge-app/plugin-sdk/testing` helpers. Legacy handoff-summary declarations (`Task.summary` and `tasks.updateSummary`) are no longer part of the contract.
31
-
32
- Desktop releases publish the SDK in the same workflow using the desktop git tag's semver; the manual publish workflow uses the checked-in package version. Both paths run `pnpm check:contract`, which packs the package, verifies all exported files, and compiles the current authoring contract against the tarball before publication.
package/dist/backend.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { BackendMethodRegistration, BackendMethodRegistry, BackendOpenForgeAPI, BackendPlugin, BackendPluginContext, BackgroundServiceRegistration, BackgroundServiceRegistry, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, Disposable, NavigationAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot } from './types';
1
+ import type { BackendFileSystemAPI, BackendMethodRegistration, BackendMethodRegistry, BackendOpenForgeAPI, BackendPlugin, BackendPluginContext, BackgroundServiceRegistration, BackgroundServiceRegistry, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, Disposable, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, NavigationAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot } from './types';
2
2
  export declare function defineBackendPlugin<const TPlugin extends BackendPlugin>(plugin: TPlugin): TPlugin;
3
- export type { BackendMethodRegistration, BackendMethodRegistry, BackendOpenForgeAPI, BackendPlugin, BackendPluginContext, BackgroundServiceRegistration, BackgroundServiceRegistry, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, Disposable, NavigationAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, };
3
+ export type { BackendFileSystemAPI, BackendMethodRegistration, BackendMethodRegistry, BackendOpenForgeAPI, BackendPlugin, BackendPluginContext, BackgroundServiceRegistration, BackgroundServiceRegistry, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, Disposable, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, NavigationAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, };
package/dist/context.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Disposable, OpenForgePackageMetadata, OpenForgePluginContext, SubscriptionSink, SupportedOpenForgeApiVersion } from './types';
1
+ import type { Disposable, OpenForgeContextChangeHandler, OpenForgePackageMetadata, OpenForgePluginContext, SubscriptionSink, SupportedOpenForgeApiVersion } from './types';
2
2
  declare class SubscriptionSet implements SubscriptionSink {
3
3
  private readonly subscriptions;
4
4
  add(subscription: Disposable | (() => void)): void;
@@ -9,6 +9,7 @@ export declare class PluginContextImpl implements OpenForgePluginContext {
9
9
  readonly apiVersion: SupportedOpenForgeApiVersion;
10
10
  readonly packageMetadata: OpenForgePackageMetadata;
11
11
  readonly subscriptions: SubscriptionSet;
12
+ onDidChange(_handler: OpenForgeContextChangeHandler): Disposable;
12
13
  constructor(pluginId: string, apiVersion: SupportedOpenForgeApiVersion, packageMetadata: OpenForgePackageMetadata);
13
14
  }
14
15
  export {};
package/dist/context.js CHANGED
@@ -21,6 +21,9 @@ export class PluginContextImpl {
21
21
  apiVersion;
22
22
  packageMetadata;
23
23
  subscriptions;
24
+ onDidChange(_handler) {
25
+ return { dispose: () => undefined };
26
+ }
24
27
  constructor(pluginId, apiVersion, packageMetadata) {
25
28
  this.pluginId = pluginId;
26
29
  this.apiVersion = apiVersion;
package/dist/domain.d.ts CHANGED
@@ -150,6 +150,7 @@ export interface PullRequestInfo {
150
150
  merge_queue_state: string | null;
151
151
  readiness_updated_at: number | null;
152
152
  }
153
+ export type PollOutcome = 'completed' | 'missing_github_token' | 'github_token_unavailable' | 'failed' | 'rate_limited';
153
154
  export interface PollResult {
154
155
  new_comments: number;
155
156
  ci_changes: number;
@@ -158,6 +159,7 @@ export interface PollResult {
158
159
  errors: number;
159
160
  rate_limited: boolean;
160
161
  rate_limit_reset_at: number | null;
162
+ outcome: PollOutcome;
161
163
  }
162
164
  export interface MergeStatusInfo {
163
165
  state: string;
@@ -1,5 +1,5 @@
1
1
  import type { BrowserSurfaceCapture, BrowserSurfaceFeedbackSelection, BrowserSurfaceVisualFeedback, BrowserSurfaceRegion, BrowserSurfaceErrorCode, BrowserSurfaceNavigationError, BrowserSurfacesAPI, GetOrCreateBrowserSurfaceRequest, TaskBrowserSurfaceController, TaskBrowserSurfaceState } from './browserSurfaces';
2
- import type { CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, Disposable, FrontendBackendBridge, FrontendOpenForgeAPI, FrontendPlugin, FrontendPluginContext, FrontendSettingsRegistry, FrontendTaskPaneRegistry, FrontendInjectionPointRegistry, FrontendTaskStartRegistry, FrontendTaskUIRegistry, FrontendViewRegistry, InjectionPointLocation, NavigationAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, PluginIcon, PluginInjectionPointProps, PluginInjectionPointRegistration, PluginSettingsSectionProps, PluginSettingsSectionRegistration, PluginSettingsSectionScope, PluginStorageScope, PluginSvgIcon, PluginTaskPaneProps, PluginTaskPaneTabRegistration, PluginTaskUISectionProps, PluginTaskUISectionRegistration, PluginViewProps, PluginViewRegistration, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol } from './types';
2
+ import type { CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, Disposable, FrontendBackendBridge, FrontendOpenForgeAPI, FrontendPlugin, FrontendPluginContext, FrontendSettingsRegistry, FrontendTaskPaneRegistry, FrontendInjectionPointRegistry, FrontendTaskStartRegistry, FrontendTaskUIRegistry, FrontendViewRegistry, InjectionPointLocation, NavigationAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, PluginIcon, PluginSidebarNavigationProps, PluginSidebarViewIdentity, PluginInjectionPointProps, PluginInjectionPointRegistration, PluginSettingsSectionProps, PluginSettingsSectionRegistration, PluginSettingsSectionScope, PluginStorageScope, PluginSvgIcon, PluginTaskPaneProps, PluginTaskPaneTabRegistration, PluginTaskUISectionProps, PluginTaskUISectionRegistration, PluginViewProps, PluginViewRegistration, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, PtyBufferState, TerminalImageProtocol } from './types';
3
3
  export declare const OPENFORGE_FRONTEND_PLUGIN_MARKER = "__openforgeFrontendPlugin";
4
4
  export type MarkedFrontendPlugin<TPlugin extends FrontendPlugin = FrontendPlugin> = TPlugin & {
5
5
  readonly [OPENFORGE_FRONTEND_PLUGIN_MARKER]: true;
@@ -7,4 +7,4 @@ export type MarkedFrontendPlugin<TPlugin extends FrontendPlugin = FrontendPlugin
7
7
  export declare function defineFrontendPlugin<const TPlugin extends FrontendPlugin>(plugin: TPlugin): MarkedFrontendPlugin<TPlugin>;
8
8
  export { BrowserSurfaceError, isAllowedBrowserSurfaceUrl } from './browserSurfaces';
9
9
  export type { BrowserSurfaceCapture, BrowserSurfaceFeedbackSelection, BrowserSurfaceVisualFeedback, BrowserSurfaceRegion, BrowserSurfaceErrorCode, BrowserSurfaceNavigationError, BrowserSurfacesAPI, GetOrCreateBrowserSurfaceRequest, TaskBrowserSurfaceController, TaskBrowserSurfaceState, };
10
- export type { CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, Disposable, FrontendBackendBridge, FrontendOpenForgeAPI, FrontendPlugin, FrontendPluginContext, FrontendSettingsRegistry, FrontendTaskPaneRegistry, FrontendInjectionPointRegistry, FrontendTaskStartRegistry, FrontendTaskUIRegistry, FrontendViewRegistry, InjectionPointLocation, NavigationAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, PluginIcon, PluginInjectionPointProps, PluginInjectionPointRegistration, PluginSettingsSectionProps, PluginSettingsSectionRegistration, PluginSettingsSectionScope, PluginStorageScope, PluginSvgIcon, PluginTaskPaneProps, PluginTaskPaneTabRegistration, PluginTaskUISectionProps, PluginTaskUISectionRegistration, PluginViewProps, PluginViewRegistration, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol, };
10
+ export type { CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, Disposable, FrontendBackendBridge, FrontendOpenForgeAPI, FrontendPlugin, FrontendPluginContext, FrontendSettingsRegistry, FrontendTaskPaneRegistry, FrontendInjectionPointRegistry, FrontendTaskStartRegistry, FrontendTaskUIRegistry, FrontendViewRegistry, InjectionPointLocation, NavigationAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, PluginIcon, PluginSidebarNavigationProps, PluginSidebarViewIdentity, PluginInjectionPointProps, PluginInjectionPointRegistration, PluginSettingsSectionProps, PluginSettingsSectionRegistration, PluginSettingsSectionScope, PluginStorageScope, PluginSvgIcon, PluginTaskPaneProps, PluginTaskPaneTabRegistration, PluginTaskUISectionProps, PluginTaskUISectionRegistration, PluginViewProps, PluginViewRegistration, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, PtyBufferState, TerminalImageProtocol, };
package/dist/index.d.ts CHANGED
@@ -2,9 +2,9 @@ export { BrowserSurfaceError, isAllowedBrowserSurfaceUrl } from './browserSurfac
2
2
  export type { BrowserSurfaceCapture, BrowserSurfaceFeedbackSelection, BrowserSurfaceVisualFeedback, BrowserSurfaceRegion, BrowserSurfaceErrorCode, BrowserSurfaceNavigationError, BrowserSurfacesAPI, GetOrCreateBrowserSurfaceRequest, TaskBrowserSurfaceController, TaskBrowserSurfaceState, } from './browserSurfaces';
3
3
  export { OPENFORGE_PACKAGE_METADATA_SCHEMA, OPENFORGE_PLUGIN_CAPABILITIES, isOpenForgePackageMetadata, isPluginPackageMetadata, isSupportedOpenForgeApiVersion, validateOpenForgePackageMetadata, validatePluginPackageMetadata, } from './manifest';
4
4
  export { createMemoryPluginStorage, createMockBackendOpenForgeApi, createMockFrontendOpenForgeApi, createMockOpenForgeApi, createMockPluginContext, createOpenForgeRegistryFake, createTestingCalls, TestingOpenForgeRegistryFake, TestingSubscriptionSink, } from './testing';
5
- export { TaskFollowUpError, MAX_SUPPORTED_API_VERSION, MIN_SUPPORTED_API_VERSION, OPENFORGE_PLUGIN_API_VERSION, SUPPORTED_OPENFORGE_API_VERSIONS, isPluginViewKey, makePluginViewKey, parsePluginViewKey, } from './types';
6
- export type { AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, FileSystemAPI, ImplementationRun, InjectionPointLocation, JsonObject, JsonPrimitive, JsonSchema, JsonValue, MaybePromise, KeyValueConfigAPI, NotificationRequest, NavigationAPI, NotificationsAPI, OpenForgeCommonAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, OpenForgePluginCapability, OpenForgePluginContext, OpenForgePluginPackageJson, PluginCommandInvocationContext, PluginCommandInvocationSource, PluginComponentLoader, PluginComponentModule, PluginEntry, PluginIcon, PluginInjectionPointProps, PluginSettingsSectionProps, PluginState, PluginSvgIcon, PluginTaskPaneProps, PluginTaskUISectionProps, PluginViewProps, PluginStorage, PluginStorageScope, PluginViewKey, ProjectsAPI, ShellAPI, ShellResizeRequest, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, TasksAPI, ValidationError, } from './types';
7
- export type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingBackgroundServiceContribution, TestingBackendMethodContribution, TestingCommandContribution, TestingContributionBase, TestingEventListenerContribution, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot, TestingRuntimeKind, TestingRuntimeScope, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution, } from './testing';
5
+ export { DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, TaskFollowUpError, MAX_SUPPORTED_API_VERSION, MIN_SUPPORTED_API_VERSION, OPENFORGE_PLUGIN_API_VERSION, SUPPORTED_OPENFORGE_API_VERSIONS, isPluginViewKey, resolveExternalTextFileChunkSize, makePluginViewKey, parsePluginViewKey, } from './types';
6
+ export type { AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, BackendFileSystemAPI, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, FileSystemAPI, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, ImplementationRun, InjectionPointLocation, JsonObject, JsonPrimitive, JsonSchema, JsonValue, MaybePromise, KeyValueConfigAPI, NotificationRequest, NavigationAPI, NotificationsAPI, OpenForgeCommonAPI, OpenForgeContextChangeHandler, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, OpenForgePluginCapability, OpenForgePluginContext, OpenForgePluginPackageJson, PluginCommandInvocationContext, PluginCommandInvocationSource, PluginComponentLoader, PluginComponentModule, PluginEntry, PluginIcon, PluginSidebarNavigationProps, PluginSidebarViewIdentity, PluginInjectionPointProps, PluginSettingsSectionProps, PluginState, PluginSvgIcon, PluginTaskPaneProps, PluginTaskUISectionProps, PluginViewProps, PluginStorage, PluginStorageScope, PluginViewKey, ProjectsAPI, PtyBufferState, ShellAPI, ShellResizeRequest, TaskStartPrefixContext, TaskStartPrefixProviderRegistration, TerminalImageProtocol, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TaskLinkHandler, TaskLinkHandlerResult, TaskLinkOpenRequest, TaskLinksAPI, TasksAPI, ValidationError, } from './types';
7
+ export type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingBackgroundServiceContribution, TestingBackendMethodContribution, TestingCommandContribution, TestingContributionBase, TestingExternalTextFile, TestingExternalTextFileChunksCall, TestingEventListenerContribution, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot, TestingRuntimeKind, TestingRuntimeScope, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution, } from './testing';
8
8
  export { parseStrictFiniteNumber } from './numberParsing';
9
9
  export { buildProjectFileTree, flattenVisibleProjectFileTree, formatProjectFileTreeSize, getProjectFileTreeDepth, getProjectFileTreeItemAccessibility, getProjectFileTreeKeyboardAction, getProjectFileTreeParentPath, hasProjectFileTreeShortcutModifier, projectFileTreePathToId, } from './projectFileTree';
10
10
  export type { ProjectFileTreeEntry, ProjectFileTreeItemAccessibility, ProjectFileTreeKeyboardAction, ProjectFileTreeNode, } from './projectFileTree';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { BrowserSurfaceError, isAllowedBrowserSurfaceUrl } from './browserSurfaces';
2
2
  export { OPENFORGE_PACKAGE_METADATA_SCHEMA, OPENFORGE_PLUGIN_CAPABILITIES, isOpenForgePackageMetadata, isPluginPackageMetadata, isSupportedOpenForgeApiVersion, validateOpenForgePackageMetadata, validatePluginPackageMetadata, } from './manifest';
3
3
  export { createMemoryPluginStorage, createMockBackendOpenForgeApi, createMockFrontendOpenForgeApi, createMockOpenForgeApi, createMockPluginContext, createOpenForgeRegistryFake, createTestingCalls, TestingOpenForgeRegistryFake, TestingSubscriptionSink, } from './testing';
4
- export { TaskFollowUpError, MAX_SUPPORTED_API_VERSION, MIN_SUPPORTED_API_VERSION, OPENFORGE_PLUGIN_API_VERSION, SUPPORTED_OPENFORGE_API_VERSIONS, isPluginViewKey, makePluginViewKey, parsePluginViewKey, } from './types';
4
+ export { DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES, TaskFollowUpError, MAX_SUPPORTED_API_VERSION, MIN_SUPPORTED_API_VERSION, OPENFORGE_PLUGIN_API_VERSION, SUPPORTED_OPENFORGE_API_VERSIONS, isPluginViewKey, resolveExternalTextFileChunkSize, makePluginViewKey, parsePluginViewKey, } from './types';
5
5
  export { parseStrictFiniteNumber } from './numberParsing';
6
6
  export { buildProjectFileTree, flattenVisibleProjectFileTree, formatProjectFileTreeSize, getProjectFileTreeDepth, getProjectFileTreeItemAccessibility, getProjectFileTreeKeyboardAction, getProjectFileTreeParentPath, hasProjectFileTreeShortcutModifier, projectFileTreePathToId, } from './projectFileTree';
7
7
  export { canMergePullRequest, getMergeReadiness, hasMergeConflicts, isClosedUnmergedPullRequest, isMergedPullRequest, isQueuedForMerge, isReadyToMerge, parseCheckRuns, preservePullRequestState, splitCheckRuns, } from './domain';
@@ -11,7 +11,7 @@ export declare const OPENFORGE_PACKAGE_METADATA_SCHEMA: {
11
11
  dependentRequired: {
12
12
  frontendStyles: string[];
13
13
  };
14
- allOf: {
14
+ allOf: ({
15
15
  if: {
16
16
  properties: {
17
17
  requires: {
@@ -19,13 +19,35 @@ export declare const OPENFORGE_PACKAGE_METADATA_SCHEMA: {
19
19
  const: string;
20
20
  };
21
21
  };
22
+ enablement?: undefined;
22
23
  };
23
24
  required: string[];
24
25
  };
25
26
  then: {
26
27
  required: string[];
28
+ properties?: undefined;
27
29
  };
28
- }[];
30
+ } | {
31
+ if: {
32
+ properties: {
33
+ requires?: undefined;
34
+ enablement: {
35
+ const: string;
36
+ };
37
+ };
38
+ required: string[];
39
+ };
40
+ then: {
41
+ properties: {
42
+ requires: {
43
+ contains: {
44
+ const: string;
45
+ };
46
+ };
47
+ };
48
+ required: string[];
49
+ };
50
+ })[];
29
51
  properties: {
30
52
  id: {
31
53
  type: string;
@@ -44,9 +66,36 @@ export declare const OPENFORGE_PACKAGE_METADATA_SCHEMA: {
44
66
  type: string;
45
67
  minLength: number;
46
68
  };
69
+ enablement: {
70
+ enum: string[];
71
+ default: string;
72
+ description: string;
73
+ };
47
74
  icon: {
48
- type: string;
49
- minLength: number;
75
+ oneOf: ({
76
+ properties?: undefined;
77
+ type: string;
78
+ minLength: number;
79
+ pattern: string;
80
+ additionalProperties?: undefined;
81
+ required?: undefined;
82
+ } | {
83
+ minLength?: undefined;
84
+ pattern?: undefined;
85
+ type: string;
86
+ additionalProperties: boolean;
87
+ required: string[];
88
+ properties: {
89
+ type: {
90
+ const: string;
91
+ };
92
+ svg: {
93
+ type: string;
94
+ minLength: number;
95
+ pattern: string;
96
+ };
97
+ };
98
+ })[];
50
99
  description: string;
51
100
  };
52
101
  frontend: {
package/dist/manifest.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import packageMetadataSchemaData from './openforgePackageMetadataSchema.json';
2
+ import { isPluginIconName, isPluginSvgIcon } from './pluginIconContract';
2
3
  import { SUPPORTED_OPENFORGE_API_VERSIONS } from './types';
3
4
  export const OPENFORGE_PACKAGE_METADATA_SCHEMA = packageMetadataSchemaData;
4
5
  export const OPENFORGE_PLUGIN_CAPABILITIES = packageMetadataSchemaData.properties.requires.items.enum;
@@ -27,6 +28,18 @@ function validateOptionalString(value, path) {
27
28
  }
28
29
  return [];
29
30
  }
31
+ function validateEnablement(value) {
32
+ if (value === undefined || value === 'app' || value === 'project') {
33
+ return [];
34
+ }
35
+ return [{ path: 'enablement', message: 'Must be "app" or "project"' }];
36
+ }
37
+ function validatePluginIcon(value) {
38
+ if (value === undefined || isPluginIconName(value) || isPluginSvgIcon(value)) {
39
+ return [];
40
+ }
41
+ return [{ path: 'icon', message: 'Must be a non-empty Lucide icon name or { type: "svg", svg }' }];
42
+ }
30
43
  function validateFrontendStyles(value) {
31
44
  if (value === undefined)
32
45
  return [];
@@ -97,7 +110,8 @@ export function validateOpenForgePackageMetadata(data) {
97
110
  errors.push(...validateApiVersion(data.apiVersion));
98
111
  errors.push(...validateRequiredString(data.displayName, 'displayName'));
99
112
  errors.push(...validateRequiredString(data.description, 'description'));
100
- errors.push(...validateOptionalString(data.icon, 'icon'));
113
+ errors.push(...validateEnablement(data.enablement));
114
+ errors.push(...validatePluginIcon(data.icon));
101
115
  errors.push(...validateOptionalString(data.frontend, 'frontend'));
102
116
  errors.push(...validateFrontendStyles(data.frontendStyles));
103
117
  if (data.frontendStyles !== undefined && !isNonEmptyString(data.frontend)) {
@@ -105,6 +119,9 @@ export function validateOpenForgePackageMetadata(data) {
105
119
  }
106
120
  errors.push(...validateOptionalString(data.backend, 'backend'));
107
121
  errors.push(...validateRequires(data.requires));
122
+ if (data.enablement === 'app' && (!Array.isArray(data.requires) || !data.requires.includes('appEnablement'))) {
123
+ errors.push({ path: 'requires', message: 'App enablement requires the appEnablement capability' });
124
+ }
108
125
  if (Array.isArray(data.requires) && data.requires.includes('browserSurfaces') && !isNonEmptyString(data.frontend)) {
109
126
  errors.push({ path: 'requires', message: 'browserSurfaces capability requires a frontend entry' });
110
127
  }
@@ -2,7 +2,15 @@ export interface RenderMarkdownOptions {
2
2
  imageBaseUrl?: string | null;
3
3
  markdownFilePath?: string | null;
4
4
  deferRepositoryImages?: boolean;
5
+ deferRemoteMedia?: boolean;
5
6
  }
7
+ /** How a deferred remote URL should be presented once it has been resolved. */
8
+ export interface ResolvedMarkdownMedia {
9
+ url: string;
10
+ kind: 'image' | 'video';
11
+ }
12
+ /** Attribute holding the original URL of a `<img>`/`<a>` awaiting resolution. */
13
+ export declare const MARKDOWN_REMOTE_MEDIA_ATTRIBUTE = "data-markdown-remote-src";
6
14
  export declare function resolveMarkdownRepositoryPath(value: string | null, markdownFilePath: string): string | null;
7
15
  export declare function getMarkdownRepositoryLinkSuffix(value: string): string;
8
16
  export declare function resolveMarkdownImageSrc(src: string | null, imageBaseUrl: string | null | undefined, markdownFilePath?: string): string | null;
package/dist/markdown.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { marked } from 'marked';
2
2
  import { sanitizeHtml } from './sanitize';
3
+ /** Attribute holding the original URL of a `<img>`/`<a>` awaiting resolution. */
4
+ export const MARKDOWN_REMOTE_MEDIA_ATTRIBUTE = 'data-markdown-remote-src';
3
5
  const markedOptions = {
4
6
  gfm: true,
5
7
  breaks: true,
@@ -71,13 +73,57 @@ export function resolveMarkdownImageSrc(src, imageBaseUrl, markdownFilePath = ''
71
73
  return null;
72
74
  }
73
75
  }
74
- function prepareMarkdownImageSources(html, options) {
75
- if (typeof document === 'undefined' || (!options.imageBaseUrl && !options.deferRepositoryImages))
76
+ function isRemoteUrl(value) {
77
+ return /^https?:\/\//i.test(value);
78
+ }
79
+ const TEXT_NODE = 3;
80
+ /**
81
+ * Whether nothing but a line break separates `anchor` from the end of its line.
82
+ * GitHub only turns a bare URL into an embedded player when it stands on its own
83
+ * line, so link markup mid-sentence stays a link.
84
+ */
85
+ function isAtLineBoundary(from, step) {
86
+ let node = from;
87
+ while (node) {
88
+ if (node.nodeType !== TEXT_NODE)
89
+ return node.nodeName === 'BR';
90
+ if ((node.textContent ?? '').trim() !== '')
91
+ return false;
92
+ node = node[step];
93
+ }
94
+ return true;
95
+ }
96
+ function isBareLinkOnItsOwnLine(anchor, href) {
97
+ return (anchor.textContent ?? '').trim() === href
98
+ && isAtLineBoundary(anchor.previousSibling, 'previousSibling')
99
+ && isAtLineBoundary(anchor.nextSibling, 'nextSibling');
100
+ }
101
+ function deferRemoteMediaLinks(root) {
102
+ for (const anchor of root.querySelectorAll('a[href]')) {
103
+ const href = anchor.getAttribute('href')?.trim() ?? '';
104
+ if (!isRemoteUrl(href) || !isBareLinkOnItsOwnLine(anchor, href))
105
+ continue;
106
+ anchor.setAttribute(MARKDOWN_REMOTE_MEDIA_ATTRIBUTE, href);
107
+ }
108
+ }
109
+ function prepareMarkdownMediaSources(html, options) {
110
+ if (typeof document === 'undefined' ||
111
+ (!options.imageBaseUrl && !options.deferRepositoryImages && !options.deferRemoteMedia))
76
112
  return html;
77
113
  const template = document.createElement('template');
78
114
  template.innerHTML = html;
115
+ if (options.deferRemoteMedia) {
116
+ deferRemoteMediaLinks(template.content);
117
+ }
79
118
  for (const image of template.content.querySelectorAll('img[src]')) {
80
119
  const source = image.getAttribute('src')?.trim() ?? '';
120
+ // Hold the remote URL back so the caller can exchange it for one the
121
+ // renderer can actually load, instead of flashing a broken image first.
122
+ if (options.deferRemoteMedia && isRemoteUrl(source)) {
123
+ image.removeAttribute('src');
124
+ image.setAttribute(MARKDOWN_REMOTE_MEDIA_ATTRIBUTE, source);
125
+ continue;
126
+ }
81
127
  const shouldDefer = options.deferRepositoryImages && source.length > 0 && !hasAbsoluteOrSpecialUrl(source);
82
128
  if (shouldDefer) {
83
129
  image.removeAttribute('src');
@@ -99,5 +145,5 @@ function prepareMarkdownImageSources(html, options) {
99
145
  }
100
146
  export function renderMarkdownHtml(content, options = {}) {
101
147
  const rawHtml = marked.parse(content, markedOptions);
102
- return sanitizeHtml(prepareMarkdownImageSources(rawHtml, options));
148
+ return sanitizeHtml(prepareMarkdownMediaSources(rawHtml, options));
103
149
  }
@@ -27,6 +27,18 @@
27
27
  "required": ["requires"]
28
28
  },
29
29
  "then": { "required": ["frontend"] }
30
+ },
31
+ {
32
+ "if": {
33
+ "properties": { "enablement": { "const": "app" } },
34
+ "required": ["enablement"]
35
+ },
36
+ "then": {
37
+ "properties": {
38
+ "requires": { "contains": { "const": "appEnablement" } }
39
+ },
40
+ "required": ["requires"]
41
+ }
30
42
  }
31
43
  ],
32
44
  "properties": {
@@ -47,10 +59,33 @@
47
59
  "type": "string",
48
60
  "minLength": 1
49
61
  },
62
+ "enablement": {
63
+ "enum": ["app", "project"],
64
+ "default": "project",
65
+ "description": "Lifecycle ownership for this plugin package. Omission means project-owned enablement."
66
+ },
50
67
  "icon": {
51
- "type": "string",
52
- "minLength": 1,
53
- "description": "Semantic OpenForge icon key or package asset reference."
68
+ "oneOf": [
69
+ {
70
+ "type": "string",
71
+ "minLength": 1,
72
+ "pattern": "\\S"
73
+ },
74
+ {
75
+ "type": "object",
76
+ "additionalProperties": false,
77
+ "required": ["type", "svg"],
78
+ "properties": {
79
+ "type": { "const": "svg" },
80
+ "svg": {
81
+ "type": "string",
82
+ "minLength": 1,
83
+ "pattern": "\\S"
84
+ }
85
+ }
86
+ }
87
+ ],
88
+ "description": "Lucide icon name in kebab-case or inline SVG geometry using the PluginIcon contract."
54
89
  },
55
90
  "frontend": {
56
91
  "type": "string",
@@ -101,7 +136,9 @@
101
136
  "config",
102
137
  "projectConfig",
103
138
  "browserSurfaces",
104
- "taskLinks"
139
+ "taskLinks",
140
+ "appEnablement",
141
+ "customSidebarNavigation"
105
142
  ]
106
143
  }
107
144
  }
@@ -0,0 +1,3 @@
1
+ import type { PluginSvgIcon } from './types';
2
+ export declare function isPluginIconName(value: unknown): value is string;
3
+ export declare function isPluginSvgIcon(value: unknown): value is PluginSvgIcon;
@@ -0,0 +1,12 @@
1
+ export function isPluginIconName(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+ export function isPluginSvgIcon(value) {
5
+ if (value === null || typeof value !== 'object')
6
+ return false;
7
+ const icon = value;
8
+ return Object.keys(icon).length === 2
9
+ && icon.type === 'svg'
10
+ && typeof icon.svg === 'string'
11
+ && icon.svg.trim().length > 0;
12
+ }
@@ -1,4 +1,5 @@
1
1
  import DOMPurify from 'dompurify';
2
+ import { isPluginSvgIcon } from './pluginIconContract';
2
3
  export const MAX_PLUGIN_SVG_ICON_CHARACTERS = 10_000;
3
4
  const ALLOWED_SVG_TAGS = ['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon'];
4
5
  const ALLOWED_SVG_ATTRIBUTES = [
@@ -33,12 +34,6 @@ const ALLOWED_SVG_ATTRIBUTES = [
33
34
  'points',
34
35
  ];
35
36
  const SAFE_PAINT_VALUE = /^(?:none|currentColor|transparent|#[0-9a-f]{3,8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([\d\s.,%+\-/]+\))$/iu;
36
- function isPluginSvgIcon(value) {
37
- return value !== null
38
- && typeof value === 'object'
39
- && value.type === 'svg'
40
- && typeof value.svg === 'string';
41
- }
42
37
  function parseSvgRoot(markup) {
43
38
  const template = document.createElement('template');
44
39
  template.innerHTML = markup.trim();
@@ -8,6 +8,7 @@ const PUBLIC_UI_COMPONENT_NAMES = Object.freeze([
8
8
  'Modal',
9
9
  'PluginPageHeader',
10
10
  'PluginViewState',
11
+ 'PluginSidebarLink',
11
12
  'FileTypeIcon',
12
13
  ])
13
14
 
@@ -1,4 +1,4 @@
1
- import type { FrontendOpenForgeAPI, OpenForgeCommonAPI } from '../types';
1
+ import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, OpenForgeCommonAPI } from '../types';
2
2
  import { type TestingRegistryServices } from './support';
3
3
  import type { TestingCommandContribution, TestingEventListenerContribution } from './contracts';
4
4
  export type TestingCommonApi = OpenForgeCommonAPI & Pick<FrontendOpenForgeAPI, 'navigation'>;
@@ -10,6 +10,7 @@ export declare class TestingCommonApiFake {
10
10
  private eventListenerSequence;
11
11
  constructor(services: TestingRegistryServices);
12
12
  createApi(): TestingCommonApi;
13
+ createBackendApi(): TestingCommonApi & Pick<BackendOpenForgeAPI, 'fs'>;
13
14
  getSnapshot(): {
14
15
  commands: TestingCommandContribution[];
15
16
  eventListeners: TestingEventListenerContribution[];
@@ -1,4 +1,22 @@
1
+ import { resolveExternalTextFileChunkSize } from '../types';
1
2
  import { assertFunction, assertTitle, commandDescriptor, createDisposable, isJsonValue, normalizeAgentCommandMetadata, } from './support';
3
+ const UTF8_ENCODER = new TextEncoder();
4
+ function* splitExternalTextFile(content, maxBytes) {
5
+ let chunk = '';
6
+ let chunkBytes = 0;
7
+ for (const character of content) {
8
+ const characterBytes = UTF8_ENCODER.encode(character).byteLength;
9
+ if (chunkBytes + characterBytes > maxBytes && chunk.length > 0) {
10
+ yield chunk;
11
+ chunk = '';
12
+ chunkBytes = 0;
13
+ }
14
+ chunk += character;
15
+ chunkBytes += characterBytes;
16
+ }
17
+ if (chunk.length > 0)
18
+ yield chunk;
19
+ }
2
20
  export class TestingCommonApiFake {
3
21
  services;
4
22
  commands = new Map();
@@ -136,7 +154,7 @@ export class TestingCommonApiFake {
136
154
  },
137
155
  getBuffer: async (request) => {
138
156
  this.services.calls.shellBuffers.push(request);
139
- return null;
157
+ return { buffer: null, isLive: false };
140
158
  },
141
159
  },
142
160
  notifications: {
@@ -183,6 +201,51 @@ export class TestingCommonApiFake {
183
201
  };
184
202
  return api;
185
203
  }
204
+ createBackendApi() {
205
+ const api = this.createApi();
206
+ return {
207
+ ...api,
208
+ fs: {
209
+ ...api.fs,
210
+ userData: {
211
+ readDir: async (request = {}) => {
212
+ this.services.calls.fsUserDataReadDirs.push(request);
213
+ return [];
214
+ },
215
+ readTextFile: async (request) => {
216
+ this.services.calls.fsUserDataReads.push(request);
217
+ return '';
218
+ },
219
+ writeTextFile: async (request) => {
220
+ this.services.calls.fsUserDataWrites.push(request);
221
+ },
222
+ },
223
+ external: {
224
+ readDir: async (request) => {
225
+ this.services.calls.fsExternalReadDirs.push(request);
226
+ return [];
227
+ },
228
+ readTextFile: async (request) => {
229
+ this.services.calls.fsExternalReads.push(request);
230
+ return '';
231
+ },
232
+ readTextFileChunks: (request) => {
233
+ const chunkSizeBytes = resolveExternalTextFileChunkSize(request.chunkSizeBytes);
234
+ const { root, path, signal } = request;
235
+ this.services.calls.fsExternalReadTextFileChunks.push({ root, path, chunkSizeBytes });
236
+ const content = this.services.externalTextFiles.find(file => file.root === root && file.path === path)?.content ?? '';
237
+ return (async function* () {
238
+ for (const chunk of splitExternalTextFile(content, chunkSizeBytes)) {
239
+ signal?.throwIfAborted();
240
+ yield chunk;
241
+ }
242
+ signal?.throwIfAborted();
243
+ })();
244
+ },
245
+ },
246
+ },
247
+ };
248
+ }
186
249
  getSnapshot() {
187
250
  return {
188
251
  commands: Array.from(this.commands.values()),
@@ -1,12 +1,18 @@
1
1
  import type { BrowserSurfaceVisualFeedback } from '../browserSurfaces';
2
2
  import type { TestingOpenForgeRegistryFake } from './registryFake';
3
- import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskLinkOpenRequest, TaskStartPrefixContext } from '../types';
3
+ import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskLinkOpenRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
4
4
  import type { Task } from '../domain';
5
5
  export type TestingRuntimeScope = 'global' | 'project' | 'task';
6
6
  export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'settings' | 'backend' | 'background';
7
7
  export type TestingMaybePromise<T> = T | Promise<T>;
8
8
  export type TestingCommandHandler = (payload?: unknown) => TestingMaybePromise<unknown>;
9
9
  export type TestingEventHandler = (payload: unknown) => void;
10
+ export interface TestingExternalTextFile extends ExternalReadFileRequest {
11
+ content: string;
12
+ }
13
+ export type TestingExternalTextFileChunksCall = Omit<ExternalReadTextFileChunksRequest, 'signal' | 'chunkSizeBytes'> & {
14
+ chunkSizeBytes: number;
15
+ };
10
16
  export interface TestingOpenForgeApiOptions {
11
17
  pluginId?: string;
12
18
  projectId?: string | null;
@@ -21,6 +27,8 @@ export interface TestingOpenForgeApiOptions {
21
27
  * mirroring the host capability. Defaults to an empty list.
22
28
  */
23
29
  tasks?: Task[];
30
+ /** UTF-8 files returned by `fs.external.readTextFileChunks`. Defaults to none. */
31
+ externalTextFiles?: TestingExternalTextFile[];
24
32
  }
25
33
  export interface TestingOpenForgeApiCalls {
26
34
  commandInvocations: Array<{
@@ -74,6 +82,12 @@ export interface TestingOpenForgeApiCalls {
74
82
  path: string;
75
83
  content: string;
76
84
  }>;
85
+ fsUserDataReadDirs: UserDataDirectoryRequest[];
86
+ fsUserDataReads: UserDataFileRequest[];
87
+ fsUserDataWrites: UserDataFileWriteRequest[];
88
+ fsExternalReadDirs: ExternalReadDirectoryRequest[];
89
+ fsExternalReads: ExternalReadFileRequest[];
90
+ fsExternalReadTextFileChunks: TestingExternalTextFileChunksCall[];
77
91
  shellSpawns: ShellSpawnRequest[];
78
92
  shellWrites: Array<{
79
93
  taskId: string;
@@ -102,6 +102,15 @@ export class TestingFrontendContributionFake {
102
102
  const qualifiedId = this.services.localQualifiedId('views', registration.id);
103
103
  assertTitle('views', registration.title);
104
104
  assertFunction('views', 'component', registration.component);
105
+ if (registration.navigationComponent !== undefined) {
106
+ assertFunction('views', 'navigationComponent', registration.navigationComponent);
107
+ if (registration.placement !== 'sidebar') {
108
+ throw new Error('views registration custom navigation requires sidebar placement');
109
+ }
110
+ if (!this.services.packageMetadata.requires?.includes('customSidebarNavigation')) {
111
+ throw new Error('views registration custom navigation requires the customSidebarNavigation capability');
112
+ }
113
+ }
105
114
  this.services.claims.claim('views', qualifiedId);
106
115
  const contribution = {
107
116
  ...registration,
@@ -58,7 +58,7 @@ export class TestingOpenForgeRegistryFake {
58
58
  if (this.cachedBackendApi)
59
59
  return this.cachedBackendApi;
60
60
  const api = {
61
- ...this.commonApi.createApi(),
61
+ ...this.commonApi.createBackendApi(),
62
62
  ...this.backendServices.createApi(),
63
63
  __testing: {
64
64
  calls: this.calls,
@@ -104,6 +104,7 @@ export class TestingOpenForgeRegistryFake {
104
104
  apiVersion: 1,
105
105
  packageMetadata: this.packageMetadata,
106
106
  subscriptions,
107
+ onDidChange: () => ({ dispose: () => undefined }),
107
108
  };
108
109
  }
109
110
  }
@@ -1,6 +1,6 @@
1
1
  import type { AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
2
2
  import type { Task } from '../domain';
3
- import type { TestingCommandContribution, TestingMaybePromise, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingRuntimeKind } from './contracts';
3
+ import type { TestingCommandContribution, TestingMaybePromise, TestingExternalTextFile, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingRuntimeKind } from './contracts';
4
4
  export declare function createDisposable(dispose: () => TestingMaybePromise<void>): Disposable;
5
5
  export declare class TestingSubscriptionSink implements SubscriptionSink {
6
6
  readonly subscriptions: Disposable[];
@@ -31,6 +31,7 @@ export declare class TestingRegistryServices {
31
31
  readonly storage: PluginStorage;
32
32
  readonly config: Map<string, JsonValue>;
33
33
  readonly seededTasks: Task[];
34
+ readonly externalTextFiles: TestingExternalTextFile[];
34
35
  readonly claims: TestingContributionClaims;
35
36
  constructor(options?: TestingOpenForgeApiOptions);
36
37
  localQualifiedId(kind: TestingRuntimeKind, id: string): string;
@@ -48,6 +48,12 @@ export function createTestingCalls() {
48
48
  taskStatusUpdates: [],
49
49
  configWrites: [],
50
50
  fsWrites: [],
51
+ fsUserDataReadDirs: [],
52
+ fsUserDataReads: [],
53
+ fsUserDataWrites: [],
54
+ fsExternalReadDirs: [],
55
+ fsExternalReads: [],
56
+ fsExternalReadTextFileChunks: [],
51
57
  shellSpawns: [],
52
58
  shellWrites: [],
53
59
  shellResizes: [],
@@ -196,6 +202,7 @@ export class TestingRegistryServices {
196
202
  storage;
197
203
  config = new Map();
198
204
  seededTasks;
205
+ externalTextFiles;
199
206
  claims = new TestingContributionClaims();
200
207
  constructor(options = {}) {
201
208
  this.pluginId = options.pluginId ?? 'test-plugin';
@@ -211,6 +218,7 @@ export class TestingRegistryServices {
211
218
  this.calls = createTestingCalls();
212
219
  this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
213
220
  this.seededTasks = options.tasks ?? [];
221
+ this.externalTextFiles = options.externalTextFiles ?? [];
214
222
  }
215
223
  localQualifiedId(kind, id) {
216
224
  assertLocalId(kind, id);
package/dist/testing.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { createMockBackendOpenForgeApi, createMockFrontendOpenForgeApi, createMockOpenForgeApi, createMockPluginContext, createOpenForgeRegistryFake, TestingOpenForgeRegistryFake, } from './testing/registryFake';
2
2
  export { createMemoryPluginStorage, createTestingCalls, TestingSubscriptionSink, } from './testing/support';
3
- export type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingBackgroundServiceContribution, TestingBackendMethodContribution, TestingCommandContribution, TestingContributionBase, TestingEventListenerContribution, TestingInjectionPointContribution, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot, TestingRuntimeKind, TestingRuntimeScope, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution, } from './testing/contracts';
3
+ export type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingBackgroundServiceContribution, TestingBackendMethodContribution, TestingCommandContribution, TestingContributionBase, TestingExternalTextFile, TestingExternalTextFileChunksCall, TestingEventListenerContribution, TestingInjectionPointContribution, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot, TestingRuntimeKind, TestingRuntimeScope, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution, } from './testing/contracts';
package/dist/types.d.ts CHANGED
@@ -17,13 +17,16 @@ export interface ValidationError {
17
17
  path: string;
18
18
  message: string;
19
19
  }
20
- export type OpenForgePluginCapability = 'commands' | 'events' | 'views' | 'taskPane' | 'taskStart' | 'settings' | 'background' | 'backend' | 'storage' | 'context' | 'navigation' | 'tasks' | 'projects' | 'fs' | 'shell' | 'notifications' | 'attention' | 'system.openUrl' | 'system.writeClipboardText' | 'config' | 'projectConfig' | 'browserSurfaces' | 'taskLinks';
20
+ declare const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS: readonly ['commands', 'events', 'views', 'injectionPoints', 'taskPane', 'taskStart', 'settings', 'background', 'backend', 'storage', 'context', 'navigation', 'tasks', 'projects', 'fs', 'shell', 'notifications', 'attention', 'system.openUrl', 'system.writeClipboardText', 'config', 'projectConfig', 'browserSurfaces', 'taskLinks', 'appEnablement', 'customSidebarNavigation'];
21
+ export type OpenForgePluginCapability = (typeof OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS)[number];
21
22
  export interface OpenForgePackageMetadata {
22
23
  id: string;
23
24
  apiVersion: SupportedOpenForgeApiVersion;
24
25
  displayName: string;
25
26
  description: string;
26
- icon?: string;
27
+ /** Defaults to project-owned lifecycle when omitted. */
28
+ enablement?: 'app' | 'project';
29
+ icon?: PluginIcon;
27
30
  frontend?: string;
28
31
  frontendStyles?: string[];
29
32
  backend?: string;
@@ -54,6 +57,7 @@ export interface OpenForgeContextSnapshot {
54
57
  projectId: string | null;
55
58
  taskId?: string | null;
56
59
  }
60
+ export type OpenForgeContextChangeHandler = (snapshot: OpenForgeContextSnapshot) => MaybePromise<void>;
57
61
  export interface OpenForgeNavigationSnapshot {
58
62
  activeProjectId: string | null;
59
63
  currentView: string;
@@ -85,6 +89,7 @@ export interface OpenForgePluginContext {
85
89
  apiVersion: SupportedOpenForgeApiVersion;
86
90
  packageMetadata: OpenForgePackageMetadata;
87
91
  subscriptions: SubscriptionSink;
92
+ onDidChange(handler: OpenForgeContextChangeHandler): Disposable;
88
93
  }
89
94
  export type FrontendPluginContext = OpenForgePluginContext;
90
95
  export type BackendPluginContext = OpenForgePluginContext;
@@ -188,6 +193,21 @@ export interface PluginViewProps extends Record<string, unknown> {
188
193
  api: FrontendOpenForgeAPI;
189
194
  context: OpenForgeContextSnapshot;
190
195
  }
196
+ export interface PluginSidebarViewIdentity {
197
+ pluginId: string;
198
+ id: string;
199
+ qualifiedId: string;
200
+ title: string;
201
+ icon: PluginIcon;
202
+ }
203
+ export interface PluginSidebarNavigationProps extends Record<string, unknown> {
204
+ api: FrontendOpenForgeAPI;
205
+ context: OpenForgeContextSnapshot;
206
+ active: boolean;
207
+ collapsed: boolean;
208
+ view: PluginSidebarViewIdentity;
209
+ onActivate: () => void;
210
+ }
191
211
  export interface PluginTaskPaneProps extends Record<string, unknown> {
192
212
  api: FrontendOpenForgeAPI;
193
213
  context: OpenForgeContextSnapshot;
@@ -230,6 +250,8 @@ export interface PluginViewRegistration {
230
250
  order?: number;
231
251
  shortcut?: string;
232
252
  component: PluginComponentLoader<PluginViewProps> | PluginComponent<PluginViewProps>;
253
+ /** Custom content for a sidebar-placed View's host-owned navigation slot. */
254
+ navigationComponent?: PluginComponentLoader<PluginSidebarNavigationProps> | PluginComponent<PluginSidebarNavigationProps>;
233
255
  }
234
256
  export interface PluginTaskPaneTabRegistration {
235
257
  id: string;
@@ -353,6 +375,49 @@ export interface FileSystemAPI {
353
375
  limit?: number;
354
376
  }): Promise<string[]>;
355
377
  }
378
+ export interface UserDataDirectoryRequest {
379
+ path?: string | null;
380
+ }
381
+ export interface UserDataFileRequest {
382
+ path: string;
383
+ }
384
+ export interface UserDataFileWriteRequest extends UserDataFileRequest {
385
+ content: string;
386
+ }
387
+ export interface ExternalReadDirectoryRequest {
388
+ root: string;
389
+ path?: string | null;
390
+ }
391
+ export interface ExternalReadFileRequest {
392
+ root: string;
393
+ path: string;
394
+ }
395
+ export declare const DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
396
+ export declare const MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 4;
397
+ export declare const MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
398
+ /** Applies the default external text chunk size and rejects values outside host limits. */
399
+ export declare function resolveExternalTextFileChunkSize(chunkSizeBytes?: number): number;
400
+ export interface ExternalReadTextFileChunksRequest extends ExternalReadFileRequest {
401
+ /** UTF-8 chunks contain at most this many bytes. Defaults to 64 KiB. */
402
+ chunkSizeBytes?: number;
403
+ /** Stops future reads. An in-flight host read may finish, but its result is discarded. */
404
+ signal?: AbortSignal;
405
+ }
406
+ export interface UserDataFileSystemAPI {
407
+ readDir(request?: UserDataDirectoryRequest): Promise<FileEntry[]>;
408
+ readTextFile(request: UserDataFileRequest): Promise<string>;
409
+ writeTextFile(request: UserDataFileWriteRequest): Promise<void>;
410
+ }
411
+ export interface ExternalReadFileSystemAPI {
412
+ readDir(request: ExternalReadDirectoryRequest): Promise<FileEntry[]>;
413
+ readTextFile(request: ExternalReadFileRequest): Promise<string>;
414
+ /** Lazily reads a UTF-8 file without retaining a host file handle between chunks. */
415
+ readTextFileChunks(request: ExternalReadTextFileChunksRequest): AsyncIterable<string>;
416
+ }
417
+ export interface BackendFileSystemAPI extends FileSystemAPI {
418
+ userData: UserDataFileSystemAPI;
419
+ external: ExternalReadFileSystemAPI;
420
+ }
356
421
  export type TerminalImageProtocol = 'iterm2';
357
422
  export interface ShellSessionRequest {
358
423
  taskId: string;
@@ -371,12 +436,16 @@ export interface ShellResizeRequest extends ShellSessionRequest {
371
436
  cols: number;
372
437
  rows: number;
373
438
  }
439
+ export interface PtyBufferState {
440
+ buffer: string | null;
441
+ isLive: boolean;
442
+ }
374
443
  export interface ShellAPI {
375
444
  spawn(request: ShellSpawnRequest): Promise<number>;
376
445
  write(request: ShellWriteRequest): Promise<void>;
377
446
  resize(request: ShellResizeRequest): Promise<void>;
378
447
  kill(request: ShellSessionRequest): Promise<void>;
379
- getBuffer(request: ShellSessionRequest): Promise<string | null>;
448
+ getBuffer(request: ShellSessionRequest): Promise<PtyBufferState>;
380
449
  }
381
450
  export interface CreateTaskRequest {
382
451
  initialPrompt: string;
@@ -518,6 +587,7 @@ export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
518
587
  taskStart: FrontendTaskStartRegistry;
519
588
  }
520
589
  export interface BackendOpenForgeAPI extends OpenForgeCommonAPI {
590
+ fs: BackendFileSystemAPI;
521
591
  backend: BackendMethodRegistry;
522
592
  background: BackgroundServiceRegistry;
523
593
  }
@@ -534,3 +604,4 @@ export declare function parsePluginViewKey(key: PluginViewKey): {
534
604
  pluginId: string;
535
605
  viewId: string;
536
606
  };
607
+ export {};
package/dist/types.js CHANGED
@@ -10,6 +10,58 @@ export const SUPPORTED_OPENFORGE_API_VERSIONS = Object.freeze(readSupportedOpenF
10
10
  export const OPENFORGE_PLUGIN_API_VERSION = SUPPORTED_OPENFORGE_API_VERSIONS[0];
11
11
  export const MIN_SUPPORTED_API_VERSION = Math.min(...SUPPORTED_OPENFORGE_API_VERSIONS);
12
12
  export const MAX_SUPPORTED_API_VERSION = Math.max(...SUPPORTED_OPENFORGE_API_VERSIONS);
13
+ const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS = [
14
+ 'commands',
15
+ 'events',
16
+ 'views',
17
+ 'injectionPoints',
18
+ 'taskPane',
19
+ 'taskStart',
20
+ 'settings',
21
+ 'background',
22
+ 'backend',
23
+ 'storage',
24
+ 'context',
25
+ 'navigation',
26
+ 'tasks',
27
+ 'projects',
28
+ 'fs',
29
+ 'shell',
30
+ 'notifications',
31
+ 'attention',
32
+ 'system.openUrl',
33
+ 'system.writeClipboardText',
34
+ 'config',
35
+ 'projectConfig',
36
+ 'browserSurfaces',
37
+ 'taskLinks',
38
+ 'appEnablement',
39
+ 'customSidebarNavigation',
40
+ ];
41
+ function assertOpenForgePluginCapabilitiesMatchSchema() {
42
+ const schemaCapabilities = packageMetadataSchemaData.properties.requires.items.enum;
43
+ if (!Array.isArray(schemaCapabilities) || !schemaCapabilities.every((capability) => typeof capability === 'string')) {
44
+ throw new Error('openforgePackageMetadataSchema.json properties.requires.items.enum must contain only strings');
45
+ }
46
+ if (schemaCapabilities.length !== OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS.length
47
+ || schemaCapabilities.some((capability, index) => capability !== OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS[index])) {
48
+ throw new Error('OpenForgePluginCapability must match openforgePackageMetadataSchema.json properties.requires.items.enum');
49
+ }
50
+ }
51
+ assertOpenForgePluginCapabilitiesMatchSchema();
52
+ export const DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 64 * 1024;
53
+ export const MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 4;
54
+ export const MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 1024 * 1024;
55
+ /** Applies the default external text chunk size and rejects values outside host limits. */
56
+ export function resolveExternalTextFileChunkSize(chunkSizeBytes) {
57
+ const size = chunkSizeBytes ?? DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES;
58
+ if (!Number.isInteger(size)
59
+ || size < MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES
60
+ || size > MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES) {
61
+ throw new RangeError(`chunkSizeBytes must be an integer between ${MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES} and ${MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES}`);
62
+ }
63
+ return size;
64
+ }
13
65
  export class TaskFollowUpError extends Error {
14
66
  code;
15
67
  constructor(code, message) {
@@ -1,12 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from 'svelte'
3
- import { getMarkdownRepositoryLinkSuffix, renderMarkdownHtml, resolveMarkdownRepositoryPath } from '../markdown'
3
+ import {
4
+ getMarkdownRepositoryLinkSuffix,
5
+ MARKDOWN_REMOTE_MEDIA_ATTRIBUTE,
6
+ renderMarkdownHtml,
7
+ resolveMarkdownRepositoryPath,
8
+ type ResolvedMarkdownMedia,
9
+ } from '../markdown'
4
10
 
5
11
  interface Props {
6
12
  content: string
7
13
  imageBaseUrl?: string | null
8
14
  markdownFilePath?: string | null
9
15
  resolveRepositoryImage?: (repositoryPath: string) => Promise<string | null>
16
+ resolveRemoteMedia?: (url: string) => Promise<ResolvedMarkdownMedia | null>
10
17
  onOpenRepositoryPath?: (repositoryPath: string, suffix: string) => void | Promise<void>
11
18
  onOpenUrl?: (url: string) => void | Promise<void>
12
19
  }
@@ -16,6 +23,7 @@
16
23
  imageBaseUrl = null,
17
24
  markdownFilePath = null,
18
25
  resolveRepositoryImage,
26
+ resolveRemoteMedia,
19
27
  onOpenRepositoryPath,
20
28
  onOpenUrl,
21
29
  }: Props = $props()
@@ -26,6 +34,7 @@
26
34
  imageBaseUrl: resolveRepositoryImage ? null : imageBaseUrl,
27
35
  markdownFilePath,
28
36
  deferRepositoryImages: Boolean(resolveRepositoryImage),
37
+ deferRemoteMedia: Boolean(resolveRemoteMedia),
29
38
  }))
30
39
 
31
40
  async function resolveRepositoryImages(runId: number) {
@@ -47,12 +56,55 @@
47
56
  }))
48
57
  }
49
58
 
59
+ function applyResolvedMedia(element: Element, url: string, resolved: ResolvedMarkdownMedia | null) {
60
+ element.removeAttribute(MARKDOWN_REMOTE_MEDIA_ATTRIBUTE)
61
+
62
+ if (element instanceof HTMLImageElement) {
63
+ element.setAttribute('src', resolved?.url ?? url)
64
+ return
65
+ }
66
+
67
+ // A bare link only becomes a player when the caller says the upload is one;
68
+ // everything else stays the link the Markdown asked for.
69
+ if (resolved?.kind !== 'video') return
70
+
71
+ const video = document.createElement('video')
72
+ video.src = resolved.url
73
+ video.controls = true
74
+ video.preload = 'metadata'
75
+ element.replaceWith(video)
76
+ }
77
+
78
+ // Remote media is held back by the renderer so the caller can swap in a URL
79
+ // this app can load (GitHub only serves upload URLs to a browser session).
80
+ // Anything the caller does not handle falls back to what the Markdown said.
81
+ async function resolveRemoteMediaElements(runId: number) {
82
+ if (!root || !resolveRemoteMedia) return
83
+
84
+ const elements = Array.from(root.querySelectorAll(`[${MARKDOWN_REMOTE_MEDIA_ATTRIBUTE}]`))
85
+ await Promise.all(elements.map(async (element) => {
86
+ const url = element.getAttribute(MARKDOWN_REMOTE_MEDIA_ATTRIBUTE)
87
+ if (!url) return
88
+
89
+ let resolved: ResolvedMarkdownMedia | null = null
90
+ try {
91
+ resolved = await resolveRemoteMedia(url)
92
+ } catch {
93
+ // Fall back to the original URL when resolution is unavailable.
94
+ }
95
+
96
+ if (runId !== imageResolutionId) return
97
+ applyResolvedMedia(element, url, resolved)
98
+ }))
99
+ }
100
+
50
101
  $effect(() => {
51
102
  const runId = ++imageResolutionId
52
103
  void html
53
- if (!root || !markdownFilePath || !resolveRepositoryImage) return
104
+ if (!root) return
54
105
 
55
106
  void resolveRepositoryImages(runId)
107
+ void resolveRemoteMediaElements(runId)
56
108
  })
57
109
 
58
110
  onDestroy(() => {
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ interface Props {
5
+ accessibleName: string
6
+ active: boolean
7
+ collapsed: boolean
8
+ onActivate: () => void
9
+ leading?: Snippet
10
+ label?: Snippet
11
+ trailing?: Snippet
12
+ class?: string
13
+ }
14
+
15
+ let {
16
+ accessibleName,
17
+ active,
18
+ collapsed,
19
+ onActivate,
20
+ leading,
21
+ label,
22
+ trailing,
23
+ class: className,
24
+ }: Props = $props()
25
+
26
+ function handleKeydown(event: KeyboardEvent): void {
27
+ if (event.key !== 'Enter' && event.key !== ' ') return
28
+ event.preventDefault()
29
+ onActivate()
30
+ }
31
+ </script>
32
+
33
+ <button
34
+ type="button"
35
+ class={[
36
+ 'relative mx-2 flex min-h-11 w-auto items-center rounded-lg gap-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
37
+ collapsed ? 'justify-center px-0' : 'px-3',
38
+ active ? 'bg-primary/10 text-primary' : 'text-base-content/55 hover:bg-base-200 hover:text-base-content',
39
+ className,
40
+ ]}
41
+ title={collapsed ? accessibleName : undefined}
42
+ aria-label={accessibleName}
43
+ aria-current={active ? 'page' : undefined}
44
+ onclick={onActivate}
45
+ onkeydown={handleKeydown}
46
+ >
47
+ {#if leading}
48
+ <span class="relative shrink-0">{@render leading()}</span>
49
+ {/if}
50
+ {#if !collapsed}
51
+ {#if label}<span class="min-w-0 text-sm font-medium">{@render label()}</span>{/if}
52
+ {#if trailing}<span class="ml-auto shrink-0">{@render trailing()}</span>{/if}
53
+ {/if}
54
+ </button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openforge-app/plugin-sdk",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -71,6 +71,7 @@
71
71
  "./ui/Modal.svelte": "./dist/ui/Modal.svelte",
72
72
  "./ui/PluginPageHeader.svelte": "./dist/ui/PluginPageHeader.svelte",
73
73
  "./ui/PluginViewState.svelte": "./dist/ui/PluginViewState.svelte",
74
+ "./ui/PluginSidebarLink.svelte": "./dist/ui/PluginSidebarLink.svelte",
74
75
  "./ui/FileTypeIcon.svelte": "./dist/ui/FileTypeIcon.svelte"
75
76
  },
76
77
  "files": [
@@ -92,17 +93,17 @@
92
93
  "test": "vitest run"
93
94
  },
94
95
  "dependencies": {
95
- "dompurify": "^3.4.13",
96
- "marked": "^18.0.9"
96
+ "dompurify": "catalog:",
97
+ "marked": "catalog:"
97
98
  },
98
99
  "peerDependencies": {
99
100
  "svelte": "^5.0.0"
100
101
  },
101
102
  "devDependencies": {
102
- "@types/node": "26.2.0",
103
- "svelte": "5.56.8",
104
- "typescript": "^6.0.3",
105
- "vitest": "^4.1.10",
103
+ "@types/node": "catalog:pinned",
104
+ "svelte": "catalog:pinned",
105
+ "typescript": "catalog:",
106
+ "vitest": "catalog:",
106
107
  "vscode-material-icons": "^0.1.1"
107
108
  }
108
109
  }