@moretea-labs/forge 1.5.1 → 1.6.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/DESIGN.md +7 -2
  3. package/README.md +4 -4
  4. package/README.zh-CN.md +2 -2
  5. package/THIRD_PARTY_NOTICES.md +1 -1
  6. package/assets/hooks/pre-edit-guard.sh +8 -39
  7. package/assets/hooks/prompt-guard.sh +2 -2
  8. package/assets/plugin-registry.v1.json +54 -3
  9. package/assets/reference-configs/hook-operations.md +1 -1
  10. package/assets/reference-configs/sprint-contracts.md +2 -2
  11. package/assets/skill-version.json +2 -2
  12. package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
  13. package/assets/templates/helpers/sprint-backlog.sh +6 -6
  14. package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
  15. package/docs/architecture/current/codegraph-context-provider.md +15 -3
  16. package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
  17. package/docs/architecture/current/entity-model.md +20 -0
  18. package/docs/architecture/current/external-plugin-governance.md +3 -3
  19. package/docs/architecture/current/system-overview.md +26 -1
  20. package/docs/architecture/current/thin-harness-v1.md +10 -0
  21. package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
  22. package/docs/operations/controller-browser-plugin.md +12 -16
  23. package/docs/operations/features.md +1 -1
  24. package/docs/operations/homebrew.md +2 -2
  25. package/docs/tutorials/01-install-and-start.md +1 -1
  26. package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
  27. package/docs/wiki/Installation.md +1 -1
  28. package/docs/wiki/Quick-Start.md +1 -1
  29. package/package.json +1 -1
  30. package/scripts/check-public-docs.sh +14 -1
  31. package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
  32. package/scripts/sprint-backlog.sh +1 -1
  33. package/src/cli/commands/chatgpt.ts +31 -2
  34. package/src/cli/commands/plugin.ts +36 -9
  35. package/src/cli/controller/context-pack.ts +158 -6
  36. package/src/cli/hook/prompt-guard-decision.ts +17 -18
  37. package/src/cli/hook/prompt-intents.ts +1 -1
  38. package/src/cli/local-bridge/console-automations.ts +205 -23
  39. package/src/cli/local-bridge/console-view-models.ts +2 -0
  40. package/src/cli/local-bridge/facade-api.ts +2 -0
  41. package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
  42. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
  43. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
  44. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
  45. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
  46. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
  47. package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
  48. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
  49. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
  50. package/src/cli/local-bridge/frontend/src/styles.css +27 -11
  51. package/src/cli/local-bridge/frontend/src/types.ts +6 -2
  52. package/src/cli/local-bridge/server.ts +29 -19
  53. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  54. package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
  55. package/src/cli/mcp/paths.ts +8 -2
  56. package/src/cli/mcp/repository-tools.ts +124 -9
  57. package/src/cli/mcp/server.ts +164 -19
  58. package/src/cli/mcp/transports/http.ts +25 -2
  59. package/src/cli/mcp/transports/session-registry.ts +24 -0
  60. package/src/cli/repositories/command-classifier.ts +113 -0
  61. package/src/cli/repositories/safe-patch.ts +6 -4
  62. package/src/cli/repositories/structured-git.ts +49 -7
  63. package/src/cli/repository/inspector.ts +16 -3
  64. package/src/runtime/assistant/intent.ts +3 -3
  65. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
  66. package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
  67. package/src/runtime/control-plane/execution/validation.ts +15 -2
  68. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
  69. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
  70. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
  71. package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
  72. package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
  73. package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
  74. package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
  75. package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
  76. package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
  77. package/src/runtime/control-plane/facade/types.ts +33 -6
  78. package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
  79. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
  80. package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
  81. package/src/runtime/diagnostics/mcp-timing.ts +6 -0
  82. package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
  83. package/src/runtime/execution/process-runtime/check-result.ts +10 -2
  84. package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
  85. package/src/runtime/execution/process-runtime/gc.ts +64 -10
  86. package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
  87. package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
  88. package/src/runtime/execution/process-runtime/runtime.ts +16 -1
  89. package/src/runtime/execution/process-runtime/store.ts +6 -0
  90. package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
  91. package/src/runtime/gateway/mcp/router.ts +1 -1
  92. package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
  93. package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
  94. package/src/runtime/plugins/browser-adapter.ts +19 -72
  95. package/src/runtime/plugins/browser-handoff-host.ts +6 -31
  96. package/src/runtime/plugins/browser-handoff.ts +0 -3
  97. package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
  98. package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
  99. package/src/runtime/plugins/external-registration.ts +84 -5
  100. package/src/runtime/plugins/first-party-registry.ts +2 -2
  101. package/src/runtime/plugins/gmail-adapter.ts +28 -2
  102. package/src/runtime/plugins/local-system-adapter.ts +2 -2
  103. package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
  104. package/src/runtime/plugins/store.ts +107 -5
  105. package/src/runtime/plugins/types.ts +4 -0
  106. package/src/runtime/recovery/actions.ts +0 -10
  107. package/src/runtime/recovery/classifier.ts +0 -9
  108. package/src/runtime/recovery/maintenance-executor.ts +43 -11
  109. package/src/runtime/recovery/probe.ts +0 -1
  110. package/src/runtime/recovery/types.ts +0 -1
  111. package/src/runtime/root/release-materialize.ts +143 -19
  112. package/src/runtime/root/service.ts +6 -4
  113. package/src/runtime/safe-tooling/index.ts +0 -1
  114. package/src/runtime/safe-tooling/types.ts +0 -30
  115. package/src/runtime/standalone-recovery/core.ts +2 -2
  116. package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
  117. package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
  118. package/src/runtime/workflow/schedules/engine.ts +357 -64
  119. package/src/runtime/workflow/schedules/types.ts +5 -0
  120. package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
  121. package/bin/forge-desktop-helper.mjs +0 -204
  122. package/docs/operations/controller-desktop-plugin.md +0 -112
  123. package/src/runtime/plugins/desktop-adapter.ts +0 -374
  124. package/src/runtime/safe-tooling/browser-safe.ts +0 -133
