@enderfga/claw-orchestrator 3.0.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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/assets/banner.jpg +0 -0
  4. package/configs/council-reviewer-prompt.md +82 -0
  5. package/configs/council-system-prompt.md +141 -0
  6. package/dist/bin/cli.d.ts +13 -0
  7. package/dist/bin/cli.js +460 -0
  8. package/dist/bin/cli.js.map +1 -0
  9. package/dist/src/base-oneshot-session.d.ts +87 -0
  10. package/dist/src/base-oneshot-session.js +228 -0
  11. package/dist/src/base-oneshot-session.js.map +1 -0
  12. package/dist/src/circuit-breaker.d.ts +21 -0
  13. package/dist/src/circuit-breaker.js +49 -0
  14. package/dist/src/circuit-breaker.js.map +1 -0
  15. package/dist/src/consensus.d.ts +20 -0
  16. package/dist/src/consensus.js +52 -0
  17. package/dist/src/consensus.js.map +1 -0
  18. package/dist/src/constants.d.ts +129 -0
  19. package/dist/src/constants.js +138 -0
  20. package/dist/src/constants.js.map +1 -0
  21. package/dist/src/council.d.ts +67 -0
  22. package/dist/src/council.js +914 -0
  23. package/dist/src/council.js.map +1 -0
  24. package/dist/src/embedded-server.d.ts +25 -0
  25. package/dist/src/embedded-server.js +360 -0
  26. package/dist/src/embedded-server.js.map +1 -0
  27. package/dist/src/inbox-manager.d.ts +38 -0
  28. package/dist/src/inbox-manager.js +111 -0
  29. package/dist/src/inbox-manager.js.map +1 -0
  30. package/dist/src/index.d.ts +63 -0
  31. package/dist/src/index.js +973 -0
  32. package/dist/src/index.js.map +1 -0
  33. package/dist/src/logger.d.ts +16 -0
  34. package/dist/src/logger.js +44 -0
  35. package/dist/src/logger.js.map +1 -0
  36. package/dist/src/models.d.ts +69 -0
  37. package/dist/src/models.js +299 -0
  38. package/dist/src/models.js.map +1 -0
  39. package/dist/src/openai-compat.d.ts +224 -0
  40. package/dist/src/openai-compat.js +756 -0
  41. package/dist/src/openai-compat.js.map +1 -0
  42. package/dist/src/persistent-codex-app-session.d.ts +108 -0
  43. package/dist/src/persistent-codex-app-session.js +465 -0
  44. package/dist/src/persistent-codex-app-session.js.map +1 -0
  45. package/dist/src/persistent-codex-session.d.ts +37 -0
  46. package/dist/src/persistent-codex-session.js +208 -0
  47. package/dist/src/persistent-codex-session.js.map +1 -0
  48. package/dist/src/persistent-cursor-session.d.ts +21 -0
  49. package/dist/src/persistent-cursor-session.js +241 -0
  50. package/dist/src/persistent-cursor-session.js.map +1 -0
  51. package/dist/src/persistent-custom-session.d.ts +78 -0
  52. package/dist/src/persistent-custom-session.js +938 -0
  53. package/dist/src/persistent-custom-session.js.map +1 -0
  54. package/dist/src/persistent-gemini-session.d.ts +21 -0
  55. package/dist/src/persistent-gemini-session.js +216 -0
  56. package/dist/src/persistent-gemini-session.js.map +1 -0
  57. package/dist/src/persistent-session.d.ts +80 -0
  58. package/dist/src/persistent-session.js +745 -0
  59. package/dist/src/persistent-session.js.map +1 -0
  60. package/dist/src/proxy/anthropic-adapter.d.ts +136 -0
  61. package/dist/src/proxy/anthropic-adapter.js +392 -0
  62. package/dist/src/proxy/anthropic-adapter.js.map +1 -0
  63. package/dist/src/proxy/handler.d.ts +39 -0
  64. package/dist/src/proxy/handler.js +365 -0
  65. package/dist/src/proxy/handler.js.map +1 -0
  66. package/dist/src/proxy/schema-cleaner.d.ts +11 -0
  67. package/dist/src/proxy/schema-cleaner.js +34 -0
  68. package/dist/src/proxy/schema-cleaner.js.map +1 -0
  69. package/dist/src/proxy/thought-cache.d.ts +19 -0
  70. package/dist/src/proxy/thought-cache.js +53 -0
  71. package/dist/src/proxy/thought-cache.js.map +1 -0
  72. package/dist/src/session-manager.d.ts +317 -0
  73. package/dist/src/session-manager.js +1528 -0
  74. package/dist/src/session-manager.js.map +1 -0
  75. package/dist/src/types.d.ts +513 -0
  76. package/dist/src/types.js +8 -0
  77. package/dist/src/types.js.map +1 -0
  78. package/dist/src/validation.d.ts +31 -0
  79. package/dist/src/validation.js +104 -0
  80. package/dist/src/validation.js.map +1 -0
  81. package/openclaw.plugin.json +122 -0
  82. package/package.json +84 -0
  83. package/skills/SKILL.md +184 -0
  84. package/skills/references/claude-cli-tracking.md +25 -0
  85. package/skills/references/cli.md +187 -0
  86. package/skills/references/council.md +210 -0
  87. package/skills/references/getting-started.md +133 -0
  88. package/skills/references/inbox.md +81 -0
  89. package/skills/references/multi-engine.md +382 -0
  90. package/skills/references/openai-compat.md +203 -0
  91. package/skills/references/sessions.md +191 -0
  92. package/skills/references/tools.md +418 -0
  93. package/skills/references/ultra.md +126 -0
