@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.
- package/dist/src/command-router/cc-handler.js +72 -0
- package/dist/src/command-router/cc-handler.js.map +1 -1
- package/dist/src/constants.d.ts +9 -0
- package/dist/src/constants.js +10 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/engines/persistent-session.d.ts +2 -0
- package/dist/src/engines/persistent-session.js +41 -11
- package/dist/src/engines/persistent-session.js.map +1 -1
- package/dist/src/lib/config.d.ts +2 -0
- package/dist/src/lib/config.js +19 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/sysprompt-strip.js +12 -12
- package/dist/src/lib/sysprompt-strip.js.map +1 -1
- package/dist/src/lib/trajectory.d.ts +1 -1
- package/dist/src/lib/trajectory.js.map +1 -1
- package/dist/src/lib/vendor-paths.d.ts +6 -4
- package/dist/src/lib/vendor-paths.js +21 -14
- package/dist/src/lib/vendor-paths.js.map +1 -1
- package/dist/src/openai-compat/openai-compat.d.ts +7 -1
- package/dist/src/openai-compat/openai-compat.js +8 -1
- package/dist/src/openai-compat/openai-compat.js.map +1 -1
- package/dist/src/openai-compat/sse-translator.d.ts +23 -3
- package/dist/src/openai-compat/sse-translator.js +45 -6
- package/dist/src/openai-compat/sse-translator.js.map +1 -1
- package/dist/src/session-bootstrap/cwd-patch.js +59 -28
- package/dist/src/session-bootstrap/cwd-patch.js.map +1 -1
- package/dist/src/types.d.ts +1 -0
- package/package.json +2 -3
- package/vendor/base-oneshot-session.d.ts +0 -87
- package/vendor/base-oneshot-session.js +0 -227
- package/vendor/base-oneshot-session.js.map +0 -1
- package/vendor/circuit-breaker.d.ts +0 -21
- package/vendor/circuit-breaker.js +0 -47
- package/vendor/circuit-breaker.js.map +0 -1
- package/vendor/consensus.d.ts +0 -20
- package/vendor/consensus.js +0 -52
- package/vendor/consensus.js.map +0 -1
- package/vendor/constants.d.ts +0 -130
- package/vendor/constants.js +0 -139
- package/vendor/constants.js.map +0 -1
- package/vendor/council.d.ts +0 -67
- package/vendor/council.js +0 -913
- package/vendor/council.js.map +0 -1
- package/vendor/embedded-server.d.ts +0 -25
- package/vendor/embedded-server.js +0 -373
- package/vendor/embedded-server.js.map +0 -1
- package/vendor/inbox-manager.d.ts +0 -38
- package/vendor/inbox-manager.js +0 -111
- package/vendor/inbox-manager.js.map +0 -1
- package/vendor/index.d.ts +0 -63
- package/vendor/index.js +0 -705
- package/vendor/index.js.map +0 -1
- package/vendor/logger.d.ts +0 -16
- package/vendor/logger.js +0 -44
- package/vendor/logger.js.map +0 -1
- package/vendor/models.d.ts +0 -69
- package/vendor/models.js +0 -289
- package/vendor/models.js.map +0 -1
- package/vendor/openai-compat.d.ts +0 -197
- package/vendor/openai-compat.js +0 -765
- package/vendor/openai-compat.js.map +0 -1
- package/vendor/persistent-codex-session.d.ts +0 -16
- package/vendor/persistent-codex-session.js +0 -105
- package/vendor/persistent-codex-session.js.map +0 -1
- package/vendor/persistent-cursor-session.d.ts +0 -21
- package/vendor/persistent-cursor-session.js +0 -241
- package/vendor/persistent-cursor-session.js.map +0 -1
- package/vendor/persistent-custom-session.d.ts +0 -78
- package/vendor/persistent-custom-session.js +0 -937
- package/vendor/persistent-custom-session.js.map +0 -1
- package/vendor/persistent-gemini-session.d.ts +0 -21
- package/vendor/persistent-gemini-session.js +0 -216
- package/vendor/persistent-gemini-session.js.map +0 -1
- package/vendor/persistent-session.d.ts +0 -74
- package/vendor/persistent-session.js +0 -684
- package/vendor/persistent-session.js.map +0 -1
- package/vendor/proxy/anthropic-adapter.d.ts +0 -136
- package/vendor/proxy/anthropic-adapter.js +0 -392
- package/vendor/proxy/anthropic-adapter.js.map +0 -1
- package/vendor/proxy/handler.d.ts +0 -39
- package/vendor/proxy/handler.js +0 -323
- package/vendor/proxy/handler.js.map +0 -1
- package/vendor/proxy/schema-cleaner.d.ts +0 -11
- package/vendor/proxy/schema-cleaner.js +0 -34
- package/vendor/proxy/schema-cleaner.js.map +0 -1
- package/vendor/proxy/thought-cache.d.ts +0 -19
- package/vendor/proxy/thought-cache.js +0 -53
- package/vendor/proxy/thought-cache.js.map +0 -1
- package/vendor/session-manager.d.ts +0 -211
- package/vendor/session-manager.js +0 -1345
- package/vendor/session-manager.js.map +0 -1
- package/vendor/skill-resolver.js +0 -107
- package/vendor/types.d.ts +0 -466
- package/vendor/types.js +0 -8
- package/vendor/types.js.map +0 -1
- package/vendor/validation.d.ts +0 -31
- package/vendor/validation.js +0 -104
- 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
|