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