@openforge-app/plugin-sdk 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,26 +1,238 @@
1
1
  # @openforge-app/plugin-sdk
2
2
 
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.
3
+ TypeScript SDK for building trusted OpenForge plugins. It includes API version 1 types, frontend and backend entry-point helpers, package metadata validation, testing fakes, Vite helpers, and shared Svelte components.
4
4
 
5
- Styled frontend plugins must declare Vite/Svelte's emitted CSS artifacts in `package.json#openforge.frontendStyles`:
5
+ OpenForge plugins run trusted code. Install plugins only from authors you trust.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @openforge-app/plugin-sdk
11
+ ```
12
+
13
+ Frontend plugins also use the Svelte 5 runtime supplied by OpenForge. Add Svelte as a peer dependency and as a development dependency in the plugin package:
14
+
15
+ ```sh
16
+ pnpm add -D svelte@^5 @sveltejs/vite-plugin-svelte vite
17
+ ```
18
+
19
+ ```json
20
+ {
21
+ "peerDependencies": {
22
+ "svelte": "^5.0.0"
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## Package metadata
28
+
29
+ Declare the plugin in `package.json#openforge` and publish every built file named by the metadata:
6
30
 
7
31
  ```json
8
32
  {
33
+ "name": "@acme/openforge-notes",
34
+ "version": "1.0.0",
35
+ "type": "module",
36
+ "files": ["dist"],
37
+ "dependencies": {
38
+ "@openforge-app/plugin-sdk": "latest"
39
+ },
40
+ "peerDependencies": {
41
+ "svelte": "^5.0.0"
42
+ },
9
43
  "openforge": {
44
+ "id": "acme.notes",
45
+ "apiVersion": 1,
46
+ "displayName": "Notes",
47
+ "description": "Project notes",
48
+ "icon": "notebook-text",
10
49
  "frontend": "./dist/frontend.js",
11
- "frontendStyles": ["./dist/plugin.css"]
50
+ "frontendStyles": ["./dist/plugin.css"],
51
+ "backend": "./dist/backend.mjs",
52
+ "requires": ["views", "storage", "backend"]
12
53
  }
13
54
  }
14
55
  ```
15
56
 
16
- The host validates, attaches, reloads, and removes these package-relative stylesheets with the frontend plugin lifecycle.
57
+ `openforge.apiVersion` controls compatibility with the OpenForge host. It is separate from the npm package version. The current host contract uses API version `1`.
58
+
59
+ OpenForge loads built artifacts from the installed package. It does not compile plugin source during installation.
60
+
61
+ ## Frontend entry point
62
+
63
+ Use `@openforge-app/plugin-sdk/frontend` for renderer contributions such as views, task UI, settings, commands, and events:
64
+
65
+ ```ts
66
+ import { defineFrontendPlugin } from '@openforge-app/plugin-sdk/frontend'
67
+
68
+ export default defineFrontendPlugin({
69
+ activate(openforge, context) {
70
+ context.subscriptions.add(openforge.views.register({
71
+ id: 'notes',
72
+ title: 'Notes',
73
+ icon: 'notebook-text',
74
+ placement: 'rail',
75
+ component: () => import('./NotesView.svelte'),
76
+ }))
77
+ },
78
+ })
79
+ ```
80
+
81
+ Add every registration and cleanup handle to `context.subscriptions`. OpenForge disposes them when it deactivates or reloads the plugin.
82
+
83
+ Task reads support project-filtered invalidation subscriptions. Notifications may be coalesced and contain identity plus a reason, not a Task snapshot. Repeat only the bounded read your view needs:
84
+
85
+ ```ts
86
+ export default defineFrontendPlugin({
87
+ activate(openforge, context) {
88
+ const projectId = openforge.context.getSnapshot().projectId
89
+ if (!projectId) return
90
+
91
+ context.subscriptions.add(openforge.tasks.onDidChange(projectId, (event) => {
92
+ const refresh = event.taskId
93
+ ? openforge.tasks.detail(event.projectId, event.taskId)
94
+ : openforge.tasks.active(event.projectId)
95
+ void refresh.then((result) => {
96
+ // Update plugin-owned state from the bounded result.
97
+ }).catch(console.error)
98
+ }))
99
+ },
100
+ })
101
+ ```
102
+
103
+ The reason is one of `created`, `updated`, `completed`, `attention`, or `execution`. Add the returned disposable to `context.subscriptions`; explicit disposal, deactivation, reload, and uninstall then stop delivery.
104
+
105
+ Frontend bundles must share Svelte with the host. Use the SDK's Vite external helper:
106
+
107
+ ```ts
108
+ import { svelte } from '@sveltejs/vite-plugin-svelte'
109
+ import { openforgePluginViteExternals } from '@openforge-app/plugin-sdk/vite'
110
+ import { defineConfig } from 'vite'
111
+
112
+ export default defineConfig({
113
+ plugins: [svelte()],
114
+ build: {
115
+ lib: {
116
+ entry: 'src/frontend.ts',
117
+ formats: ['es'],
118
+ fileName: 'frontend',
119
+ },
120
+ rollupOptions: {
121
+ external: openforgePluginViteExternals,
122
+ },
123
+ },
124
+ })
125
+ ```
126
+
127
+ Svelte library builds emit CSS separately. List each emitted CSS file in `openforge.frontendStyles`; otherwise OpenForge will not load it.
128
+
129
+ ## Backend entry point
130
+
131
+ Use `@openforge-app/plugin-sdk/backend` for Node dependencies, plugin-local RPC methods, and background services:
132
+
133
+ ```ts
134
+ import { defineBackendPlugin } from '@openforge-app/plugin-sdk/backend'
135
+
136
+ export default defineBackendPlugin({
137
+ activate(openforge, context) {
138
+ context.subscriptions.add(openforge.backend.registerMethod('ping', {
139
+ input: { type: 'object', additionalProperties: false },
140
+ output: {
141
+ type: 'object',
142
+ required: ['ok'],
143
+ properties: { ok: { type: 'boolean' } },
144
+ },
145
+ handler: async () => ({ ok: true }),
146
+ }))
147
+ },
148
+ })
149
+ ```
150
+
151
+ Backend plugins run in the trusted Node plugin host. They cannot register Svelte views, task UI, settings sections, or frontend navigation.
152
+
153
+ ## Testing
154
+
155
+ `@openforge-app/plugin-sdk/testing` provides mock APIs and a registry fake for registration, capability, storage, and cleanup tests:
156
+
157
+ ```ts
158
+ import { describe, expect, it } from 'vitest'
159
+ import { createOpenForgeRegistryFake } from '@openforge-app/plugin-sdk/testing'
160
+ import plugin from '../src/frontend'
161
+
162
+ describe('frontend plugin', () => {
163
+ it('registers its view', async () => {
164
+ const registry = createOpenForgeRegistryFake({
165
+ pluginId: 'acme.notes',
166
+ projectId: 'P-1',
167
+ })
168
+
169
+ await registry.activateFrontend(plugin)
170
+
171
+ expect(registry.snapshot.views).toMatchObject([
172
+ { id: 'notes', qualifiedId: 'acme.notes.notes', title: 'Notes' },
173
+ ])
174
+ })
175
+ })
176
+ ```
177
+
178
+ Use `registry.emitTaskChange(...)` to drive invalidation behavior without importing host events or renderer stores:
179
+
180
+ ```ts
181
+ registry.emitTaskChange({
182
+ projectId: 'P-1',
183
+ taskId: 'T-42',
184
+ reason: 'updated',
185
+ })
186
+ ```
187
+
188
+ ## Public entry points
189
+
190
+ The package exports these modules:
191
+
192
+ | Import | Purpose |
193
+ | --- | --- |
194
+ | `@openforge-app/plugin-sdk` | Shared types, metadata validation, constants, and domain helpers |
195
+ | `@openforge-app/plugin-sdk/frontend` | Frontend plugin definition and renderer types |
196
+ | `@openforge-app/plugin-sdk/backend` | Backend plugin definition and Node-host types |
197
+ | `@openforge-app/plugin-sdk/testing` | Mock APIs, storage, subscriptions, and registry fakes |
198
+ | `@openforge-app/plugin-sdk/vite` | Host-runtime externals for plugin builds |
199
+ | `@openforge-app/plugin-sdk/package-metadata-schema.json` | JSON Schema for `package.json#openforge` |
200
+ | `@openforge-app/plugin-sdk/domain` | Shared OpenForge domain types |
201
+ | `@openforge-app/plugin-sdk/prStatusPresentation` | Pull request status presentation helpers |
202
+ | `@openforge-app/plugin-sdk/markdown` | Markdown rendering helpers |
203
+ | `@openforge-app/plugin-sdk/numberParsing` | Numeric parsing helpers |
204
+ | `@openforge-app/plugin-sdk/projectFileTree` | Project file tree helpers |
205
+ | `@openforge-app/plugin-sdk/sanitize` | HTML and SVG sanitization helpers |
206
+ | `@openforge-app/plugin-sdk/pluginIcons` | Plugin icon validation and sanitization |
207
+ | `@openforge-app/plugin-sdk/fileIcons` | File-type icon lookup helpers |
208
+ | `@openforge-app/plugin-sdk/collapsibleSectionState` | Persisted collapse-state helpers |
209
+ | `@openforge-app/plugin-sdk/taskBrowserDevToolsShortcuts` | Task Browser DevTools shortcut helpers |
210
+
211
+ Shared Svelte components use explicit imports. The package exports:
212
+
213
+ - `@openforge-app/plugin-sdk/ui/Button.svelte`
214
+ - `@openforge-app/plugin-sdk/ui/Checkbox.svelte`
215
+ - `@openforge-app/plugin-sdk/ui/CollapsibleSection.svelte`
216
+ - `@openforge-app/plugin-sdk/ui/FileTypeIcon.svelte`
217
+ - `@openforge-app/plugin-sdk/ui/MarkdownContent.svelte`
218
+ - `@openforge-app/plugin-sdk/ui/Modal.svelte`
219
+ - `@openforge-app/plugin-sdk/ui/PluginPageHeader.svelte`
220
+ - `@openforge-app/plugin-sdk/ui/PluginPageShell.svelte`
221
+ - `@openforge-app/plugin-sdk/ui/PluginSidebarLink.svelte`
222
+ - `@openforge-app/plugin-sdk/ui/PluginViewState.svelte`
223
+ - `@openforge-app/plugin-sdk/ui/ProjectFileTree.svelte`
224
+ - `@openforge-app/plugin-sdk/ui/ResizablePanel.svelte`
225
+ Use only documented package exports. Do not import OpenForge renderer stores, Electron or preload APIs, Rust internals, app IPC wrappers, or files under this package's `src/` directory.
226
+
227
+ ## Documentation
17
228
 
18
- ## Publishing
229
+ - [Plugin authoring guide](https://github.com/koenvg/openforge/blob/main/docs/plugin-authoring.md)
230
+ - [SDK reference](https://github.com/koenvg/openforge/blob/main/docs/plugins/sdk-reference.md)
19
231
 
20
- Automated releases use npm trusted publishing rather than a long-lived npm token. Configure the package's npm Trusted Publisher with repository `koenvg/openforge`, workflow `publish-plugin-sdk.yml`, and environment `npm-publish`. The workflow is the caller npm validates even though the shared publish steps live in `reusable-publish-plugin-sdk.yml`. For a failed tag release, dispatch this workflow manually with the missing `package_version` and disable dry-run.
232
+ The authoring guide documents package metadata, capabilities, frontend and backend runtime boundaries, storage, task APIs, browser surfaces, and CSS loading. The SDK reference lists the public types and shared UI components.
21
233
 
22
234
  ## License
23
235
 
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.
236
+ The SDK is available under the [MIT License](./LICENSE). Plugins may use, modify, and redistribute it under those terms, including in commercial and private plugins.
25
237
 
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.
238
+ The OpenForge desktop application has a separate license. The SDK license does not grant permission to commercially resell or redistribute the app.
package/dist/domain.d.ts CHANGED
@@ -14,8 +14,10 @@ export type WritableBoardStatus = Exclude<BoardStatus, 'done'>;
14
14
  export type WorktreeSource = 'newBranchFromMain' | 'existingBranch' | 'disabled';
15
15
  export interface Task {
16
16
  id: string;
17
+ /** @deprecated Use `TaskDetail.prompt` in the bounded Task read APIs. */
17
18
  initial_prompt: string;
18
19
  status: BoardStatus;
20
+ /** @deprecated Legacy execution override retained for API version 1 compatibility. */
19
21
  prompt: string | null;
20
22
  /** Explicit display title; null means fall back to the prompt-derived title. */
21
23
  title: string | null;
@@ -37,6 +39,53 @@ export interface Task {
37
39
  created_at: number;
38
40
  updated_at: number;
39
41
  }
42
+ export interface TaskLabel {
43
+ id: number;
44
+ projectId: string;
45
+ name: string;
46
+ }
47
+ export interface TaskReference {
48
+ id: string;
49
+ status: BoardStatus;
50
+ projectId: string;
51
+ title: string;
52
+ dependsOn: string[];
53
+ }
54
+ export interface TaskSummary extends TaskReference {
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ promptPreview: string;
58
+ labels: TaskLabel[];
59
+ sourceTicketUrl: string | null;
60
+ }
61
+ export interface TaskDetail extends TaskSummary {
62
+ prompt: string;
63
+ agent: string | null;
64
+ permissionMode: string | null;
65
+ worktreeSource: WorktreeSource | null;
66
+ worktreeBranch: string | null;
67
+ titleSource: 'manual' | 'generated' | null;
68
+ titleGeneratedAt: number | null;
69
+ }
70
+ export interface ActiveTasks {
71
+ tasks: TaskDetail[];
72
+ related: TaskReference[];
73
+ }
74
+ export interface CompletedTaskQuery {
75
+ /** At most 200 Unicode characters. ASCII case-insensitive; non-ASCII casing is exact. */
76
+ search?: string;
77
+ /** At most 20 Task Label names; each trimmed name is at most 40 Unicode characters. */
78
+ labels?: string[];
79
+ cursor?: string | null;
80
+ }
81
+ export interface CompletedTaskPage {
82
+ tasks: TaskSummary[];
83
+ nextCursor: string | null;
84
+ }
85
+ export interface TaskRead {
86
+ task: TaskDetail;
87
+ related: TaskReference[];
88
+ }
40
89
  export interface GitBranchInfo {
41
90
  name: string;
42
91
  is_current: boolean;
@@ -601,7 +650,7 @@ export interface FileEntry {
601
650
  }
602
651
  /** File content with type information */
603
652
  export interface FileContent {
604
- type: 'text' | 'image' | 'binary' | 'document' | 'large-file';
653
+ type: 'text' | 'image' | 'video' | 'binary' | 'document' | 'large-file';
605
654
  content: string;
606
655
  mimeType: string | null;
607
656
  size: number;
package/dist/fileIcons.js CHANGED
@@ -35,6 +35,7 @@ const EXTENSION_ICONS = {
35
35
  svg: 'svg',
36
36
  png: 'image', jpg: 'image', jpeg: 'image', gif: 'image',
37
37
  webp: 'image', bmp: 'image', ico: 'image', avif: 'image',
38
+ mp4: 'video', m4v: 'video', webm: 'video', ogv: 'video', ogg: 'video', mov: 'video',
38
39
  pdf: 'pdf',
39
40
  zip: 'zip', tar: 'zip', gz: 'zip', tgz: 'zip', rar: 'zip', '7z': 'zip',
40
41
  lock: 'lock',
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { BrowserSurfaceCapture, BrowserSurfaceFeedbackSelection, BrowserSur
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
5
  export { MAX_AGENT_SESSION_PAGE_SIZE, 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 { AgentSessionCursor, AgentSessionOverlap, AgentSessionsAPI, AgentSessionSummary, AgentSessionSummaryPage, AgentSessionTaskSummary, AgentSessionWorkspace, AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, BackendFileSystemAPI, ExternalFileMetadata, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, FileSystemAPI, UserDataDirectoryRequest, UserDataFileAppendResult, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, ImplementationRun, ListAgentSessionsRequest, ListTaskSessionsRequest, 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, TerminalViewSnapshot, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TasksAPI, ValidationError, } from './types';
6
+ export type { AgentSessionCursor, AgentSessionOverlap, AgentSessionsAPI, AgentSessionSummary, AgentSessionSummaryPage, AgentSessionTaskSummary, AgentSessionWorkspace, AgentCommandDescriptor, AgentCommandMetadata, AgentCommandRuntime, AttentionAPI, BackendReadyState, CommandDescriptor, CommandRegistry, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ComposeTaskResult, ConfigureStartPromptContributionRequest, CreateTaskRequest, Disposable, BackendFileSystemAPI, ExternalFileMetadata, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, ExternalReadFileSystemAPI, FileSystemAPI, UserDataDirectoryRequest, UserDataFileAppendResult, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, ImplementationRun, ListAgentSessionsRequest, ListTaskSessionsRequest, 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, TerminalViewSnapshot, ShellSessionRequest, ShellSpawnRequest, ShellWriteRequest, StartPromptContribution, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskFollowUpDisposition, TaskFollowUpErrorCode, TaskFollowUpReceipt, SubscriptionSink, SupportedOpenForgeApiVersion, SystemAPI, TaskChangeEvent, TaskChangeReason, TaskOperationsAPI, TasksAPI, ValidationError, } from './types';
7
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';
package/dist/markdown.js CHANGED
@@ -1,10 +1,16 @@
1
- import { marked } from 'marked';
1
+ import { marked, Renderer } from 'marked';
2
2
  import { sanitizeHtml } from './sanitize';
3
3
  /** Attribute holding the original URL of a `<img>`/`<a>` awaiting resolution. */
4
4
  export const MARKDOWN_REMOTE_MEDIA_ATTRIBUTE = 'data-markdown-remote-src';
5
+ class MarkdownRenderer extends Renderer {
6
+ table(token) {
7
+ return `<div class="markdown-table-scroll">${super.table(token)}</div>`;
8
+ }
9
+ }
5
10
  const markedOptions = {
6
11
  gfm: true,
7
12
  breaks: true,
13
+ renderer: new MarkdownRenderer(),
8
14
  };
9
15
  const RENDERED_MARKDOWN_CACHE_CAPACITY = 100;
10
16
  const renderedMarkdownCache = new Map();
@@ -1,14 +1,16 @@
1
- import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, OpenForgeCommonAPI } from '../types';
1
+ import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, TaskChangeEvent, OpenForgeCommonAPI } from '../types';
2
2
  import { type TestingRegistryServices } from './support.js';
3
3
  import type { TestingCommandContribution, TestingEventListenerContribution } from './contracts';
4
- export type TestingCommonApi = OpenForgeCommonAPI & Pick<FrontendOpenForgeAPI, 'navigation'>;
4
+ export type TestingCommonApi = Omit<OpenForgeCommonAPI, 'tasks'> & Pick<FrontendOpenForgeAPI, 'tasks' | 'navigation'>;
5
5
  export declare class TestingCommonApiFake {
6
6
  private readonly services;
7
7
  private readonly commands;
8
8
  private readonly eventListeners;
9
9
  private readonly eventHandlers;
10
+ private readonly taskChangeHandlers;
10
11
  private eventListenerSequence;
11
12
  constructor(services: TestingRegistryServices);
13
+ emitTaskChange(event: TaskChangeEvent): void;
12
14
  createApi(): TestingCommonApi;
13
15
  createBackendApi(): TestingCommonApi & Pick<BackendOpenForgeAPI, 'fs'>;
14
16
  getSnapshot(): {
@@ -115,15 +115,172 @@ function* splitExternalTextFile(content, maxBytes) {
115
115
  if (chunk.length > 0)
116
116
  yield chunk;
117
117
  }
118
+ function isTestingImageReferenceDefinition(line) {
119
+ const separator = line.indexOf(':');
120
+ if (separator < 0)
121
+ return false;
122
+ const marker = line.slice(0, separator);
123
+ const value = line.slice(separator + 1);
124
+ const imageNumber = marker.startsWith('[image#') && marker.endsWith(']')
125
+ ? marker.slice('[image#'.length, -1)
126
+ : null;
127
+ return imageNumber !== null
128
+ && imageNumber.length > 0
129
+ && /^\d+$/u.test(imageNumber)
130
+ && value.trimStart().startsWith('data:image/')
131
+ && value.includes(';base64,');
132
+ }
133
+ function testingTaskPromptPreview(task) {
134
+ const lines = task.initial_prompt
135
+ .split(/\r?\n/u)
136
+ .filter(line => !isTestingImageReferenceDefinition(line));
137
+ while (lines.at(-1)?.trim() === '')
138
+ lines.pop();
139
+ return [...lines.join('\n')].slice(0, 120).join('');
140
+ }
141
+ function testingTaskTitle(task, preview) {
142
+ const explicitTitle = task.title?.trim();
143
+ if (explicitTitle)
144
+ return explicitTitle;
145
+ const fallback = preview.split(/\r?\n/u).map(line => line.trim()).find(Boolean) || task.id;
146
+ return [...fallback].slice(0, 120).join('');
147
+ }
148
+ function taskReference(task) {
149
+ const preview = testingTaskPromptPreview(task);
150
+ if (!task.project_id)
151
+ throw new Error(`Task ${task.id} must belong to a project`);
152
+ return {
153
+ id: task.id,
154
+ status: task.status,
155
+ projectId: task.project_id,
156
+ title: testingTaskTitle(task, preview),
157
+ dependsOn: [...task.depends_on],
158
+ };
159
+ }
160
+ function taskSummary(task, labels = []) {
161
+ return {
162
+ ...taskReference(task),
163
+ createdAt: task.created_at,
164
+ updatedAt: task.updated_at,
165
+ promptPreview: testingTaskPromptPreview(task),
166
+ labels: [...labels],
167
+ sourceTicketUrl: task.source_ticket_url,
168
+ };
169
+ }
170
+ function taskDetail(task, labels = []) {
171
+ return {
172
+ ...taskSummary(task, labels),
173
+ prompt: task.initial_prompt,
174
+ agent: task.agent,
175
+ permissionMode: task.permission_mode,
176
+ worktreeSource: task.worktree_source,
177
+ worktreeBranch: task.worktree_branch,
178
+ titleSource: task.title_source,
179
+ titleGeneratedAt: task.title_generated_at,
180
+ };
181
+ }
182
+ function asciiLowercase(value) {
183
+ return value.replace(/[A-Z]/gu, character => character.toLowerCase());
184
+ }
185
+ function compareCompletedTasks(left, right) {
186
+ if (left.updated_at !== right.updated_at)
187
+ return right.updated_at - left.updated_at;
188
+ if (left.id === right.id)
189
+ return 0;
190
+ return left.id > right.id ? -1 : 1;
191
+ }
192
+ function testingCompletedTaskScope(projectId, query) {
193
+ return {
194
+ projectId,
195
+ search: asciiLowercase(query.search?.trim() ?? ''),
196
+ labels: [...new Set((query.labels ?? [])
197
+ .map(name => name.trim().toLowerCase())
198
+ .filter(Boolean))].sort(),
199
+ };
200
+ }
201
+ function encodeTestingCompletedTaskCursor(cursor) {
202
+ return `testing:${encodeURIComponent(JSON.stringify(cursor))}`;
203
+ }
204
+ function decodeTestingCompletedTaskCursor(encoded, scope) {
205
+ try {
206
+ if (!encoded.startsWith('testing:'))
207
+ throw new Error('wrong cursor format');
208
+ const cursor = JSON.parse(decodeURIComponent(encoded.slice('testing:'.length)));
209
+ if (cursor.version !== 1
210
+ || !Number.isSafeInteger(cursor.updatedAt)
211
+ || typeof cursor.id !== 'string'
212
+ || JSON.stringify(cursor.scope) !== JSON.stringify(scope)) {
213
+ throw new Error('invalid cursor payload');
214
+ }
215
+ return cursor;
216
+ }
217
+ catch {
218
+ throw new Error('Invalid Task cursor');
219
+ }
220
+ }
221
+ function listTestingCompletedTasks(allTasks, projectId, query = {}, labelsByTaskId = new Map()) {
222
+ if (!projectId.trim())
223
+ throw new RangeError('projectId is required');
224
+ const submittedLabels = query.labels ?? [];
225
+ if (submittedLabels.length > 20) {
226
+ throw new RangeError('Completed Task reads support at most 20 Task Label filters');
227
+ }
228
+ if (submittedLabels.some(name => [...name.trim()].length > 40)) {
229
+ throw new RangeError('Completed Task Label filters must be 40 characters or fewer');
230
+ }
231
+ if ([...(query.search?.trim() ?? '')].length > 200) {
232
+ throw new RangeError('Completed Task search must be 200 characters or fewer');
233
+ }
234
+ const scope = testingCompletedTaskScope(projectId, query);
235
+ const labels = new Set(scope.labels);
236
+ const cursor = query.cursor ? decodeTestingCompletedTaskCursor(query.cursor, scope) : null;
237
+ const matching = allTasks
238
+ .filter(task => task.status === 'done' && task.project_id === projectId)
239
+ .filter(task => {
240
+ const summary = taskSummary(task, labelsByTaskId.get(task.id));
241
+ return !scope.search || [summary.id, summary.title, summary.promptPreview]
242
+ .some(value => asciiLowercase(value).includes(scope.search));
243
+ })
244
+ .filter(task => {
245
+ if (labels.size === 0)
246
+ return true;
247
+ const names = taskSummary(task, labelsByTaskId.get(task.id))
248
+ .labels.map(label => label.name.toLowerCase());
249
+ return [...labels].every(label => names.includes(label));
250
+ })
251
+ .sort(compareCompletedTasks);
252
+ const remaining = cursor
253
+ ? matching.filter(task => task.updated_at < cursor.updatedAt
254
+ || (task.updated_at === cursor.updatedAt && task.id < cursor.id))
255
+ : matching;
256
+ const pageTasks = remaining.slice(0, 50);
257
+ const tasks = pageTasks.map(task => taskSummary(task, labelsByTaskId.get(task.id)));
258
+ const last = pageTasks.at(-1);
259
+ const nextCursor = remaining.length > 50 && last
260
+ ? encodeTestingCompletedTaskCursor({
261
+ version: 1,
262
+ scope,
263
+ updatedAt: last.updated_at,
264
+ id: last.id,
265
+ })
266
+ : null;
267
+ return { tasks, nextCursor };
268
+ }
118
269
  export class TestingCommonApiFake {
119
270
  services;
120
271
  commands = new Map();
121
272
  eventListeners = new Map();
122
273
  eventHandlers = new Map();
274
+ taskChangeHandlers = new Map();
123
275
  eventListenerSequence = 0;
124
276
  constructor(services) {
125
277
  this.services = services;
126
278
  }
279
+ emitTaskChange(event) {
280
+ for (const handler of this.taskChangeHandlers.get(event.projectId) ?? []) {
281
+ handler(event);
282
+ }
283
+ }
127
284
  createApi() {
128
285
  const api = {
129
286
  commands: {
@@ -238,6 +395,16 @@ export class TestingCommonApiFake {
238
395
  },
239
396
  },
240
397
  tasks: {
398
+ onDidChange: (projectId, handler) => {
399
+ const handlers = this.taskChangeHandlers.get(projectId) ?? new Set();
400
+ handlers.add(handler);
401
+ this.taskChangeHandlers.set(projectId, handlers);
402
+ return createDisposable(() => {
403
+ handlers.delete(handler);
404
+ if (handlers.size === 0)
405
+ this.taskChangeHandlers.delete(projectId);
406
+ });
407
+ },
241
408
  list: async (request) => {
242
409
  const projectId = request?.projectId ?? null;
243
410
  const includeDone = request?.includeDone ?? false;
@@ -245,12 +412,54 @@ export class TestingCommonApiFake {
245
412
  return this.services.seededTasks.filter((task) => {
246
413
  if (projectId !== null && task.project_id !== projectId)
247
414
  return false;
248
- if (!includeDone && task.status === 'done')
415
+ if (projectId !== null && !includeDone && task.status === 'done')
249
416
  return false;
250
417
  return true;
251
418
  });
252
419
  },
253
- get: async () => null,
420
+ get: async (taskId) => this.services.seededTasks.find(task => task.id === taskId) ?? null,
421
+ active: async (projectId) => {
422
+ this.services.calls.taskActiveRequests.push({ projectId });
423
+ const activeTasks = this.services.seededTasks.filter(task => task.project_id === projectId && task.status !== 'done');
424
+ const activeIds = new Set(activeTasks.map(task => task.id));
425
+ const relatedIds = new Set();
426
+ for (const task of this.services.seededTasks) {
427
+ if (activeIds.has(task.id)) {
428
+ for (const dependencyId of task.depends_on)
429
+ relatedIds.add(dependencyId);
430
+ }
431
+ else if (task.depends_on.some(dependencyId => activeIds.has(dependencyId))) {
432
+ relatedIds.add(task.id);
433
+ }
434
+ }
435
+ return {
436
+ tasks: activeTasks.map(task => taskDetail(task, this.services.seededTaskLabelAssignments.get(task.id))),
437
+ related: this.services.seededTasks
438
+ .filter(task => relatedIds.has(task.id) && !activeIds.has(task.id))
439
+ .map(taskReference),
440
+ };
441
+ },
442
+ completed: async (projectId, query = {}) => {
443
+ this.services.calls.taskCompletedRequests.push({ projectId, ...query });
444
+ return listTestingCompletedTasks(this.services.seededTasks, projectId, query, this.services.seededTaskLabelAssignments);
445
+ },
446
+ detail: async (projectId, taskId) => {
447
+ this.services.calls.taskDetailRequests.push({ projectId, taskId });
448
+ const task = this.services.seededTasks.find(candidate => candidate.id === taskId && candidate.project_id === projectId);
449
+ if (!task)
450
+ return null;
451
+ const relatedIds = new Set(task.depends_on);
452
+ for (const candidate of this.services.seededTasks) {
453
+ if (candidate.depends_on.includes(taskId))
454
+ relatedIds.add(candidate.id);
455
+ }
456
+ return {
457
+ task: taskDetail(task, this.services.seededTaskLabelAssignments.get(task.id)),
458
+ related: this.services.seededTasks
459
+ .filter(candidate => relatedIds.has(candidate.id))
460
+ .map(taskReference),
461
+ };
462
+ },
254
463
  create: async (request) => {
255
464
  this.services.calls.taskCreations.push(request);
256
465
  return {
@@ -334,7 +543,8 @@ export class TestingCommonApiFake {
334
543
  },
335
544
  fs: {
336
545
  readDir: async () => [],
337
- readFile: async () => ({ type: 'text', content: '', mimeType: null, size: 0 }),
546
+ readFile: async ({ path }) => this.services.projectFileContents[path]
547
+ ?? { type: 'text', content: '', mimeType: null, size: 0 },
338
548
  writeFile: async (request) => {
339
549
  this.services.calls.fsWrites.push(request);
340
550
  },
@@ -1,7 +1,8 @@
1
1
  import type { BrowserSurfaceVisualFeedback } from '../browserSurfaces';
2
+ import type { CompletedTaskQuery } from '../domain';
2
3
  import type { TestingOpenForgeRegistryFake } from './registryFake';
3
4
  import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, AgentSessionWorkspace, ListAgentSessionsRequest, ListTaskSessionsRequest, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginReviewRowActionRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
4
- import type { AgentSession, Task } from '../domain';
5
+ import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
5
6
  export type TestingRuntimeScope = 'global' | 'project' | 'task';
6
7
  export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'reviewUI' | 'settings' | 'backend' | 'background';
7
8
  export type TestingMaybePromise<T> = T | Promise<T>;
@@ -16,6 +17,10 @@ export interface TestingExternalTextFile extends ExternalReadFileRequest {
16
17
  export type TestingExternalTextFileChunksCall = Omit<ExternalReadTextFileChunksRequest, 'signal' | 'chunkSizeBytes'> & {
17
18
  chunkSizeBytes: number;
18
19
  };
20
+ export interface TestingTaskLabelAssignment {
21
+ taskId: string;
22
+ labels: TaskLabel[];
23
+ }
19
24
  export interface TestingOpenForgeApiOptions {
20
25
  pluginId?: string;
21
26
  projectId?: string | null;
@@ -26,12 +31,16 @@ export interface TestingOpenForgeApiOptions {
26
31
  storage?: PluginStorage;
27
32
  /** Initial files exposed through `fs.userData`. Defaults to none. */
28
33
  userDataTextFiles?: UserDataFileWriteRequest[];
34
+ /** File contents returned by `fs.readFile`, keyed by project-relative path. */
35
+ projectFileContents?: Readonly<Record<string, FileContent>>;
29
36
  /**
30
37
  * Tasks returned by `tasks.list`. The mock filters them by the requested
31
38
  * `projectId` (when given) and drops `done` tasks unless `includeDone: true`,
32
39
  * mirroring the host capability. Defaults to an empty list.
33
40
  */
34
41
  tasks?: Task[];
42
+ /** Task Label assignments used by canonical Task projections. */
43
+ taskLabelAssignments?: TestingTaskLabelAssignment[];
35
44
  /** Agent Sessions returned by `tasks.listSessions`. Defaults to an empty list. */
36
45
  agentSessions?: AgentSession[];
37
46
  /** Compact workspace context keyed by Task ID for `agentSessions.list`. Defaults to none. */
@@ -76,6 +85,16 @@ export interface TestingOpenForgeApiCalls {
76
85
  projectId: string | null;
77
86
  includeDone: boolean;
78
87
  }>;
88
+ taskActiveRequests: Array<{
89
+ projectId: string;
90
+ }>;
91
+ taskCompletedRequests: Array<{
92
+ projectId: string;
93
+ } & CompletedTaskQuery>;
94
+ taskDetailRequests: Array<{
95
+ projectId: string;
96
+ taskId: string;
97
+ }>;
79
98
  agentSessionListRequests: ListAgentSessionsRequest[];
80
99
  taskSessionListRequests: ListTaskSessionsRequest[];
81
100
  taskStatusUpdates: Array<{
@@ -1,5 +1,5 @@
1
1
  import type { TaskBrowserSurfaceState } from '../browserSurfaces';
2
- import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, PluginStorage } from '../types';
2
+ import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, TaskChangeEvent, PluginStorage } from '../types';
3
3
  import type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot } from './contracts';
4
4
  import { TestingSubscriptionSink } from './support.js';
5
5
  export declare class TestingOpenForgeRegistryFake {
@@ -28,6 +28,7 @@ export declare class TestingOpenForgeRegistryFake {
28
28
  createBackendContext(): BackendPluginContext;
29
29
  activateFrontend(plugin: FrontendPlugin): Promise<void>;
30
30
  activateBackend(plugin: BackendPlugin): Promise<void>;
31
+ emitTaskChange(event: TaskChangeEvent): void;
31
32
  setBrowserSurfaceState(taskId: string, id: string, patch: Partial<TaskBrowserSurfaceState>): void;
32
33
  disposeAll(): Promise<void>;
33
34
  getSnapshot(): TestingOpenForgeRegistrySnapshot;
@@ -82,6 +82,9 @@ export class TestingOpenForgeRegistryFake {
82
82
  await plugin.activate(this.backendApi, this.createBackendContext());
83
83
  await this.backendServices.startNewBackgroundServices(existingServices);
84
84
  }
85
+ emitTaskChange(event) {
86
+ this.commonApi.emitTaskChange(event);
87
+ }
85
88
  setBrowserSurfaceState(taskId, id, patch) {
86
89
  this.frontendContributions.setBrowserSurfaceState(taskId, id, patch);
87
90
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentSessionWorkspace, AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
2
- import type { AgentSession, Task } from '../domain';
2
+ import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
3
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 {
@@ -31,10 +31,12 @@ export declare class TestingRegistryServices {
31
31
  readonly storage: PluginStorage;
32
32
  readonly config: Map<string, JsonValue>;
33
33
  readonly seededTasks: Task[];
34
+ readonly seededTaskLabelAssignments: Map<string, TaskLabel[]>;
34
35
  readonly seededAgentSessions: AgentSession[];
35
36
  readonly agentSessionWorkspaces: Readonly<Record<string, AgentSessionWorkspace>>;
36
37
  readonly externalTextFiles: TestingExternalTextFile[];
37
38
  readonly userDataTextFiles: Map<string, string>;
39
+ readonly projectFileContents: Readonly<Record<string, FileContent>>;
38
40
  readonly claims: TestingContributionClaims;
39
41
  constructor(options?: TestingOpenForgeApiOptions);
40
42
  localQualifiedId(kind: TestingRuntimeKind, id: string): string;
@@ -44,6 +44,9 @@ export function createTestingCalls() {
44
44
  taskImplementationStarts: [],
45
45
  taskFollowUps: [],
46
46
  taskListRequests: [],
47
+ taskActiveRequests: [],
48
+ taskCompletedRequests: [],
49
+ taskDetailRequests: [],
47
50
  agentSessionListRequests: [],
48
51
  taskSessionListRequests: [],
49
52
  taskStatusUpdates: [],
@@ -205,10 +208,12 @@ export class TestingRegistryServices {
205
208
  storage;
206
209
  config = new Map();
207
210
  seededTasks;
211
+ seededTaskLabelAssignments;
208
212
  seededAgentSessions;
209
213
  agentSessionWorkspaces;
210
214
  externalTextFiles;
211
215
  userDataTextFiles = new Map();
216
+ projectFileContents;
212
217
  claims = new TestingContributionClaims();
213
218
  constructor(options = {}) {
214
219
  this.pluginId = options.pluginId ?? 'test-plugin';
@@ -224,9 +229,11 @@ export class TestingRegistryServices {
224
229
  this.calls = createTestingCalls();
225
230
  this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
226
231
  this.seededTasks = options.tasks ?? [];
232
+ this.seededTaskLabelAssignments = new Map((options.taskLabelAssignments ?? []).map(assignment => [assignment.taskId, assignment.labels]));
227
233
  this.seededAgentSessions = options.agentSessions ?? [];
228
234
  this.agentSessionWorkspaces = options.agentSessionWorkspaces ?? {};
229
235
  this.externalTextFiles = options.externalTextFiles ?? [];
236
+ this.projectFileContents = options.projectFileContents ?? {};
230
237
  for (const file of options.userDataTextFiles ?? []) {
231
238
  this.userDataTextFiles.set(file.path, file.content);
232
239
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { BrowserSurfacesAPI } from './browserSurfaces';
3
- import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WorktreeSource, WritableBoardStatus } from './domain';
3
+ import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ActiveTasks, CompletedTaskPage, CompletedTaskQuery, TaskRead, TaskDetail, ReviewPullRequest, Task, TaskWorkspaceInfo, WorktreeSource, WritableBoardStatus } from './domain';
4
4
  export type SupportedOpenForgeApiVersion = 1;
5
5
  export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
6
6
  export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
@@ -202,6 +202,7 @@ export interface PluginTaskPaneProps extends Record<string, unknown> {
202
202
  api: FrontendOpenForgeAPI;
203
203
  context: OpenForgeContextSnapshot;
204
204
  taskId: string;
205
+ task: TaskDetail;
205
206
  projectId: string | null;
206
207
  }
207
208
  export interface PluginTaskUISectionProps extends PluginTaskPaneProps {
@@ -609,19 +610,31 @@ export interface AgentSessionSummaryPage {
609
610
  export interface AgentSessionsAPI {
610
611
  list(request: ListAgentSessionsRequest): Promise<AgentSessionSummaryPage>;
611
612
  }
612
- export interface TasksAPI {
613
+ export type TaskChangeReason = 'created' | 'updated' | 'completed' | 'attention' | 'execution';
614
+ /**
615
+ * A coalescible signal that one or more bounded Task reads may now be stale.
616
+ * It never contains a Task snapshot.
617
+ */
618
+ export interface TaskChangeEvent {
619
+ projectId: string;
620
+ taskId: string | null;
621
+ reason: TaskChangeReason;
622
+ }
623
+ export interface TaskOperationsAPI {
613
624
  /**
614
- * Lists tasks, optionally scoped to a project. By default done tasks are
615
- * excluded (matching the app board's active-only view); pass
616
- * `includeDone: true` to include tasks in the terminal `done` state. The
617
- * unscoped listing (no `projectId`) always returns all states, so
618
- * `includeDone` only affects the project-scoped path.
625
+ * Lists legacy Task rows. Project-scoped reads exclude Completed Tasks unless
626
+ * `includeDone` is true; unscoped reads preserve the complete legacy array.
627
+ * @deprecated Use `active`, `completed`, or `detail`. Removed in version 2.
619
628
  */
620
629
  list(request?: {
621
630
  projectId?: string | null;
622
631
  includeDone?: boolean;
623
632
  }): Promise<Task[]>;
633
+ /** @deprecated Use `detail`. Removed in version 2. */
624
634
  get(taskId: string): Promise<Task | null>;
635
+ active(projectId: string): Promise<ActiveTasks>;
636
+ completed(projectId: string, query?: CompletedTaskQuery): Promise<CompletedTaskPage>;
637
+ detail(projectId: string, taskId: string): Promise<TaskRead | null>;
625
638
  create(request: CreateTaskRequest): Promise<Task>;
626
639
  /**
627
640
  * Opens the host's create-task dialog pre-filled, letting the user edit the
@@ -642,6 +655,13 @@ export interface TasksAPI {
642
655
  /** Returns matching Agent Sessions newest first. */
643
656
  listSessions(request: ListTaskSessionsRequest): Promise<AgentSession[]>;
644
657
  }
658
+ export interface TasksAPI extends TaskOperationsAPI {
659
+ /**
660
+ * Subscribes to coalescible Task invalidations for one Project.
661
+ * Repeat the relevant bounded read instead of treating events as snapshots.
662
+ */
663
+ onDidChange(projectId: string, handler: (event: TaskChangeEvent) => void): Disposable;
664
+ }
645
665
  export interface ProjectsAPI {
646
666
  list(): Promise<Project[]>;
647
667
  get(projectId: string): Promise<Project | null>;
@@ -673,7 +693,7 @@ export interface OpenForgeCommonAPI {
673
693
  getSnapshot(): OpenForgeContextSnapshot;
674
694
  };
675
695
  agentSessions: AgentSessionsAPI;
676
- tasks: TasksAPI;
696
+ tasks: TaskOperationsAPI;
677
697
  projects: ProjectsAPI;
678
698
  fs: FileSystemAPI;
679
699
  shell: ShellAPI;
@@ -684,6 +704,7 @@ export interface OpenForgeCommonAPI {
684
704
  projectConfig: KeyValueConfigAPI;
685
705
  }
686
706
  export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
707
+ tasks: TasksAPI;
687
708
  browserSurfaces: BrowserSurfacesAPI;
688
709
  navigation: NavigationAPI;
689
710
  views: FrontendViewRegistry;
@@ -23,17 +23,12 @@
23
23
  class: className,
24
24
  }: Props = $props()
25
25
 
26
- function handleKeydown(event: KeyboardEvent): void {
27
- if (event.key !== 'Enter' && event.key !== ' ') return
28
- event.preventDefault()
29
- onActivate()
30
- }
31
26
  </script>
32
27
 
33
28
  <button
34
29
  type="button"
35
30
  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',
31
+ 'relative mx-2 flex min-h-11 w-[calc(100%_-_1rem)] 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
32
  collapsed ? 'justify-center px-0' : 'px-3',
38
33
  active ? 'bg-primary/10 text-primary' : 'text-base-content/55 hover:bg-base-200 hover:text-base-content',
39
34
  className,
@@ -42,7 +37,6 @@
42
37
  aria-label={accessibleName}
43
38
  aria-current={active ? 'page' : undefined}
44
39
  onclick={onActivate}
45
- onkeydown={handleKeydown}
46
40
  >
47
41
  {#if leading}
48
42
  <span class="relative shrink-0">{@render leading()}</span>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <path d="M24,6l2,6H22L20,6H17l2,6H15L13,6H10l2,6H8L6,6H5A3,3,0,0,0,2,9V23a3,3,0,0,0,3,3H27a3,3,0,0,0,3-3V6Z" style="fill: #ff9800"/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "@openforge-app/plugin-sdk",
3
- "version": "0.2.11",
3
+ "version": "0.3.0",
4
+ "description": "TypeScript SDK for building trusted OpenForge frontend and backend plugins.",
5
+ "homepage": "https://github.com/koenvg/openforge/blob/main/docs/plugin-authoring.md",
6
+ "keywords": [
7
+ "openforge",
8
+ "plugin",
9
+ "sdk",
10
+ "svelte"
11
+ ],
4
12
  "license": "MIT",
5
13
  "repository": {
6
14
  "type": "git",