@moretea-labs/forge 1.7.0 → 1.7.1

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 (175) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +13 -0
  3. package/CLAUDE.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/agents/openai.yaml +1 -1
  6. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  7. package/assets/plugin-registry.v1.json +3 -3
  8. package/assets/skill-version.json +2 -2
  9. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  10. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  11. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  12. package/assets/templates/helpers/workflow-contract.ts +5 -1
  13. package/docs/architecture/CURRENT.md +12 -4
  14. package/docs/forge-plugin-management.md +1 -1
  15. package/docs/operations/controller-performance-and-502.md +19 -0
  16. package/docs/operations/homebrew.md +2 -2
  17. package/package.json +5 -2
  18. package/scripts/architecture-event.ts +2 -820
  19. package/scripts/architecture-queue.sh +2 -645
  20. package/scripts/archive-architecture-request.sh +2 -242
  21. package/scripts/archive-workflow.sh +1 -291
  22. package/scripts/benchmark-work-efficiency.ts +0 -0
  23. package/scripts/capability-config.ts +2 -392
  24. package/scripts/capability-resolver.ts +2 -503
  25. package/scripts/capture-plan.sh +1 -332
  26. package/scripts/check-agent-tooling.sh +2 -1531
  27. package/scripts/check-architecture-sync.sh +2 -302
  28. package/scripts/check-brain-manifest.sh +2 -253
  29. package/scripts/check-context-files.sh +2 -47
  30. package/scripts/check-deploy-sql-order.sh +2 -115
  31. package/scripts/check-runtime-architecture.mjs +243 -16
  32. package/scripts/check-skill-version.ts +3 -191
  33. package/scripts/check-source-duplication.mjs +61 -0
  34. package/scripts/check-stable-baseline.ts +0 -0
  35. package/scripts/check-task-sync.sh +2 -63
  36. package/scripts/check-task-workflow.sh +2 -1000
  37. package/scripts/codex-handoff-resume.sh +2 -267
  38. package/scripts/context-contract-sync.sh +2 -514
  39. package/scripts/contract-run.ts +2 -463
  40. package/scripts/contract-worktree.sh +1 -702
  41. package/scripts/ensure-task-workflow.sh +1 -1192
  42. package/scripts/inspect-project-state.ts +4 -279
  43. package/scripts/lib/project-init-lib.sh +7 -2
  44. package/scripts/lib/run-forge-helper.sh +19 -0
  45. package/scripts/lib/run-forge-helper.ts +26 -0
  46. package/scripts/migrate-project-template.sh +1 -33
  47. package/scripts/migrate-workflow-docs.ts +4 -412
  48. package/scripts/new-plan.sh +1 -180
  49. package/scripts/new-spec.sh +1 -47
  50. package/scripts/new-sprint.sh +1 -46
  51. package/scripts/plan-to-todo.sh +1 -771
  52. package/scripts/prepare-codex-handoff.sh +2 -201
  53. package/scripts/prepare-handoff.sh +1 -78
  54. package/scripts/refresh-current-status.sh +2 -467
  55. package/scripts/run-governed-gate.ts +3 -0
  56. package/scripts/select-agent-context-blocks.sh +2 -64
  57. package/scripts/ship-worktrees.sh +1 -531
  58. package/scripts/sprint-backlog.sh +1 -834
  59. package/scripts/stage-runtime-release.ts +2 -1
  60. package/scripts/summarize-failures.sh +2 -123
  61. package/scripts/switch-plan.sh +1 -150
  62. package/scripts/sync-brain-docs.sh +2 -379
  63. package/scripts/sync-generated-authority.mjs +175 -0
  64. package/scripts/verify-contract.sh +2 -593
  65. package/scripts/verify-sprint.sh +1 -500
  66. package/scripts/workflow-contract.ts +4 -201
  67. package/scripts/workstream-sync.sh +2 -261
  68. package/src/cli/agent-jobs/executor-health.ts +1 -1
  69. package/src/cli/commands/plugin.ts +12 -3
  70. package/src/cli/controller/check-runner.ts +7 -2
  71. package/src/cli/controller/context-pack.ts +46 -21
  72. package/src/cli/github/contracts.ts +16 -0
  73. package/src/cli/github/github.ts +2 -16
  74. package/src/cli/github/session.ts +2 -1
  75. package/src/cli/local-bridge/facade-api.ts +37 -47
  76. package/src/cli/local-bridge/server.ts +2 -2
  77. package/src/cli/mcp/legacy-tool-service.ts +10 -33
  78. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  79. package/src/cli/mcp/repository-tools.ts +85 -89
  80. package/src/cli/mcp/server.ts +57 -13
  81. package/src/cli/mcp/tool-contract.ts +14 -0
  82. package/src/cli/mcp/toolset.ts +2 -2
  83. package/src/cli/mcp/transports/http.ts +23 -0
  84. package/src/cli/repositories/command-classifier.ts +2 -2
  85. package/src/cli/repositories/command-executor.ts +24 -6
  86. package/src/cli/repositories/repository-snapshot.ts +5 -2
  87. package/src/cli/repositories/structured-git.ts +57 -3
  88. package/src/cli/repository/inspector.ts +11 -3
  89. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  90. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  91. package/src/runtime/context/semantic-navigation.ts +9 -61
  92. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +18 -25
  93. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  94. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  95. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  96. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  97. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  98. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  99. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  100. package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
  101. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  102. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  103. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  104. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  105. package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
  106. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
  107. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  108. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  109. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  110. package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
  111. package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
  112. package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
  113. package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
  114. package/src/runtime/control-plane/facade/index.ts +2 -0
  115. package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
  116. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  117. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  118. package/src/runtime/control-plane/facade/types.ts +68 -5
  119. package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
  120. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  121. package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
  122. package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
  123. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
  124. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
  125. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
  126. package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
  127. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  128. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  129. package/src/runtime/control-plane/routing/route-policy.ts +18 -6
  130. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  131. package/src/runtime/evidence/process-check-execution.ts +18 -0
  132. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  133. package/src/runtime/execution/process-runtime/check-result.ts +53 -18
  134. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  135. package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
  136. package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
  137. package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
  138. package/src/runtime/execution/process-runtime/runtime.ts +10 -0
  139. package/src/runtime/execution/process-runtime/types.ts +2 -12
  140. package/src/runtime/execution/work-activity.ts +21 -0
  141. package/src/runtime/execution/workers/executor.ts +18 -13
  142. package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
  143. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  144. package/src/runtime/gateway/mcp/router.ts +2 -2
  145. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
  146. package/src/runtime/gateway/mcp/runtime-tools.ts +805 -1009
  147. package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
  148. package/src/runtime/plugins/browser-adapter.ts +256 -1083
  149. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  150. package/src/runtime/plugins/browser-handoff.ts +18 -3
  151. package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
  152. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  153. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  154. package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
  155. package/src/runtime/plugins/browser-runtime.ts +10 -0
  156. package/src/runtime/plugins/browser-session-store.ts +121 -0
  157. package/src/runtime/plugins/browser-session-types.ts +98 -0
  158. package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
  159. package/src/runtime/plugins/external-adapter.ts +92 -58
  160. package/src/runtime/plugins/macos-capability-broker.ts +0 -8
  161. package/src/runtime/plugins/store.ts +66 -9
  162. package/src/runtime/plugins/types.ts +3 -1
  163. package/src/runtime/recovery/maintenance-executor.ts +61 -25
  164. package/src/runtime/root/package-connector-service.ts +46 -4
  165. package/src/runtime/root/package-runtime-release.ts +1 -1
  166. package/src/runtime/root/package-runtime-service.ts +33 -2
  167. package/src/runtime/root/release-manifest.ts +137 -16
  168. package/src/runtime/root/release-materialize.ts +109 -35
  169. package/src/runtime/root/types.ts +10 -0
  170. package/src/runtime/standalone-recovery/core.ts +425 -194
  171. package/src/runtime/standalone-recovery/entry.ts +1 -1
  172. package/src/runtime/standalone-recovery/installer.ts +1 -1
  173. package/src/runtime/workflow/schedules/engine.ts +113 -87
  174. package/src/runtime/workflow/schedules/settlement.ts +26 -23
  175. package/src/runtime/workflow/schedules/store.ts +79 -10