@@ -0,0 +1,1528 @@
1
+ /**
2
+ * SessionManager โ€” manages multiple PersistentClaudeSession instances
3
+ *
4
+ * Replaces the Express server layer. Pure class with no HTTP dependency.
5
+ * Can be used by Plugin tools, CLI, or any other consumer.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
10
+ import { execFile, execFileSync } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+ const execFileAsync = promisify(execFile);
13
+ import * as http from 'node:http';
14
+ import { createRequire } from 'node:module';
15
+ const _require = createRequire(import.meta.url);
16
+ function getPluginVersion() {
17
+ try {
18
+ // Walk up from this file to find package.json
19
+ let dir = path.dirname(_require.resolve('./session-manager.js').replace('/dist/', '/'));
20
+ for (let i = 0; i < 5; i++) {
21
+ const pkgPath = path.join(dir, 'package.json');
22
+ if (fs.existsSync(pkgPath)) {
23
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
24
+ if (pkg.version)
25
+ return pkg.version;
26
+ }
27
+ dir = path.dirname(dir);
28
+ }
29
+ }
30
+ catch {
31
+ /* ignore */
32
+ }
33
+ return 'unknown';
34
+ }
35
+ // โ”€โ”€โ”€ Persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
36
+ const PERSIST_DIR = path.join(os.homedir(), '.openclaw');
37
+ const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
38
+ function loadPersistedSessions() {
39
+ try {
40
+ if (!fs.existsSync(PERSIST_FILE))
41
+ return new Map();
42
+ const raw = fs.readFileSync(PERSIST_FILE, 'utf8');
43
+ const arr = JSON.parse(raw);
44
+ const now = Date.now();
45
+ // Filter out entries older than disk TTL
46
+ const valid = arr.filter((s) => now - s.lastActivity < PERSIST_DISK_TTL_MS);
47
+ return new Map(valid.map((s) => [s.name, s]));
48
+ }
49
+ catch {
50
+ return new Map();
51
+ }
52
+ }
53
+ // Atomic write: write to .tmp then rename to avoid corrupt reads on crash
54
+ function savePersistedSessions(sessions, logger) {
55
+ try {
56
+ fs.mkdirSync(PERSIST_DIR, { recursive: true });
57
+ const arr = Array.from(sessions.values());
58
+ const tmp = PERSIST_FILE + '.tmp';
59
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
60
+ fs.renameSync(tmp, PERSIST_FILE);
61
+ }
62
+ catch (err) {
63
+ (logger || createConsoleLogger('SessionManager')).warn('Failed to persist sessions:', err.message);
64
+ }
65
+ }
66
+ // Async version for hot-path (sendMessage, TTL cleanup)
67
+ function savePersistedSessionsAsync(sessions, logger) {
68
+ const log = logger || createConsoleLogger('SessionManager');
69
+ const arr = Array.from(sessions.values());
70
+ const tmp = PERSIST_FILE + '.tmp';
71
+ fs.mkdir(PERSIST_DIR, { recursive: true }, (mkdirErr) => {
72
+ if (mkdirErr) {
73
+ log.error('Failed to create persist dir:', mkdirErr.message);
74
+ return;
75
+ }
76
+ fs.writeFile(tmp, JSON.stringify(arr, null, 2), (writeErr) => {
77
+ if (writeErr) {
78
+ log.error('Failed to write session file:', writeErr.message);
79
+ return;
80
+ }
81
+ fs.rename(tmp, PERSIST_FILE, (renameErr) => {
82
+ if (renameErr) {
83
+ log.error('Failed to rename session file:', renameErr.message);
84
+ // Clean up orphan tmp file
85
+ fs.unlink(tmp, () => { });
86
+ }
87
+ });
88
+ });
89
+ });
90
+ }
91
+ // Debounce helper โ€” coalesces rapid writes into one
92
+ function makeDebounced(fn, ms) {
93
+ let timer = null;
94
+ return () => {
95
+ if (timer)
96
+ clearTimeout(timer);
97
+ timer = setTimeout(() => {
98
+ timer = null;
99
+ fn();
100
+ }, ms);
101
+ };
102
+ }
103
+ import { createConsoleLogger } from './logger.js';
104
+ import { CircuitBreaker } from './circuit-breaker.js';
105
+ import { InboxManager } from './inbox-manager.js';
106
+ import { sanitizeCwd, validateName } from './validation.js';
107
+ import { PersistentClaudeSession } from './persistent-session.js';
108
+ import { PersistentGeminiSession } from './persistent-gemini-session.js';
109
+ import { PersistentCodexSession } from './persistent-codex-session.js';
110
+ import { PersistentCodexAppServerSession } from './persistent-codex-app-session.js';
111
+ import { PersistentCursorSession } from './persistent-cursor-session.js';
112
+ import { PersistentCustomSession } from './persistent-custom-session.js';
113
+ import { overrideModelPricing, } from './types.js';
114
+ import { resolveAlias, isClaudeModel } from './models.js';
115
+ import { Council } from './council.js';
116
+ import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, RESULT_TTL_MS, ULTRAPLAN_TIMEOUT_MS, ULTRAREVIEW_POLL_INTERVAL_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, } from './constants.js';
117
+ // โ”€โ”€โ”€ SessionManager โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+ export class SessionManager {
119
+ sessions = new Map();
120
+ _pendingSessions = new Map();
121
+ cleanupTimer = null;
122
+ pluginConfig;
123
+ persistedSessions;
124
+ _debouncedSave;
125
+ _proxyServer = null;
126
+ _proxyPort = null;
127
+ _activePids = new Map();
128
+ _circuitBreaker = new CircuitBreaker();
129
+ _inbox = new InboxManager();
130
+ logger;
131
+ constructor(config, logger) {
132
+ this.logger = logger || createConsoleLogger('SessionManager');
133
+ this.pluginConfig = {
134
+ claudeBin: config?.claudeBin || 'claude',
135
+ defaultModel: config?.defaultModel,
136
+ defaultPermissionMode: config?.defaultPermissionMode || 'acceptEdits',
137
+ defaultEffort: config?.defaultEffort || 'auto',
138
+ maxConcurrentSessions: config?.maxConcurrentSessions || 5,
139
+ sessionTtlMinutes: config?.sessionTtlMinutes || 120,
140
+ };
141
+ // Apply pricing overrides if provided
142
+ if (config?.pricingOverrides) {
143
+ overrideModelPricing(config.pricingOverrides);
144
+ }
145
+ // Load persisted session registry from disk
146
+ this.persistedSessions = loadPersistedSessions();
147
+ // Clean up orphaned child processes from a previous unclean exit
148
+ this._cleanupOrphanedPids();
149
+ // Debounced async writer โ€” at most one write per 5 seconds on hot paths
150
+ this._debouncedSave = makeDebounced(() => savePersistedSessionsAsync(this.persistedSessions, this.logger), DEBOUNCED_SAVE_MS);
151
+ // Start TTL cleanup timer
152
+ this.cleanupTimer = setInterval(() => this._cleanupIdleSessions(), CLEANUP_INTERVAL_MS);
153
+ }
154
+ // โ”€โ”€โ”€ Session Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
155
+ async startSession(config) {
156
+ const name = config.name || `session-${Date.now()}`;
157
+ // Check pending first โ€” a concurrent caller may have already started creation
158
+ const pending = this._pendingSessions.get(name);
159
+ if (pending)
160
+ return pending;
161
+ if (this.sessions.has(name)) {
162
+ const existing = this.sessions.get(name);
163
+ return this._toSessionInfo(name, existing);
164
+ }
165
+ // Create the promise and register it in _pendingSessions BEFORE any async work,
166
+ // so concurrent callers arriving between now and completion see the pending entry.
167
+ const promise = this._doStartSession(name, config);
168
+ this._pendingSessions.set(name, promise);
169
+ try {
170
+ return await promise;
171
+ }
172
+ finally {
173
+ this._pendingSessions.delete(name);
174
+ }
175
+ }
176
+ async _doStartSession(name, config) {
177
+ if (this.sessions.size >= this.pluginConfig.maxConcurrentSessions) {
178
+ throw new Error(`Max concurrent sessions (${this.pluginConfig.maxConcurrentSessions}) reached`);
179
+ }
180
+ // Auto-resume: if we have a persisted claudeSessionId for this name, inject it.
181
+ // Skip when config.skipPersistence is set (e.g. openai-compat bridge sessions
182
+ // that must NOT resume stale CLI state from a previous server run).
183
+ const skipPersist = !!config.skipPersistence;
184
+ const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
185
+ // Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
186
+ const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
187
+ const fullConfig = {
188
+ name,
189
+ cwd: config.cwd || persisted?.cwd || process.cwd(),
190
+ permissionMode: config.permissionMode || this.pluginConfig.defaultPermissionMode,
191
+ effort: config.effort || this.pluginConfig.defaultEffort,
192
+ model: config.model || persisted?.model || this.pluginConfig.defaultModel,
193
+ ...config,
194
+ ...(resumeId ? { resumeSessionId: resumeId } : {}),
195
+ };
196
+ // Resolve model alias
197
+ if (fullConfig.model) {
198
+ fullConfig.resolvedModel = this._resolveModel(fullConfig.model, fullConfig.modelOverrides);
199
+ }
200
+ // Auto-inject proxy baseUrl for non-Claude models on the claude engine.
201
+ // Starts a local proxy server that converts Anthropic โ†’ OpenAI format
202
+ // and forwards to the OpenClaw gateway. Zero config required.
203
+ const engine = fullConfig.engine || persisted?.engine || 'claude';
204
+ // Circuit breaker โ€” reject early if engine is in backoff
205
+ this._circuitBreaker.check(engine);
206
+ if (engine === 'claude' && fullConfig.resolvedModel && !fullConfig.baseUrl) {
207
+ if (!isClaudeModel(fullConfig.resolvedModel)) {
208
+ const proxyPort = await this._ensureProxyServer();
209
+ if (proxyPort) {
210
+ fullConfig.baseUrl = `http://127.0.0.1:${proxyPort}`;
211
+ }
212
+ }
213
+ }
214
+ const session = this._createSession(engine, fullConfig);
215
+ session.on(SESSION_EVENT.LOG, (...args) => this.logger.info(`[Session:${name}]`, ...args));
216
+ try {
217
+ await session.start();
218
+ }
219
+ catch (err) {
220
+ this._circuitBreaker.recordFailure(engine);
221
+ throw err;
222
+ }
223
+ // Engine started successfully โ€” reset circuit breaker
224
+ this._circuitBreaker.reset(engine);
225
+ // Track child process PID for orphan cleanup
226
+ if (session.pid) {
227
+ this._activePids.set(name, session.pid);
228
+ this._savePids();
229
+ }
230
+ const managed = {
231
+ session,
232
+ config: fullConfig,
233
+ created: persisted?.originalCreated || new Date().toISOString(),
234
+ lastActivity: Date.now(),
235
+ cwd: fullConfig.cwd,
236
+ claudeSessionId: session.sessionId,
237
+ };
238
+ this.sessions.set(name, managed);
239
+ // Persist registry after session is live (skip for ephemeral sessions
240
+ // like the openai-compat bridge that set skipPersistence: true)
241
+ if (!skipPersist) {
242
+ this._persistSession(name, managed);
243
+ }
244
+ return this._toSessionInfo(name, managed);
245
+ }
246
+ async sendMessage(name, message, options = {}) {
247
+ const managed = this._getSession(name);
248
+ // Per-session serialization. Two concurrent sendMessage() calls on the
249
+ // same session previously raced on PersistentClaudeSession._streamCallbacks
250
+ // and the shared TURN_COMPLETE listener โ€” the second caller would receive
251
+ // the first caller's response, and stream callbacks would clobber each
252
+ // other. Chain waiters via a per-session promise so a slow turn blocks
253
+ // (rather than corrupts) subsequent sends.
254
+ const prior = managed.sendChain ?? Promise.resolve();
255
+ let releaseChain;
256
+ const link = new Promise((resolve) => {
257
+ releaseChain = resolve;
258
+ });
259
+ managed.sendChain = prior.then(() => link).catch(() => link);
260
+ try {
261
+ await prior;
262
+ }
263
+ catch {
264
+ /* prior failure shouldn't block this caller */
265
+ }
266
+ try {
267
+ managed.lastActivity = Date.now();
268
+ const sendOpts = {
269
+ waitForComplete: true,
270
+ timeout: options.timeout || TURN_TIMEOUT_MS,
271
+ };
272
+ if (options.effort)
273
+ sendOpts.effort = options.effort;
274
+ if (options.plan)
275
+ sendOpts.plan = true;
276
+ if (options.onEvent || options.onChunk) {
277
+ sendOpts.callbacks = {
278
+ onText: (text) => {
279
+ if (options.onChunk)
280
+ options.onChunk(text);
281
+ if (options.onEvent)
282
+ options.onEvent({ type: 'text', result: text });
283
+ },
284
+ onToolUse: (event) => {
285
+ if (options.onEvent)
286
+ options.onEvent({ type: 'tool_use', ...event });
287
+ },
288
+ onToolResult: (event) => {
289
+ if (options.onEvent)
290
+ options.onEvent({ type: 'tool_result', ...event });
291
+ },
292
+ };
293
+ }
294
+ const result = await managed.session.send(message, sendOpts);
295
+ // Update session ID if available (skip disk persist for ephemeral
296
+ // sessions that were started with skipPersistence)
297
+ if (managed.session.sessionId) {
298
+ managed.claudeSessionId = managed.session.sessionId;
299
+ if (this.persistedSessions.has(name)) {
300
+ this._persistSession(name, managed);
301
+ }
302
+ }
303
+ if ('text' in result) {
304
+ return {
305
+ output: result.text,
306
+ sessionId: managed.claudeSessionId,
307
+ events: [],
308
+ };
309
+ }
310
+ return { output: '', sessionId: managed.claudeSessionId, events: [] };
311
+ }
312
+ finally {
313
+ releaseChain();
314
+ // If this was the tail of the chain, clear it so memory doesn't grow.
315
+ if (managed.sendChain === link)
316
+ managed.sendChain = undefined;
317
+ }
318
+ }
319
+ async stopSession(name) {
320
+ const managed = this._getSession(name);
321
+ managed.session.stop();
322
+ this.sessions.delete(name);
323
+ // Remove PID tracking
324
+ this._activePids.delete(name);
325
+ this._savePids();
326
+ // Explicit stop = user intent to end session โ€” remove from disk too
327
+ this.persistedSessions.delete(name);
328
+ savePersistedSessions(this.persistedSessions, this.logger);
329
+ }
330
+ listSessions() {
331
+ return Array.from(this.sessions.entries()).map(([name, managed]) => this._toSessionInfo(name, managed));
332
+ }
333
+ listPersistedSessions() {
334
+ return Array.from(this.persistedSessions.values());
335
+ }
336
+ getStatus(name) {
337
+ const managed = this._getSession(name);
338
+ return {
339
+ ...this._toSessionInfo(name, managed),
340
+ stats: managed.session.getStats(),
341
+ };
342
+ }
343
+ // โ”€โ”€โ”€ Session Operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
344
+ async grepSession(name, pattern, limit = DEFAULT_HISTORY_LIMIT) {
345
+ const managed = this._getSession(name);
346
+ const history = managed.session.getHistory(GREP_HISTORY_FETCH);
347
+ const regex = new RegExp(pattern, 'i');
348
+ return history
349
+ .filter((ev) => regex.test(JSON.stringify(ev)))
350
+ .slice(0, limit)
351
+ .map((ev) => ({
352
+ time: ev.time,
353
+ type: ev.type,
354
+ content: JSON.stringify(ev.event),
355
+ }));
356
+ }
357
+ async compactSession(name, summary) {
358
+ const managed = this._getSession(name);
359
+ await managed.session.compact(summary);
360
+ }
361
+ setEffort(name, level) {
362
+ const managed = this._getSession(name);
363
+ managed.session.setEffort(level);
364
+ managed.config.effort = level;
365
+ }
366
+ /**
367
+ * Switch model for a session.
368
+ * Updates in-memory config only (takes effect on next restart/resume).
369
+ * For immediate effect, call restartWithConfig() explicitly.
370
+ */
371
+ setModel(name, model) {
372
+ const managed = this._getSession(name);
373
+ const resolved = this._resolveModel(model, managed.config.modelOverrides);
374
+ managed.config.model = model;
375
+ managed.config.resolvedModel = resolved;
376
+ }
377
+ /**
378
+ * Switch model immediately by restarting the session with --resume.
379
+ * Conversation history is preserved via the claude session ID.
380
+ *
381
+ * Guards:
382
+ * - Rejects if session is currently processing a message (busy guard)
383
+ * - Validates model string against known aliases before restarting
384
+ * - Rolls back to old session if startSession fails
385
+ */
386
+ async switchModel(name, model) {
387
+ const managed = this._getSession(name);
388
+ // Busy guard โ€” don't restart mid-message
389
+ if (managed.session.isBusy) {
390
+ throw new Error(`Session '${name}' is currently processing a message. Wait for it to finish before switching model.`);
391
+ }
392
+ const sessionId = managed.claudeSessionId || managed.session.sessionId;
393
+ if (!sessionId)
394
+ throw new Error(`Session '${name}' has no claude session ID โ€” cannot resume after restart`);
395
+ // Validate model โ€” must be a known alias or contain a recognisable pattern
396
+ const resolvedModel = this._resolveModel(model, managed.config.modelOverrides);
397
+ const knownPatterns = ['claude-', 'gemini-', 'gpt-', 'anthropic/', 'google/', 'openai/'];
398
+ const looksValid = knownPatterns.some((p) => resolvedModel.includes(p));
399
+ if (!looksValid) {
400
+ throw new Error(`Unknown model '${model}' (resolved: '${resolvedModel}'). Use a known alias (opus, sonnet, haiku, gemini-pro, etc.) or a full provider/model string.`);
401
+ }
402
+ const oldConfig = { ...managed.config };
403
+ managed.session.stop();
404
+ this.sessions.delete(name);
405
+ try {
406
+ return await this.startSession({
407
+ ...oldConfig,
408
+ name,
409
+ model,
410
+ resumeSessionId: sessionId,
411
+ });
412
+ }
413
+ catch (err) {
414
+ // Rollback: restart with original config
415
+ this.logger.error(`switchModel failed for '${name}', attempting rollback:`, err);
416
+ try {
417
+ await this.startSession({ ...oldConfig, name, resumeSessionId: sessionId });
418
+ }
419
+ catch (rollbackErr) {
420
+ this.logger.error(`Rollback also failed for '${name}':`, rollbackErr);
421
+ }
422
+ throw new Error(`Failed to switch model for '${name}': ${err.message}`);
423
+ }
424
+ }
425
+ /**
426
+ * Update allowedTools or disallowedTools at runtime.
427
+ *
428
+ * The claude CLI does not support changing tool lists while running, so
429
+ * the only way to apply new constraints is to restart the process with
430
+ * the updated flags and --resume to replay conversation history.
431
+ *
432
+ * Guards:
433
+ * - Rejects if session is busy
434
+ * - Rolls back to old session if startSession fails
435
+ * - merge:true adds tools; removeTools removes specific tools from the list
436
+ */
437
+ async updateTools(name, opts) {
438
+ const managed = this._getSession(name);
439
+ // Busy guard
440
+ if (managed.session.isBusy) {
441
+ throw new Error(`Session '${name}' is currently processing a message. Wait for it to finish before updating tools.`);
442
+ }
443
+ const sessionId = managed.claudeSessionId || managed.session.sessionId;
444
+ if (!sessionId)
445
+ throw new Error(`Session '${name}' has no claude session ID โ€” cannot resume after restart`);
446
+ const oldConfig = { ...managed.config };
447
+ let newAllowed = opts.allowedTools;
448
+ let newDisallowed = opts.disallowedTools;
449
+ if (opts.merge) {
450
+ newAllowed = opts.allowedTools
451
+ ? [...new Set([...(oldConfig.allowedTools || []), ...opts.allowedTools])]
452
+ : oldConfig.allowedTools;
453
+ newDisallowed = opts.disallowedTools
454
+ ? [...new Set([...(oldConfig.disallowedTools || []), ...opts.disallowedTools])]
455
+ : oldConfig.disallowedTools;
456
+ }
457
+ // Remove specific tools if requested
458
+ if (opts.removeTools?.length) {
459
+ const removeSet = new Set(opts.removeTools);
460
+ if (newAllowed)
461
+ newAllowed = newAllowed.filter((t) => !removeSet.has(t));
462
+ if (newDisallowed)
463
+ newDisallowed = newDisallowed.filter((t) => !removeSet.has(t));
464
+ }
465
+ managed.session.stop();
466
+ this.sessions.delete(name);
467
+ try {
468
+ return await this.startSession({
469
+ ...oldConfig,
470
+ name,
471
+ allowedTools: newAllowed,
472
+ disallowedTools: newDisallowed,
473
+ resumeSessionId: sessionId,
474
+ });
475
+ }
476
+ catch (err) {
477
+ this.logger.error(`updateTools failed for '${name}', attempting rollback:`, err);
478
+ try {
479
+ await this.startSession({ ...oldConfig, name, resumeSessionId: sessionId });
480
+ }
481
+ catch (rollbackErr) {
482
+ this.logger.error(`Rollback also failed for '${name}':`, rollbackErr);
483
+ }
484
+ throw new Error(`Failed to update tools for '${name}': ${err.message}`);
485
+ }
486
+ }
487
+ getCost(name) {
488
+ const managed = this._getSession(name);
489
+ return managed.session.getCost();
490
+ }
491
+ // โ”€โ”€โ”€ Agent/Skill/Rule Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
492
+ listAgents(cwd) {
493
+ const safeCwd = sanitizeCwd(cwd);
494
+ const projectDir = path.join(safeCwd || os.homedir(), '.claude', 'agents');
495
+ const globalDir = path.join(os.homedir(), '.claude', 'agents');
496
+ const project = this._listMdFiles(projectDir);
497
+ const global = this._listMdFiles(globalDir);
498
+ const seen = new Set(project.map((a) => a.name));
499
+ return [...project, ...global.filter((a) => !seen.has(a.name))];
500
+ }
501
+ createAgent(name, cwd, description, prompt) {
502
+ validateName(name);
503
+ const safeCwd = sanitizeCwd(cwd);
504
+ const dir = path.join(safeCwd || os.homedir(), '.claude', 'agents');
505
+ fs.mkdirSync(dir, { recursive: true });
506
+ const filePath = path.join(dir, `${name}.md`);
507
+ const content = `---\ndescription: ${description || name}\n---\n\n${prompt || `You are ${name}.`}\n`;
508
+ fs.writeFileSync(filePath, content);
509
+ return filePath;
510
+ }
511
+ listSkills(cwd) {
512
+ const safeCwd = sanitizeCwd(cwd);
513
+ const dirs = [
514
+ path.join(safeCwd || os.homedir(), '.claude', 'skills'),
515
+ path.join(os.homedir(), '.claude', 'skills'),
516
+ ];
517
+ const all = [];
518
+ const seen = new Set();
519
+ for (const dir of dirs) {
520
+ if (!fs.existsSync(dir))
521
+ continue;
522
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
523
+ if (!entry.isDirectory() || seen.has(entry.name))
524
+ continue;
525
+ seen.add(entry.name);
526
+ const skillMd = path.join(dir, entry.name, 'SKILL.md');
527
+ let description = '';
528
+ if (fs.existsSync(skillMd)) {
529
+ const content = fs.readFileSync(skillMd, 'utf8');
530
+ const match = content.match(/^---\n[\s\S]*?description:\s*(.+)/m);
531
+ if (match)
532
+ description = match[1].trim();
533
+ }
534
+ all.push({ name: entry.name, hasSkillMd: fs.existsSync(skillMd), description });
535
+ }
536
+ }
537
+ return all;
538
+ }
539
+ createSkill(name, cwd, opts) {
540
+ validateName(name);
541
+ const safeCwd = sanitizeCwd(cwd);
542
+ const dir = path.join(safeCwd || os.homedir(), '.claude', 'skills', name);
543
+ fs.mkdirSync(dir, { recursive: true });
544
+ const filePath = path.join(dir, 'SKILL.md');
545
+ let content = '---\n';
546
+ if (opts?.description)
547
+ content += `description: ${opts.description}\n`;
548
+ if (opts?.trigger)
549
+ content += `trigger: ${opts.trigger}\n`;
550
+ content += `---\n\n${opts?.prompt || `# ${name}\n\nSkill instructions here.\n`}\n`;
551
+ fs.writeFileSync(filePath, content);
552
+ return filePath;
553
+ }
554
+ listRules(cwd) {
555
+ const safeCwd = sanitizeCwd(cwd);
556
+ const dirs = [path.join(safeCwd || os.homedir(), '.claude', 'rules'), path.join(os.homedir(), '.claude', 'rules')];
557
+ const all = [];
558
+ const seen = new Set();
559
+ for (const dir of dirs) {
560
+ if (!fs.existsSync(dir))
561
+ continue;
562
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.md'))) {
563
+ const name = f.replace('.md', '');
564
+ if (seen.has(name))
565
+ continue;
566
+ seen.add(name);
567
+ const content = fs.readFileSync(path.join(dir, f), 'utf8');
568
+ const descMatch = content.match(/^---\n[\s\S]*?description:\s*(.+)/m);
569
+ const pathsMatch = content.match(/^---\n[\s\S]*?paths:\s*(.+)/m);
570
+ const ifMatch = content.match(/^---\n[\s\S]*?if:\s*(.+)/m);
571
+ all.push({
572
+ name,
573
+ file: f,
574
+ description: descMatch?.[1]?.trim() || '',
575
+ paths: pathsMatch?.[1]?.trim() || '',
576
+ condition: ifMatch?.[1]?.trim() || '',
577
+ });
578
+ }
579
+ }
580
+ return all;
581
+ }
582
+ createRule(name, cwd, opts) {
583
+ validateName(name);
584
+ const safeCwd = sanitizeCwd(cwd);
585
+ const dir = path.join(safeCwd || os.homedir(), '.claude', 'rules');
586
+ fs.mkdirSync(dir, { recursive: true });
587
+ const filePath = path.join(dir, `${name}.md`);
588
+ let fileContent = '---\n';
589
+ if (opts?.description)
590
+ fileContent += `description: ${opts.description}\n`;
591
+ if (opts?.paths)
592
+ fileContent += `paths: ${opts.paths}\n`;
593
+ if (opts?.condition)
594
+ fileContent += `if: ${opts.condition}\n`;
595
+ fileContent += `---\n\n${opts?.content || `# ${name}\n\nRule instructions here.\n`}\n`;
596
+ fs.writeFileSync(filePath, fileContent);
597
+ return filePath;
598
+ }
599
+ // โ”€โ”€โ”€ Agent Teams โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
600
+ async teamList(name) {
601
+ // Validate the calling session exists, but list all other sessions as virtual
602
+ // teammates regardless of engine. Claude Code's native Agent Teams (v2.1.32+,
603
+ // gated by CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) is an in-process TUI
604
+ // mechanism โ€” it has no `/team` slash command and no stdin-driven mailbox
605
+ // accessible to a subprocess wrapper. Earlier code assumed `/team` existed
606
+ // and got back `Unknown command: /team` (issue #48).
607
+ this._getSession(name);
608
+ const teammates = [];
609
+ for (const [sessionName, m] of this.sessions) {
610
+ if (sessionName === name)
611
+ continue;
612
+ const eng = m.config.engine || 'claude';
613
+ const stats = m.session.getStats();
614
+ const status = m.session.isBusy ? 'busy' : m.session.isPaused ? 'paused' : 'idle';
615
+ teammates.push(`- ${sessionName} (${eng}, ${status}, ${stats.turns} turns)`);
616
+ }
617
+ return teammates.length > 0
618
+ ? `Virtual team (${teammates.length} sessions):\n${teammates.join('\n')}`
619
+ : 'No other active sessions';
620
+ }
621
+ async teamSend(name, teammate, message) {
622
+ const managed = this._getSession(name);
623
+ if (!this.sessions.has(teammate)) {
624
+ throw new Error(`Target session '${teammate}' not found. Use team_list to see available sessions.`);
625
+ }
626
+ const deliveryResult = await this.sessionSendTo(name, teammate, message, `team message from ${name}`);
627
+ return {
628
+ output: deliveryResult.delivered
629
+ ? `Message delivered to ${teammate}`
630
+ : `Message queued for ${teammate} (session is busy)`,
631
+ sessionId: managed.claudeSessionId,
632
+ events: [],
633
+ };
634
+ }
635
+ // โ”€โ”€โ”€ Health โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
636
+ /**
637
+ * Returns an overview of all active sessions โ€” analogous to a dashboard.
638
+ * Unlike claude_session_status (single session), this gives the aggregate
639
+ * view: how many sessions are running, which are busy, total uptime, etc.
640
+ */
641
+ health() {
642
+ const details = Array.from(this.sessions.entries()).map(([name, managed]) => {
643
+ const stats = managed.session.getStats();
644
+ return {
645
+ name,
646
+ ready: stats.isReady,
647
+ busy: managed.session.isBusy,
648
+ paused: managed.session.isPaused,
649
+ turns: stats.turns,
650
+ costUsd: stats.costUsd,
651
+ contextPercent: stats.contextPercent,
652
+ lastActivity: stats.lastActivity,
653
+ };
654
+ });
655
+ return {
656
+ ok: true,
657
+ version: getPluginVersion(),
658
+ sessions: this.sessions.size,
659
+ sessionNames: Array.from(this.sessions.keys()),
660
+ uptime: process.uptime(),
661
+ details,
662
+ circuitBreakers: this._circuitBreaker.getStatus(),
663
+ };
664
+ }
665
+ /** Return plugin version from package.json */
666
+ getVersion() {
667
+ return getPluginVersion();
668
+ }
669
+ // โ”€โ”€โ”€ Shutdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
670
+ /**
671
+ * Gracefully shut down the session manager.
672
+ *
673
+ * 1. Cancels the periodic TTL cleanup timer
674
+ * 2. Stops all ultrareview polling intervals
675
+ * 3. Sends SIGTERM to all active session child processes
676
+ * 4. Persists final session registry to disk
677
+ *
678
+ * After shutdown(), no new sessions can be started. Idempotent.
679
+ */
680
+ async shutdown() {
681
+ if (this.cleanupTimer) {
682
+ clearInterval(this.cleanupTimer);
683
+ this.cleanupTimer = null;
684
+ }
685
+ // Stop ultrareview pollers
686
+ for (const [, timer] of this.ultrareviewPollers)
687
+ clearInterval(timer);
688
+ this.ultrareviewPollers.clear();
689
+ // Stop all sessions
690
+ for (const [name, managed] of this.sessions) {
691
+ try {
692
+ managed.session.stop();
693
+ }
694
+ catch {
695
+ // Best-effort โ€” session may already be dead; must not block cleanup
696
+ }
697
+ this.logger.info(`Stopped session: ${name}`);
698
+ }
699
+ this.sessions.clear();
700
+ // Clear PID tracking
701
+ this._activePids.clear();
702
+ this._savePids();
703
+ // Stop proxy server
704
+ if (this._proxyServer) {
705
+ this._proxyServer.close();
706
+ this._proxyServer = null;
707
+ this._proxyPort = null;
708
+ }
709
+ // Persist final state (TTL-expired sessions already removed by cleanup)
710
+ savePersistedSessions(this.persistedSessions, this.logger);
711
+ }
712
+ // โ”€โ”€โ”€ Codex /goal helpers (codex-app engine only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
713
+ /**
714
+ * Send a `/goal <args>` slash command to a `codex-app` session. Used by
715
+ * the `codex_goal_*` tools. The server-side parser interprets the slash
716
+ * command and emits goal-related notifications which the session class
717
+ * caches.
718
+ *
719
+ * Errors clearly when called against a non-`codex-app` session โ€” those
720
+ * sessions cannot interpret `/goal` (the `codex exec` path has no slash
721
+ * command surface).
722
+ */
723
+ async codexGoalCommand(name, slashArgs, timeoutMs) {
724
+ const managed = this.sessions.get(name);
725
+ if (!managed)
726
+ throw new Error(`Session not found: ${name}`);
727
+ const session = managed.session;
728
+ if (typeof session.sendGoalCommand !== 'function') {
729
+ const engine = managed.config.engine || 'claude';
730
+ throw new Error(`Session "${name}" uses engine "${engine}" which does not support /goal. ` +
731
+ `Start a session with engine: "codex-app" to use the goal tools.`);
732
+ }
733
+ const result = await session.sendGoalCommand(slashArgs, timeoutMs);
734
+ return { ok: true, text: result.text, goal: result.goal };
735
+ }
736
+ /**
737
+ * Read the cached goal state from a `codex-app` session without sending
738
+ * any command. Returns null if no goal is set or the session has not yet
739
+ * received a `thread/goal/updated` notification.
740
+ */
741
+ codexGoalGet(name) {
742
+ const managed = this.sessions.get(name);
743
+ if (!managed)
744
+ throw new Error(`Session not found: ${name}`);
745
+ const session = managed.session;
746
+ if (!('goal' in session)) {
747
+ const engine = managed.config.engine || 'claude';
748
+ throw new Error(`Session "${name}" uses engine "${engine}" which does not track goal state. ` +
749
+ `Start a session with engine: "codex-app" to use the goal tools.`);
750
+ }
751
+ return { ok: true, goal: session.goal ?? null };
752
+ }
753
+ // โ”€โ”€โ”€ Codex one-shot wrappers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
754
+ _codexBin() {
755
+ return process.env.CODEX_BIN || 'codex';
756
+ }
757
+ /**
758
+ * Parse Codex `--json` JSONL output from a stdout buffer.
759
+ *
760
+ * Codex 0.128 emits one event per line: `thread.started`, `turn.started`,
761
+ * `item.completed` (with `item.type === 'agent_message'` for assistant text
762
+ * or tool-use types for shell/MCP calls), `turn.completed` (with usage).
763
+ *
764
+ * Returns the concatenated assistant text plus the thread_id (if present)
765
+ * and the raw event list for callers that want full visibility.
766
+ */
767
+ _parseCodexJsonl(stdout) {
768
+ let assistantText = '';
769
+ let threadId;
770
+ let usage;
771
+ const events = [];
772
+ for (const line of stdout.split('\n')) {
773
+ const trimmed = line.trim();
774
+ if (!trimmed)
775
+ continue;
776
+ let ev;
777
+ try {
778
+ ev = JSON.parse(trimmed);
779
+ }
780
+ catch {
781
+ continue; // Non-JSON lines are tolerated (banners, etc.)
782
+ }
783
+ events.push(ev);
784
+ const e = ev;
785
+ if (e.type === 'thread.started') {
786
+ const t = ev;
787
+ if (t.thread_id)
788
+ threadId = t.thread_id;
789
+ }
790
+ else if (e.type === 'item.completed') {
791
+ const it = ev;
792
+ if (it.item?.type === 'agent_message' && typeof it.item.text === 'string') {
793
+ assistantText += it.item.text;
794
+ }
795
+ }
796
+ else if (e.type === 'turn.completed') {
797
+ const tc = ev;
798
+ if (tc.usage)
799
+ usage = tc.usage;
800
+ }
801
+ }
802
+ return { assistantText, threadId, usage, events };
803
+ }
804
+ /**
805
+ * Wraps `codex exec resume [SESSION_ID|--last] [PROMPT]` (Codex 0.119+).
806
+ *
807
+ * Resumes a previously recorded Codex thread by UUID/name or picks the most
808
+ * recent via `--last`. Always uses `--json` + `--sandbox workspace-write`
809
+ * so the output can be parsed into structured fields.
810
+ *
811
+ * Note: this is a one-shot operation independent of the session manager's
812
+ * tracked sessions. For in-session continuity (each send within one session
813
+ * resumes the prior thread automatically), `PersistentCodexSession`
814
+ * already handles that via the captured `thread_id` from `thread.started`.
815
+ */
816
+ async codexResume(opts) {
817
+ if (!opts.sessionId && !opts.last) {
818
+ throw new Error('codexResume requires either sessionId or last=true');
819
+ }
820
+ // `codex exec resume` does not accept --sandbox or -C โ€” sandbox policy
821
+ // and cwd are inherited from the original session. Forward `cwd` via
822
+ // the spawn process's working directory so Codex's --last picker scopes
823
+ // correctly when no SESSION_ID is given.
824
+ const args = ['exec', 'resume'];
825
+ if (opts.last)
826
+ args.push('--last');
827
+ else if (opts.sessionId)
828
+ args.push(opts.sessionId);
829
+ args.push('--skip-git-repo-check', '--json');
830
+ if (opts.model)
831
+ args.push('--model', opts.model);
832
+ args.push(opts.message);
833
+ const { stdout } = await execFileAsync(this._codexBin(), args, {
834
+ cwd: opts.cwd ? path.resolve(opts.cwd) : undefined,
835
+ maxBuffer: 32 * 1024 * 1024,
836
+ timeout: opts.timeout || 300_000,
837
+ });
838
+ const parsed = this._parseCodexJsonl(stdout);
839
+ return {
840
+ ok: true,
841
+ text: parsed.assistantText,
842
+ threadId: parsed.threadId,
843
+ usage: parsed.usage,
844
+ events: parsed.events,
845
+ };
846
+ }
847
+ /**
848
+ * Wraps `codex review [PROMPT] [--uncommitted | --base BRANCH | --commit SHA]`.
849
+ *
850
+ * Codex 0.128's review subcommand outputs plain text (no `--json` flag),
851
+ * so the wrapper just captures stdout/stderr verbatim.
852
+ */
853
+ async codexReview(opts) {
854
+ // Mutex: at most one diff scope flag.
855
+ const scopes = [opts.uncommitted, opts.base, opts.commit].filter((v) => v != null && v !== false);
856
+ if (scopes.length > 1) {
857
+ throw new Error('codexReview: --uncommitted, --base, and --commit are mutually exclusive');
858
+ }
859
+ const args = ['review'];
860
+ if (opts.uncommitted)
861
+ args.push('--uncommitted');
862
+ if (opts.base)
863
+ args.push('--base', opts.base);
864
+ if (opts.commit)
865
+ args.push('--commit', opts.commit);
866
+ if (opts.title)
867
+ args.push('--title', opts.title);
868
+ if (opts.model)
869
+ args.push('-c', `model="${opts.model}"`);
870
+ if (opts.prompt)
871
+ args.push(opts.prompt);
872
+ const { stdout, stderr } = await execFileAsync(this._codexBin(), args, {
873
+ cwd: opts.cwd ? path.resolve(opts.cwd) : undefined,
874
+ maxBuffer: 16 * 1024 * 1024,
875
+ timeout: opts.timeout || 600_000,
876
+ });
877
+ return { ok: true, stdout, stderr };
878
+ }
879
+ // โ”€โ”€โ”€ Project Purge (CLI 2.1.126) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
880
+ /**
881
+ * Wraps `claude project purge` โ€” deletes Claude Code state for a project
882
+ * (transcripts, tasks, file history, config entry).
883
+ *
884
+ * Defaults to dry-run for safety: callers must pass `dryRun: false` to
885
+ * actually delete. When `all` is true, `path` is ignored.
886
+ *
887
+ * The `--yes` flag is always passed (we have no TTY for confirmation prompts);
888
+ * safety is enforced via the dry-run default at the wrapper level instead.
889
+ */
890
+ async purgeProject(opts) {
891
+ const dryRun = opts.dryRun !== false; // default true
892
+ const args = ['project', 'purge'];
893
+ if (opts.all)
894
+ args.push('--all');
895
+ if (dryRun)
896
+ args.push('--dry-run');
897
+ else
898
+ args.push('--yes');
899
+ if (!opts.all && opts.path)
900
+ args.push(path.resolve(opts.path));
901
+ const { stdout, stderr } = await execFileAsync(this.pluginConfig.claudeBin, args, {
902
+ maxBuffer: 8 * 1024 * 1024,
903
+ });
904
+ return { stdout, stderr, dryRun };
905
+ }
906
+ // โ”€โ”€โ”€ Auto Proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
907
+ /**
908
+ * Read OpenClaw gateway config from ~/.openclaw/openclaw.json.
909
+ * Returns { url, key } or null if not configured.
910
+ */
911
+ _readGatewayConfig() {
912
+ try {
913
+ const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
914
+ if (!fs.existsSync(configPath))
915
+ return null;
916
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
917
+ const gw = config.gateway;
918
+ if (!gw)
919
+ return null;
920
+ const port = gw.port || 18789;
921
+ const auth = gw.auth;
922
+ // Support both password and token auth modes
923
+ const key = auth?.password || auth?.token || '';
924
+ return { url: `http://127.0.0.1:${port}/v1`, key };
925
+ }
926
+ catch {
927
+ return null;
928
+ }
929
+ }
930
+ /**
931
+ * Start a local proxy server (if not running) that converts Anthropic format
932
+ * to OpenAI format and forwards to the OpenClaw gateway.
933
+ * Returns the proxy port, or null if gateway is not available.
934
+ */
935
+ async _ensureProxyServer() {
936
+ if (this._proxyPort)
937
+ return this._proxyPort;
938
+ // Auto-detect gateway config
939
+ const gwConfig = this._readGatewayConfig();
940
+ const gatewayUrl = process.env.GATEWAY_URL || gwConfig?.url;
941
+ const gatewayKey = process.env.GATEWAY_KEY || gwConfig?.key;
942
+ if (!gatewayUrl) {
943
+ this.logger.info('No OpenClaw gateway found โ€” proxy not available');
944
+ return null;
945
+ }
946
+ // Lazy import to avoid circular deps
947
+ const { createProxyHandler } = await import('./proxy/handler.js');
948
+ const proxyHandler = createProxyHandler(undefined, {
949
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
950
+ openaiApiKey: process.env.OPENAI_API_KEY,
951
+ geminiApiKey: process.env.GEMINI_API_KEY,
952
+ gatewayUrl,
953
+ gatewayKey,
954
+ });
955
+ return new Promise((resolve) => {
956
+ const server = http.createServer((req, res) => {
957
+ let body = '';
958
+ req.on('data', (chunk) => {
959
+ body += chunk.toString();
960
+ });
961
+ req.on('end', () => {
962
+ const httpReq = {
963
+ method: req.method || 'GET',
964
+ url: req.url || '/',
965
+ headers: req.headers,
966
+ json: async () => JSON.parse(body),
967
+ };
968
+ const httpRes = {
969
+ status: (code) => {
970
+ res.statusCode = code;
971
+ return httpRes;
972
+ },
973
+ json: (data) => {
974
+ res.setHeader('Content-Type', 'application/json');
975
+ res.end(JSON.stringify(data));
976
+ },
977
+ setHeader: (k, v) => res.setHeader(k, v),
978
+ write: (data) => res.write(data),
979
+ end: () => res.end(),
980
+ flushHeaders: () => res.flushHeaders(),
981
+ };
982
+ proxyHandler(httpReq, httpRes).catch((err) => {
983
+ res.statusCode = 500;
984
+ res.end(JSON.stringify({ error: err.message }));
985
+ });
986
+ });
987
+ });
988
+ server.listen(0, '127.0.0.1', () => {
989
+ const addr = server.address();
990
+ this._proxyServer = server;
991
+ this._proxyPort = addr.port;
992
+ this.logger.info(`Auto-proxy started on port ${addr.port} (gateway: ${gatewayUrl})`);
993
+ resolve(addr.port);
994
+ });
995
+ server.on('error', (err) => {
996
+ this.logger.error('Failed to start proxy server:', err.message);
997
+ resolve(null);
998
+ });
999
+ });
1000
+ }
1001
+ // โ”€โ”€โ”€ Private โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1002
+ _persistSession(name, managed) {
1003
+ if (!managed.claudeSessionId)
1004
+ return;
1005
+ const existing = this.persistedSessions.get(name);
1006
+ this.persistedSessions.set(name, {
1007
+ name,
1008
+ claudeSessionId: managed.claudeSessionId,
1009
+ cwd: managed.cwd,
1010
+ model: managed.config.resolvedModel || managed.config.model,
1011
+ engine: managed.config.engine,
1012
+ originalCreated: existing?.originalCreated || managed.created,
1013
+ lastResumed: new Date().toISOString(),
1014
+ lastActivity: managed.lastActivity,
1015
+ });
1016
+ this._debouncedSave();
1017
+ }
1018
+ // โ”€โ”€โ”€ PID Tracking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1019
+ static PID_FILE = path.join(os.homedir(), '.openclaw', 'session-pids.json');
1020
+ _savePids() {
1021
+ try {
1022
+ const dir = path.dirname(SessionManager.PID_FILE);
1023
+ if (!fs.existsSync(dir))
1024
+ fs.mkdirSync(dir, { recursive: true });
1025
+ fs.writeFileSync(SessionManager.PID_FILE, JSON.stringify(Object.fromEntries(this._activePids)));
1026
+ }
1027
+ catch {
1028
+ /* best effort */
1029
+ }
1030
+ }
1031
+ /**
1032
+ * Verify that a PID belongs to a known coding CLI before killing it.
1033
+ * Prevents killing unrelated processes if the OS recycled the PID.
1034
+ */
1035
+ _isKnownCliProcess(pid) {
1036
+ // Match known CLI binaries by basename to avoid false positives
1037
+ // (e.g., 'agent' must not match 'ssh-agent' or 'gpg-agent')
1038
+ const knownPatterns = [
1039
+ /\bclaude\b/, // claude CLI
1040
+ /\bcodex\b/, // codex CLI
1041
+ /\bgemini\b/, // gemini CLI
1042
+ /\bcursor-agent\b/, // cursor-agent CLI
1043
+ /(?:^|\/)agent\s/, // 'agent' as standalone command (not ssh-agent etc.)
1044
+ ];
1045
+ try {
1046
+ const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
1047
+ encoding: 'utf8',
1048
+ timeout: 3_000,
1049
+ }).trim();
1050
+ return knownPatterns.some((pattern) => pattern.test(cmd));
1051
+ }
1052
+ catch {
1053
+ return false; // ps failed โ€” process likely dead or not accessible
1054
+ }
1055
+ }
1056
+ _cleanupOrphanedPids() {
1057
+ try {
1058
+ if (!fs.existsSync(SessionManager.PID_FILE))
1059
+ return;
1060
+ const data = JSON.parse(fs.readFileSync(SessionManager.PID_FILE, 'utf8'));
1061
+ for (const [name, pid] of Object.entries(data)) {
1062
+ try {
1063
+ process.kill(pid, 0); // check if alive
1064
+ // Alive โ€” but verify it's actually a coding CLI, not a recycled PID
1065
+ if (!this._isKnownCliProcess(pid)) {
1066
+ this.logger.info(`PID ${pid} (session: ${name}) is alive but not a known CLI โ€” skipping kill`);
1067
+ continue;
1068
+ }
1069
+ this.logger.info(`Killing orphaned process ${pid} (session: ${name})`);
1070
+ // Graceful shutdown: SIGTERM first
1071
+ try {
1072
+ process.kill(-pid, 'SIGTERM');
1073
+ }
1074
+ catch {
1075
+ /* group kill failed */
1076
+ }
1077
+ try {
1078
+ process.kill(pid, 'SIGTERM');
1079
+ }
1080
+ catch {
1081
+ /* individual kill failed */
1082
+ }
1083
+ // Give process time to shut down, then SIGKILL
1084
+ setTimeout(() => {
1085
+ try {
1086
+ process.kill(pid, 0);
1087
+ process.kill(-pid, 'SIGKILL');
1088
+ }
1089
+ catch {
1090
+ /* already dead or group kill failed */
1091
+ }
1092
+ try {
1093
+ process.kill(pid, 0);
1094
+ process.kill(pid, 'SIGKILL');
1095
+ }
1096
+ catch {
1097
+ /* already dead */
1098
+ }
1099
+ }, STOP_SIGKILL_DELAY_MS);
1100
+ }
1101
+ catch {
1102
+ // Process already dead โ€” nothing to do
1103
+ }
1104
+ }
1105
+ }
1106
+ catch {
1107
+ /* file doesn't exist or parse error */
1108
+ }
1109
+ // Clear the PID file
1110
+ this._savePids();
1111
+ }
1112
+ // Circuit breaker is delegated to this._circuitBreaker (src/circuit-breaker.ts)
1113
+ _getSession(name) {
1114
+ const managed = this.sessions.get(name);
1115
+ if (!managed)
1116
+ throw new Error(`Session '${name}' not found`);
1117
+ return managed;
1118
+ }
1119
+ _toSessionInfo(name, managed) {
1120
+ const stats = managed.session.getStats();
1121
+ return {
1122
+ name,
1123
+ claudeSessionId: managed.claudeSessionId,
1124
+ created: managed.created,
1125
+ cwd: managed.cwd,
1126
+ model: managed.config.resolvedModel || managed.config.model,
1127
+ paused: false,
1128
+ stats,
1129
+ };
1130
+ }
1131
+ _resolveModel(alias, overrides) {
1132
+ if (overrides?.[alias])
1133
+ return overrides[alias];
1134
+ return resolveAlias(alias);
1135
+ }
1136
+ _listMdFiles(dir) {
1137
+ if (!fs.existsSync(dir))
1138
+ return [];
1139
+ return fs
1140
+ .readdirSync(dir)
1141
+ .filter((f) => f.endsWith('.md'))
1142
+ .map((f) => {
1143
+ const content = fs.readFileSync(path.join(dir, f), 'utf8');
1144
+ const match = content.match(/^---\n[\s\S]*?description:\s*(.+)/m);
1145
+ return { name: f.replace('.md', ''), file: f, description: match?.[1]?.trim() || '' };
1146
+ });
1147
+ }
1148
+ _createSession(engine, config) {
1149
+ switch (engine) {
1150
+ case 'gemini':
1151
+ return new PersistentGeminiSession(config, process.env.GEMINI_BIN);
1152
+ case 'codex':
1153
+ return new PersistentCodexSession(config, process.env.CODEX_BIN);
1154
+ case 'codex-app':
1155
+ return new PersistentCodexAppServerSession(config, process.env.CODEX_BIN);
1156
+ case 'cursor':
1157
+ return new PersistentCursorSession(config, process.env.CURSOR_BIN);
1158
+ case 'custom':
1159
+ if (!config.customEngine)
1160
+ throw new Error('customEngine config is required for engine type "custom"');
1161
+ return new PersistentCustomSession(config);
1162
+ case 'claude':
1163
+ default:
1164
+ return new PersistentClaudeSession(config, this.pluginConfig.claudeBin);
1165
+ }
1166
+ }
1167
+ // โ”€โ”€โ”€ Council โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1168
+ councils = new Map();
1169
+ councilCleanupTimers = new Map();
1170
+ councilStart(task, config) {
1171
+ const council = new Council(config, this, this.logger);
1172
+ const initialSession = council.init(task);
1173
+ // Store BEFORE running so council_status/abort/inject work while it's active
1174
+ this.councils.set(initialSession.id, council);
1175
+ // Run in background โ€” callers poll via councilStatus()
1176
+ council
1177
+ .run()
1178
+ .then(() => {
1179
+ // Keep completed council queryable; schedule cleanup after TTL
1180
+ this._scheduleCouncilCleanup(initialSession.id);
1181
+ })
1182
+ .catch((err) => {
1183
+ this.logger.error(`Council ${initialSession.id} failed:`, err);
1184
+ this._scheduleCouncilCleanup(initialSession.id);
1185
+ });
1186
+ return initialSession;
1187
+ }
1188
+ _scheduleCouncilCleanup(id) {
1189
+ // Clear any existing timer before scheduling a new one
1190
+ const existing = this.councilCleanupTimers.get(id);
1191
+ if (existing)
1192
+ clearTimeout(existing);
1193
+ const timer = setTimeout(() => {
1194
+ // Abort if still running to prevent orphaned background tasks
1195
+ const council = this.councils.get(id);
1196
+ if (council) {
1197
+ const session = council.getSession();
1198
+ if (session?.status === 'running') {
1199
+ this.logger.info(`Council ${id} still running at TTL expiry โ€” aborting`);
1200
+ council.abort();
1201
+ }
1202
+ }
1203
+ this.councils.delete(id);
1204
+ this.councilCleanupTimers.delete(id);
1205
+ }, RESULT_TTL_MS);
1206
+ this.councilCleanupTimers.set(id, timer);
1207
+ }
1208
+ councilStatus(id) {
1209
+ const council = this.councils.get(id);
1210
+ return council?.getSession();
1211
+ }
1212
+ councilAbort(id) {
1213
+ const council = this.councils.get(id);
1214
+ if (!council)
1215
+ throw new Error(`Council '${id}' not found`);
1216
+ council.abort();
1217
+ this.councils.delete(id);
1218
+ }
1219
+ councilInject(id, message) {
1220
+ const council = this.councils.get(id);
1221
+ if (!council)
1222
+ throw new Error(`Council '${id}' not found`);
1223
+ council.injectMessage(message);
1224
+ }
1225
+ async councilReview(id) {
1226
+ const council = this.councils.get(id);
1227
+ if (!council)
1228
+ throw new Error(`Council '${id}' not found`);
1229
+ this._scheduleCouncilCleanup(id); // reset TTL โ€” user is actively reviewing
1230
+ return council.review();
1231
+ }
1232
+ async councilAccept(id) {
1233
+ const council = this.councils.get(id);
1234
+ if (!council)
1235
+ throw new Error(`Council '${id}' not found`);
1236
+ const result = await council.accept();
1237
+ // Accepted โ€” no longer needed, clean up after short grace period
1238
+ this._scheduleCouncilCleanup(id);
1239
+ return result;
1240
+ }
1241
+ async councilReject(id, feedback) {
1242
+ const council = this.councils.get(id);
1243
+ if (!council)
1244
+ throw new Error(`Council '${id}' not found`);
1245
+ const result = await council.reject(feedback);
1246
+ this._scheduleCouncilCleanup(id); // reset TTL โ€” council may be restarted
1247
+ return result;
1248
+ }
1249
+ // โ”€โ”€โ”€ Inbox (cross-session messaging) โ€” delegated to InboxManager โ”€โ”€โ”€โ”€
1250
+ get _sessionLookup() {
1251
+ return {
1252
+ getSession: (name) => this.sessions.get(name),
1253
+ exists: (name) => this.sessions.has(name),
1254
+ allNames: () => this.sessions.keys(),
1255
+ };
1256
+ }
1257
+ async sessionSendTo(from, to, message, summary) {
1258
+ return this._inbox.sendTo(from, to, message, this._sessionLookup, summary, (name, err) => {
1259
+ this.logger.error(`Broadcast delivery to '${name}' failed:`, err.message);
1260
+ });
1261
+ }
1262
+ sessionInbox(name, unreadOnly = true) {
1263
+ return this._inbox.inbox(name, unreadOnly);
1264
+ }
1265
+ async sessionDeliverInbox(name) {
1266
+ return this._inbox.deliverInbox(name, this._sessionLookup);
1267
+ }
1268
+ // โ”€โ”€โ”€ Ultraplan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1269
+ ultraplans = new Map();
1270
+ ultraplanStart(task, opts) {
1271
+ const id = `ultraplan-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1272
+ const sessionName = `ultraplan-${id}`;
1273
+ const timeout = opts?.timeout || ULTRAPLAN_TIMEOUT_MS;
1274
+ const result = {
1275
+ id,
1276
+ status: 'running',
1277
+ sessionName,
1278
+ startTime: new Date().toISOString(),
1279
+ };
1280
+ this.ultraplans.set(id, result);
1281
+ // Run in background
1282
+ this._runUltraplan(id, sessionName, task, opts?.model || 'opus', opts?.cwd || process.cwd(), timeout)
1283
+ .catch((err) => {
1284
+ result.status = 'error';
1285
+ result.error = err.message;
1286
+ result.endTime = new Date().toISOString();
1287
+ })
1288
+ .finally(() => {
1289
+ // Cleanup session
1290
+ this.stopSession(sessionName).catch((err) => {
1291
+ this.logger.error(`Failed to stop ultraplan session '${sessionName}':`, err);
1292
+ });
1293
+ setTimeout(() => {
1294
+ // Mark as error if still running at TTL expiry
1295
+ const plan = this.ultraplans.get(id);
1296
+ if (plan?.status === 'running') {
1297
+ this.logger.info(`Ultraplan ${id} still running at TTL expiry โ€” marking as error`);
1298
+ plan.status = 'error';
1299
+ plan.error = 'Timed out (TTL expired)';
1300
+ plan.endTime = new Date().toISOString();
1301
+ }
1302
+ this.ultraplans.delete(id);
1303
+ }, RESULT_TTL_MS);
1304
+ });
1305
+ return result;
1306
+ }
1307
+ async _runUltraplan(id, sessionName, task, model, cwd, timeout) {
1308
+ const result = this.ultraplans.get(id);
1309
+ await this.startSession({
1310
+ name: sessionName,
1311
+ cwd,
1312
+ model,
1313
+ permissionMode: 'plan',
1314
+ effort: 'max',
1315
+ appendSystemPrompt: 'You are in ultraplan mode. Explore the project thoroughly, analyze feasibility, and produce a detailed, actionable plan. Do NOT write code โ€” plan only. Output your final plan in a clear markdown format.',
1316
+ });
1317
+ const planPrompt = `# Ultraplan Task\n\n${task}\n\nExplore the project, understand the codebase, analyze feasibility, and produce a comprehensive implementation plan. Take your time (up to 30 minutes). Be thorough.`;
1318
+ const sendResult = await this.sendMessage(sessionName, planPrompt, { timeout });
1319
+ // Detect error responses: empty output or output that looks like an error message
1320
+ const output = sendResult.output?.trim() || '';
1321
+ const looksLikeError = !output ||
1322
+ /^(Error|not logged in|authentication|auth failed|permission denied)/i.test(output) ||
1323
+ (sendResult.error && sendResult.error.length > 0);
1324
+ if (looksLikeError) {
1325
+ result.status = 'error';
1326
+ result.error = sendResult.error || output || 'Empty response from engine';
1327
+ }
1328
+ else {
1329
+ result.plan = output;
1330
+ result.status = 'completed';
1331
+ }
1332
+ result.endTime = new Date().toISOString();
1333
+ }
1334
+ ultraplanStatus(id) {
1335
+ return this.ultraplans.get(id);
1336
+ }
1337
+ // โ”€โ”€โ”€ Ultrareview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1338
+ ultrareviews = new Map();
1339
+ ultrareviewPollers = new Map();
1340
+ ultrareviewStart(cwd, opts) {
1341
+ const id = `ultrareview-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1342
+ const agentCount = Math.min(20, Math.max(1, opts?.agentCount || 5));
1343
+ const result = {
1344
+ id,
1345
+ status: 'running',
1346
+ councilId: '',
1347
+ agentCount,
1348
+ startTime: new Date().toISOString(),
1349
+ };
1350
+ this.ultrareviews.set(id, result);
1351
+ // Build reviewer agents
1352
+ const reviewAngles = [
1353
+ {
1354
+ name: 'SecurityReviewer',
1355
+ emoji: '๐Ÿ”’',
1356
+ persona: 'You are a security expert. Focus on: injection vulnerabilities, auth flaws, data exposure, OWASP top 10, secrets in code.',
1357
+ },
1358
+ {
1359
+ name: 'LogicReviewer',
1360
+ emoji: '๐Ÿง ',
1361
+ persona: 'You are a logic analyst. Focus on: off-by-one errors, race conditions, null/undefined handling, edge cases, incorrect assumptions.',
1362
+ },
1363
+ {
1364
+ name: 'PerformanceReviewer',
1365
+ emoji: 'โšก',
1366
+ persona: 'You are a performance engineer. Focus on: O(n^2) loops, memory leaks, unnecessary allocations, missing caching, N+1 queries.',
1367
+ },
1368
+ {
1369
+ name: 'APIReviewer',
1370
+ emoji: '๐Ÿ”Œ',
1371
+ persona: 'You are an API design reviewer. Focus on: inconsistent interfaces, missing validation, error handling gaps, backwards compatibility.',
1372
+ },
1373
+ {
1374
+ name: 'TestReviewer',
1375
+ emoji: '๐Ÿงช',
1376
+ persona: 'You are a test coverage analyst. Focus on: untested code paths, missing edge case tests, flaky test patterns, assertion quality.',
1377
+ },
1378
+ {
1379
+ name: 'TypeReviewer',
1380
+ emoji: '๐Ÿ“',
1381
+ persona: 'You are a type safety reviewer. Focus on: any casts, unsafe assertions, missing null checks, generic misuse, type narrowing gaps.',
1382
+ },
1383
+ {
1384
+ name: 'ConcurrencyReviewer',
1385
+ emoji: '๐Ÿ”€',
1386
+ persona: 'You are a concurrency expert. Focus on: race conditions, deadlocks, shared state mutations, async error handling, promise leaks.',
1387
+ },
1388
+ {
1389
+ name: 'ErrorReviewer',
1390
+ emoji: '๐Ÿ’ฅ',
1391
+ persona: 'You are an error handling reviewer. Focus on: swallowed errors, missing try/catch, unhelpful error messages, crash-on-startup paths.',
1392
+ },
1393
+ {
1394
+ name: 'DependencyReviewer',
1395
+ emoji: '๐Ÿ“ฆ',
1396
+ persona: 'You are a dependency auditor. Focus on: outdated packages, known CVEs, unnecessary dependencies, license issues.',
1397
+ },
1398
+ {
1399
+ name: 'ReadabilityReviewer',
1400
+ emoji: '๐Ÿ“–',
1401
+ persona: 'You are a readability reviewer. Focus on: unclear naming, complex functions, missing context, dead code, confusing control flow.',
1402
+ },
1403
+ {
1404
+ name: 'DataReviewer',
1405
+ emoji: '๐Ÿ’พ',
1406
+ persona: 'You are a data integrity reviewer. Focus on: data validation, schema mismatches, migration issues, encoding problems, data loss paths.',
1407
+ },
1408
+ {
1409
+ name: 'ConfigReviewer',
1410
+ emoji: 'โš™๏ธ',
1411
+ persona: 'You are a configuration reviewer. Focus on: hardcoded values, missing env vars, insecure defaults, missing fallbacks.',
1412
+ },
1413
+ {
1414
+ name: 'ScalabilityReviewer',
1415
+ emoji: '๐Ÿ“ˆ',
1416
+ persona: 'You are a scalability reviewer. Focus on: single points of failure, stateful bottlenecks, missing pagination, unbounded growth.',
1417
+ },
1418
+ {
1419
+ name: 'DocReviewer',
1420
+ emoji: '๐Ÿ“',
1421
+ persona: 'You are a documentation reviewer. Focus on: outdated docs, missing API docs, misleading comments, undocumented behavior.',
1422
+ },
1423
+ {
1424
+ name: 'A11yReviewer',
1425
+ emoji: 'โ™ฟ',
1426
+ persona: 'You are an accessibility reviewer. Focus on: missing ARIA labels, keyboard navigation, color contrast, screen reader support.',
1427
+ },
1428
+ {
1429
+ name: 'I18nReviewer',
1430
+ emoji: '๐ŸŒ',
1431
+ persona: 'You are an i18n reviewer. Focus on: hardcoded strings, locale handling, date/number formatting, RTL support.',
1432
+ },
1433
+ {
1434
+ name: 'NetworkReviewer',
1435
+ emoji: '๐ŸŒ',
1436
+ persona: 'You are a network reviewer. Focus on: missing timeouts, retry logic, connection pooling, request size limits.',
1437
+ },
1438
+ {
1439
+ name: 'AuthReviewer',
1440
+ emoji: '๐Ÿ”‘',
1441
+ persona: 'You are an auth reviewer. Focus on: token handling, session management, CSRF protection, permission checks.',
1442
+ },
1443
+ {
1444
+ name: 'CryptoReviewer',
1445
+ emoji: '๐Ÿ”',
1446
+ persona: 'You are a cryptography reviewer. Focus on: weak algorithms, key management, random number generation, hash collisions.',
1447
+ },
1448
+ {
1449
+ name: 'MemoryReviewer',
1450
+ emoji: '๐Ÿงน',
1451
+ persona: 'You are a memory reviewer. Focus on: memory leaks, circular references, large object retention, stream handling.',
1452
+ },
1453
+ ];
1454
+ const agents = reviewAngles.slice(0, agentCount).map((a) => ({
1455
+ ...a,
1456
+ model: opts?.model,
1457
+ }));
1458
+ const maxMinutes = Math.min(25, Math.max(5, opts?.maxDurationMinutes || 10));
1459
+ const focus = opts?.focus || 'Find bugs, security issues, and code quality problems';
1460
+ const councilConfig = {
1461
+ name: 'ultrareview',
1462
+ agents,
1463
+ maxRounds: 2, // Review doesn't need many rounds โ€” find bugs, then synthesize
1464
+ projectDir: cwd,
1465
+ agentTimeoutMs: maxMinutes * 60 * 1000,
1466
+ maxTurnsPerAgent: 20,
1467
+ };
1468
+ const councilSession = this.councilStart(`# Code Review Task\n\nReview the codebase in this project. ${focus}.\n\nEach reviewer: examine the code from your specialty angle, report bugs found with file paths and line numbers. Vote [CONSENSUS: YES] when your review is complete.`, councilConfig);
1469
+ result.councilId = councilSession.id;
1470
+ // Poll council for completion (store ref for shutdown cleanup)
1471
+ const pollInterval = setInterval(() => {
1472
+ try {
1473
+ const status = this.councilStatus(councilSession.id);
1474
+ if (!status || status.status === 'running')
1475
+ return;
1476
+ clearInterval(pollInterval);
1477
+ this.ultrareviewPollers.delete(id);
1478
+ result.status = status.status === 'error' ? 'error' : 'completed';
1479
+ result.endTime = new Date().toISOString();
1480
+ // Synthesize findings from all agent responses
1481
+ if (status.responses.length > 0) {
1482
+ result.findings = status.responses.map((r) => `## ${r.agent}\n\n${r.content}`).join('\n\n---\n\n');
1483
+ }
1484
+ setTimeout(() => this.ultrareviews.delete(id), RESULT_TTL_MS);
1485
+ }
1486
+ catch {
1487
+ // Council may have been cleaned up; stop polling
1488
+ clearInterval(pollInterval);
1489
+ this.ultrareviewPollers.delete(id);
1490
+ }
1491
+ }, ULTRAREVIEW_POLL_INTERVAL_MS);
1492
+ this.ultrareviewPollers.set(id, pollInterval);
1493
+ return result;
1494
+ }
1495
+ ultrareviewStatus(id) {
1496
+ return this.ultrareviews.get(id);
1497
+ }
1498
+ _cleanupIdleSessions() {
1499
+ const ttlMs = this.pluginConfig.sessionTtlMinutes * 60_000;
1500
+ const now = Date.now();
1501
+ for (const [name, managed] of this.sessions) {
1502
+ if (now - managed.lastActivity > ttlMs) {
1503
+ this.logger.info(`Cleaning up idle in-memory session: ${name}`);
1504
+ try {
1505
+ managed.session.stop();
1506
+ }
1507
+ catch {
1508
+ // Best-effort โ€” session may already be dead; must not block TTL cleanup
1509
+ }
1510
+ this.sessions.delete(name);
1511
+ // NOTE: do NOT delete from persistedSessions โ€” idle cleanup is
1512
+ // in-memory only. Persisted entries survive for PERSIST_DISK_TTL_MS
1513
+ // (7 days) so the session can be resumed after a gateway restart.
1514
+ }
1515
+ }
1516
+ // Prune disk entries that exceeded the longer disk TTL
1517
+ let pruned = false;
1518
+ for (const [name, entry] of this.persistedSessions) {
1519
+ if (now - entry.lastActivity > PERSIST_DISK_TTL_MS) {
1520
+ this.persistedSessions.delete(name);
1521
+ pruned = true;
1522
+ }
1523
+ }
1524
+ if (pruned)
1525
+ savePersistedSessionsAsync(this.persistedSessions);
1526
+ }
1527
+ }
1528
+ //# sourceMappingURL=session-manager.js.map