@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.
- package/CHANGELOG.md +20 -0
- package/DESIGN.md +7 -2
- package/README.md +4 -4
- package/README.zh-CN.md +2 -2
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/tutorials/01-install-and-start.md +1 -1
- package/docs/tutorials/01-install-and-start.zh-CN.md +1 -1
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +29 -19
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/transports/http.ts +25 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/release-materialize.ts +143 -19
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- 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
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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.
|