@oh-my-matrix/autopilot 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/index.d.ts +28 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +1034 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/audit-persister.d.ts +25 -0
  8. package/dist/src/audit-persister.d.ts.map +1 -0
  9. package/dist/src/audit-persister.js +162 -0
  10. package/dist/src/audit-persister.js.map +1 -0
  11. package/dist/src/autopilot-state.d.ts +35 -0
  12. package/dist/src/autopilot-state.d.ts.map +1 -0
  13. package/dist/src/autopilot-state.js +121 -0
  14. package/dist/src/autopilot-state.js.map +1 -0
  15. package/dist/src/command-runner.d.ts +13 -0
  16. package/dist/src/command-runner.d.ts.map +1 -0
  17. package/dist/src/command-runner.js +148 -0
  18. package/dist/src/command-runner.js.map +1 -0
  19. package/dist/src/completion-detector.d.ts +21 -0
  20. package/dist/src/completion-detector.d.ts.map +1 -0
  21. package/dist/src/completion-detector.js +104 -0
  22. package/dist/src/completion-detector.js.map +1 -0
  23. package/dist/src/continuation-engine.d.ts +9 -0
  24. package/dist/src/continuation-engine.d.ts.map +1 -0
  25. package/dist/src/continuation-engine.js +76 -0
  26. package/dist/src/continuation-engine.js.map +1 -0
  27. package/dist/src/effort-injection.d.ts +9 -0
  28. package/dist/src/effort-injection.d.ts.map +1 -0
  29. package/dist/src/effort-injection.js +17 -0
  30. package/dist/src/effort-injection.js.map +1 -0
  31. package/dist/src/evidence-gate.d.ts +24 -0
  32. package/dist/src/evidence-gate.d.ts.map +1 -0
  33. package/dist/src/evidence-gate.js +86 -0
  34. package/dist/src/evidence-gate.js.map +1 -0
  35. package/dist/src/goal-manager.d.ts +5 -0
  36. package/dist/src/goal-manager.d.ts.map +1 -0
  37. package/dist/src/goal-manager.js +22 -0
  38. package/dist/src/goal-manager.js.map +1 -0
  39. package/dist/src/logger.d.ts +27 -0
  40. package/dist/src/logger.d.ts.map +1 -0
  41. package/dist/src/logger.js +90 -0
  42. package/dist/src/logger.js.map +1 -0
  43. package/dist/src/orchestrator.d.ts +16 -0
  44. package/dist/src/orchestrator.d.ts.map +1 -0
  45. package/dist/src/orchestrator.js +252 -0
  46. package/dist/src/orchestrator.js.map +1 -0
  47. package/dist/src/permission-policy.d.ts +36 -0
  48. package/dist/src/permission-policy.d.ts.map +1 -0
  49. package/dist/src/permission-policy.js +265 -0
  50. package/dist/src/permission-policy.js.map +1 -0
  51. package/dist/src/project-detector.d.ts +8 -0
  52. package/dist/src/project-detector.d.ts.map +1 -0
  53. package/dist/src/project-detector.js +130 -0
  54. package/dist/src/project-detector.js.map +1 -0
  55. package/dist/src/projection.d.ts +47 -0
  56. package/dist/src/projection.d.ts.map +1 -0
  57. package/dist/src/projection.js +58 -0
  58. package/dist/src/projection.js.map +1 -0
  59. package/dist/src/retry-queue.d.ts +25 -0
  60. package/dist/src/retry-queue.d.ts.map +1 -0
  61. package/dist/src/retry-queue.js +77 -0
  62. package/dist/src/retry-queue.js.map +1 -0
  63. package/dist/src/stall-detector.d.ts +22 -0
  64. package/dist/src/stall-detector.d.ts.map +1 -0
  65. package/dist/src/stall-detector.js +26 -0
  66. package/dist/src/stall-detector.js.map +1 -0
  67. package/dist/src/tool-error-tracker.d.ts +4 -0
  68. package/dist/src/tool-error-tracker.d.ts.map +1 -0
  69. package/dist/src/tool-error-tracker.js +18 -0
  70. package/dist/src/tool-error-tracker.js.map +1 -0
  71. package/dist/src/types.d.ts +223 -0
  72. package/dist/src/types.d.ts.map +1 -0
  73. package/dist/src/types.js +55 -0
  74. package/dist/src/types.js.map +1 -0
  75. package/dist/src/workflow-config.d.ts +13 -0
  76. package/dist/src/workflow-config.d.ts.map +1 -0
  77. package/dist/src/workflow-config.js +422 -0
  78. package/dist/src/workflow-config.js.map +1 -0
  79. package/openclaw.plugin.json +62 -0
  80. package/package.json +56 -0
