@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
@@ -13,6 +13,7 @@ import { listCandidateFindings } from '../findings/store';
13
13
  import { getControllerSession, getWorkContract, isTerminalWorkContractStatus, listHandoffItems } from '../../control-plane/facade';
14
14
  import { launchSuperController } from '../../control-plane/launcher/thin-launcher';
15
15
  import { getExternalControllerLaunchReservation } from '../../control-plane/launcher/launch-reservation-store';
16
+ import { runScheduledChatgptPrompt, runWorkChatgptContinuation } from '../../control-plane/launcher/chatgpt-work-continuation';
16
17
  import {
17
18
  getOccurrence,
18
19
  getSchedule,
@@ -25,6 +26,7 @@ import {
25
26
  saveScheduleDecision,
26
27
  } from './store';
27
28
  import { applyScheduleFailure } from './settlement';
29
+ import { classifyScheduledBrowserObservation, executeScheduledBrowserProbe } from './browser-probe';
28
30
  import type {
29
31
  RepositorySchedule,
30
32
  ScheduleDecisionType,
@@ -35,12 +37,16 @@ import type {
35
37
  const execFileAsync = promisify(execFile);
36
38
  const LIVE_MAINTENANCE_OPERATION = 'runtime_maintenance_apply';
37
39
  const EXTERNAL_CONTROLLER_WAKE_OPERATION = 'external_controller_wake';
40
+ const CHATGPT_BROWSER_PROMPT_OPERATION = 'chatgpt_browser_prompt';
41
+ const BROWSER_PROBE_OPERATION = 'browser_probe';
38
42
 
39
43
  function isDeterministicSchedule(schedule: RepositorySchedule): boolean {
40
- // Kernel may execute only fully deterministic, allowlisted schedule operations.
41
- // Semantic / model-backed schedules remain external-controller handoffs.
44
+ // Kernel may execute only allowlisted, deterministic dispatch operations.
45
+ // ChatGPT owns semantic work; the Scheduler only dispatches the configured prompt.
42
46
  return schedule.action.operation === LIVE_MAINTENANCE_OPERATION
43
- || schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION;
47
+ || schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION
48
+ || schedule.action.operation === CHATGPT_BROWSER_PROMPT_OPERATION
49
+ || schedule.action.operation === BROWSER_PROBE_OPERATION;
44
50
  }
45
51
 
46
52
  function normalizedWindow(minutes: number, at = Date.now()): string {
@@ -234,8 +240,9 @@ async function triggerDue(
234
240
  }
235
241
  }
236
242
 
237
- function continuationWorkId(schedule: RepositorySchedule): string | undefined {
238
- if (schedule.action.operation !== EXTERNAL_CONTROLLER_WAKE_OPERATION) return undefined;
243
+ function workBoundScheduleWorkId(schedule: RepositorySchedule): string | undefined {
244
+ if (schedule.action.operation !== EXTERNAL_CONTROLLER_WAKE_OPERATION
245
+ && schedule.action.operation !== BROWSER_PROBE_OPERATION) return undefined;
239
246
  const value = schedule.action.arguments?.work_id;
240
247
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
241
248
  }
@@ -251,7 +258,7 @@ function executionJobBelongsToWork(job: ReturnType<typeof listExecutionJobs>[num
251
258
 
252
259
  async function stopReason(controllerHome: string, schedule: RepositorySchedule): Promise<string | undefined> {
253
260
  const projection = readRepositoryProjection(controllerHome, schedule.repoId);
254
- const workId = continuationWorkId(schedule);
261
+ const workId = workBoundScheduleWorkId(schedule);
255
262
  const work = workId ? getWorkContract({ controllerHome, repoId: schedule.repoId }, workId) : undefined;
256
263
  if (schedule.stopConditions.includes('human_review_required')) {
257
264
  if (workId) {
@@ -344,6 +351,251 @@ function decideOccurrence(
344
351
  return saveOccurrence(controllerHome, { ...occurrence, decision, decisionId, status, reason });
345
352
  }
346
353
 
354
+ function externalControllerLaunchArgs(args: Record<string, unknown>, controllerType: string): string[] {
355
+ const launchArgs = Array.isArray(args.launch_args) ? args.launch_args.map(String) : [];
356
+ if (controllerType !== 'chatgpt') return launchArgs;
357
+ const explicit = new Set(launchArgs.filter((value) => value.startsWith('--')).map((value) => value.split('=', 1)[0]));
358
+ const add = (flag: string, value: unknown) => {
359
+ if (explicit.has(flag) || typeof value !== 'string' || !value.trim()) return;
360
+ launchArgs.push(flag, value.trim());
361
+ };
362
+ add('--model', args.model);
363
+ add('--reasoning', args.reasoning);
364
+ add('--tab-policy', args.tab_policy);
365
+ return launchArgs;
366
+ }
367
+
368
+ async function executeChatgptBrowserPrompt(
369
+ controllerHome: string,
370
+ schedule: RepositorySchedule,
371
+ occurrence: ScheduleOccurrence,
372
+ timestamp: string,
373
+ args: Record<string, unknown>,
374
+ ): Promise<ScheduleOccurrence> {
375
+ const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
376
+ if (!prompt) {
377
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'CHATGPT_BROWSER_PROMPT_REQUIRED');
378
+ }
379
+ const model = typeof args.model === 'string' ? args.model : 'gpt-5.6';
380
+ const reasoning = args.reasoning === 'medium' || args.reasoning === 'xhigh' ? args.reasoning : 'high';
381
+ const tabPolicy = args.tab_policy === 'reuse' || args.tab_policy === 'new' ? args.tab_policy : 'auto';
382
+ const dispatching = decideOccurrence(
383
+ controllerHome,
384
+ schedule,
385
+ occurrence,
386
+ 'execute',
387
+ 'running',
388
+ 'Forge Scheduler is dispatching the configured prompt through the controller-owned ChatGPT browser session.',
389
+ occurrenceDecisionEvidence({ operation: CHATGPT_BROWSER_PROMPT_OPERATION, model, reasoning, tabPolicy }),
390
+ );
391
+ const result = await runScheduledChatgptPrompt({
392
+ controllerHome,
393
+ repoId: schedule.repoId,
394
+ automationId: schedule.scheduleId,
395
+ prompt,
396
+ title: schedule.name,
397
+ browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
398
+ conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
399
+ model,
400
+ reasoning,
401
+ tabPolicy,
402
+ timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
403
+ });
404
+ if (result.status === 'failed') {
405
+ const reason = result.error?.message ?? 'ChatGPT browser prompt dispatch failed.';
406
+ const failed = applyScheduleFailure(controllerHome, schedule.scheduleId, schedule.repoId, occurrence.occurrenceId, {
407
+ outcome: 'failed',
408
+ decision: 'execute',
409
+ reason,
410
+ handoff: {
411
+ title: `ChatGPT scheduled prompt ${occurrence.occurrenceId} failed`,
412
+ summary: 'Forge recorded the trigger but could not dispatch the configured ChatGPT prompt.',
413
+ reason,
414
+ creationReason: 'repeated_infrastructure_failure',
415
+ blockingDecision: 'Repair ChatGPT/browser readiness before unattended prompt dispatch resumes.',
416
+ recommendedDecision: 'Inspect browser attachment and ChatGPT execution preference verification, then retrigger this automation.',
417
+ recommendedPrompt: `Inspect failed ChatGPT prompt occurrence ${occurrence.occurrenceId} for schedule ${schedule.scheduleId}.`,
418
+ statusSummary: 'Scheduled ChatGPT prompt dispatch failed.',
419
+ blockedBy: ['chatgpt_browser_prompt_failed'],
420
+ attemptedActions: [`schedule:${schedule.scheduleId}`, `browser-session:${result.browserSessionId}`],
421
+ },
422
+ });
423
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
424
+ saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
425
+ return failed.occurrence ?? saveOccurrence(controllerHome, { ...dispatching, status: 'failed', reason });
426
+ }
427
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
428
+ const durableConversationUrl = result.conversationUrl && /\/c\/[^/?#]+/.test(result.conversationUrl)
429
+ ? result.conversationUrl
430
+ : undefined;
431
+ saveSchedule(controllerHome, {
432
+ ...latest,
433
+ action: durableConversationUrl && tabPolicy !== 'new'
434
+ ? {
435
+ ...latest.action,
436
+ arguments: { ...latest.action.arguments, conversation_url: durableConversationUrl },
437
+ }
438
+ : latest.action,
439
+ lastTriggeredAt: timestamp,
440
+ lastOccurrenceId: occurrence.occurrenceId,
441
+ consecutiveFailures: 0,
442
+ nextEligibleAt: undefined,
443
+ pausedReason: undefined,
444
+ });
445
+ return saveOccurrence(controllerHome, {
446
+ ...dispatching,
447
+ status: 'succeeded',
448
+ reason: `ChatGPT prompt dispatched via ${result.browserSessionId}${result.reusedSession ? ' (reused)' : ' (new session)'}; execution preference ${result.executionPreferenceVerified ? 'verified' : 'not verified'}.`,
449
+ });
450
+ }
451
+
452
+ async function executeExternalControllerWake(
453
+ controllerHome: string,
454
+ schedule: RepositorySchedule,
455
+ occurrence: ScheduleOccurrence,
456
+ timestamp: string,
457
+ args: Record<string, unknown>,
458
+ extraEvidence: Record<string, unknown> = {},
459
+ ): Promise<ScheduleOccurrence> {
460
+ const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
461
+ const controllerType = typeof args.controller_type === 'string' ? args.controller_type.trim() : 'chatgpt';
462
+ if (!workId) {
463
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_WORK_ID_REQUIRED');
464
+ }
465
+ if (!['chatgpt', 'codex', 'claude', 'grok'].includes(controllerType)) {
466
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_TYPE_INVALID');
467
+ }
468
+ const workStore = { controllerHome, repoId: schedule.repoId };
469
+ const work = getWorkContract(workStore, workId);
470
+ if (!work) {
471
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
472
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `EXTERNAL_CONTROLLER_WAKE_WORK_NOT_FOUND:${workId}`);
473
+ }
474
+ if (isTerminalWorkContractStatus(work.status)) {
475
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
476
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); automatic continuation stopped.`);
477
+ }
478
+ const existingOwner = getControllerSession(workStore, workId);
479
+ if (existingOwner) {
480
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
481
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has an active Controller ${existingOwner.controllerId}.`);
482
+ }
483
+ const launchReservation = getExternalControllerLaunchReservation(workStore, workId);
484
+ if (launchReservation) {
485
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
486
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has a pending external Controller launch ${launchReservation.reservationId}.`);
487
+ }
488
+ const wakeDecision = decideOccurrence(
489
+ controllerHome,
490
+ schedule,
491
+ occurrence,
492
+ 'execute',
493
+ 'running',
494
+ 'Deterministic external Controller wake is starting through Thin Launcher.',
495
+ occurrenceDecisionEvidence({
496
+ operation: schedule.action.operation,
497
+ workId,
498
+ controllerType,
499
+ ...(controllerType === 'chatgpt' ? {
500
+ model: typeof args.model === 'string' ? args.model : 'gpt-5.6',
501
+ reasoning: typeof args.reasoning === 'string' ? args.reasoning : 'high',
502
+ tabPolicy: typeof args.tab_policy === 'string' ? args.tab_policy : 'auto',
503
+ } : {}),
504
+ ...extraEvidence,
505
+ }),
506
+ );
507
+ try {
508
+ const repository = getRepository(schedule.repoId, controllerHome, { includeRemoved: true });
509
+ const continuationPrompt = typeof args.continuation_prompt === 'string'
510
+ ? args.continuation_prompt
511
+ : `Scheduled continuation ${occurrence.occurrenceId} from ${schedule.scheduleId}. Read current Forge state and continue only the bounded Work objective.`;
512
+ if (controllerType === 'chatgpt') {
513
+ const reasoning = args.reasoning === 'medium' || args.reasoning === 'xhigh' ? args.reasoning : 'high';
514
+ const tabPolicy = args.tab_policy === 'reuse' || args.tab_policy === 'new' ? args.tab_policy : 'auto';
515
+ const dispatched = await runWorkChatgptContinuation({
516
+ controllerHome,
517
+ repoId: schedule.repoId,
518
+ repoRoot: repository.canonicalRoot,
519
+ workId,
520
+ prompt: continuationPrompt,
521
+ title: schedule.name,
522
+ browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
523
+ conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
524
+ model: typeof args.model === 'string' ? args.model : 'gpt-5.6',
525
+ reasoning,
526
+ tabPolicy,
527
+ timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
528
+ });
529
+ if (dispatched.status === 'failed') throw new Error(dispatched.error?.message ?? 'CHATGPT_WORK_CONTINUATION_FAILED');
530
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
531
+ saveSchedule(controllerHome, {
532
+ ...latest,
533
+ lastTriggeredAt: timestamp,
534
+ lastOccurrenceId: occurrence.occurrenceId,
535
+ consecutiveFailures: 0,
536
+ nextEligibleAt: undefined,
537
+ pausedReason: undefined,
538
+ });
539
+ return saveOccurrence(controllerHome, {
540
+ ...wakeDecision,
541
+ status: 'succeeded',
542
+ reason: `ChatGPT Work continuation dispatched via ${dispatched.browserSessionId}; execution preference verified.`,
543
+ });
544
+ }
545
+ const launched = launchSuperController({
546
+ work: { controllerHome, repoId: schedule.repoId },
547
+ handoff: { controllerHome, repoId: schedule.repoId },
548
+ }, {
549
+ controllerType: controllerType as 'codex' | 'claude' | 'grok',
550
+ executable: typeof args.executable === 'string' ? args.executable : undefined,
551
+ args: externalControllerLaunchArgs(args, controllerType),
552
+ workId,
553
+ handoffId: typeof args.handoff_id === 'string' ? args.handoff_id : undefined,
554
+ browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
555
+ conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
556
+ launchReservationMs: typeof args.launch_reservation_ms === 'number' ? args.launch_reservation_ms : typeof args.lease_ms === 'number' ? args.lease_ms : undefined,
557
+ continuationPrompt,
558
+ cwd: repository.canonicalRoot,
559
+ });
560
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
561
+ saveSchedule(controllerHome, {
562
+ ...latest,
563
+ lastTriggeredAt: timestamp,
564
+ lastOccurrenceId: occurrence.occurrenceId,
565
+ consecutiveFailures: 0,
566
+ nextEligibleAt: undefined,
567
+ pausedReason: undefined,
568
+ });
569
+ return saveOccurrence(controllerHome, {
570
+ ...wakeDecision,
571
+ status: 'succeeded',
572
+ reason: `External Controller wake started ${launched.controllerType} pid=${String(launched.pid ?? 'unknown')} for Work ${workId}.`,
573
+ });
574
+ } catch (error) {
575
+ const reason = error instanceof Error ? error.message : String(error);
576
+ const failed = applyScheduleFailure(controllerHome, schedule.scheduleId, schedule.repoId, occurrence.occurrenceId, {
577
+ outcome: 'failed',
578
+ decision: 'execute',
579
+ reason,
580
+ handoff: {
581
+ title: `External Controller wake ${occurrence.occurrenceId} failed`,
582
+ summary: 'Forge recorded the schedule trigger but could not wake the configured external Controller.',
583
+ reason,
584
+ creationReason: 'repeated_infrastructure_failure',
585
+ blockingDecision: 'Repair the external Controller/browser launch path or update the saved session reference.',
586
+ recommendedDecision: 'Inspect launcher/browser readiness, then retrigger this bounded continuation.',
587
+ recommendedPrompt: `Resume Work ${workId} manually, inspect failed wake occurrence ${occurrence.occurrenceId}, and repair the configured external Controller launch path before unattended continuation resumes.`,
588
+ statusSummary: 'Scheduled external Controller wake failed.',
589
+ blockedBy: ['external_controller_wake_failed'],
590
+ attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, `controller:${controllerType}`],
591
+ },
592
+ });
593
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
594
+ saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
595
+ return failed.occurrence ?? saveOccurrence(controllerHome, { ...wakeDecision, status: 'failed', reason });
596
+ }
597
+ }
598
+
347
599
  export async function evaluateSchedule(
348
600
  controllerHome: string,
349
601
  schedule: RepositorySchedule,
@@ -548,76 +800,117 @@ export async function evaluateSchedule(
548
800
 
549
801
  const args = schedule.action.arguments ?? {};
550
802
  if (schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION) {
803
+ return executeExternalControllerWake(controllerHome, schedule, occurrence, timestamp, args);
804
+ }
805
+
806
+ if (schedule.action.operation === CHATGPT_BROWSER_PROMPT_OPERATION) {
807
+ return executeChatgptBrowserPrompt(controllerHome, schedule, occurrence, timestamp, args);
808
+ }
809
+
810
+ if (schedule.action.operation === BROWSER_PROBE_OPERATION) {
551
811
  const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
552
- const controllerType = typeof args.controller_type === 'string' ? args.controller_type.trim() : 'chatgpt';
553
812
  if (!workId) {
554
- return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_WORK_ID_REQUIRED');
555
- }
556
- if (!['chatgpt', 'codex', 'claude', 'grok'].includes(controllerType)) {
557
- return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_TYPE_INVALID');
813
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'SCHEDULE_BROWSER_PROBE_WORK_ID_REQUIRED');
558
814
  }
559
815
  const workStore = { controllerHome, repoId: schedule.repoId };
560
816
  const work = getWorkContract(workStore, workId);
561
817
  if (!work) {
562
818
  saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
563
- return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `EXTERNAL_CONTROLLER_WAKE_WORK_NOT_FOUND:${workId}`);
819
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `SCHEDULE_BROWSER_PROBE_WORK_NOT_FOUND:${workId}`);
564
820
  }
565
821
  if (isTerminalWorkContractStatus(work.status)) {
566
822
  saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
567
- return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); automatic continuation stopped.`);
568
- }
569
- const existingOwner = getControllerSession(workStore, workId);
570
- if (existingOwner) {
571
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
572
- return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has an active Controller ${existingOwner.controllerId}.`);
573
- }
574
- const launchReservation = getExternalControllerLaunchReservation(workStore, workId);
575
- if (launchReservation) {
576
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
577
- return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has a pending external Controller launch ${launchReservation.reservationId}.`);
823
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); browser watcher stopped before probing.`);
578
824
  }
579
- const wakeDecision = decideOccurrence(
580
- controllerHome,
581
- schedule,
582
- occurrence,
583
- 'execute',
584
- 'running',
585
- 'Deterministic external Controller wake is starting through Thin Launcher.',
586
- occurrenceDecisionEvidence({ operation: schedule.action.operation, workId, controllerType }),
587
- );
825
+
588
826
  try {
589
827
  const repository = getRepository(schedule.repoId, controllerHome, { includeRemoved: true });
590
- const launched = launchSuperController({
591
- work: { controllerHome, repoId: schedule.repoId },
592
- handoff: { controllerHome, repoId: schedule.repoId },
593
- }, {
594
- controllerType: controllerType as 'chatgpt' | 'codex' | 'claude' | 'grok',
595
- executable: typeof args.executable === 'string' ? args.executable : undefined,
596
- args: Array.isArray(args.launch_args) ? args.launch_args.map(String) : [],
597
- workId,
598
- handoffId: typeof args.handoff_id === 'string' ? args.handoff_id : undefined,
599
- browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
600
- conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
601
- launchReservationMs: typeof args.launch_reservation_ms === 'number' ? args.launch_reservation_ms : typeof args.lease_ms === 'number' ? args.lease_ms : undefined,
602
- continuationPrompt: typeof args.continuation_prompt === 'string'
603
- ? args.continuation_prompt
604
- : `Scheduled continuation ${occurrence.occurrenceId} from ${schedule.scheduleId}. Read current Forge state and continue only the bounded Work objective.`,
605
- cwd: repository.canonicalRoot,
606
- });
828
+ const probe = await executeScheduledBrowserProbe({ controllerHome, repository, occurrenceId, args });
607
829
  const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
608
- saveSchedule(controllerHome, {
830
+ const observationEvidence = occurrenceDecisionEvidence({
831
+ probeStatus: probe.status,
832
+ url: probe.url,
833
+ projectedLineCount: probe.projectedLineCount,
834
+ observedChars: probe.observedChars,
835
+ truncated: probe.truncated,
836
+ });
837
+
838
+ if (probe.status === 'auth_required') {
839
+ const observedSchedule = saveSchedule(controllerHome, {
840
+ ...latest,
841
+ lastTriggeredAt: timestamp,
842
+ lastOccurrenceId: occurrenceId,
843
+ lastObservationAt: timestamp,
844
+ lastObservationStatus: 'auth_required',
845
+ consecutiveFailures: 0,
846
+ nextEligibleAt: undefined,
847
+ pausedReason: undefined,
848
+ });
849
+ if (args.wake_on_auth_required !== false) {
850
+ const wakeArgs = {
851
+ ...args,
852
+ continuation_prompt: typeof args.auth_required_prompt === 'string'
853
+ ? args.auth_required_prompt
854
+ : typeof args.continuation_prompt === 'string'
855
+ ? args.continuation_prompt
856
+ : `Scheduled browser watcher ${schedule.scheduleId} detected that authentication is required (${probe.authReason ?? 'login marker'}). Inspect the bound external dependency and request user login only if it cannot be restored safely.`,
857
+ };
858
+ return executeExternalControllerWake(controllerHome, observedSchedule, occurrence, timestamp, wakeArgs, { ...observationEvidence, authReason: probe.authReason });
859
+ }
860
+ return decideOccurrence(controllerHome, observedSchedule, occurrence, 'operation_blocked', 'skipped', `Browser watcher requires authentication: ${probe.authReason ?? 'login marker matched'}`, observationEvidence);
861
+ }
862
+
863
+ if (probe.status === 'keepalive') {
864
+ const observedSchedule = saveSchedule(controllerHome, {
865
+ ...latest,
866
+ lastTriggeredAt: timestamp,
867
+ lastOccurrenceId: occurrenceId,
868
+ lastObservationAt: timestamp,
869
+ lastObservationStatus: 'keepalive',
870
+ consecutiveFailures: 0,
871
+ consecutiveNoops: 0,
872
+ nextEligibleAt: undefined,
873
+ pausedReason: undefined,
874
+ });
875
+ return decideOccurrence(controllerHome, observedSchedule, occurrence, 'execute', 'succeeded', 'Browser session keepalive refreshed successfully.', observationEvidence);
876
+ }
877
+
878
+ const observation = classifyScheduledBrowserObservation(
879
+ latest.lastObservationFingerprint,
880
+ probe.fingerprint,
881
+ args.wake_on_first_observation === true,
882
+ );
883
+ const changed = observation.status === 'changed';
884
+ const shouldWake = args.wake_on_change !== false && observation.shouldWake;
885
+ const observationStatus: RepositorySchedule['lastObservationStatus'] = observation.status;
886
+ const observedSchedule = saveSchedule(controllerHome, {
609
887
  ...latest,
610
888
  lastTriggeredAt: timestamp,
611
889
  lastOccurrenceId: occurrenceId,
890
+ lastObservationAt: timestamp,
891
+ lastObservationFingerprint: probe.fingerprint,
892
+ lastObservationChangedAt: changed ? timestamp : latest.lastObservationChangedAt,
893
+ lastObservationStatus: observationStatus,
612
894
  consecutiveFailures: 0,
895
+ consecutiveNoops: shouldWake ? 0 : (latest.consecutiveNoops ?? 0) + 1,
613
896
  nextEligibleAt: undefined,
614
897
  pausedReason: undefined,
615
898
  });
616
- return saveOccurrence(controllerHome, {
617
- ...wakeDecision,
618
- status: 'succeeded',
619
- reason: `External Controller wake started ${launched.controllerType} pid=${String(launched.pid ?? 'unknown')} for Work ${workId}.`,
620
- });
899
+
900
+ if (!shouldWake) {
901
+ const reason = observation.status === 'baseline'
902
+ ? 'Browser watcher baseline recorded; no Controller wake was emitted.'
903
+ : 'Browser watcher observation is unchanged; no Controller wake was emitted.';
904
+ return decideOccurrence(controllerHome, observedSchedule, occurrence, 'nothing_to_do', 'skipped', reason, { ...observationEvidence, observationStatus });
905
+ }
906
+
907
+ const wakeArgs = {
908
+ ...args,
909
+ continuation_prompt: typeof args.continuation_prompt === 'string'
910
+ ? args.continuation_prompt
911
+ : `Scheduled browser watcher ${schedule.scheduleId} detected a changed external observation. Read the current external state, correlate it with Work ${workId}, and continue only the bounded Work objective.`,
912
+ };
913
+ return executeExternalControllerWake(controllerHome, observedSchedule, occurrence, timestamp, wakeArgs, { ...observationEvidence, observationStatus });
621
914
  } catch (error) {
622
915
  const reason = error instanceof Error ? error.message : String(error);
623
916
  const failed = applyScheduleFailure(controllerHome, schedule.scheduleId, schedule.repoId, occurrence.occurrenceId, {
@@ -625,21 +918,21 @@ export async function evaluateSchedule(
625
918
  decision: 'execute',
626
919
  reason,
627
920
  handoff: {
628
- title: `External Controller wake ${occurrence.occurrenceId} failed`,
629
- summary: 'Forge recorded the schedule trigger but could not wake the configured external Controller.',
921
+ title: `Browser watcher occurrence ${occurrence.occurrenceId} failed`,
922
+ summary: 'A bounded browser probe failed before Forge could compare the external observation.',
630
923
  reason,
631
924
  creationReason: 'repeated_infrastructure_failure',
632
- blockingDecision: 'Repair the external Controller/browser launch path or update the saved session reference.',
633
- recommendedDecision: 'Inspect launcher/browser readiness, then retrigger this bounded continuation.',
634
- recommendedPrompt: `Resume Work ${workId} manually, inspect failed wake occurrence ${occurrence.occurrenceId}, and repair the configured external Controller launch path before unattended continuation resumes.`,
635
- statusSummary: 'Scheduled external Controller wake failed.',
636
- blockedBy: ['external_controller_wake_failed'],
637
- attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, `controller:${controllerType}`],
925
+ blockingDecision: 'Repair browser/session readiness or update the watcher target before unattended probing resumes.',
926
+ recommendedDecision: 'Inspect the browser plugin/session and retrigger the watcher after the target is readable.',
927
+ recommendedPrompt: `Inspect browser watcher schedule ${schedule.scheduleId} for Work ${workId}, repair the failed browser probe, then retrigger one bounded occurrence.`,
928
+ statusSummary: 'Scheduled browser watcher failed.',
929
+ blockedBy: ['browser_probe_failed'],
930
+ attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, 'operation:browser_probe'],
638
931
  },
639
932
  });
640
933
  const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
641
934
  saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
642
- return failed.occurrence ?? saveOccurrence(controllerHome, { ...wakeDecision, status: 'failed', reason });
935
+ return failed.occurrence ?? saveOccurrence(controllerHome, { ...occurrence, status: 'failed', decision: 'execute', reason });
643
936
  }
644
937
  }
645
938
 
@@ -73,6 +73,11 @@ export interface RepositorySchedule {
73
73
  consecutiveNoops?: number;
74
74
  nextEligibleAt?: string;
75
75
  pausedReason?: string;
76
+ /** Last deterministic external observation for browser_watch/browser_keepalive. */
77
+ lastObservationAt?: string;
78
+ lastObservationFingerprint?: string;
79
+ lastObservationChangedAt?: string;
80
+ lastObservationStatus?: 'baseline' | 'unchanged' | 'changed' | 'keepalive' | 'auth_required';
76
81
  }
77
82
 
78
83
  export type ScheduleDecisionType =