@openforge-app/plugin-sdk 0.1.0 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/backend.d.ts +2 -2
  4. package/dist/browserSurfaces.d.ts +78 -0
  5. package/dist/browserSurfaces.js +17 -0
  6. package/dist/browserSurfacesTesting.d.ts +60 -0
  7. package/dist/browserSurfacesTesting.js +240 -0
  8. package/dist/domain.d.ts +74 -15
  9. package/dist/domain.js +5 -1
  10. package/dist/fileIcons.d.ts +6 -0
  11. package/dist/fileIcons.js +84 -0
  12. package/dist/frontend.d.ts +5 -2
  13. package/dist/frontend.js +1 -0
  14. package/dist/index.d.ts +5 -3
  15. package/dist/index.js +2 -1
  16. package/dist/manifest.d.ts +80 -2
  17. package/dist/manifest.js +55 -1
  18. package/dist/markdown.d.ts +5 -1
  19. package/dist/markdown.js +67 -18
  20. package/dist/openforgePackageMetadataSchema.json +80 -4
  21. package/dist/pluginIconContract.d.ts +3 -0
  22. package/dist/pluginIconContract.js +12 -0
  23. package/dist/pluginIcons.d.ts +7 -0
  24. package/dist/pluginIcons.js +99 -0
  25. package/dist/publicUiExports.d.mts +16 -0
  26. package/dist/publicUiExports.mjs +60 -0
  27. package/dist/svelteHostRuntimeContract.d.mts +1 -0
  28. package/dist/svelteHostRuntimeContract.mjs +16 -0
  29. package/dist/testing/backendServicesFake.d.ts +19 -0
  30. package/dist/testing/backendServicesFake.js +87 -0
  31. package/dist/testing/commonApiFake.d.ts +23 -0
  32. package/dist/testing/commonApiFake.js +306 -0
  33. package/dist/testing/contracts.d.ts +236 -0
  34. package/dist/testing/contracts.js +1 -0
  35. package/dist/testing/frontendContributionFake.d.ts +36 -0
  36. package/dist/testing/frontendContributionFake.js +204 -0
  37. package/dist/testing/registryFake.d.ts +40 -0
  38. package/dist/testing/registryFake.js +124 -0
  39. package/dist/testing/support.d.ts +40 -0
  40. package/dist/testing/support.js +247 -0
  41. package/dist/testing.d.ts +3 -223
  42. package/dist/testing.js +2 -625
  43. package/dist/types.d.ts +251 -9
  44. package/dist/types.js +47 -0
  45. package/dist/ui/Button.svelte +49 -0
  46. package/dist/ui/Checkbox.svelte +137 -0
  47. package/dist/ui/FileTypeIcon.svelte +37 -0
  48. package/dist/ui/MarkdownContent.svelte +71 -11
  49. package/dist/ui/Modal.svelte +157 -0
  50. package/dist/ui/PluginPageHeader.svelte +26 -0
  51. package/dist/ui/PluginSidebarLink.svelte +54 -0
  52. package/dist/ui/PluginViewState.svelte +76 -0
  53. package/dist/ui/ResizablePanel.svelte +10 -0
  54. package/dist/ui/icons/README.md +9 -0
  55. package/dist/ui/icons/c.svg +1 -0
  56. package/dist/ui/icons/console.svg +1 -0
  57. package/dist/ui/icons/cpp.svg +1 -0
  58. package/dist/ui/icons/csharp.svg +1 -0
  59. package/dist/ui/icons/css.svg +3 -0
  60. package/dist/ui/icons/database.svg +1 -0
  61. package/dist/ui/icons/docker.svg +1 -0
  62. package/dist/ui/icons/document.svg +4 -0
  63. package/dist/ui/icons/file.svg +1 -0
  64. package/dist/ui/icons/folder-open.svg +1 -0
  65. package/dist/ui/icons/folder.svg +1 -0
  66. package/dist/ui/icons/git.svg +3 -0
  67. package/dist/ui/icons/go.svg +1 -0
  68. package/dist/ui/icons/graphql.svg +20 -0
  69. package/dist/ui/icons/h.svg +3 -0
  70. package/dist/ui/icons/html.svg +3 -0
  71. package/dist/ui/icons/image.svg +3 -0
  72. package/dist/ui/icons/java.svg +6 -0
  73. package/dist/ui/icons/javascript.svg +3 -0
  74. package/dist/ui/icons/json.svg +3 -0
  75. package/dist/ui/icons/kotlin.svg +1 -0
  76. package/dist/ui/icons/lock.svg +3 -0
  77. package/dist/ui/icons/markdown.svg +6 -0
  78. package/dist/ui/icons/nodejs.svg +6 -0
  79. package/dist/ui/icons/npm.svg +3 -0
  80. package/dist/ui/icons/pdf.svg +1 -0
  81. package/dist/ui/icons/php.svg +1 -0
  82. package/dist/ui/icons/python.svg +1 -0
  83. package/dist/ui/icons/react.svg +8 -0
  84. package/dist/ui/icons/react_ts.svg +8 -0
  85. package/dist/ui/icons/readme.svg +4 -0
  86. package/dist/ui/icons/ruby.svg +1 -0
  87. package/dist/ui/icons/rust.svg +1 -0
  88. package/dist/ui/icons/sass.svg +3 -0
  89. package/dist/ui/icons/settings.svg +4 -0
  90. package/dist/ui/icons/svelte.svg +1 -0
  91. package/dist/ui/icons/svg.svg +3 -0
  92. package/dist/ui/icons/swift.svg +1 -0
  93. package/dist/ui/icons/tune.svg +11 -0
  94. package/dist/ui/icons/typescript-def.svg +6 -0
  95. package/dist/ui/icons/typescript.svg +3 -0
  96. package/dist/ui/icons/vue.svg +1 -0
  97. package/dist/ui/icons/xml.svg +1 -0
  98. package/dist/ui/icons/yaml.svg +3 -0
  99. package/dist/ui/icons/zip.svg +3 -0
  100. package/dist/vite.js +5 -2
  101. package/package.json +35 -7
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Koen Van Geert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @openforge-app/plugin-sdk
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.
4
+
5
+ Styled frontend plugins must declare Vite/Svelte's emitted CSS artifacts in `package.json#openforge.frontendStyles`:
6
+
7
+ ```json
8
+ {
9
+ "openforge": {
10
+ "frontend": "./dist/frontend.js",
11
+ "frontendStyles": ["./dist/plugin.css"]
12
+ }
13
+ }
14
+ ```
15
+
16
+ The host validates, attaches, reloads, and removes these package-relative stylesheets with the frontend plugin lifecycle.
17
+
18
+ ## Publishing
19
+
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.
21
+
22
+ ## License
23
+
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
+
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.
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, ExternalReadFileSystemAPI, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, NavigationAPI, 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, ExternalReadFileSystemAPI, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileSystemAPI, UserDataFileWriteRequest, NavigationAPI, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, };
@@ -0,0 +1,78 @@
1
+ import type { Disposable } from './types';
2
+ export type BrowserSurfaceErrorCode = 'CAPABILITY_UNAVAILABLE' | 'HOST_UNAVAILABLE' | 'INVALID_TASK' | 'PLUGIN_NOT_ENABLED' | 'INVALID_ID' | 'INVALID_URL' | 'INVALID_BOUNDS' | 'CAPTURE_UNAVAILABLE' | 'CAPTURE_FAILED' | 'SURFACE_ACCESS_DENIED' | 'SURFACE_DESTROYED';
3
+ export declare class BrowserSurfaceError extends Error {
4
+ readonly code: BrowserSurfaceErrorCode;
5
+ constructor(code: BrowserSurfaceErrorCode, message: string);
6
+ }
7
+ export interface BrowserSurfaceNavigationError {
8
+ code: string;
9
+ message: string;
10
+ url: string;
11
+ }
12
+ export interface TaskBrowserSurfaceState {
13
+ url: string;
14
+ title: string;
15
+ loading: boolean;
16
+ canGoBack: boolean;
17
+ canGoForward: boolean;
18
+ error: BrowserSurfaceNavigationError | null;
19
+ }
20
+ export interface BrowserSurfaceCapture {
21
+ artifactId: string;
22
+ /** Absolute local PNG path retained for the Task Agent after acknowledgement. */
23
+ absolutePath: string;
24
+ mediaType: 'image/png';
25
+ width: number;
26
+ height: number;
27
+ url: string;
28
+ title: string;
29
+ capturedAt: string;
30
+ dataUrl: string;
31
+ }
32
+ export interface GetOrCreateBrowserSurfaceRequest {
33
+ taskId: string;
34
+ id: string;
35
+ initialUrl?: string;
36
+ }
37
+ export interface BrowserSurfaceRegion {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ }
43
+ export interface BrowserSurfaceFeedbackSelection {
44
+ region: BrowserSurfaceRegion;
45
+ comment: string;
46
+ /** Host-assigned number rendered on the live page. Older hosts may omit it. */
47
+ annotationNumber?: number;
48
+ }
49
+ export interface BrowserSurfaceVisualFeedback {
50
+ annotationNumber: number;
51
+ url: string;
52
+ region: BrowserSurfaceRegion;
53
+ comment: string;
54
+ }
55
+ export interface TaskBrowserSurfaceController {
56
+ attach(element: HTMLElement): Promise<Disposable>;
57
+ detach(): Promise<void>;
58
+ destroy(): Promise<void>;
59
+ getState(): Promise<TaskBrowserSurfaceState>;
60
+ onStateChanged(handler: (state: TaskBrowserSurfaceState) => void): Disposable;
61
+ navigate(url: string): Promise<TaskBrowserSurfaceState>;
62
+ goBack(): Promise<TaskBrowserSurfaceState>;
63
+ goForward(): Promise<TaskBrowserSurfaceState>;
64
+ reload(): Promise<TaskBrowserSurfaceState>;
65
+ stop(): Promise<TaskBrowserSurfaceState>;
66
+ selectVisibleRegion(): Promise<BrowserSurfaceFeedbackSelection | null>;
67
+ cancelVisibleRegionSelection(): Promise<void>;
68
+ clearVisualFeedback(): Promise<void>;
69
+ replaceVisualFeedback(feedback: readonly BrowserSurfaceVisualFeedback[]): Promise<void>;
70
+ captureExists(artifactId: string): Promise<boolean>;
71
+ captureVisibleViewport(): Promise<BrowserSurfaceCapture>;
72
+ discardCapture(artifactId: string): Promise<void>;
73
+ }
74
+ export interface BrowserSurfacesAPI {
75
+ getOrCreate(request: GetOrCreateBrowserSurfaceRequest): Promise<TaskBrowserSurfaceController>;
76
+ resetSession(): Promise<void>;
77
+ }
78
+ export declare function isAllowedBrowserSurfaceUrl(value: string): boolean;
@@ -0,0 +1,17 @@
1
+ export class BrowserSurfaceError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.name = 'BrowserSurfaceError';
6
+ this.code = code;
7
+ }
8
+ }
9
+ export function isAllowedBrowserSurfaceUrl(value) {
10
+ try {
11
+ const url = new URL(value);
12
+ return url.protocol === 'http:' || url.protocol === 'https:';
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
@@ -0,0 +1,60 @@
1
+ import type { BrowserSurfacesAPI, BrowserSurfaceVisualFeedback, GetOrCreateBrowserSurfaceRequest, TaskBrowserSurfaceState } from './browserSurfaces';
2
+ export interface TestingBrowserSurfaceCalls {
3
+ browserSurfaceGetOrCreate: GetOrCreateBrowserSurfaceRequest[];
4
+ browserSurfaceAttachments: Array<{
5
+ taskId: string;
6
+ id: string;
7
+ element: HTMLElement;
8
+ }>;
9
+ browserSurfaceDetaches: Array<{
10
+ taskId: string;
11
+ id: string;
12
+ }>;
13
+ browserSurfaceDestroys: Array<{
14
+ taskId: string;
15
+ id: string;
16
+ }>;
17
+ browserSurfaceNavigations: Array<{
18
+ taskId: string;
19
+ id: string;
20
+ url: string;
21
+ }>;
22
+ browserSurfaceControls: Array<{
23
+ taskId: string;
24
+ id: string;
25
+ action: 'goBack' | 'goForward' | 'reload' | 'stop';
26
+ }>;
27
+ browserSurfaceSelections: Array<{
28
+ taskId: string;
29
+ id: string;
30
+ }>;
31
+ browserSurfaceFeedbackClears: Array<{
32
+ taskId: string;
33
+ id: string;
34
+ }>;
35
+ browserSurfaceFeedbackReplacements: Array<{
36
+ taskId: string;
37
+ id: string;
38
+ feedback: BrowserSurfaceVisualFeedback[];
39
+ }>;
40
+ browserSurfaceCaptureChecks: Array<{
41
+ taskId: string;
42
+ id: string;
43
+ artifactId: string;
44
+ }>;
45
+ browserSurfaceCaptures: Array<{
46
+ taskId: string;
47
+ id: string;
48
+ }>;
49
+ browserSurfaceCaptureDiscards: Array<{
50
+ taskId: string;
51
+ id: string;
52
+ artifactId: string;
53
+ }>;
54
+ browserSurfaceSessionResets: Array<Record<string, never>>;
55
+ }
56
+ export interface TestingBrowserSurfaces {
57
+ readonly api: BrowserSurfacesAPI;
58
+ setState(taskId: string, id: string, patch: Partial<TaskBrowserSurfaceState>): void;
59
+ }
60
+ export declare function createTestingBrowserSurfaces(calls: TestingBrowserSurfaceCalls): TestingBrowserSurfaces;
@@ -0,0 +1,240 @@
1
+ import { BrowserSurfaceError, isAllowedBrowserSurfaceUrl, } from './browserSurfaces';
2
+ function disposable(dispose) {
3
+ let disposed = false;
4
+ return {
5
+ async dispose() {
6
+ if (disposed)
7
+ return;
8
+ disposed = true;
9
+ await dispose();
10
+ },
11
+ };
12
+ }
13
+ function surfaceKey(taskId, id) {
14
+ return `${taskId}\u0000${id}`;
15
+ }
16
+ function copyState(state) {
17
+ return { ...state, error: state.error ? { ...state.error } : null };
18
+ }
19
+ function validateIdentity(taskId, id) {
20
+ if (!taskId.trim())
21
+ throw new BrowserSurfaceError('INVALID_TASK', 'Task Browser Surface requires a non-empty Task ID');
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) {
23
+ throw new BrowserSurfaceError('INVALID_ID', 'Task Browser Surface id must be a stable non-empty local identifier');
24
+ }
25
+ }
26
+ class TestingTaskBrowserSurface {
27
+ taskId;
28
+ id;
29
+ calls;
30
+ onDestroyed;
31
+ state;
32
+ history;
33
+ historyIndex = 0;
34
+ listeners = new Set();
35
+ currentAttachment = 0;
36
+ destroyed = false;
37
+ nextAnnotationNumber = 1;
38
+ capturedArtifactIds = new Set();
39
+ constructor(taskId, id, initialUrl, calls, onDestroyed) {
40
+ this.taskId = taskId;
41
+ this.id = id;
42
+ this.calls = calls;
43
+ this.onDestroyed = onDestroyed;
44
+ const url = initialUrl ?? 'about:blank';
45
+ this.history = [url];
46
+ this.state = {
47
+ url,
48
+ title: '',
49
+ loading: false,
50
+ canGoBack: false,
51
+ canGoForward: false,
52
+ error: null,
53
+ };
54
+ }
55
+ async attach(element) {
56
+ this.assertLive();
57
+ const attachment = ++this.currentAttachment;
58
+ this.calls.browserSurfaceAttachments.push({ taskId: this.taskId, id: this.id, element });
59
+ return disposable(async () => {
60
+ if (this.destroyed || attachment !== this.currentAttachment)
61
+ return;
62
+ await this.detach();
63
+ });
64
+ }
65
+ async detach() {
66
+ this.assertLive();
67
+ this.currentAttachment += 1;
68
+ this.calls.browserSurfaceDetaches.push({ taskId: this.taskId, id: this.id });
69
+ }
70
+ async destroy() {
71
+ if (this.destroyed)
72
+ return;
73
+ this.destroyed = true;
74
+ this.listeners.clear();
75
+ this.calls.browserSurfaceDestroys.push({ taskId: this.taskId, id: this.id });
76
+ this.onDestroyed();
77
+ }
78
+ async getState() {
79
+ this.assertLive();
80
+ return copyState(this.state);
81
+ }
82
+ onStateChanged(handler) {
83
+ this.assertLive();
84
+ this.listeners.add(handler);
85
+ return disposable(() => { this.listeners.delete(handler); });
86
+ }
87
+ async navigate(url) {
88
+ this.assertLive();
89
+ if (!isAllowedBrowserSurfaceUrl(url)) {
90
+ throw new BrowserSurfaceError('INVALID_URL', 'Task Browser Surfaces only allow HTTP(S) navigation');
91
+ }
92
+ this.calls.browserSurfaceNavigations.push({ taskId: this.taskId, id: this.id, url });
93
+ this.history.splice(this.historyIndex + 1);
94
+ this.history.push(url);
95
+ this.historyIndex = this.history.length - 1;
96
+ this.publish({ url, title: '', error: null });
97
+ return this.getState();
98
+ }
99
+ async goBack() {
100
+ this.assertLive();
101
+ this.calls.browserSurfaceControls.push({ taskId: this.taskId, id: this.id, action: 'goBack' });
102
+ if (this.historyIndex > 0)
103
+ this.historyIndex -= 1;
104
+ this.publish({ url: this.history[this.historyIndex], error: null });
105
+ return this.getState();
106
+ }
107
+ async goForward() {
108
+ this.assertLive();
109
+ this.calls.browserSurfaceControls.push({ taskId: this.taskId, id: this.id, action: 'goForward' });
110
+ if (this.historyIndex < this.history.length - 1)
111
+ this.historyIndex += 1;
112
+ this.publish({ url: this.history[this.historyIndex], error: null });
113
+ return this.getState();
114
+ }
115
+ async reload() {
116
+ this.assertLive();
117
+ this.calls.browserSurfaceControls.push({ taskId: this.taskId, id: this.id, action: 'reload' });
118
+ this.publish({ error: null });
119
+ return this.getState();
120
+ }
121
+ async stop() {
122
+ this.assertLive();
123
+ this.calls.browserSurfaceControls.push({ taskId: this.taskId, id: this.id, action: 'stop' });
124
+ this.publish({ loading: false });
125
+ return this.getState();
126
+ }
127
+ async selectVisibleRegion() {
128
+ this.assertLive();
129
+ this.calls.browserSurfaceSelections.push({ taskId: this.taskId, id: this.id });
130
+ return {
131
+ region: { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
132
+ comment: 'Example visual feedback',
133
+ annotationNumber: this.nextAnnotationNumber++,
134
+ };
135
+ }
136
+ async cancelVisibleRegionSelection() {
137
+ this.assertLive();
138
+ }
139
+ async clearVisualFeedback() {
140
+ this.assertLive();
141
+ this.calls.browserSurfaceFeedbackClears.push({ taskId: this.taskId, id: this.id });
142
+ this.nextAnnotationNumber = 1;
143
+ }
144
+ async replaceVisualFeedback(feedback) {
145
+ this.assertLive();
146
+ this.calls.browserSurfaceFeedbackReplacements.push({
147
+ taskId: this.taskId,
148
+ id: this.id,
149
+ feedback: feedback.map(marker => ({ ...marker, region: { ...marker.region } })),
150
+ });
151
+ this.nextAnnotationNumber = feedback.reduce((maximum, marker) => Math.max(maximum, marker.annotationNumber), 0) + 1;
152
+ }
153
+ async captureExists(artifactId) {
154
+ this.assertLive();
155
+ if (!artifactId.trim())
156
+ throw new BrowserSurfaceError('INVALID_ID', 'Browser Surface capture requires an artifact ID');
157
+ this.calls.browserSurfaceCaptureChecks.push({ taskId: this.taskId, id: this.id, artifactId });
158
+ return this.capturedArtifactIds.has(artifactId);
159
+ }
160
+ async captureVisibleViewport() {
161
+ this.assertLive();
162
+ this.calls.browserSurfaceCaptures.push({ taskId: this.taskId, id: this.id });
163
+ const artifactId = `capture-${this.calls.browserSurfaceCaptures.length}`;
164
+ this.capturedArtifactIds.add(artifactId);
165
+ return {
166
+ artifactId,
167
+ absolutePath: `/tmp/openforge-browser-captures/${this.taskId}/capture-${this.calls.browserSurfaceCaptures.length}.png`,
168
+ mediaType: 'image/png',
169
+ width: 800,
170
+ height: 600,
171
+ url: this.state.url,
172
+ title: this.state.title,
173
+ capturedAt: '2026-01-01T00:00:00.000Z',
174
+ dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
175
+ };
176
+ }
177
+ async discardCapture(artifactId) {
178
+ this.assertLive();
179
+ if (!artifactId.trim())
180
+ throw new BrowserSurfaceError('INVALID_ID', 'Browser Surface capture requires an artifact ID');
181
+ this.calls.browserSurfaceCaptureDiscards.push({ taskId: this.taskId, id: this.id, artifactId });
182
+ this.capturedArtifactIds.delete(artifactId);
183
+ }
184
+ setState(patch) {
185
+ this.assertLive();
186
+ this.state = copyState({ ...this.state, ...patch });
187
+ this.notify();
188
+ }
189
+ publish(patch) {
190
+ this.state = {
191
+ ...this.state,
192
+ ...patch,
193
+ canGoBack: this.historyIndex > 0,
194
+ canGoForward: this.historyIndex < this.history.length - 1,
195
+ };
196
+ this.notify();
197
+ }
198
+ notify() {
199
+ for (const listener of this.listeners)
200
+ listener(copyState(this.state));
201
+ }
202
+ assertLive() {
203
+ if (this.destroyed)
204
+ throw new BrowserSurfaceError('SURFACE_DESTROYED', 'Task Browser Surface has been destroyed');
205
+ }
206
+ }
207
+ export function createTestingBrowserSurfaces(calls) {
208
+ const surfaces = new Map();
209
+ const api = {
210
+ async getOrCreate(request) {
211
+ const recorded = request.initialUrl === undefined ? { taskId: request.taskId, id: request.id } : { ...request };
212
+ calls.browserSurfaceGetOrCreate.push(recorded);
213
+ validateIdentity(request.taskId, request.id);
214
+ const key = surfaceKey(request.taskId, request.id);
215
+ let surface = surfaces.get(key);
216
+ if (!surface) {
217
+ if (request.initialUrl !== undefined && !isAllowedBrowserSurfaceUrl(request.initialUrl)) {
218
+ throw new BrowserSurfaceError('INVALID_URL', 'Task Browser Surfaces only allow HTTP(S) initial URLs');
219
+ }
220
+ surface = new TestingTaskBrowserSurface(request.taskId, request.id, request.initialUrl, calls, () => surfaces.delete(key));
221
+ surfaces.set(key, surface);
222
+ }
223
+ return surface;
224
+ },
225
+ async resetSession() {
226
+ calls.browserSurfaceSessionResets.push({});
227
+ for (const surface of Array.from(surfaces.values()))
228
+ await surface.destroy();
229
+ },
230
+ };
231
+ return {
232
+ api,
233
+ setState(taskId, id, patch) {
234
+ const surface = surfaces.get(surfaceKey(taskId, id));
235
+ if (!surface)
236
+ throw new BrowserSurfaceError('SURFACE_DESTROYED', 'Task Browser Surface is not live');
237
+ surface.setState(patch);
238
+ },
239
+ };
240
+ }
package/dist/domain.d.ts CHANGED
@@ -23,17 +23,15 @@ export interface Task {
23
23
  title_source: 'manual' | 'generated' | null;
24
24
  /** Unix timestamp for the first generated title write; null means generation has not written yet. */
25
25
  title_generated_at: number | null;
26
- summary: string | null;
27
26
  agent: string | null;
28
27
  permission_mode: string | null;
29
28
  worktree_source: WorktreeSource | null;
30
29
  worktree_branch: string | null;
31
30
  /**
32
- * Whether the task's start prompt includes the OpenForge handoff-notes
33
- * (task management) block. Defaults to true; false opts the task out so the
34
- * agent is not instructed to maintain Handoff Notes.
31
+ * Optional link to the source ticket this task originated from (e.g. a GitHub
32
+ * issue URL or Jira browse link). `null` when no ticket was provided.
35
33
  */
36
- handoff_notes_enabled: boolean;
34
+ source_ticket_url: string | null;
37
35
  depends_on: string[];
38
36
  project_id: string | null;
39
37
  created_at: number;
@@ -90,6 +88,7 @@ export interface AgentSession {
90
88
  provider: string;
91
89
  claude_session_id: string | null;
92
90
  pi_session_id: string | null;
91
+ grok_session_id: string | null;
93
92
  }
94
93
  export interface ClaudeInstallStatus {
95
94
  installed: boolean;
@@ -114,6 +113,8 @@ export interface PrComment {
114
113
  file_path: string | null;
115
114
  line_number: number | null;
116
115
  addressed: number;
116
+ /** 1 when GitHub considers the comment outdated (its diff line changed). */
117
+ outdated: number;
117
118
  created_at: number;
118
119
  }
119
120
  export interface PullRequestInfo {
@@ -250,12 +251,25 @@ export interface RateLimitNotification {
250
251
  export interface AgentInfo {
251
252
  name: string;
252
253
  }
253
- /** Command info from provider command endpoints — used for / autocomplete */
254
+ /** Command info from provider command endpoints — used for / autocomplete and injectable catalogs */
254
255
  export interface CommandInfo {
255
256
  name: string;
256
257
  description: string | null;
257
258
  source: string | null;
258
259
  agent: string | null;
260
+ /** Where it comes from: "personal" | "project" | "plugin" | "builtin". Optional enrichment — only claude-code populates it. */
261
+ origin?: string | null;
262
+ /** "auto+manual" | "manual-only" — derived from disable-model-invocation / command semantics */
263
+ triggerMode?: string | null;
264
+ /** false => hidden background skill; injectable catalogs drop it */
265
+ userInvocable?: boolean | null;
266
+ /** e.g. ".claude" | ".agents"; null for builtin/plugin */
267
+ sourceDir?: string | null;
268
+ /** stable on-disk identity under the source skills dir — for a skill, its folder name
269
+ * (a single path component, e.g. "pr-writer"); for a command, the file name. null for builtin */
270
+ sourcePath?: string | null;
271
+ /** full SKILL.md body for a consumer's reading pane; null when there is no source file */
272
+ content?: string | null;
259
273
  }
260
274
  /** Extended agent info from provider agent endpoints — used for @ autocomplete */
261
275
  export interface AutocompleteAgentInfo {
@@ -294,6 +308,17 @@ export interface ProjectAttention {
294
308
  unaddressed_comments: number;
295
309
  completed_agents: number;
296
310
  }
311
+ export type TaskAttentionState = 'idle' | 'needs-input' | 'paused' | 'agent-done' | 'failed' | 'interrupted' | 'pr-draft' | 'pr-open' | 'ci-failed' | 'changes-requested' | 'unaddressed-comments' | 'ready-to-merge' | 'ready-to-enqueue' | 'pr-queued' | 'pr-merged' | 'pr-closed' | 'ci-running' | 'review-pending' | 'merge-conflict';
312
+ /** Backend-owned, Task-only Needs Attention read model. */
313
+ export interface TaskAttentionRow {
314
+ task_id: string;
315
+ project_id: string;
316
+ project_name: string;
317
+ title: string;
318
+ state: TaskAttentionState;
319
+ reason: string;
320
+ activity_at: number;
321
+ }
297
322
  export interface WorktreeInfo {
298
323
  id: number;
299
324
  task_id: string;
@@ -401,9 +426,13 @@ export interface ReviewPullRequest {
401
426
  }
402
427
  /** GitHub label that suppresses a PR from review counting and grays out its card. */
403
428
  export declare const DO_NOT_REVIEW_LABEL = "DO NOT REVIEW";
404
- /** True when the PR carries the "DO NOT REVIEW" label (case-insensitive, trimmed). */
429
+ /**
430
+ * True when the PR carries the "DO NOT REVIEW" label (case-insensitive, trimmed).
431
+ * Tolerates a missing or null `labels` field so a single implementation can back
432
+ * both the plugin PR views and the app-side sort/badge helpers.
433
+ */
405
434
  export declare function hasDoNotReviewLabel(pr: {
406
- labels: PrLabel[];
435
+ labels?: PrLabel[] | null;
407
436
  }): boolean;
408
437
  /** File diff from PR files endpoint */
409
438
  export interface PrFileDiff {
@@ -516,6 +545,43 @@ export interface AgentReviewComment {
516
545
  created_at: number;
517
546
  updated_at: number;
518
547
  }
548
+ /** One message in a local "Ask the AI author" Q&A thread. Never sent to GitHub. */
549
+ export interface AiThreadMessage {
550
+ role: 'user' | 'ai';
551
+ body: string;
552
+ created_at: number;
553
+ }
554
+ /**
555
+ * Where a Q&A thread is anchored: a specific diff line, a walkthrough step, or a
556
+ * specific AI review comment (a follow-up like "why did you suggest this?"). The
557
+ * `comment` variant carries the AI comment's id (so its suggestion can be quoted
558
+ * as context) plus the denormalized diff location so it renders inline like a
559
+ * line-anchored thread.
560
+ */
561
+ export type AiThreadAnchor = {
562
+ type: 'line';
563
+ filename: string;
564
+ line: number;
565
+ side: 'LEFT' | 'RIGHT';
566
+ } | {
567
+ type: 'step';
568
+ step_id: string;
569
+ } | {
570
+ type: 'comment';
571
+ comment_id: number;
572
+ filename: string;
573
+ line: number;
574
+ side: 'LEFT' | 'RIGHT';
575
+ };
576
+ /** A private, per-commit reviewer↔AI conversation, stored locally (never on GitHub). */
577
+ export interface AiThread {
578
+ id: string;
579
+ anchor: AiThreadAnchor;
580
+ status: 'draft' | 'pending' | 'answered' | 'error';
581
+ messages: AiThreadMessage[];
582
+ created_at: number;
583
+ updated_at: number;
584
+ }
519
585
  /** App-level view for top-bar navigation */
520
586
  export type CoreAppView = 'board' | 'settings' | 'global_settings' | 'files';
521
587
  /** App-level view for top-bar navigation */
@@ -551,13 +617,6 @@ export declare function splitCheckRuns(checks: CheckRunInfo[]): {
551
617
  visible: CheckRunInfo[];
552
618
  passingCount: number;
553
619
  };
554
- export interface Action {
555
- id: string;
556
- name: string;
557
- prompt: string;
558
- builtin: boolean;
559
- enabled: boolean;
560
- }
561
620
  export type VoiceInputState = 'idle' | 'recording' | 'transcribing' | 'error';
562
621
  export interface TranscriptionResult {
563
622
  text: string;
package/dist/domain.js CHANGED
@@ -293,7 +293,11 @@ export function preservePullRequestState(oldPr, newPr) {
293
293
  }
294
294
  /** GitHub label that suppresses a PR from review counting and grays out its card. */
295
295
  export const DO_NOT_REVIEW_LABEL = 'DO NOT REVIEW';
296
- /** True when the PR carries the "DO NOT REVIEW" label (case-insensitive, trimmed). */
296
+ /**
297
+ * True when the PR carries the "DO NOT REVIEW" label (case-insensitive, trimmed).
298
+ * Tolerates a missing or null `labels` field so a single implementation can back
299
+ * both the plugin PR views and the app-side sort/badge helpers.
300
+ */
297
301
  export function hasDoNotReviewLabel(pr) {
298
302
  return (pr.labels ?? []).some((label) => label.name.trim().toUpperCase() === DO_NOT_REVIEW_LABEL);
299
303
  }
@@ -0,0 +1,6 @@
1
+ export declare const DEFAULT_FILE_ICON = "file";
2
+ export declare const DEFAULT_FOLDER_ICON = "folder";
3
+ export declare const DEFAULT_FOLDER_OPEN_ICON = "folder-open";
4
+ export declare function getFileIconName(filename: string): string;
5
+ export declare function getFolderIconName(open: boolean): string;
6
+ export declare const BUNDLED_ICON_NAMES: readonly string[];