package/dist/index.js ADDED
@@ -0,0 +1,1034 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.version = exports.name = exports.id = void 0;
4
+ exports._resetForTest = _resetForTest;
5
+ exports._setAuditSetModeForTest = _setAuditSetModeForTest;
6
+ exports._getInternalStateForTest = _getInternalStateForTest;
7
+ exports._triggerRetryCheckForTest = _triggerRetryCheckForTest;
8
+ exports._generateRunIdForTest = _generateRunIdForTest;
9
+ exports.register = register;
10
+ const continuation_engine_1 = require("./src/continuation-engine");
11
+ const tool_error_tracker_1 = require("./src/tool-error-tracker");
12
+ const stall_detector_1 = require("./src/stall-detector");
13
+ const effort_injection_1 = require("./src/effort-injection");
14
+ const logger_1 = require("./src/logger");
15
+ const autopilot_state_1 = require("./src/autopilot-state");
16
+ const goal_manager_1 = require("./src/goal-manager");
17
+ const projection_1 = require("./src/projection");
18
+ const types_1 = require("./src/types");
19
+ const orchestrator_1 = require("./src/orchestrator");
20
+ const permission_policy_1 = require("@oh-my-matrix/permission-policy");
21
+ const workflow_config_1 = require("./src/workflow-config");
22
+ const evidence_gate_1 = require("./src/evidence-gate");
23
+ const command_runner_1 = require("./src/command-runner");
24
+ const project_detector_1 = require("./src/project-detector");
25
+ const permission_policy_2 = require("@oh-my-matrix/permission-policy");
26
+ const fs_1 = require("fs");
27
+ const path_1 = require("path");
28
+ /**
29
+ * Validate a renderer-supplied workspacePath before storing it as the
30
+ * containment boundary. Rejects relative paths, non-existent paths, and
31
+ * paths that are not directories (e.g. plain "/" is accepted only if it is
32
+ * an actual directory — which on POSIX it always is — but that edge-case is
33
+ * intentionally left to the WORKFLOW.md destructiveGit.allow gate and is not
34
+ * a new vulnerability introduced by this fix).
35
+ *
36
+ * Returns undefined when the path is invalid so callers fall back to
37
+ * process.cwd() rather than using an untrusted value.
38
+ */
39
+ function validateWorkspacePath(p) {
40
+ if (!p || !(0, path_1.isAbsolute)(p))
41
+ return undefined;
42
+ try {
43
+ return (0, fs_1.statSync)(p).isDirectory() ? p : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ exports.id = 'autopilot';
50
+ exports.name = 'Autopilot Continuous Mode';
51
+ exports.version = '2.0.0';
52
+ /** GAP-25: Maximum number of concurrent run states before eviction kicks in */
53
+ const MAX_RUN_STATES = 50;
54
+ /** GAP-26: Health check threshold — sessions inactive for 24h are orphaned */
55
+ const ORPHAN_THRESHOLD_MS = 24 * 60 * 60 * 1000;
56
+ let stateByRun = new Map();
57
+ let sessionIdToKey = new Map();
58
+ let sessionKeyToRunId = new Map();
59
+ let canaryFired = new Set();
60
+ let stallInterval = null;
61
+ /**
62
+ * before_tool_call priority — must be higher than matrixassistant-audit (priority 9).
63
+ * Ensures autopilot audit trail is recorded before audit can short-circuit.
64
+ * @see the host's matrixassistant-audit plugin (AUDIT_HOOK_PRIORITY = 9)
65
+ */
66
+ const BEFORE_TOOL_CALL_PRIORITY = 10;
67
+ // Cross-plugin coordination with audit plugin — same Node.js process, CommonJS, package-name require.
68
+ // Lazy load: if audit plugin absent, autopilot still works (degraded but safe).
69
+ let _auditSetMode = null;
70
+ try {
71
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS lazy import; audit plugin is an optional peer
72
+ const auditPlugin = require('@openclaw/matrixassistant-audit');
73
+ if (typeof auditPlugin === 'object' &&
74
+ auditPlugin !== null &&
75
+ 'audit_setMode' in auditPlugin &&
76
+ typeof auditPlugin.audit_setMode === 'function') {
77
+ _auditSetMode = auditPlugin.audit_setMode;
78
+ }
79
+ }
80
+ catch {
81
+ (0, logger_1.warn)('[autopilot] audit plugin not loaded — monitor mode coordination unavailable');
82
+ }
83
+ function setAuditMode(mode) {
84
+ try {
85
+ _auditSetMode?.(mode);
86
+ (0, logger_1.log)(`[autopilot] audit mode → '${mode}'`);
87
+ }
88
+ catch (err) {
89
+ (0, logger_1.warn)(`[autopilot] setAuditMode(${mode}) failed (non-fatal): ${err}`);
90
+ }
91
+ }
92
+ function _resetForTest() {
93
+ if (process.env.NODE_ENV === 'production') {
94
+ throw new Error('_resetForTest must not be called in production');
95
+ }
96
+ stateByRun = new Map();
97
+ sessionIdToKey = new Map();
98
+ sessionKeyToRunId = new Map();
99
+ canaryFired = new Set();
100
+ if (stallInterval) {
101
+ clearInterval(stallInterval);
102
+ stallInterval = null;
103
+ }
104
+ }
105
+ /** Test-only: inject a mock audit_setMode so the closed-over _auditSetMode reference is replaceable. */
106
+ function _setAuditSetModeForTest(fn) {
107
+ if (process.env.NODE_ENV === 'production') {
108
+ throw new Error('_setAuditSetModeForTest must not be called in production');
109
+ }
110
+ _auditSetMode = fn;
111
+ }
112
+ function _getInternalStateForTest() {
113
+ return {
114
+ stateByRunSize: stateByRun.size,
115
+ sessionIdToKeySize: sessionIdToKey.size,
116
+ sessionKeyToRunIdSize: sessionKeyToRunId.size,
117
+ canaryFiredSize: canaryFired.size,
118
+ };
119
+ }
120
+ /**
121
+ * Test helper: inject a partial state for a session and immediately run the
122
+ * retry_due check logic, returning the resulting state.
123
+ * Only available in test environments — do NOT call from production code.
124
+ */
125
+ function _triggerRetryCheckForTest(overrides) {
126
+ if (process.env.NODE_ENV === 'production') {
127
+ throw new Error('_triggerRetryCheckForTest must not be called in production');
128
+ }
129
+ let runId = sessionKeyToRunId.get(overrides.sessionKey);
130
+ // If no run exists yet, create a synthetic one for testing
131
+ if (!runId) {
132
+ runId = `test-run-${overrides.sessionKey}`;
133
+ sessionKeyToRunId.set(overrides.sessionKey, runId);
134
+ }
135
+ const existing = stateByRun.get(runId);
136
+ // Inject the overridden fields (or create fresh state if no existing run)
137
+ const base = existing ?? {
138
+ sessionKey: overrides.sessionKey,
139
+ status: 'running',
140
+ enabled: true,
141
+ startedAt: Date.now(),
142
+ totalContinuations: 0,
143
+ turnAttempts: 0,
144
+ totalTokensUsed: 0,
145
+ degraded: false,
146
+ needsCrossTurnResume: false,
147
+ };
148
+ const injected = {
149
+ ...base,
150
+ orchestrationState: overrides.orchestrationState,
151
+ retry: overrides.retry,
152
+ enabled: true,
153
+ };
154
+ setState(runId, injected);
155
+ // Run the same retry_due logic used by the stall interval
156
+ const now = Date.now();
157
+ const state = stateByRun.get(runId);
158
+ if (state.enabled &&
159
+ state.orchestrationState === 'retry_queued' &&
160
+ state.retry?.nextRetryAt != null &&
161
+ state.retry.nextRetryAt <= now) {
162
+ const updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'retry_due', runId, now });
163
+ setState(runId, updated);
164
+ }
165
+ return stateByRun.get(runId);
166
+ }
167
+ /** GAP-25: Evict oldest runs when Map exceeds MAX_RUN_STATES */
168
+ function evictOldestRuns() {
169
+ while (stateByRun.size > MAX_RUN_STATES) {
170
+ // Find the run with the earliest startedAt (FIFO eviction)
171
+ let oldestRunId = null;
172
+ let oldestAt = Infinity;
173
+ for (const [runId, state] of stateByRun) {
174
+ if ((state.startedAt ?? Infinity) < oldestAt) {
175
+ oldestAt = state.startedAt ?? Infinity;
176
+ oldestRunId = runId;
177
+ }
178
+ }
179
+ if (oldestRunId == null)
180
+ break;
181
+ const oldestState = stateByRun.get(oldestRunId);
182
+ stateByRun.delete(oldestRunId);
183
+ if (oldestState) {
184
+ sessionKeyToRunId.delete(oldestState.sessionKey);
185
+ canaryFired.delete(oldestState.sessionKey);
186
+ // GAP-25: also clean up sessionIdToKey to prevent orphaned sid→skey entries
187
+ for (const [sid, skey] of sessionIdToKey) {
188
+ if (skey === oldestState.sessionKey) {
189
+ sessionIdToKey.delete(sid);
190
+ break;
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ function setState(runId, state) {
197
+ stateByRun.set(runId, state);
198
+ if (stateByRun.size > MAX_RUN_STATES)
199
+ evictOldestRuns();
200
+ }
201
+ /** GAP-23: Cleanup all state on shutdown */
202
+ function cleanupAll() {
203
+ // Release audit monitor refCount for every active run before clearing state.
204
+ // Each run acquired one refCount via setAuditMode('monitor'), so each needs a matching release.
205
+ for (let i = 0; i < stateByRun.size; i++) {
206
+ setAuditMode('active');
207
+ }
208
+ stateByRun.clear();
209
+ sessionIdToKey.clear();
210
+ sessionKeyToRunId.clear();
211
+ canaryFired.clear();
212
+ if (stallInterval) {
213
+ clearInterval(stallInterval);
214
+ stallInterval = null;
215
+ }
216
+ }
217
+ function findRunBySession(sessionKey) {
218
+ const runId = sessionKeyToRunId.get(sessionKey);
219
+ if (!runId)
220
+ return undefined;
221
+ const state = stateByRun.get(runId);
222
+ return state ? [runId, state] : undefined;
223
+ }
224
+ /** Generate a unique run ID using crypto.randomUUID (exported for testing). */
225
+ function _generateRunIdForTest() {
226
+ return `run-${crypto.randomUUID()}`;
227
+ }
228
+ function generateRunId() {
229
+ return _generateRunIdForTest();
230
+ }
231
+ function register(api) {
232
+ // Read user config from OpenClaw, merge with defaults
233
+ // pluginConfig is Record<string, unknown> — coerce values to expected types
234
+ const uc = api.pluginConfig ?? {};
235
+ const numOrUndefined = (v) => typeof v === 'number' ? v : undefined;
236
+ const config = {
237
+ ...types_1.DEFAULT_CONFIG,
238
+ ...(numOrUndefined(uc.maxAttemptsPerTurn) != null ? { maxAttemptsPerTurn: numOrUndefined(uc.maxAttemptsPerTurn) } : {}),
239
+ ...(numOrUndefined(uc.maxTotalContinuations) != null ? { maxTotalContinuations: numOrUndefined(uc.maxTotalContinuations) } : {}),
240
+ ...(numOrUndefined(uc.toolErrorThreshold) != null ? { toolErrorThreshold: numOrUndefined(uc.toolErrorThreshold) } : {}),
241
+ ...(Array.isArray(uc.excludedAgents) ? { excludedAgents: uc.excludedAgents } : {}),
242
+ ...(Array.isArray(uc.highRiskTools) ? { highRiskTools: uc.highRiskTools } : {}),
243
+ ...(numOrUndefined(uc.tokenBudget) != null ? { tokenBudget: numOrUndefined(uc.tokenBudget) } : {}),
244
+ ...(numOrUndefined(uc.maxConcurrentAutopilot) != null ? { maxConcurrentAutopilot: numOrUndefined(uc.maxConcurrentAutopilot) } : {}),
245
+ };
246
+ (0, logger_1.log)(`[autopilot] config: maxAttemptsPerTurn=${config.maxAttemptsPerTurn} maxTotalContinuations=${config.maxTotalContinuations} toolErrorThreshold=${config.toolErrorThreshold} excludedAgents=${JSON.stringify(config.excludedAgents)} highRiskTools=${JSON.stringify(config.highRiskTools)} tokenBudget=${config.tokenBudget}`);
247
+ // --- Hooks (use api.on for typed hooks when available, registerHook as fallback) ---
248
+ const registerHook = api.on?.bind(api) ?? api.registerHook?.bind(api);
249
+ if (!registerHook) {
250
+ (0, logger_1.error)('[autopilot] hook registration API unavailable (api.on and api.registerHook both missing) — plugin disabled');
251
+ return;
252
+ }
253
+ registerHook('before_agent_finalize', async (event) => {
254
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
255
+ if (sessionKey)
256
+ canaryFired.add(sessionKey);
257
+ if (!sessionKey)
258
+ return { action: 'continue' };
259
+ const entry = findRunBySession(sessionKey);
260
+ if (!entry)
261
+ return { action: 'continue' };
262
+ const [runId, rawState] = entry;
263
+ // Fix: clear needsCrossTurnResume at start of new turn triggered by cross-turn resume.
264
+ // Without this, sessions.changed keeps firing with needsCrossTurnResume=true → infinite loop.
265
+ const state = rawState.needsCrossTurnResume
266
+ ? { ...rawState, needsCrossTurnResume: false }
267
+ : rawState;
268
+ if (rawState.needsCrossTurnResume)
269
+ setState(runId, state);
270
+ const decision = (0, continuation_engine_1.decideContinuation)(state, {
271
+ lastAssistantMessage: event.lastAssistantMessage,
272
+ stopHookActive: event.stopHookActive,
273
+ });
274
+ (0, logger_1.log)(`[autopilot] before_agent_finalize: session=${sessionKey} action=${decision.action} turn=${state.turnAttempts}/${state.maxAttemptsPerTurn} total=${state.totalContinuations}/${state.maxTotalContinuations}`);
275
+ switch (decision.action) {
276
+ case 'revise': {
277
+ const updated = (0, autopilot_state_1.incrementTotal)((0, autopilot_state_1.incrementTurn)(state));
278
+ setState(runId, updated);
279
+ return {
280
+ action: 'revise',
281
+ retry: {
282
+ instruction: decision.retryInstruction,
283
+ idempotencyKey: `autopilot-${runId}-${updated.totalContinuations}`,
284
+ maxAttempts: state.maxAttemptsPerTurn - updated.turnAttempts,
285
+ },
286
+ };
287
+ }
288
+ case 'cross_turn': {
289
+ const enqueue = api.session?.workflow?.enqueueNextTurnInjection;
290
+ if (typeof enqueue === 'function') {
291
+ const updated = { ...(0, autopilot_state_1.incrementTotal)(state), needsCrossTurnResume: true, turnAttempts: 0 };
292
+ try {
293
+ const result = await enqueue({
294
+ sessionKey,
295
+ text: decision.retryInstruction || 'Continue from where you left off.',
296
+ idempotencyKey: `autopilot-cross-${sessionKey}-${updated.totalContinuations}`,
297
+ placement: 'prepend_context',
298
+ ttlMs: 300000,
299
+ });
300
+ if (result && typeof result === 'object' && result.enqueued === false) {
301
+ (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection rejected for session=${sessionKey}, falling back to revise`);
302
+ const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
303
+ setState(runId, fallbackState);
304
+ return {
305
+ action: 'revise',
306
+ retry: {
307
+ instruction: decision.retryInstruction || 'Continue from where you left off.',
308
+ idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
309
+ maxAttempts: fallbackState.maxAttemptsPerTurn,
310
+ },
311
+ };
312
+ }
313
+ setState(runId, updated);
314
+ return { action: 'finalize' };
315
+ }
316
+ catch (err) {
317
+ (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection failed for session=${sessionKey}: ${err}, falling back to revise`);
318
+ const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
319
+ setState(runId, fallbackState);
320
+ return {
321
+ action: 'revise',
322
+ retry: {
323
+ instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
324
+ idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
325
+ maxAttempts: fallbackState.maxAttemptsPerTurn,
326
+ },
327
+ };
328
+ }
329
+ }
330
+ // Fallback: injection API unavailable, use same-turn revise instead
331
+ const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
332
+ setState(runId, fallbackState);
333
+ return {
334
+ action: 'revise',
335
+ retry: {
336
+ instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
337
+ idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
338
+ maxAttempts: fallbackState.maxAttemptsPerTurn,
339
+ },
340
+ };
341
+ }
342
+ case 'pause': {
343
+ setState(runId, (0, autopilot_state_1.pause)(state, decision.pauseReason));
344
+ // Release audit monitor during pause — resume will re-acquire when session continues.
345
+ setAuditMode('active');
346
+ return { action: 'finalize' };
347
+ }
348
+ case 'complete': {
349
+ const now = Date.now();
350
+ // M5: Evidence Gate — evaluate before marking done.
351
+ // M5.3: Execute configured validation commands via child_process.exec before evaluating.
352
+ let evidenceSummary;
353
+ try {
354
+ const evidenceCommands = state.workflow?.validation.commands ?? [];
355
+ const evidenceResults = evidenceCommands.length > 0
356
+ ? await (0, command_runner_1.runValidationCommands)(evidenceCommands, state.workspace?.path)
357
+ : [];
358
+ evidenceSummary = (0, evidence_gate_1.evaluateEvidence)({
359
+ commands: evidenceCommands,
360
+ results: evidenceResults,
361
+ diffSummary: '',
362
+ now,
363
+ });
364
+ }
365
+ catch (err) {
366
+ // Fail-open: if evidence evaluation throws, treat as skipped to avoid zombie sessions
367
+ (0, logger_1.warn)(`[autopilot] evidence gate error (failing open): ${err} session=${sessionKey}`);
368
+ evidenceSummary = { status: 'skipped', diffSummary: '', commands: [], completedAt: now, failureReason: 'evaluation error' };
369
+ }
370
+ // Dispatch evidence events only when orchestration state machine is active (released state)
371
+ let updated = state;
372
+ if (state.orchestrationState === 'released') {
373
+ updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'evidence_started', runId, now });
374
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, {
375
+ type: 'evidence_finished',
376
+ runId,
377
+ now,
378
+ evidence: evidenceSummary,
379
+ });
380
+ }
381
+ // Apply evidence to state + mark done.
382
+ // Guard (H1): evidence_finished (passed/skipped) already sets status:'done' via orchestrator.
383
+ // complete() requires status='running' and would throw — skip it when orchestrator completed.
384
+ setState(runId, updated.status === 'done'
385
+ ? { ...updated, evidence: evidenceSummary }
386
+ : (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
387
+ (0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status });
388
+ // Release audit monitor when task completes — session is done, no more tool calls needed.
389
+ setAuditMode('active');
390
+ return { action: 'finalize' };
391
+ }
392
+ default:
393
+ return { action: 'continue' };
394
+ }
395
+ });
396
+ registerHook('after_tool_call', (event) => {
397
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
398
+ if (!sessionKey)
399
+ return;
400
+ const entry = findRunBySession(sessionKey);
401
+ if (!entry?.[1].enabled)
402
+ return;
403
+ const [runId, state] = entry;
404
+ // B-1: dispatch tool_result activity so stall detector resets lastActivityAt.
405
+ // Merge with error tracking into one setState to avoid double subscriber firing.
406
+ const afterActivity = (0, orchestrator_1.orchestratorReducer)(state, {
407
+ type: 'agent_activity',
408
+ runId,
409
+ activity: 'tool_result',
410
+ now: Date.now(),
411
+ });
412
+ if (!event.error) {
413
+ setState(runId, afterActivity);
414
+ return;
415
+ }
416
+ const withError = (0, tool_error_tracker_1.trackToolError)(afterActivity, {
417
+ tool: event.toolName,
418
+ args: JSON.stringify(event.params ?? {}).substring(0, 200),
419
+ error: (event.error ?? '').substring(0, 200),
420
+ });
421
+ setState(runId, withError);
422
+ (0, logger_1.log)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
423
+ });
424
+ registerHook('before_compaction', (event) => {
425
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
426
+ if (!sessionKey)
427
+ return;
428
+ const entry = findRunBySession(sessionKey);
429
+ if (entry?.[1].enabled) {
430
+ (0, logger_1.log)(`[autopilot] before_compaction: session=${sessionKey} preserving goal`);
431
+ setState(entry[0], (0, goal_manager_1.preserveGoalBeforeCompaction)(entry[1]));
432
+ }
433
+ });
434
+ registerHook('after_compaction', (event) => {
435
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
436
+ if (!sessionKey)
437
+ return;
438
+ const entry = findRunBySession(sessionKey);
439
+ if (entry?.[1].enabled) {
440
+ (0, logger_1.log)(`[autopilot] after_compaction: session=${sessionKey} restoring goal`);
441
+ setState(entry[0], (0, goal_manager_1.restoreGoalAfterCompaction)(entry[1]));
442
+ }
443
+ });
444
+ registerHook('agent_turn_prepare', (event, ctx) => {
445
+ const sessionKey = ctx?.sessionKey ?? event.sessionKey;
446
+ if (!sessionKey)
447
+ return;
448
+ const entry = findRunBySession(sessionKey);
449
+ if (!entry?.[1].enabled)
450
+ return;
451
+ const [runId, state] = entry;
452
+ let updated = state;
453
+ // Phase 1: Dispatch agent_turn_started through orchestrator reducer
454
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, {
455
+ type: 'agent_turn_started',
456
+ runId,
457
+ now: Date.now(),
458
+ });
459
+ // Capture goal from first user prompt if not already set
460
+ if (!updated.goal && event.prompt) {
461
+ updated = (0, goal_manager_1.captureGoal)(updated, event.prompt);
462
+ if (updated.goal) {
463
+ setState(runId, updated);
464
+ (0, logger_1.log)(`[autopilot] agent_turn_prepare: captured goal "${updated.goal.substring(0, 80)}"`);
465
+ }
466
+ }
467
+ // Skip injection right after compaction (compaction hooks handle it).
468
+ // Escape hatch: if goalSnapshot is set but goal exists, the snapshot is stale
469
+ // (after_compaction never fired after before_compaction). Clear it to unblock injection.
470
+ if (updated.goalSnapshot) {
471
+ if (updated.goal) {
472
+ updated = { ...updated, goalSnapshot: undefined, progressSnapshot: undefined };
473
+ setState(runId, updated);
474
+ (0, logger_1.warn)(`[autopilot] agent_turn_prepare: cleared stale goalSnapshot for session=${sessionKey}`);
475
+ }
476
+ else {
477
+ return;
478
+ }
479
+ }
480
+ // Inject goal reinforcement
481
+ const parts = [];
482
+ // Agent-facing context injections — not user-visible, intentionally bypass i18n.
483
+ // English used for consistent model comprehension regardless of UI language.
484
+ if (updated.goal) {
485
+ parts.push(`[Autopilot] Current goal: ${updated.goal}`);
486
+ }
487
+ if (updated.progress) {
488
+ parts.push(`[Autopilot] Progress so far: ${updated.progress}`);
489
+ }
490
+ if (parts.length === 0)
491
+ return;
492
+ // Effort injection: ensure high effort for every autopilot turn (TD-1)
493
+ const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status);
494
+ if (effortCtx)
495
+ parts.push(effortCtx);
496
+ // Completion awareness instruction
497
+ parts.push('[Autopilot] When all tasks are complete, explicitly state "All tasks completed".');
498
+ return { appendContext: parts.join('\n') };
499
+ });
500
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
501
+ registerHook('before_agent_run', (_event, ctx) => {
502
+ const sessionKey = ctx?.sessionKey;
503
+ if (!sessionKey)
504
+ return { outcome: 'pass' };
505
+ const entry = findRunBySession(sessionKey);
506
+ if (!entry?.[1].enabled)
507
+ return { outcome: 'pass' };
508
+ const agentId = ctx?.agentId;
509
+ if (agentId && config.excludedAgents?.includes(agentId)) {
510
+ (0, logger_1.log)(`[autopilot] before_agent_run: blocked agent=${agentId} session=${sessionKey} (excluded)`);
511
+ return {
512
+ outcome: 'block',
513
+ reason: `autopilot excluded agent: ${agentId}`,
514
+ message: `Autopilot mode is not allowed on agent "${agentId}"`,
515
+ };
516
+ }
517
+ return { outcome: 'pass' };
518
+ });
519
+ registerHook('before_tool_call', (event, ctx) => {
520
+ const sessionKey = ctx?.sessionKey;
521
+ if (!sessionKey)
522
+ return;
523
+ const entry = findRunBySession(sessionKey);
524
+ if (!entry?.[1].enabled)
525
+ return;
526
+ const [runId, state] = entry;
527
+ const toolName = event.toolName;
528
+ // B-1: dispatch tool_call activity so stall detector resets lastActivityAt.
529
+ // Use withActivity as base for all subsequent setState calls to preserve lastActivityAt.
530
+ const withActivity = (0, orchestrator_1.orchestratorReducer)(state, {
531
+ type: 'agent_activity',
532
+ runId,
533
+ activity: 'tool_call',
534
+ now: Date.now(),
535
+ });
536
+ // Real OpenClaw event: {toolName, params:{command?, workdir?}, runId, toolCallId}.
537
+ // NO event.args / event.toolKind / event.cwd (verified live 2026-06-28). Command
538
+ // lives in params.command; cwd in params.workdir.
539
+ const { cwd: eventCwd } = (0, permission_policy_1.extractCommandSegments)(event);
540
+ const isConfiguredHighRisk = Array.isArray(config.highRiskTools) && config.highRiskTools.includes(toolName);
541
+ const decision = isConfiguredHighRisk
542
+ ? ({ outcome: 'block', reason: `${toolName} is configured as high-risk tool`, message: `Tool "${toolName}" is blocked by operator config (highRiskTools)` })
543
+ : (0, permission_policy_1.decidePermissionForEvent)(event, {
544
+ cwd: eventCwd ?? state.workspace?.path ?? process.cwd(),
545
+ workspacePath: state.workspace?.path,
546
+ workspaceRoot: state.workspace?.root ?? process.cwd(),
547
+ workflowAllowsDestructiveGit: state.workflow?.destructiveGit?.allow ?? false,
548
+ // trusted autopilot run-scoped: keep allow-by-default (no defaultDeny)
549
+ });
550
+ // GAP-9: Log every tool call to permission audit trail (cap at 200 entries)
551
+ const commandClass = decision.commandClass ?? (0, permission_policy_1.classifyCommand)(toolName);
552
+ const auditEntry = {
553
+ at: Date.now(),
554
+ runId,
555
+ toolName,
556
+ commandClass,
557
+ outcome: decision.outcome,
558
+ reason: decision.reason,
559
+ };
560
+ const MAX_AUDIT = 200;
561
+ const prevAudit = withActivity.permissionAudit ?? [];
562
+ const nextAudit = prevAudit.length >= MAX_AUDIT
563
+ ? [...prevAudit.slice(-(MAX_AUDIT - 1)), auditEntry]
564
+ : [...prevAudit, auditEntry];
565
+ setState(runId, {
566
+ ...withActivity,
567
+ permissionAudit: nextAudit,
568
+ });
569
+ // Persist audit entry to disk (fail-silent)
570
+ (0, permission_policy_2.appendAuditEntry)(auditEntry, state.workspace?.path ?? process.cwd());
571
+ if (decision.outcome === 'allow')
572
+ return;
573
+ // Block: hard veto — gateway honors hookResult.block directly (line 995 of
574
+ // agent-tools.before-tool-call), bypassing plugin.approval.* channel entirely.
575
+ // Using requireApproval+timeoutMs:1 was broken — it still walked the approval
576
+ // pipeline which has no handler, causing 10s+ "Approval timed out" errors.
577
+ if (decision.outcome === 'block') {
578
+ (0, logger_1.logWithContext)('info', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
579
+ return {
580
+ block: true,
581
+ blockReason: decision.message,
582
+ };
583
+ }
584
+ }, { priority: BEFORE_TOOL_CALL_PRIORITY });
585
+ registerHook('llm_output', (event, ctx) => {
586
+ const sessionKey = ctx?.sessionKey;
587
+ if (!sessionKey)
588
+ return;
589
+ const entry = findRunBySession(sessionKey);
590
+ if (!entry?.[1].enabled)
591
+ return;
592
+ const usage = event.usage;
593
+ if (!usage?.total)
594
+ return;
595
+ // H4: Guard against NaN / negative / non-finite token counts
596
+ const added = typeof usage.total === 'number' && Number.isFinite(usage.total) && usage.total >= 0
597
+ ? usage.total : 0;
598
+ const [runId, state] = entry;
599
+ let updated = { ...state, totalTokensUsed: state.totalTokensUsed + added };
600
+ // Phase 1: Dispatch agent_activity through orchestrator reducer
601
+ // Note: inputTokensUsed/outputTokensUsed are updated by the reducer's agent_activity case
602
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, {
603
+ type: 'agent_activity',
604
+ runId,
605
+ activity: 'llm_output',
606
+ now: Date.now(),
607
+ tokens: { input: usage.input, output: usage.output, total: added },
608
+ });
609
+ setState(runId, updated);
610
+ (0, logger_1.log)(`[autopilot] llm_output: session=${sessionKey} tokens=+${added} total=${updated.totalTokensUsed}${updated.tokenBudget ? `/${updated.tokenBudget}` : ''}`);
611
+ });
612
+ registerHook('session_start', (event) => {
613
+ if (event.sessionId && event.sessionKey) {
614
+ sessionIdToKey.set(event.sessionId, event.sessionKey);
615
+ (0, logger_1.log)(`[autopilot] session_start: ${event.sessionId} → ${event.sessionKey}`);
616
+ }
617
+ });
618
+ registerHook('session_end', (event) => {
619
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
620
+ sessionIdToKey.delete(event.sessionId);
621
+ if (!sessionKey)
622
+ return;
623
+ const entry = findRunBySession(sessionKey);
624
+ if (entry) {
625
+ const [runId] = entry;
626
+ stateByRun.delete(runId);
627
+ sessionKeyToRunId.delete(sessionKey);
628
+ canaryFired.delete(sessionKey);
629
+ }
630
+ (0, logger_1.log)(`[autopilot] session_end: session=${sessionKey} state cleaned up`);
631
+ });
632
+ registerHook('agent_end', async (event) => {
633
+ const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
634
+ if (!sessionKey)
635
+ return;
636
+ const entry = findRunBySession(sessionKey);
637
+ if (!entry)
638
+ return;
639
+ if (!entry[1].enabled)
640
+ return;
641
+ const [runId, state] = entry;
642
+ const didFire = canaryFired.has(sessionKey);
643
+ canaryFired.delete(sessionKey);
644
+ if (!didFire) {
645
+ const updated = { ...state, degraded: true };
646
+ // M-4: When at max continuations, pause directly instead of requesting cross-turn
647
+ // (cross-turn would just hit max_total_reached again — wasted IPC round-trip)
648
+ if (state.status === 'running' && state.totalContinuations >= state.maxTotalContinuations) {
649
+ setState(runId, (0, autopilot_state_1.pause)(updated, 'max_total_reached'));
650
+ (0, logger_1.warn)(`[autopilot] agent_end: degraded at max continuations, pausing session=${sessionKey}`);
651
+ return;
652
+ }
653
+ if (state.status === 'running' && state.totalContinuations < state.maxTotalContinuations) {
654
+ const continued = (0, autopilot_state_1.incrementTotal)((0, autopilot_state_1.resetTurnAttempts)(updated));
655
+ const enqueue = api.session?.workflow?.enqueueNextTurnInjection;
656
+ if (typeof enqueue === 'function') {
657
+ try {
658
+ const injectResult = await enqueue({
659
+ sessionKey,
660
+ text: (0, continuation_engine_1.buildRetryInstruction)(continued),
661
+ idempotencyKey: `autopilot-degraded-${sessionKey}-${continued.totalContinuations}`,
662
+ placement: 'prepend_context',
663
+ ttlMs: 300000,
664
+ });
665
+ if (injectResult && typeof injectResult === 'object' && injectResult.enqueued === false) {
666
+ (0, logger_1.warn)(`[autopilot] agent_end: degraded fallback enqueue rejected for session=${sessionKey}`);
667
+ }
668
+ else {
669
+ // H-1: Merge cross-turn fields onto current state (preserves intermediate changes)
670
+ const current = stateByRun.get(runId);
671
+ if (current) {
672
+ setState(runId, {
673
+ ...current,
674
+ totalContinuations: current.totalContinuations + 1,
675
+ needsCrossTurnResume: true,
676
+ turnAttempts: 0,
677
+ degraded: true,
678
+ });
679
+ }
680
+ else {
681
+ setState(runId, continued);
682
+ }
683
+ (0, logger_1.warn)(`[autopilot] agent_end: degraded fallback cross-turn for session=${sessionKey}`);
684
+ return;
685
+ }
686
+ }
687
+ catch (err) {
688
+ (0, logger_1.warn)(`[autopilot] agent_end: degraded fallback injection failed: ${err}`);
689
+ }
690
+ }
691
+ }
692
+ setState(runId, { ...updated, needsCrossTurnResume: true });
693
+ (0, logger_1.warn)(`[autopilot] agent_end: canary check failed for session=${sessionKey} — before_agent_finalize never fired, hook may be disabled`);
694
+ return;
695
+ }
696
+ const isBreaker = !event.success && event.error?.toLowerCase().includes('circuit breaker');
697
+ const afterPause = isBreaker ? (0, autopilot_state_1.pause)(state, 'loop_breaker_triggered') : state;
698
+ // GAP-24: Clear degraded when canary fired — system recovered from degradation
699
+ const afterDegradedClear = didFire ? { ...afterPause, degraded: false } : afterPause;
700
+ // Phase 1: Dispatch agent_turn_finished through orchestrator reducer
701
+ const afterOrchestrator = (0, orchestrator_1.orchestratorReducer)((0, autopilot_state_1.resetTurnAttempts)(afterDegradedClear), {
702
+ type: 'agent_turn_finished',
703
+ runId,
704
+ success: event.success !== false,
705
+ error: event.error,
706
+ now: Date.now(),
707
+ });
708
+ // GAP-8: Write progress after each agent turn
709
+ const afterProgress = {
710
+ ...afterOrchestrator,
711
+ progress: `Turn ${afterOrchestrator.totalContinuations}/${afterOrchestrator.maxTotalContinuations} completed`,
712
+ };
713
+ setState(runId, afterProgress);
714
+ (0, logger_1.logWithContext)('info', 'agent_end', { sessionKey, runId, success: event.success, isBreaker, orchState: afterProgress.orchestrationState ?? 'n/a', progress: afterProgress.progress });
715
+ });
716
+ // --- Session Extension ---
717
+ const registerSessionExt = api.session?.state?.registerSessionExtension;
718
+ if (typeof registerSessionExt !== 'function') {
719
+ (0, logger_1.error)('[autopilot] registerSessionExtension API unavailable — session extension not registered, toggle will use default idle state');
720
+ }
721
+ else {
722
+ registerSessionExt({
723
+ namespace: 'autopilot',
724
+ description: 'Autopilot continuous mode state projection',
725
+ sessionEntrySlotKey: 'autopilot',
726
+ project: (ctx) => {
727
+ if (!ctx.sessionKey)
728
+ return undefined;
729
+ const entry = findRunBySession(ctx.sessionKey);
730
+ if (entry)
731
+ return (0, projection_1.projectState)(entry[1]);
732
+ return {
733
+ status: 'idle',
734
+ enabled: false,
735
+ turnAttempts: 0,
736
+ totalContinuations: 0,
737
+ maxAttemptsPerTurn: config.maxAttemptsPerTurn,
738
+ maxTotalContinuations: config.maxTotalContinuations,
739
+ maxConcurrentAutopilot: config.maxConcurrentAutopilot ?? 5,
740
+ needsCrossTurnResume: false,
741
+ canStop: false,
742
+ totalTokensUsed: 0,
743
+ degraded: false,
744
+ };
745
+ },
746
+ cleanup: (ctx) => {
747
+ if (ctx.sessionKey) {
748
+ const entry = findRunBySession(ctx.sessionKey);
749
+ if (entry)
750
+ stateByRun.delete(entry[0]);
751
+ }
752
+ },
753
+ });
754
+ }
755
+ // --- Gateway Methods (OpenClaw-native session-level operations) ---
756
+ if (typeof api.registerGatewayMethod !== 'function') {
757
+ (0, logger_1.error)('[autopilot] registerGatewayMethod API unavailable — gateway methods not registered');
758
+ }
759
+ else {
760
+ api.registerGatewayMethod('autopilot.activate', async ({ params: ctx, respond }) => {
761
+ const sessionKey = ctx.sessionKey;
762
+ (0, logger_1.log)('[autopilot] activate called — sessionKey:', sessionKey, 'params:', JSON.stringify(ctx));
763
+ if (!sessionKey) {
764
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'missing sessionKey' });
765
+ return;
766
+ }
767
+ // GAP-7: Extract payload fields from RPC call (sent by AutopilotCreateDialog)
768
+ const payloadGoal = ctx.goal;
769
+ const payloadMaxContinuations = ctx.maxTotalContinuations;
770
+ const payloadWorkspacePath = validateWorkspacePath(ctx.workspacePath);
771
+ const payloadTokenBudget = typeof ctx.tokenBudget === 'number' && ctx.tokenBudget > 0 ? ctx.tokenBudget : undefined;
772
+ // Concurrency guard: count sessions with status === 'running'
773
+ const maxConcurrent = config.maxConcurrentAutopilot ?? 5;
774
+ const runningCount = Array.from(stateByRun.values()).filter(s => s.status === 'running').length;
775
+ // Only enforce if the current session is NOT already running (re-activating an existing running session is handled below)
776
+ const currentEntry = findRunBySession(sessionKey);
777
+ const currentlyRunning = currentEntry?.[1].status === 'running';
778
+ if (!currentlyRunning && runningCount >= maxConcurrent) {
779
+ (0, logger_1.warn)(`[autopilot] activate rejected: max_concurrent_reached (running=${runningCount} max=${maxConcurrent})`);
780
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'max_concurrent_reached' });
781
+ return;
782
+ }
783
+ /** Apply payload overrides to state (GAP-7 wiring) */
784
+ const applyPayload = (s) => {
785
+ const next = { ...s };
786
+ if (payloadMaxContinuations != null)
787
+ next.maxTotalContinuations = Math.min(500, Math.max(1, Math.round(payloadMaxContinuations)));
788
+ if (payloadTokenBudget != null)
789
+ next.tokenBudget = payloadTokenBudget;
790
+ return next;
791
+ };
792
+ // GAP-6: Load workflow config from WORKFLOW.md
793
+ const applyWorkflowConfig = (s) => {
794
+ try {
795
+ // Use payloadWorkspacePath (validated in outer scope by validateWorkspacePath) rather than
796
+ // re-reading ctx.workspacePath raw — prevents path-traversal via WORKFLOW.md loading.
797
+ const result = (0, workflow_config_1.loadWorkflowConfig)(process.cwd(), payloadWorkspacePath);
798
+ // R-3: Auto-fill validation commands when WORKFLOW.md has none.
799
+ // Only auto-detect when an explicit workspace path is provided (not cwd fallback)
800
+ // to avoid running project tests in unexpected directories during tests/CI.
801
+ const autoCommands = result.config.validation.commands.length === 0 && payloadWorkspacePath
802
+ ? (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath)
803
+ : result.config.validation.commands;
804
+ return {
805
+ ...s,
806
+ workflow: {
807
+ ...result.config,
808
+ validation: { ...result.config.validation, commands: autoCommands },
809
+ },
810
+ maxTotalContinuations: s.maxTotalContinuations,
811
+ };
812
+ }
813
+ catch (err) {
814
+ // Graceful fallback — use defaults
815
+ return {
816
+ ...s,
817
+ workflow: { ...workflow_config_1.DEFAULT_WORKFLOW_CONFIG },
818
+ workflowConfigError: err instanceof Error ? err.message : String(err),
819
+ };
820
+ }
821
+ };
822
+ if (currentEntry) {
823
+ const [oldRunId, state] = currentEntry;
824
+ // Allow re-activation from idle/done, OR from a STUCK running session
825
+ // (stalled — orchState=retry_queued or no activity beyond stallTimeout).
826
+ // A stuck run would otherwise block every future activation until a
827
+ // gateway restart, because the stall handler leaves status='running'.
828
+ // Genuinely-active runs (recent activity) still fall through to reject.
829
+ const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), config.tokenBudget ? 300_000 : 600_000);
830
+ if (state.status === 'idle' || state.status === 'done' || stuckRecovery) {
831
+ if (stuckRecovery) {
832
+ (0, logger_1.warn)(`[autopilot] activate: recovering stuck session=${sessionKey} (status=${state.status}, orchState=${state.orchestrationState ?? 'none'}) — discarding stale run ${oldRunId}`);
833
+ }
834
+ // Release audit monitor for the old run before discarding it.
835
+ setAuditMode('active');
836
+ stateByRun.delete(oldRunId);
837
+ sessionKeyToRunId.delete(sessionKey);
838
+ const runId = generateRunId();
839
+ let newState = (0, autopilot_state_1.activate)((0, types_1.createInitialState)(sessionKey, runId, config));
840
+ // Preserve existing goal only if no new goal provided in payload
841
+ const goalForEvent = payloadGoal ?? state.goal ?? newState.goal;
842
+ newState = (0, orchestrator_1.orchestratorReducer)(newState, { type: 'activate_requested', sessionKey, goal: goalForEvent, now: Date.now() });
843
+ newState = applyPayload(newState);
844
+ newState = applyWorkflowConfig(newState);
845
+ newState = (0, orchestrator_1.orchestratorReducer)(newState, {
846
+ type: 'workspace_ready',
847
+ runId,
848
+ workspace: { root: payloadWorkspacePath ?? process.cwd(), path: payloadWorkspacePath ?? process.cwd(), workspaceKey: runId, branchName: '', baseBranch: 'HEAD', createdNow: false, reusable: true },
849
+ now: Date.now(),
850
+ });
851
+ setState(runId, newState);
852
+ sessionKeyToRunId.set(sessionKey, runId);
853
+ (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goal=${goalForEvent ?? 'none'})`);
854
+ }
855
+ else {
856
+ (0, logger_1.warn)(`[autopilot] activate rejected: session=${sessionKey} status=${state.status}`);
857
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: `cannot activate from status "${state.status}", must be "idle" or "done"` });
858
+ return;
859
+ }
860
+ }
861
+ else {
862
+ const runId = `run-${Math.random().toString(36).slice(2, 10)}`;
863
+ let state = (0, autopilot_state_1.activate)((0, types_1.createInitialState)(sessionKey, runId, config));
864
+ state = (0, orchestrator_1.orchestratorReducer)(state, { type: 'activate_requested', sessionKey, goal: payloadGoal, now: Date.now() });
865
+ state = applyPayload(state);
866
+ state = applyWorkflowConfig(state);
867
+ state = (0, orchestrator_1.orchestratorReducer)(state, {
868
+ type: 'workspace_ready',
869
+ runId,
870
+ workspace: { root: payloadWorkspacePath ?? process.cwd(), path: payloadWorkspacePath ?? process.cwd(), workspaceKey: runId, branchName: '', baseBranch: 'HEAD', createdNow: false, reusable: true },
871
+ now: Date.now(),
872
+ });
873
+ setState(runId, state);
874
+ sessionKeyToRunId.set(sessionKey, runId);
875
+ (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (goal=${payloadGoal ?? 'none'})`);
876
+ }
877
+ // Suppress audit confirm dialogs for all autopilot sessions.
878
+ setAuditMode('monitor');
879
+ (0, logger_1.log)('[autopilot] activate success — sessionKey:', sessionKey);
880
+ respond(true, { ok: true });
881
+ });
882
+ api.registerGatewayMethod('autopilot.resume', async ({ params: ctx, respond }) => {
883
+ const sessionKey = ctx.sessionKey;
884
+ if (!sessionKey) {
885
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'missing sessionKey' });
886
+ return;
887
+ }
888
+ const entry = findRunBySession(sessionKey);
889
+ if (!entry) {
890
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'no active run for session' });
891
+ return;
892
+ }
893
+ const [runId, state] = entry;
894
+ if (state.status !== 'paused') {
895
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: `cannot resume from status "${state.status}"` });
896
+ return;
897
+ }
898
+ // M2: Dispatch resume_requested through orchestrator reducer
899
+ const orchestrated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'resume_requested', runId, now: Date.now() });
900
+ setState(runId, (0, autopilot_state_1.resume)(orchestrated));
901
+ (0, logger_1.log)(`[autopilot] resume: session=${sessionKey} paused→running, errors reset`);
902
+ // Re-acquire audit monitor mode on resume.
903
+ setAuditMode('monitor');
904
+ respond(true, { ok: true });
905
+ });
906
+ api.registerGatewayMethod('autopilot.stop', async ({ params: ctx, respond }) => {
907
+ const sessionKey = ctx.sessionKey;
908
+ if (!sessionKey) {
909
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'missing sessionKey' });
910
+ return;
911
+ }
912
+ const entry = findRunBySession(sessionKey);
913
+ if (!entry) {
914
+ respond(true, { ok: true });
915
+ return;
916
+ }
917
+ const [runId, state] = entry;
918
+ if (state.status === 'running' || state.status === 'paused' || state.status === 'done') {
919
+ // M2: Dispatch stop_requested through orchestrator reducer for M2 state tracking
920
+ const orchestrated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'stop_requested', runId, now: Date.now() });
921
+ setState(runId, (0, autopilot_state_1.deactivate)(orchestrated));
922
+ (0, logger_1.log)(`[autopilot] stop: session=${sessionKey} ${state.status}→idle`);
923
+ }
924
+ // Release audit monitor refcount when session stops.
925
+ setAuditMode('active');
926
+ respond(true, { ok: true });
927
+ });
928
+ api.registerGatewayMethod('autopilot.status', async ({ params: ctx, respond }) => {
929
+ const sessionKey = ctx.sessionKey;
930
+ const entry = sessionKey ? findRunBySession(sessionKey) : undefined;
931
+ const projection = entry ? (0, projection_1.projectState)(entry[1]) : undefined;
932
+ // Also expose raw state fields not in projection (progress, permissionAudit)
933
+ const state = entry ? entry[1] : undefined;
934
+ respond(true, {
935
+ projection,
936
+ progress: state?.progress,
937
+ // Merge in-memory entries with persisted entries; in-memory takes precedence
938
+ permissionAudit: state?.permissionAudit?.length
939
+ ? state.permissionAudit
940
+ : (0, permission_policy_2.loadRecentAuditEntries)(state?.workspace?.path ?? process.cwd(), 200),
941
+ workflow: state?.workflow,
942
+ workflowConfigError: state?.workflowConfigError,
943
+ });
944
+ });
945
+ api.registerGatewayMethod('autopilot.setGoal', async ({ params: ctx, respond }) => {
946
+ const sessionKey = ctx.sessionKey;
947
+ const goal = ctx.goal;
948
+ if (!sessionKey) {
949
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'missing sessionKey' });
950
+ return;
951
+ }
952
+ if (typeof goal !== 'string' || !goal.trim()) {
953
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'goal must be a non-empty string' });
954
+ return;
955
+ }
956
+ const entry = findRunBySession(sessionKey);
957
+ if (!entry) {
958
+ respond(false, undefined, { code: 'INVALID_REQUEST', message: 'no active run for session' });
959
+ return;
960
+ }
961
+ const [runId, state] = entry;
962
+ setState(runId, (0, autopilot_state_1.setGoal)(state, goal));
963
+ (0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goal="${goal.substring(0, 80)}"`);
964
+ respond(true, { ok: true });
965
+ });
966
+ // GAP-23: Cleanup action for graceful shutdown
967
+ api.registerGatewayMethod('autopilot.cleanup', async ({ respond }) => {
968
+ cleanupAll(); // releases audit monitor for all full_yolo sessions internally
969
+ (0, logger_1.log)('[autopilot] cleanup: all state cleared');
970
+ respond(true, { ok: true });
971
+ });
972
+ }
973
+ // ─── Phase 2: Stall Detection (GAP-4) ───────────────────────────
974
+ // Periodically check all active runs for stall (no activity for stallTimeoutMs).
975
+ // When a stall is detected, dispatch stall_timeout through the orchestrator reducer.
976
+ const stallCheckIntervalMs = 60_000; // Check every 60 seconds
977
+ const stallTimeoutMs = config.tokenBudget ? 300_000 : 600_000; // 5min or 10min default
978
+ // M-7: Clear previous interval before creating new one (HMR / double-register safety)
979
+ if (stallInterval) {
980
+ clearInterval(stallInterval);
981
+ stallInterval = null;
982
+ }
983
+ stallInterval = setInterval(() => {
984
+ const now = Date.now();
985
+ const orphanRunIds = [];
986
+ for (const [runId, state] of stateByRun.entries()) {
987
+ // GAP-4: Stall detection for active runs
988
+ if (state.enabled && state.status === 'running' && state.orchestrationState === 'running') {
989
+ const stallResult = (0, stall_detector_1.checkStall)({
990
+ lastActivityAt: state.lastActivityAt ?? state.startedAt ?? now,
991
+ now,
992
+ stallTimeoutMs,
993
+ orchestrationState: state.orchestrationState,
994
+ });
995
+ if (stallResult.stalled) {
996
+ const updated = (0, orchestrator_1.orchestratorReducer)(state, {
997
+ type: 'stall_timeout',
998
+ runId,
999
+ now,
1000
+ });
1001
+ setState(runId, updated);
1002
+ (0, logger_1.warn)(`[autopilot] stall detected: session=${state.sessionKey} run=${runId} lastActivity=${stallResult.stallDurationMs ?? 0}ms stall, orchState=${updated.orchestrationState}`);
1003
+ }
1004
+ }
1005
+ // Auto-retry: dispatch retry_due when backoff period expires for retry_queued runs
1006
+ if (state.enabled && state.orchestrationState === 'retry_queued' &&
1007
+ state.retry?.nextRetryAt != null && state.retry.nextRetryAt <= now) {
1008
+ const updated = (0, orchestrator_1.orchestratorReducer)(state, {
1009
+ type: 'retry_due',
1010
+ runId,
1011
+ now,
1012
+ });
1013
+ setState(runId, updated);
1014
+ (0, logger_1.log)(`[autopilot] retry_due: session=${state.sessionKey} run=${runId} attempt=${state.retry?.attempt ?? 1}`);
1015
+ }
1016
+ // GAP-26: Health check — detect orphaned sessions (no activity for 24h)
1017
+ const lastActivity = state.lastActivityAt ?? state.startedAt ?? 0;
1018
+ if (lastActivity > 0 && (now - lastActivity) > ORPHAN_THRESHOLD_MS) {
1019
+ orphanRunIds.push(runId);
1020
+ }
1021
+ }
1022
+ // Clean up orphaned sessions
1023
+ for (const runId of orphanRunIds) {
1024
+ const state = stateByRun.get(runId);
1025
+ if (state) {
1026
+ (0, logger_1.warn)(`[autopilot] health check: cleaning orphaned session=${state.sessionKey} run=${runId}`);
1027
+ stateByRun.delete(runId);
1028
+ sessionKeyToRunId.delete(state.sessionKey);
1029
+ canaryFired.delete(state.sessionKey);
1030
+ }
1031
+ }
1032
+ }, stallCheckIntervalMs);
1033
+ }
1034
+ //# sourceMappingURL=index.js.map