@@ -0,0 +1,222 @@
1
+ import { AssistantPluginError } from './errors';
2
+ import {
3
+ closeMacOsBrowserOwnedTab,
4
+ listMacOsBrowserTabs,
5
+ macOsBrowserPageHandleStale,
6
+ readMacOsBrowserOwnedTabMetadata,
7
+ type MacOsBrowserProduct,
8
+ } from './browser-macos-bridge';
9
+ import { listSavedBrowserSessions, removeBrowserSession } from './browser-session-store';
10
+ import type { BrowserSessionInventoryItem, BrowserSessionState } from './browser-session-types';
11
+
12
+ export interface NativeOwnedSessionInspection {
13
+ items: Map<string, BrowserSessionInventoryItem>;
14
+ liveCount: number;
15
+ deadCount: number;
16
+ unverifiedCount: number;
17
+ prunedCount: number;
18
+ closedInvalidCount: number;
19
+ failedCleanupCount: number;
20
+ }
21
+
22
+ export function nativeTabInventoryUnsupported(error: unknown): boolean {
23
+ if (error instanceof AssistantPluginError && error.code === 'BROWSER_AUTOMATION_ACTION_UNSUPPORTED') return true;
24
+ const message = error instanceof Error ? error.message : String(error ?? '');
25
+ return /\bBROWSER_AUTOMATION_ACTION_UNSUPPORTED\b/.test(message);
26
+ }
27
+
28
+ function isDiscardableNativeOwnedTabUrl(url: string): boolean {
29
+ const normalized = url.trim().toLowerCase();
30
+ return normalized === ''
31
+ || normalized === 'about:blank'
32
+ || normalized === 'chrome://newtab/'
33
+ || normalized === 'chrome://new-tab-page/'
34
+ || normalized === 'vivaldi://newtab/'
35
+ || normalized === 'vivaldi://startpage/';
36
+ }
37
+
38
+ /**
39
+ * Native tab liveness/cleanup belongs to one service so the Browser adapter does
40
+ * not reinterpret stable native identity or prune policy action-by-action.
41
+ */
42
+ export async function inspectNativeOwnedSessions(input: {
43
+ repoRoot: string;
44
+ savedSessions: BrowserSessionState[];
45
+ timeoutMs: number;
46
+ pruneDead?: boolean;
47
+ }): Promise<NativeOwnedSessionInspection> {
48
+ const { repoRoot, savedSessions, timeoutMs } = input;
49
+ const items = new Map<string, BrowserSessionInventoryItem>();
50
+ const inventories = new Map<MacOsBrowserProduct, Awaited<ReturnType<typeof listMacOsBrowserTabs>> | Error>();
51
+ const groups = new Map<string, BrowserSessionState[]>();
52
+ let liveCount = 0;
53
+ let deadCount = 0;
54
+ let unverifiedCount = 0;
55
+ let prunedCount = 0;
56
+ let closedInvalidCount = 0;
57
+ let failedCleanupCount = 0;
58
+
59
+ for (const session of savedSessions) {
60
+ const browser = session.browser;
61
+ const tab = browser?.tab;
62
+ const product = browser?.browserProduct;
63
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !product || !tab.windowId || !tab.tabId) continue;
64
+ const key = `${product}:${tab.windowId}:${tab.tabId}`;
65
+ const group = groups.get(key) ?? [];
66
+ group.push(session);
67
+ groups.set(key, group);
68
+ }
69
+
70
+ for (const group of groups.values()) {
71
+ const canonical = group.reduce((latest, session) => session.updatedAt > latest.updatedAt ? session : latest);
72
+ const browser = canonical.browser!;
73
+ const tab = browser.tab!;
74
+ const product = browser.browserProduct!;
75
+ let inventory = inventories.get(product);
76
+ if (!inventory) {
77
+ try {
78
+ inventory = await listMacOsBrowserTabs(product, timeoutMs);
79
+ } catch (error) {
80
+ inventory = error instanceof Error ? error : new Error(String(error));
81
+ }
82
+ inventories.set(product, inventory);
83
+ }
84
+ if (inventory instanceof Error) {
85
+ if (!nativeTabInventoryUnsupported(inventory)) {
86
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError: inventory.message });
87
+ unverifiedCount += group.length;
88
+ continue;
89
+ }
90
+
91
+ const ref = { windowId: tab.windowId!, tabId: tab.tabId! };
92
+ let metadata: Awaited<ReturnType<typeof readMacOsBrowserOwnedTabMetadata>>;
93
+ try {
94
+ metadata = await readMacOsBrowserOwnedTabMetadata(product, ref, timeoutMs);
95
+ } catch (metadataError) {
96
+ if (macOsBrowserPageHandleStale(metadataError)) {
97
+ const pruned = input.pruneDead === true;
98
+ if (pruned) {
99
+ for (const session of group) removeBrowserSession(repoRoot, session.sessionId);
100
+ prunedCount += group.length;
101
+ }
102
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
103
+ deadCount += group.length;
104
+ } else {
105
+ const cleanupError = metadataError instanceof Error ? metadataError.message : String(metadataError);
106
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'unverified', evidence: 'native_inventory_unavailable', cleanupError });
107
+ unverifiedCount += group.length;
108
+ }
109
+ continue;
110
+ }
111
+
112
+ if (isDiscardableNativeOwnedTabUrl(metadata.url)) {
113
+ deadCount += group.length;
114
+ if (input.pruneDead === true) {
115
+ try {
116
+ await closeMacOsBrowserOwnedTab(product, ref, timeoutMs);
117
+ for (const session of group) removeBrowserSession(repoRoot, session.sessionId);
118
+ prunedCount += group.length;
119
+ closedInvalidCount += 1;
120
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
121
+ } catch (error) {
122
+ failedCleanupCount += 1;
123
+ const cleanupError = error instanceof Error ? error.message : String(error);
124
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
125
+ }
126
+ } else {
127
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
128
+ }
129
+ } else {
130
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
131
+ liveCount += group.length;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ const live = inventory.tabs.find((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId);
137
+ if (!live) {
138
+ const pruned = input.pruneDead === true;
139
+ if (pruned) {
140
+ for (const session of group) removeBrowserSession(repoRoot, session.sessionId);
141
+ prunedCount += group.length;
142
+ }
143
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_missing', pruned });
144
+ deadCount += group.length;
145
+ continue;
146
+ }
147
+
148
+ if (isDiscardableNativeOwnedTabUrl(live.url)) {
149
+ deadCount += group.length;
150
+ if (input.pruneDead === true) {
151
+ try {
152
+ await closeMacOsBrowserOwnedTab(product, { windowId: tab.windowId!, tabId: tab.tabId! }, timeoutMs);
153
+ for (const session of group) removeBrowserSession(repoRoot, session.sessionId);
154
+ prunedCount += group.length;
155
+ closedInvalidCount += 1;
156
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_closed', pruned: true });
157
+ } catch (error) {
158
+ failedCleanupCount += 1;
159
+ const cleanupError = error instanceof Error ? error.message : String(error);
160
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid_close_failed', cleanupError });
161
+ }
162
+ } else {
163
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'dead', evidence: 'native_tab_invalid' });
164
+ }
165
+ continue;
166
+ }
167
+
168
+ for (const session of group) items.set(session.sessionId, { session, liveness: 'live', evidence: 'native_tab_live' });
169
+ liveCount += group.length;
170
+ }
171
+
172
+ return { items, liveCount, deadCount, unverifiedCount, prunedCount, closedInvalidCount, failedCleanupCount };
173
+ }
174
+
175
+ export async function closeTrackedNativeOwnedSession(
176
+ session: BrowserSessionState,
177
+ timeoutMs: number,
178
+ ): Promise<{ resourceClosed: boolean; resourceAlreadyMissing: boolean }> {
179
+ const browser = session.browser;
180
+ const tab = browser?.tab;
181
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned') {
182
+ return { resourceClosed: false, resourceAlreadyMissing: false };
183
+ }
184
+ if (!browser.browserProduct || !tab.windowId || !tab.tabId) {
185
+ throw new AssistantPluginError('PLUGIN_BROWSER_NATIVE_OWNERSHIP_MISSING', 'Tracked plugin-owned native tab is missing stable browser identity; retaining session metadata instead of orphaning the tab.', {
186
+ retryable: false, details: { sessionId: session.sessionId, browserProduct: browser.browserProduct, windowId: tab.windowId, tabId: tab.tabId },
187
+ });
188
+ }
189
+ const ref = { windowId: tab.windowId, tabId: tab.tabId };
190
+ let inventory: Awaited<ReturnType<typeof listMacOsBrowserTabs>> | undefined;
191
+ try {
192
+ inventory = await listMacOsBrowserTabs(browser.browserProduct, timeoutMs);
193
+ } catch (error) {
194
+ if (!nativeTabInventoryUnsupported(error)) throw error;
195
+ try {
196
+ await readMacOsBrowserOwnedTabMetadata(browser.browserProduct, ref, timeoutMs);
197
+ } catch (metadataError) {
198
+ if (macOsBrowserPageHandleStale(metadataError)) return { resourceClosed: false, resourceAlreadyMissing: true };
199
+ throw metadataError;
200
+ }
201
+ }
202
+ if (inventory && !inventory.tabs.some((entry) => entry.windowId === tab.windowId && entry.tabId === tab.tabId)) {
203
+ return { resourceClosed: false, resourceAlreadyMissing: true };
204
+ }
205
+ await closeMacOsBrowserOwnedTab(browser.browserProduct, ref, timeoutMs);
206
+ return { resourceClosed: true, resourceAlreadyMissing: false };
207
+ }
208
+
209
+ export function nativeOwnedAliasSessionIds(repoRoot: string, session: BrowserSessionState): string[] {
210
+ const browser = session.browser;
211
+ const tab = browser?.tab;
212
+ if (browser?.provider !== 'macos-apple-events' || tab?.ownership !== 'plugin_owned' || !browser.browserProduct || !tab.windowId || !tab.tabId) {
213
+ return [session.sessionId];
214
+ }
215
+ return listSavedBrowserSessions(repoRoot)
216
+ .filter((candidate) => candidate.browser?.provider === 'macos-apple-events'
217
+ && candidate.browser.browserProduct === browser.browserProduct
218
+ && candidate.browser.tab?.ownership === 'plugin_owned'
219
+ && candidate.browser.tab.windowId === tab.windowId
220
+ && candidate.browser.tab.tabId === tab.tabId)
221
+ .map((candidate) => candidate.sessionId);
222
+ }
@@ -1,3 +1,5 @@
1
+ export const MAX_BROWSER_CDP_ENDPOINT_CANDIDATES = 5;
2
+
1
3
  export type BrowserProviderCapability =