@@ -19,9 +19,11 @@ import type {
19
19
  } from './types';
20
20
 
21
21
  export type ContinuationControllerType = 'chatgpt' | 'codex' | 'claude' | 'grok';
22
+ export type WorkScheduleMode = 'continuation' | 'browser_watch' | 'browser_keepalive';
22
23
 
23
24
  export interface WorkContinuationScheduleInput {
24
25
  workId: string;
26
+ scheduleMode?: WorkScheduleMode;
25
27
  controllerType?: ContinuationControllerType;
26
28
  executable?: string;
27
29
  launchArgs?: string[];
@@ -30,6 +32,18 @@ export interface WorkContinuationScheduleInput {
30
32
  browserSessionId?: string;
31
33
  conversationUrl?: string;
32
34
  continuationPrompt?: string;
35
+ probeUrl?: string;
36
+ probeBrowserSessionId?: string;
37
+ probeSelector?: string;
38
+ probeMaxChars?: number;
39
+ probeTimeoutMs?: number;
40
+ includeTerms?: string[];
41
+ ignorePatterns?: string[];
42
+ loginUrlTerms?: string[];
43
+ loginTextTerms?: string[];
44
+ wakeOnFirstObservation?: boolean;
45
+ wakeOnAuthRequired?: boolean;
46
+ authRequiredPrompt?: string;
33
47
  scheduleName?: string;
34
48
  requestId?: string;
35
49
  triggerType?: ScheduleTriggerType;
@@ -70,7 +84,7 @@ function activeWork(controllerHome: string, repoId: string, workId: string): Wor
70
84
  }
71
85
 
72
86
  function continuationSchedule(schedule: RepositorySchedule): RepositorySchedule {
73
- if (schedule.action.operation !== 'external_controller_wake') {
87
+ if (!['external_controller_wake', 'browser_probe'].includes(schedule.action.operation)) {
74
88
  throw new Error(`SCHEDULE_NOT_WORK_CONTINUATION: ${schedule.scheduleId}`);
75
89
  }
76
90
  return schedule;
@@ -105,6 +119,29 @@ function wakeArguments(input: WorkContinuationScheduleInput, controllerType: Con
105
119
  };
106
120
  }
107
121
 
122
+ function probeArguments(input: WorkContinuationScheduleInput, controllerType: ContinuationControllerType, keepaliveOnly: boolean): Record<string, unknown> {
123
+ if (!input.probeUrl?.trim() && !input.probeBrowserSessionId?.trim()) {
124
+ throw new Error('SCHEDULE_BROWSER_PROBE_TARGET_REQUIRED');
125
+ }
126
+ return {
127
+ ...wakeArguments(input, controllerType),
128
+ ...(input.probeUrl?.trim() ? { probe_url: input.probeUrl.trim() } : {}),
129
+ ...(input.probeBrowserSessionId?.trim() ? { probe_session_id: input.probeBrowserSessionId.trim() } : {}),
130
+ ...(input.probeSelector?.trim() ? { selector: input.probeSelector.trim() } : {}),
131
+ ...(input.probeMaxChars !== undefined ? { max_chars: Math.max(256, Math.min(Math.trunc(input.probeMaxChars), 100_000)) } : {}),
132
+ ...(input.probeTimeoutMs !== undefined ? { timeout_ms: Math.max(1_000, Math.min(Math.trunc(input.probeTimeoutMs), 120_000)) } : {}),
133
+ ...(input.includeTerms ? { include_terms: input.includeTerms.map(String).filter(Boolean) } : {}),
134
+ ...(input.ignorePatterns ? { ignore_patterns: input.ignorePatterns.map(String).filter(Boolean) } : {}),
135
+ ...(input.loginUrlTerms ? { login_url_terms: input.loginUrlTerms.map(String).filter(Boolean) } : {}),
136
+ ...(input.loginTextTerms ? { login_text_terms: input.loginTextTerms.map(String).filter(Boolean) } : {}),
137
+ ...(input.wakeOnFirstObservation === true ? { wake_on_first_observation: true } : {}),
138
+ ...(input.wakeOnAuthRequired === false ? { wake_on_auth_required: false } : {}),
139
+ ...(input.authRequiredPrompt?.trim() ? { auth_required_prompt: input.authRequiredPrompt.trim() } : {}),
140
+ keepalive_only: keepaliveOnly,
141
+ wake_on_change: !keepaliveOnly,
142
+ };
143
+ }
144
+
108
145
  export function createWorkContinuationSchedule(
109
146
  controllerHome: string,
110
147
  repoId: string,
@@ -112,26 +149,70 @@ export function createWorkContinuationSchedule(
112
149
  ): { schedule: RepositorySchedule; work: WorkContract } {
113
150
  const work = activeWork(controllerHome, repoId, input.workId);
114
151
  const controllerType = input.controllerType ?? 'chatgpt';
152
+ const scheduleMode = input.scheduleMode ?? 'continuation';
115
153
  const trigger = normalizedTrigger(input);
116
- const actionArguments = wakeArguments(input, controllerType);
117
- assertAutomatedOperationAllowed('external_controller_wake', actionArguments);
118
- const name = input.scheduleName?.trim() || `Continue Work ${work.workId}`;
154
+ const operation = scheduleMode === 'continuation' ? 'external_controller_wake' : 'browser_probe';
155
+ const actionArguments = scheduleMode === 'continuation'
156
+ ? wakeArguments(input, controllerType)
157
+ : probeArguments(input, controllerType, scheduleMode === 'browser_keepalive');
158
+ assertAutomatedOperationAllowed(operation, actionArguments);
159
+ const name = input.scheduleName?.trim()
160
+ || (scheduleMode === 'browser_watch' ? `Watch external state for Work ${work.workId}`
161
+ : scheduleMode === 'browser_keepalive' ? `Keep browser session alive for Work ${work.workId}`
162
+ : `Continue Work ${work.workId}`);
163
+ const policy = {
164
+ maxActiveOccurrences: 1,
165
+ maxFailures: input.maxFailures !== undefined ? Math.max(1, Math.trunc(input.maxFailures)) : 3,
166
+ cooldownMinutes: input.cooldownMinutes !== undefined ? Math.max(0, Math.trunc(input.cooldownMinutes)) : 120,
167
+ dailyBudgetMinutes: input.dailyBudgetMinutes !== undefined ? Math.max(1, Math.trunc(input.dailyBudgetMinutes)) : 180,
168
+ shadowMode: input.shadowMode !== false,
169
+ backoffBaseMinutes: input.backoffBaseMinutes !== undefined ? Math.max(1, Math.trunc(input.backoffBaseMinutes)) : 5,
170
+ backoffMaxMinutes: input.backoffMaxMinutes !== undefined ? Math.max(1, Math.trunc(input.backoffMaxMinutes)) : 24 * 60,
171
+ };
172
+ const stopConditions = input.stopConditions ?? ['work_terminal', 'human_review_required', 'external_blocker'];
173
+
174
+ // A durable Work has one authoritative continuation lane. Changing cadence,
175
+ // prompt, controller, or policy updates that lane instead of minting another
176
+ // Workflow. Browser watches/keepalives are intentionally excluded because one
177
+ // Work may legitimately observe several independent external targets.
178
+ if (scheduleMode === 'continuation') {
179
+ const existing = listSchedules(controllerHome, repoId)
180
+ .filter((candidate) => candidate.action.operation === 'external_controller_wake')
181
+ .filter((candidate) => candidate.action.arguments?.work_id === work.workId);
182
+ const authoritative = [...existing].reverse().find((candidate) => candidate.enabled) ?? existing.at(-1);
183
+ if (authoritative) {
184
+ const schedule = saveSchedule(controllerHome, {
185
+ ...authoritative,
186
+ name,
187
+ enabled: true,
188
+ trigger,
189
+ policy,
190
+ action: { operation, target: 'runtime', arguments: actionArguments, resourceClaims: [] },
191
+ stopConditions,
192
+ pausedReason: undefined,
193
+ });
194
+ for (const duplicate of existing) {
195
+ if (duplicate.scheduleId === schedule.scheduleId || !duplicate.enabled) continue;
196
+ saveSchedule(controllerHome, {
197
+ ...duplicate,
198
+ enabled: false,
199
+ pausedReason: `Superseded by authoritative continuation ${schedule.scheduleId} for Work ${work.workId}.`,
200
+ });
201
+ }
202
+ return { schedule, work };
203
+ }
204
+ }
205
+
119
206
  const semantic = JSON.stringify({
120
207
  repoId,
121
208
  workId: work.workId,
122
209
  controllerType,
210
+ scheduleMode,
123
211
  trigger,
124
212
  name,
125
213
  actionArguments,
126
- policy: {
127
- maxFailures: input.maxFailures,
128
- cooldownMinutes: input.cooldownMinutes,
129
- dailyBudgetMinutes: input.dailyBudgetMinutes,
130
- shadowMode: input.shadowMode,
131
- backoffBaseMinutes: input.backoffBaseMinutes,
132
- backoffMaxMinutes: input.backoffMaxMinutes,
133
- },
134
- stopConditions: input.stopConditions,
214
+ policy,
215
+ stopConditions,
135
216
  });
136
217
  const requestId = input.requestId?.trim()
137
218
  || `work-continuation:${repoId}:${work.workId}:${createHash('sha256').update(semantic).digest('hex').slice(0, 16)}`;
@@ -141,19 +222,11 @@ export function createWorkContinuationSchedule(
141
222
  name,
142
223
  enabled: true,
143
224
  trigger,
144
- policy: {
145
- maxActiveOccurrences: 1,
146
- maxFailures: input.maxFailures !== undefined ? Math.max(1, Math.trunc(input.maxFailures)) : 3,
147
- cooldownMinutes: input.cooldownMinutes !== undefined ? Math.max(0, Math.trunc(input.cooldownMinutes)) : 120,
148
- dailyBudgetMinutes: input.dailyBudgetMinutes !== undefined ? Math.max(1, Math.trunc(input.dailyBudgetMinutes)) : 180,
149
- shadowMode: input.shadowMode !== false,
150
- backoffBaseMinutes: input.backoffBaseMinutes !== undefined ? Math.max(1, Math.trunc(input.backoffBaseMinutes)) : 5,
151
- backoffMaxMinutes: input.backoffMaxMinutes !== undefined ? Math.max(1, Math.trunc(input.backoffMaxMinutes)) : 24 * 60,
152
- },
153
- // The schedule only wakes a Controller. The Controller acquires Work/resource
154
- // ownership separately, so the wake itself must not claim repository writes.
155
- action: { operation: 'external_controller_wake', target: 'runtime', arguments: actionArguments, resourceClaims: [] },
156
- stopConditions: input.stopConditions ?? ['work_terminal', 'human_review_required', 'external_blocker'],
225
+ policy,
226
+ // Work-bound schedules only perform a deterministic wake or a bounded read-only
227
+ // browser probe. Controller/Work write ownership is acquired separately.
228
+ action: { operation, target: 'runtime', arguments: actionArguments, resourceClaims: [] },
229
+ stopConditions,
157
230
  });
158
231
  return { schedule, work };
159
232
  }
@@ -164,7 +237,7 @@ export function listWorkContinuationSchedules(
164
237
  options: WorkContinuationScheduleListOptions = {},
165
238
  ): { schedules: RepositorySchedule[]; occurrences?: ScheduleOccurrence[] } {
166
239
  const schedules = listSchedules(controllerHome, repoId)
167
- .filter((entry) => entry.action.operation === 'external_controller_wake')
240
+ .filter((entry) => ['external_controller_wake', 'browser_probe'].includes(entry.action.operation))
168
241
  .filter((entry) => !options.workId || entry.action.arguments?.work_id === options.workId);
169
242
  if (!options.includeOccurrences) return { schedules };
170
243
  const scheduleIds = new Set(schedules.map((entry) => entry.scheduleId));
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env node
2
- import { execFile } from 'child_process';
3
- import { existsSync } from 'fs';
4
- import { createInterface } from 'readline';
5
-
6
- const SCHEMA_VERSION = 1;
7
- const PLUGIN_ID = 'desktop';
8
- const HELPER_VERSION = '1.0.0';
9
- const MAX_LINE_BYTES = 1_048_576;
10
- const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
11
- const OSASCRIPT = '/usr/bin/osascript';
12
- const OPEN = '/usr/bin/open';
13
- const CAPABILITIES = ['status', 'observe', 'open_application'];
14
-
15
- function write(value) {
16
- process.stdout.write(`${JSON.stringify(value)}\n`);
17
- }
18
-
19
- function bounded(value, max = 2_000) {
20
- const source = String(value ?? '').replace(/(token|secret|password|authorization)\s*[=:]\s*\S+/gi, '$1=[REDACTED]');
21
- return source.length > max ? source.slice(-max) : source;
22
- }
23
-
24
- function failure(requestId, code, message, retryable = false, details) {
25
- write({
26
- schemaVersion: SCHEMA_VERSION,
27
- type: 'result',
28
- requestId,
29
- ok: false,
30
- error: { code, message, retryable, ...(details ? { details } : {}) },
31
- });
32
- }
33
-
34
- function success(requestId, result) {
35
- write({ schemaVersion: SCHEMA_VERSION, type: 'result', requestId, ok: true, result });
36
- }
37
-
38
- function run(command, args, timeoutMs = 10_000) {
39
- return new Promise((resolve) => {
40
- execFile(command, args, {
41
- encoding: 'utf8',
42
- timeout: timeoutMs,
43
- maxBuffer: MAX_COMMAND_OUTPUT_BYTES,
44
- windowsHide: true,
45
- env: {
46
- PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
47
- HOME: process.env.HOME,
48
- TMPDIR: process.env.TMPDIR,
49
- LANG: process.env.LANG,
50
- LC_ALL: process.env.LC_ALL,
51
- },
52
- }, (error, stdout, stderr) => {
53
- resolve({
54
- ok: !error,
55
- code: typeof error?.code === 'number' ? error.code : null,
56
- stdout: String(stdout ?? ''),
57
- stderr: bounded(stderr || error?.message || ''),
58
- });
59
- });
60
- });
61
- }
62
-
63
- function permissionDiagnostics() {
64
- return {
65
- accessibility: {
66
- state: 'not_probed',
67
- reason: 'The bundled helper does not trigger macOS consent prompts during status or observation.',
68
- },
69
- screenRecording: {
70
- state: 'not_required',
71
- reason: 'This version does not capture screenshots or screen pixels.',
72
- },
73
- };
74
- }
75
-
76
- async function status() {
77
- return {
78
- platform: process.platform,
79
- helperVersion: HELPER_VERSION,
80
- protocolVersion: SCHEMA_VERSION,
81
- managed: true,
82
- transport: 'stdio-jsonl',
83
- capabilities: CAPABILITIES,
84
- commands: {
85
- openAvailable: existsSync(OPEN),
86
- frontmostApplicationProbeAvailable: existsSync(OSASCRIPT),
87
- },
88
- permissions: permissionDiagnostics(),
89
- };
90
- }
91
-
92
- async function observe() {
93
- if (process.platform !== 'darwin') {
94
- throw Object.assign(new Error('Desktop observation currently supports macOS only.'), { code: 'PLUGIN_DESKTOP_PLATFORM_UNSUPPORTED' });
95
- }
96
- if (!existsSync(OSASCRIPT)) {
97
- return {
98
- observed: false,
99
- frontmostApplication: null,
100
- permissions: permissionDiagnostics(),
101
- warnings: ['The macOS NSWorkspace inspection bridge is unavailable.'],
102
- };
103
- }
104
- const script = "ObjC.import('AppKit'); const app=$.NSWorkspace.sharedWorkspace.frontmostApplication; JSON.stringify({name:ObjC.unwrap(app.localizedName),bundleId:ObjC.unwrap(app.bundleIdentifier),pid:Number(app.processIdentifier)})";
105
- const inspected = await run(OSASCRIPT, ['-l', 'JavaScript', '-e', script], 5_000);
106
- if (!inspected.ok || !inspected.stdout.trim()) {
107
- return {
108
- observed: false,
109
- frontmostApplication: null,
110
- permissions: permissionDiagnostics(),
111
- warnings: [bounded(inspected.stderr || 'Unable to resolve the frontmost application.')],
112
- };
113
- }
114
- try {
115
- const app = JSON.parse(inspected.stdout.trim());
116
- return {
117
- observed: true,
118
- frontmostApplication: {
119
- name: typeof app.name === 'string' ? app.name : null,
120
- bundleId: typeof app.bundleId === 'string' ? app.bundleId : null,
121
- pid: Number.isInteger(app.pid) && app.pid > 0 ? app.pid : null,
122
- },
123
- permissions: permissionDiagnostics(),
124
- warnings: [],
125
- };
126
- } catch {
127
- return {
128
- observed: false,
129
- frontmostApplication: null,
130
- permissions: permissionDiagnostics(),
131
- warnings: ['The macOS NSWorkspace bridge returned malformed application metadata.'],
132
- };
133
- }
134
- }
135
-
136
- async function openApplication(input) {
137
- if (process.platform !== 'darwin') {
138
- throw Object.assign(new Error('Application opening currently supports macOS only.'), { code: 'PLUGIN_DESKTOP_PLATFORM_UNSUPPORTED' });
139
- }
140
- const appName = typeof input.app_name === 'string' ? input.app_name.trim() : '';
141
- const bundleId = typeof input.bundle_id === 'string' ? input.bundle_id.trim() : '';
142
- if ((!appName && !bundleId) || (appName && bundleId)) {
143
- throw Object.assign(new Error('Provide exactly one of app_name or bundle_id.'), { code: 'PLUGIN_ACTION_ARGUMENT_INVALID' });
144
- }
145
- const args = bundleId ? ['-b', bundleId] : ['-a', appName];
146
- const opened = await run(OPEN, args, 20_000);
147
- if (!opened.ok) {
148
- throw Object.assign(new Error(opened.stderr || 'macOS open command failed.'), {
149
- code: 'PLUGIN_DESKTOP_OPEN_FAILED',
150
- retryable: true,
151
- });
152
- }
153
- return { opened: true, selector: bundleId ? { bundleId } : { appName } };
154
- }
155
-
156
- async function execute(message) {
157
- if (!message || typeof message !== 'object' || message.schemaVersion !== SCHEMA_VERSION || message.type !== 'execute') {
158
- throw Object.assign(new Error('Invalid managed Desktop request envelope.'), { code: 'PLUGIN_MANAGED_PROCESS_PROTOCOL_ERROR' });
159
- }
160
- if (typeof message.requestId !== 'string' || typeof message.actionId !== 'string' || !message.input || typeof message.input !== 'object' || Array.isArray(message.input)) {
161
- throw Object.assign(new Error('Invalid managed Desktop request fields.'), { code: 'PLUGIN_MANAGED_PROCESS_PROTOCOL_ERROR' });
162
- }
163
- if (message.actionId === 'status') return await status();
164
- if (message.actionId === 'observe') return await observe();
165
- if (message.actionId === 'open_application') return await openApplication(message.input);
166
- throw Object.assign(new Error(`desktop/${message.actionId} is not supported.`), { code: 'PLUGIN_ACTION_NOT_SUPPORTED' });
167
- }
168
-
169
- write({
170
- schemaVersion: SCHEMA_VERSION,
171
- type: 'handshake',
172
- protocolVersion: SCHEMA_VERSION,
173
- pluginId: PLUGIN_ID,
174
- helperVersion: HELPER_VERSION,
175
- capabilities: CAPABILITIES,
176
- });
177
-
178
- const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
179
- let handled = false;
180
- lines.on('line', async (line) => {
181
- if (handled) return;
182
- handled = true;
183
- if (Buffer.byteLength(line, 'utf8') > MAX_LINE_BYTES) {
184
- failure('unknown', 'PLUGIN_MANAGED_PROCESS_REQUEST_TOO_LARGE', 'Managed Desktop request exceeded the bounded input limit.');
185
- lines.close();
186
- return;
187
- }
188
- let message;
189
- try {
190
- message = JSON.parse(line);
191
- const result = await execute(message);
192
- success(message.requestId, result);
193
- } catch (error) {
194
- const requestId = typeof message?.requestId === 'string' ? message.requestId : 'unknown';
195
- failure(
196
- requestId,
197
- typeof error?.code === 'string' ? error.code : 'PLUGIN_DESKTOP_ACTION_FAILED',
198
- bounded(error?.message || error),
199
- error?.retryable === true,
200
- );
201
- } finally {
202
- lines.close();
203
- }
204
- });
@@ -1,112 +0,0 @@
1
- # Controller Desktop Plugin
2
-
3
- The `desktop` plugin is a bundled first-party Forge capability for bounded
4
- macOS desktop observation and application launch. It is not downloaded from a
5
- marketplace and it does not require a user-configured socket, executable path, or
6
- registration descriptor.
7
-
8
- ## Architecture
9
-
10
- ```text
11
- ChatGPT / CLI / Local UI
12
- -> list_plugins / get_plugin / plugin_action_execute
13
- -> existing plugin policy, schema, receipt, and audit layers
14
- -> Desktop adapter (Controller process)
15
- -> one-shot managed child process
16
- -> JSON-lines stdio handshake and action
17
- -> bundled forge-desktop-helper.mjs
18
- ```
19
-
20
- The Controller starts the helper on demand for one action. The child performs a
21
- versioned handshake, executes one bounded request, returns one structured result,
22
- and exits. Forge enforces request and response size limits, timeout,
23
- cancellation, capability negotiation, bounded diagnostics, and process cleanup.
24
- The helper is launched directly without a shell.
25
-
26
- This initial implementation deliberately does not provide:
27
-
28
- - plugin search, download, installation, or a marketplace;
29
- - arbitrary commands or user-configured helper paths;
30
- - sockets, background registration files, or a second plugin registry;
31
- - screen capture, clicks, coordinates, keyboard input, or text entry;
32
- - dynamic MCP tool loading.
33
-
34
- The existing static MCP tools remain the public entry point, while action schemas,
35
- permissions, lifecycle, health, receipts, and audit events remain authoritative in
36
- the existing plugin runtime.
37
-
38
- ## Actions
39
-
40
- | Action | Effect | Policy |
41
- | --- | --- | --- |
42
- | `configure` | Enable or disable the bundled Desktop plugin. | Authorization required. |
43
- | `status` | Run a live helper handshake and return bounded readiness diagnostics. | Read-only. |
44
- | `observe` | Read the frontmost macOS application through `NSWorkspace`; no screen pixels are captured. | Read-only. |
45
- | `open_application` | Open one app by exact `app_name` or `bundle_id`. | Authorization required. |
46
-
47
- `open_application` accepts exactly one selector. It invokes `/usr/bin/open` with an
48
- argv array and never interpolates a shell command.
49
-
50
- ## Enable on this Mac
51
-
52
- Use the controller-scoped plugin action without a repository id:
53
-
54
- ```json
55
- {
56
- "plugin_id": "desktop",
57
- "action_id": "configure",
58
- "request_id": "desktop-enable-1",
59
- "confirm_authorization": true,
60
- "arguments": {
61
- "enabled": true
62
- }
63
- }
64
- ```
65
-
66
- Then call `get_plugin` for `desktop`, followed by `status` and `observe`.
67
- No ChatGPT reconnect is required because Forge reuses the existing
68
- `list_plugins`, `get_plugin`, and `plugin_action_execute` tool schemas. Loading a
69
- new Runtime revision may require the external lifecycle owner to restart the
70
- existing single Forge Runtime; it must not create a second Runtime or rollout slot.
71
-
72
- Configuration authority is stored under:
73
-
74
- ```text
75
- <controller-home>/system/desktop/config.json
76
- ```
77
-
78
- The derived manifest and registry remain Controller projections. The helper path
79
- is resolved from the active Forge installation and is not returned as
80
- user-facing configuration.
81
-
82
- ## Permissions
83
-
84
- The current version does not request Screen Recording permission because it does
85
- not capture screenshots. Read-only observation uses AppKit `NSWorkspace` through
86
- the system JavaScript bridge and does not intentionally trigger Accessibility
87
- consent prompts. Health reports Accessibility as `not_probed` rather than claiming
88
- permission that has not been verified.
89
-
90
- Future click, keyboard, screenshot, or accessibility-tree actions must be added as
91
- separate capabilities with explicit permission probes, stronger policy, redaction,
92
- and fault-injection coverage. They must not be inferred from this initial plugin.
93
-
94
- ## Failure behavior
95
-
96
- The plugin fails closed when:
97
-
98
- - it is disabled;
99
- - the platform is not macOS;
100
- - the bundled helper is missing;
101
- - the helper handshake or capability set is incompatible;
102
- - a response is malformed or exceeds the output bound;
103
- - the helper crashes, times out, or is cancelled;
104
- - `open_application` receives zero or multiple selectors.
105
-
106
- A helper failure is surfaced as a structured `PLUGIN_MANAGED_PROCESS_*` or
107
- `PLUGIN_DESKTOP_*` error through the existing plugin receipt and audit path.
108
-
109
-
110
- ## Immutable release packaging
111
-
112
- Whole-Runtime releases include `forge-desktop-helper.mjs` as a required, hashed sibling artifact. The Desktop adapter resolves it from the validated active Runtime release identity, then uses argv/source candidates only as development compatibility fallbacks. It launches the helper with the same trusted Node resolver used by the bounded Node bridge path; it does not execute the compiled Forge binary as a JavaScript interpreter. Release validation fails closed when the helper is missing or empty, and plugin health reports helper and trusted-runtime availability without returning either local path.