2
4
  | 'dom.read'
3
5
  | 'dom.interact'
@@ -85,6 +85,16 @@ export function browserRuntimeActionPolicy(actionId: string): BrowserRuntimeActi
85
85
  };
86
86
  }
87
87
 
88
+
89
+ /**
90
+ * Post-dispatch replay is a semantic BrowserRuntime decision. Only read-only
91
+ * actions fenced to an already-existing resource may be replayed after a
92
+ * transient provider failure; mutations retain unknown-outcome semantics.
93
+ */
94
+ export function browserActionCanReplayAfterDispatch(actionId: string, requireExistingResource = true): boolean {
95
+ return requireExistingResource && browserRuntimeActionPolicy(actionId).replaySafety === 'read_only';
96
+ }
97
+
88
98
  function stringArgument(args: Record<string, unknown>, key: string): string | undefined {
89
99
  const value = args[key];
90
100
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
@@ -0,0 +1,121 @@
1
+ import { readdirSync, readFileSync, rmSync } from 'fs';
2
+ import { basename, join } from 'path';
3
+ import { writeJsonAtomic } from '../shared/json-files';
4
+ import { AssistantPluginError } from './errors';
5
+ import {
6
+ currentBrowserSessionAuthorityContext,
7
+ findBrowserSession as findBrowserSessionAuthority,
8
+ listAllBrowserSessionsForRepository,
9
+ saveBrowserSession as saveBrowserSessionAuthority,
10
+ tombstoneBrowserSession,
11
+ } from './browser-session-authority';
12
+
13
+ const BROWSER_STATE_ROOT = '.forge/browser';
14
+
15
+ import type { BrowserSessionState } from './browser-session-types';
16
+
17
+ /**
18
+ * Repository-local browser paths remain compatibility storage only. During
19
+ * Runtime/plugin execution, BrowserSessionAuthority (SQLite) is the sole writer;
20
+ * the JSON files are read/written only when no Controller authority context is
21
+ * present (for legacy migration/tests/standalone helpers).
22
+ */
23
+ export function browserStateDir(
24
+ repoRoot: string,
25
+ name: 'sessions' | 'screenshots' | 'profiles' | 'downloads' | 'diagnostics',
26
+ ): string {
27
+ return join(repoRoot, BROWSER_STATE_ROOT, name);
28
+ }
29
+
30
+ function sessionPath(repoRoot: string, sessionId: string): string {
31
+ return join(browserStateDir(repoRoot, 'sessions'), `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
32
+ }
33
+
34
+ function readLegacyBrowserSessionJson(path: string): BrowserSessionState | undefined {
35
+ let raw: string;
36
+ try {
37
+ raw = readFileSync(path, 'utf-8');
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined;
40
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session metadata could not be read.', {
41
+ retryable: true,
42
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
43
+ });
44
+ }
45
+ try {
46
+ return JSON.parse(raw) as BrowserSessionState;
47
+ } catch (error) {
48
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_CORRUPT', 'Saved browser session metadata is malformed; refusing to treat corrupt state as a missing session.', {
49
+ retryable: false,
50
+ details: { fileName: basename(path), cause: error instanceof Error ? error.message : String(error) },
51
+ });
52
+ }
53
+ }
54
+
55
+ export function saveBrowserSession(repoRoot: string, session: BrowserSessionState): BrowserSessionState {
56
+ const authority = currentBrowserSessionAuthorityContext();
57
+ if (!authority) {
58
+ writeJsonAtomic(sessionPath(repoRoot, session.sessionId), session);
59
+ return session;
60
+ }
61
+ const saved = saveBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, session);
62
+ if (saved.sessionId === session.sessionId || !saved.browser?.sessionResume) return saved;
63
+ return {
64
+ ...saved,
65
+ browser: {
66
+ ...saved.browser,
67
+ sessionResume: { ...saved.browser.sessionResume, sessionId: saved.sessionId },
68
+ },
69
+ };
70
+ }
71
+
72
+ export function findBrowserSession(repoRoot: string, sessionId?: string): BrowserSessionState | undefined {
73
+ if (!sessionId) return undefined;
74
+ const authority = currentBrowserSessionAuthorityContext();
75
+ return authority
76
+ ? findBrowserSessionAuthority<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot, sessionId)
77
+ : readLegacyBrowserSessionJson(sessionPath(repoRoot, sessionId));
78
+ }
79
+
80
+ export function listSavedBrowserSessions(repoRoot: string): BrowserSessionState[] {
81
+ const authority = currentBrowserSessionAuthorityContext();
82
+ if (authority) {
83
+ return listAllBrowserSessionsForRepository<BrowserSessionState>(authority.controllerHome, authority.repoId, repoRoot);
84
+ }
85
+ const root = browserStateDir(repoRoot, 'sessions');
86
+ let names: string[];
87
+ try {
88
+ names = readdirSync(root);
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
91
+ throw new AssistantPluginError('PLUGIN_BROWSER_SESSION_STATE_READ_FAILED', 'Saved browser session directory could not be read.', {
92
+ retryable: true,
93
+ details: { cause: error instanceof Error ? error.message : String(error) },
94
+ });
95
+ }
96
+ return names
97
+ .filter((name) => name.endsWith('.json'))
98
+ .map((name) => readLegacyBrowserSessionJson(join(root, name)))
99
+ .filter((session): session is BrowserSessionState => Boolean(session));
100
+ }
101
+
102
+ export function removeBrowserSession(repoRoot: string, sessionId: string): void {
103
+ const authority = currentBrowserSessionAuthorityContext();
104
+ if (authority) {
105
+ tombstoneBrowserSession(authority.controllerHome, authority.repoId, repoRoot, sessionId);
106
+ return;
107
+ }
108
+ rmSync(sessionPath(repoRoot, sessionId), { force: true });
109
+ }
110
+
111
+ export function loadBrowserSession(repoRoot: string, sessionId?: string): BrowserSessionState | undefined {
112
+ return sessionId ? findBrowserSession(repoRoot, sessionId) : undefined;
113
+ }
114
+
115
+ export function requireBrowserSession(repoRoot: string, sessionId: string): BrowserSessionState {
116
+ const session = findBrowserSession(repoRoot, sessionId);
117
+ if (!session) {
118
+ throw new AssistantPluginError('PLUGIN_SESSION_NOT_FOUND', `Browser session not found: ${sessionId}`, { retryable: false });
119
+ }
120
+ return session;
121
+ }
@@ -0,0 +1,98 @@
1
+ export type BrowserMode = 'attach_preferred' | 'managed_persistent' | 'isolated';
2
+ export type BrowserProfileMode = 'repo_local' | 'custom';
3
+ export type BrowserCdpAttachFallback = 'managed_persistent' | 'fail_closed';
4
+ export type BrowserNativeProduct = 'chrome' | 'vivaldi';
5
+ export type BrowserTabMatchReason = 'owned_token' | 'recovered_tab' | 'saved_url_title' | 'saved_url' | 'exact_url' | 'blank' | 'new_page';
6
+
7
+ export interface CdpAttachAttempt {
8
+ endpoint: string;
9
+ discoveredEndpoint?: string;
10
+ probeUrl?: string;
11
+ browserVersion?: string;
12
+ error?: string;
13
+ }
14
+
15
+ export interface BrowserNativeAttachAttempt {
16
+ product: BrowserNativeProduct;
17
+ appName: string;
18
+ bundleId: string;
19
+ status: 'selected' | 'available' | 'not_installed' | 'not_running' | 'unavailable';
20
+ frontmost?: boolean;
21
+ error?: string;
22
+ }
23
+
24
+ export interface BrowserSessionResumeDiagnostic {
25
+ sessionId: string;
26
+ status: 'matched' | 'stale_tab' | 'no_saved_tab';
27
+ reason: string;
28
+ savedTab?: Pick<BrowserTabResumeState, 'key' | 'url' | 'title' | 'index'>;
29
+ }
30
+
31
+ export interface BrowserConnectionFallback {
32
+ policy: BrowserCdpAttachFallback;
33
+ from: 'attach_preferred';
34
+ to: 'managed_persistent';
35
+ reason: string;
36
+ attempts: CdpAttachAttempt[];
37
+ nativeAttempts?: BrowserNativeAttachAttempt[];
38
+ }
39
+
40
+ export interface BrowserTabResumeState {
41
+ key: string;
42
+ index: number;
43
+ url: string;
44
+ title?: string;
45
+ matchedBy: BrowserTabMatchReason;
46
+ inventoryCount: number;
47
+ capturedAt: string;
48
+ ownership?: 'plugin_owned' | 'user_owned';
49
+ ownerToken?: string;
50
+ windowId?: string;
51
+ tabId?: string;
52
+ }
53
+
54
+ export interface BrowserSessionConnectionState {
55
+ mode: BrowserMode;
56
+ activeMode: BrowserMode;
57
+ provider: 'playwright-cdp' | 'playwright-persistent-context' | 'macos-apple-events';
58
+ endpoint?: string;
59
+ browserVersion?: string;
60
+ browserProduct?: BrowserNativeProduct;
61
+ nativeBrowserCandidates?: BrowserNativeProduct[];
62
+ fallback?: BrowserConnectionFallback;
63
+ tab?: BrowserTabResumeState;
64
+ sessionResume?: BrowserSessionResumeDiagnostic;
65
+ }
66
+
67
+ export interface BrowserSessionState {
68
+ schemaVersion: 1;
69
+ sessionId: string;
70
+ url: string;
71
+ title?: string;
72
+ createdAt: string;
73
+ updatedAt: string;
74
+ browser?: BrowserSessionConnectionState;
75
+ }
76
+
77
+ export type BrowserSessionLiveness = 'live' | 'unverified' | 'dead';
78
+ export type BrowserSessionLivenessEvidence =
79
+ | 'runtime_session_binding'
80
+ | 'runtime_owner_token'
81
+ | 'runtime_bound_page_missing'
82
+ | 'runtime_context_unavailable'
83
+ | 'runtime_owner_token_not_observed'
84
+ | 'provider_unverified'
85
+ | 'native_tab_live'
86
+ | 'native_tab_missing'
87
+ | 'native_tab_invalid'
88
+ | 'native_tab_invalid_closed'
89
+ | 'native_tab_invalid_close_failed'
90
+ | 'native_inventory_unavailable';
91
+
92
+ export interface BrowserSessionInventoryItem {
93
+ session: BrowserSessionState;
94
+ liveness: BrowserSessionLiveness;
95
+ evidence: BrowserSessionLivenessEvidence;
96
+ pruned?: boolean;
97
+ cleanupError?: string;
98
+ }
@@ -119,7 +119,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
119
119
  {
120
120
  actionId: 'desktop_press',
121
121
  title: 'Press desktop element',
122
- description: 'Press one Accessibility element semantically in the background; pointer/coordinate fallback is intentionally unavailable.',
122
+ description: 'Perform one bounded Accessibility semantic action in the background, including one-page list scrolling; pointer/coordinate and raw input fallback are intentionally unavailable.',
123
123
  readOnly: false,
124
124
  risk: 'workspace_write',
125
125
  confirmation: 'authorization',
@@ -135,8 +135,8 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
135
135
  selector: SELECTOR_SCHEMA,
136
136
  semantic_action: {
137
137
  type: 'string',
138
- enum: ['press', 'show_menu', 'pick', 'open', 'confirm'],
139
- description: 'Bounded Accessibility action supported by the trusted Desktop Operator provider; defaults to press when omitted.',
138
+ enum: ['press', 'show_menu', 'pick', 'open', 'confirm', 'scroll_down_page', 'scroll_up_page'],
139
+ description: 'Bounded Accessibility action supported by the trusted Desktop Operator provider; scroll actions move exactly one page and default to press when omitted.',
140
140
  },
141
141
  },
142
142
  required: ['interaction_id', 'selector'],
@@ -170,7 +170,7 @@ function desktopOperatorActions(): AssistantPluginActionDescriptor[] {
170
170
  {
171
171
  actionId: 'desktop_key',
172
172
  title: 'Press desktop keys',
173
- description: 'Send one bounded key chord only when the explicit target session is already foreground; the operator never activates it.',
173
+ description: 'Send one bounded key chord to the exact target session after Forge independently re-establishes and verifies that application as system-frontmost at the action boundary.',
174
174
  readOnly: false,
175
175
  risk: 'workspace_write',
176
176
  confirmation: 'authorization',