@enderfga/claw-orchestrator 4.6.0 → 4.8.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/README.md +6 -5
- package/configs/autoloop-coder-prompt.md +4 -4
- package/configs/autoloop-planner-prompt.md +10 -10
- package/configs/autoloop-reviewer-prompt.md +3 -4
- package/dist/bin/cli.js +1 -1
- package/dist/bin/cli.js.map +1 -1
- package/dist/src/agy-conversation.d.ts +2 -0
- package/dist/src/agy-conversation.js +10 -0
- package/dist/src/agy-conversation.js.map +1 -0
- package/dist/src/autoloop/dispatcher.d.ts +59 -10
- package/dist/src/autoloop/dispatcher.js +261 -40
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/autoloop/planner-tools.d.ts +4 -3
- package/dist/src/autoloop/planner-tools.js +35 -2
- package/dist/src/autoloop/planner-tools.js.map +1 -1
- package/dist/src/autoloop/types.d.ts +2 -0
- package/dist/src/autoloop/types.js.map +1 -1
- package/dist/src/dashboard/index.html +185 -94
- package/dist/src/embedded-server.js +67 -9
- package/dist/src/embedded-server.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +98 -61
- package/dist/src/index.js.map +1 -1
- package/dist/src/models.js +68 -3
- package/dist/src/models.js.map +1 -1
- package/dist/src/persistent-agy-session.d.ts +60 -0
- package/dist/src/persistent-agy-session.js +228 -0
- package/dist/src/persistent-agy-session.js.map +1 -0
- package/dist/src/persistent-codex-session.js +3 -0
- package/dist/src/persistent-codex-session.js.map +1 -1
- package/dist/src/persistent-cursor-session.js +9 -6
- package/dist/src/persistent-cursor-session.js.map +1 -1
- package/dist/src/persistent-custom-session.js +18 -32
- package/dist/src/persistent-custom-session.js.map +1 -1
- package/dist/src/persistent-gemini-session.d.ts +4 -0
- package/dist/src/persistent-gemini-session.js +58 -7
- package/dist/src/persistent-gemini-session.js.map +1 -1
- package/dist/src/persistent-opencode-session.js +37 -7
- package/dist/src/persistent-opencode-session.js.map +1 -1
- package/dist/src/persistent-session.js +8 -9
- package/dist/src/persistent-session.js.map +1 -1
- package/dist/src/sanitize.d.ts +23 -0
- package/dist/src/sanitize.js +57 -0
- package/dist/src/sanitize.js.map +1 -0
- package/dist/src/session-manager.d.ts +49 -2
- package/dist/src/session-manager.js +254 -35
- package/dist/src/session-manager.js.map +1 -1
- package/dist/src/types.d.ts +8 -4
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
- package/skills/SKILL.md +17 -14
- package/skills/references/autoloop.md +50 -6
- package/skills/references/claude-cli-tracking.md +3 -1
- package/skills/references/cli.md +4 -3
- package/skills/references/mcp.md +1 -1
- package/skills/references/multi-engine.md +53 -8
- package/skills/references/sessions.md +2 -2
- package/skills/references/tools.md +40 -21
|
@@ -10,12 +10,13 @@ interface PersistedSession {
|
|
|
10
10
|
cwd: string;
|
|
11
11
|
model?: string;
|
|
12
12
|
engine?: EngineType;
|
|
13
|
+
sandboxMode?: SessionConfig['sandboxMode'];
|
|
13
14
|
originalCreated: string;
|
|
14
15
|
lastResumed: string;
|
|
15
16
|
lastActivity: number;
|
|
16
17
|
}
|
|
17
18
|
import { type Logger } from './logger.js';
|
|
18
|
-
import { type SessionConfig, type SessionInfo, type SendResult, type PluginConfig, type EffortLevel, type EngineType, type AgentInfo, type SkillInfo, type RuleInfo, type StreamEvent, type ISession, type CouncilConfig, type CouncilSession, type CouncilReviewResult, type CouncilAcceptResult, type CouncilRejectResult, type InboxMessage, type UltraplanResult, type UltrareviewResult } from './types.js';
|
|
19
|
+
import { type SessionConfig, type SessionInfo, type SendResult, type PluginConfig, type EffortLevel, type EngineType, type CustomEngineConfig, type AgentInfo, type SkillInfo, type RuleInfo, type StreamEvent, type ISession, type CouncilConfig, type CouncilSession, type CouncilReviewResult, type CouncilAcceptResult, type CouncilRejectResult, type InboxMessage, type UltraplanResult, type UltrareviewResult } from './types.js';
|
|
19
20
|
import { Council } from './council.js';
|
|
20
21
|
import { type FanoutConfig, type FanoutSession } from './fanout.js';
|
|
21
22
|
import { AutoloopRunner } from './autoloop/runner.js';
|
|
@@ -50,9 +51,27 @@ export interface AutoloopRegistryEntry {
|
|
|
50
51
|
ledger_dir: string;
|
|
51
52
|
started_at: string;
|
|
52
53
|
planner_session: string;
|
|
54
|
+
/** Optional for compatibility with registry rows written before role-level engine support. */
|
|
55
|
+
planner_engine?: EngineType;
|
|
56
|
+
planner_model?: string;
|
|
57
|
+
coder_engine?: EngineType;
|
|
58
|
+
coder_model?: string;
|
|
59
|
+
reviewer_engine?: EngineType;
|
|
60
|
+
reviewer_model?: string;
|
|
53
61
|
}
|
|
54
62
|
/** Append-only registry write. Safe under concurrent writers — append is atomic for short lines. */
|
|
55
63
|
export declare function appendAutoloopRegistry(file: string, entry: AutoloopRegistryEntry): void;
|
|
64
|
+
/**
|
|
65
|
+
* Write the current row for a run, dropping any older rows for the same id.
|
|
66
|
+
*
|
|
67
|
+
* The registry is append-only and `listAutoloopsFromRegistry` dedups on read
|
|
68
|
+
* (newest wins), so correctness never depended on cleanup — but a run now emits
|
|
69
|
+
* a row at start, another on every successful `spawn_subagents`, and another on
|
|
70
|
+
* every resume, none of which were ever removed. The file grew monotonically and
|
|
71
|
+
* every list / resume parses all of it. Callers use this AFTER the operation
|
|
72
|
+
* succeeds, so a failed start still leaves the previous row intact.
|
|
73
|
+
*/
|
|
74
|
+
export declare function upsertAutoloopRegistry(file: string, entry: AutoloopRegistryEntry): void;
|
|
56
75
|
/**
|
|
57
76
|
* Read the registry, dedup by run_id (newest entry wins), drop entries whose
|
|
58
77
|
* ledger_dir no longer exists on disk (cleanup of moved/deleted workspaces).
|
|
@@ -424,6 +443,14 @@ export declare class SessionManager {
|
|
|
424
443
|
private _getSession;
|
|
425
444
|
private _toSessionInfo;
|
|
426
445
|
private _resolveModel;
|
|
446
|
+
private _managedResumeId;
|
|
447
|
+
/**
|
|
448
|
+
* Return only IDs that can actually resume the engine. Agy and Codex expose
|
|
449
|
+
* harvested conversation/thread IDs; their BaseOneShot sessionId values are
|
|
450
|
+
* synthetic wrapper identifiers and must never be persisted for resume.
|
|
451
|
+
*/
|
|
452
|
+
private _sessionResumeId;
|
|
453
|
+
private _storedResumeId;
|
|
427
454
|
private _listMdFiles;
|
|
428
455
|
private _createSession;
|
|
429
456
|
private councils;
|
|
@@ -489,6 +516,14 @@ export declare class SessionManager {
|
|
|
489
516
|
ultrareviewStatus(id: string): UltrareviewResult | undefined;
|
|
490
517
|
private autoloops;
|
|
491
518
|
private _deletingAutoloops;
|
|
519
|
+
/**
|
|
520
|
+
* Runs whose Planner is mid-startup. The delete fence was one-directional:
|
|
521
|
+
* a start could not race a delete, but a delete COULD race a start — it would
|
|
522
|
+
* resolve `true`, drop the registry row, and leave the still-starting Planner
|
|
523
|
+
* session orphaned (no run to stop it, no entry to find it by). Deleting a run
|
|
524
|
+
* that is still coming up is rejected instead.
|
|
525
|
+
*/
|
|
526
|
+
private _startingAutoloops;
|
|
492
527
|
/**
|
|
493
528
|
* Start a v2 autoloop in chat mode. Creates the Planner persistent session,
|
|
494
529
|
* returns the run handle. Coder/Reviewer are NOT started until S3's
|
|
@@ -498,7 +533,15 @@ export declare class SessionManager {
|
|
|
498
533
|
runId: string;
|
|
499
534
|
workspace: string;
|
|
500
535
|
plannerPromptPath?: string;
|
|
536
|
+
plannerEngine?: EngineType;
|
|
501
537
|
plannerModel?: string;
|
|
538
|
+
plannerCustomEngine?: CustomEngineConfig;
|
|
539
|
+
coderEngine?: EngineType;
|
|
540
|
+
coderModel?: string;
|
|
541
|
+
coderCustomEngine?: CustomEngineConfig;
|
|
542
|
+
reviewerEngine?: EngineType;
|
|
543
|
+
reviewerModel?: string;
|
|
544
|
+
reviewerCustomEngine?: CustomEngineConfig;
|
|
502
545
|
sendTimeoutMs?: number;
|
|
503
546
|
}): Promise<{
|
|
504
547
|
runId: string;
|
|
@@ -543,7 +586,11 @@ export declare class SessionManager {
|
|
|
543
586
|
* is still served via /autoloop/<id>/chat_history so the dashboard can
|
|
544
587
|
* replay the conversation visually.
|
|
545
588
|
*/
|
|
546
|
-
autoloopResume(runId: string
|
|
589
|
+
autoloopResume(runId: string, opts?: {
|
|
590
|
+
plannerCustomEngine?: CustomEngineConfig;
|
|
591
|
+
coderCustomEngine?: CustomEngineConfig;
|
|
592
|
+
reviewerCustomEngine?: CustomEngineConfig;
|
|
593
|
+
}): Promise<AutoloopState>;
|
|
547
594
|
/**
|
|
548
595
|
* Delete a run from the system: stop the runner if it's still alive in this
|
|
549
596
|
* process, then scrub the row from the cross-process registry so it stops
|
|
@@ -111,9 +111,11 @@ import { PersistentCodexSession } from './persistent-codex-session.js';
|
|
|
111
111
|
import { PersistentCodexAppServerSession } from './persistent-codex-app-session.js';
|
|
112
112
|
import { PersistentCursorSession } from './persistent-cursor-session.js';
|
|
113
113
|
import { PersistentOpencodeSession } from './persistent-opencode-session.js';
|
|
114
|
+
import { PersistentAgySession } from './persistent-agy-session.js';
|
|
114
115
|
import { PersistentCustomSession } from './persistent-custom-session.js';
|
|
115
|
-
import { overrideModelPricing, } from './types.js';
|
|
116
|
+
import { ENGINE_TYPES, overrideModelPricing, } from './types.js';
|
|
116
117
|
import { resolveAlias, isClaudeModel } from './models.js';
|
|
118
|
+
import { isAgyConversationId } from './agy-conversation.js';
|
|
117
119
|
import { Council } from './council.js';
|
|
118
120
|
import { Fanout } from './fanout.js';
|
|
119
121
|
import { AutoloopRunner } from './autoloop/runner.js';
|
|
@@ -133,11 +135,86 @@ import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEO
|
|
|
133
135
|
// - Autoloop: registry at ~/.claw-orchestrator/autoloop-registry.jsonl
|
|
134
136
|
const DEFAULT_COUNCIL_LOG_DIR = path.join(os.homedir(), '.openclaw', 'council-logs');
|
|
135
137
|
const DEFAULT_AUTOLOOP_REGISTRY = path.join(os.homedir(), '.claw-orchestrator', 'autoloop-registry.jsonl');
|
|
138
|
+
function isStringRecord(value) {
|
|
139
|
+
return (typeof value === 'object' &&
|
|
140
|
+
value !== null &&
|
|
141
|
+
!Array.isArray(value) &&
|
|
142
|
+
Object.values(value).every((entry) => typeof entry === 'string'));
|
|
143
|
+
}
|
|
144
|
+
function validateAutoloopCustomEngine(role, config) {
|
|
145
|
+
const label = role[0].toUpperCase() + role.slice(1);
|
|
146
|
+
const raw = config;
|
|
147
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
148
|
+
throw new Error(`${label} custom engine config must be an object`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof raw.name !== 'string' || !raw.name.trim()) {
|
|
151
|
+
throw new Error(`${label} custom engine config.name must be a non-empty string`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof raw.bin !== 'string' || !raw.bin.trim()) {
|
|
154
|
+
throw new Error(`${label} custom engine config.bin must be a non-empty string`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw.args !== 'object' || raw.args === null || Array.isArray(raw.args)) {
|
|
157
|
+
throw new Error(`${label} custom engine config.args must be an object`);
|
|
158
|
+
}
|
|
159
|
+
for (const [key, value] of Object.entries(raw.args)) {
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
if (key === 'extra') {
|
|
163
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
164
|
+
throw new Error(`${label} custom engine config.args.extra must be an array of strings`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (typeof value !== 'string') {
|
|
168
|
+
throw new Error(`${label} custom engine config.args.${key} must be a string`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (raw.persistent !== undefined && typeof raw.persistent !== 'boolean') {
|
|
172
|
+
throw new Error(`${label} custom engine config.persistent must be a boolean`);
|
|
173
|
+
}
|
|
174
|
+
if (raw.env !== undefined && !isStringRecord(raw.env)) {
|
|
175
|
+
throw new Error(`${label} custom engine config.env must contain only string values`);
|
|
176
|
+
}
|
|
177
|
+
if (raw.permissionModes !== undefined && !isStringRecord(raw.permissionModes)) {
|
|
178
|
+
throw new Error(`${label} custom engine config.permissionModes must contain only string values`);
|
|
179
|
+
}
|
|
180
|
+
if (raw.sanitizePatterns !== undefined &&
|
|
181
|
+
(!Array.isArray(raw.sanitizePatterns) || raw.sanitizePatterns.some((entry) => typeof entry !== 'string'))) {
|
|
182
|
+
throw new Error(`${label} custom engine config.sanitizePatterns must be an array of strings`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function validateAutoloopRole(role, engine, customEngine) {
|
|
186
|
+
const resolved = engine ?? 'claude';
|
|
187
|
+
const label = role[0].toUpperCase() + role.slice(1);
|
|
188
|
+
if (!ENGINE_TYPES.includes(resolved)) {
|
|
189
|
+
throw new Error(`${label} engine '${String(resolved)}' is not supported`);
|
|
190
|
+
}
|
|
191
|
+
if (resolved === 'custom') {
|
|
192
|
+
if (!customEngine)
|
|
193
|
+
throw new Error(`${label} custom engine config is required`);
|
|
194
|
+
validateAutoloopCustomEngine(role, customEngine);
|
|
195
|
+
}
|
|
196
|
+
return resolved;
|
|
197
|
+
}
|
|
136
198
|
/** Append-only registry write. Safe under concurrent writers — append is atomic for short lines. */
|
|
137
199
|
export function appendAutoloopRegistry(file, entry) {
|
|
138
200
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
139
201
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
140
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* Write the current row for a run, dropping any older rows for the same id.
|
|
205
|
+
*
|
|
206
|
+
* The registry is append-only and `listAutoloopsFromRegistry` dedups on read
|
|
207
|
+
* (newest wins), so correctness never depended on cleanup — but a run now emits
|
|
208
|
+
* a row at start, another on every successful `spawn_subagents`, and another on
|
|
209
|
+
* every resume, none of which were ever removed. The file grew monotonically and
|
|
210
|
+
* every list / resume parses all of it. Callers use this AFTER the operation
|
|
211
|
+
* succeeds, so a failed start still leaves the previous row intact.
|
|
212
|
+
*/
|
|
213
|
+
export function upsertAutoloopRegistry(file, entry) {
|
|
214
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
215
|
+
removeAutoloopFromRegistry(file, entry.run_id);
|
|
216
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
217
|
+
}
|
|
141
218
|
/**
|
|
142
219
|
* Read the registry, dedup by run_id (newest entry wins), drop entries whose
|
|
143
220
|
* ledger_dir no longer exists on disk (cleanup of moved/deleted workspaces).
|
|
@@ -354,12 +431,23 @@ export class SessionManager {
|
|
|
354
431
|
const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
|
|
355
432
|
// Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
|
|
356
433
|
const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
|
|
434
|
+
// ORDER IS LOAD-BEARING — do not "fix" it by moving `...config` up.
|
|
435
|
+
//
|
|
436
|
+
// Object spread copies own keys even when their value is `undefined`, so any
|
|
437
|
+
// key the caller sets EXPLICITLY (even to undefined) wins over the resolved
|
|
438
|
+
// fallbacks above it. That is deliberate: the autoloop dispatcher passes
|
|
439
|
+
// `model: undefined` for a non-Claude role to mean "use that engine's own
|
|
440
|
+
// default", which must NOT be replaced by the Claude-shaped global default;
|
|
441
|
+
// likewise `sandboxMode: undefined` means "no sandbox". Callers that simply
|
|
442
|
+
// omit a key (MCP session_start, HTTP /session/start, auto-resume by name)
|
|
443
|
+
// leave it absent, so the persisted/default fallback below still applies.
|
|
357
444
|
const fullConfig = {
|
|
358
445
|
name,
|
|
359
446
|
cwd: config.cwd || persisted?.cwd || process.cwd(),
|
|
360
447
|
permissionMode: config.permissionMode || this.pluginConfig.defaultPermissionMode,
|
|
361
448
|
effort: config.effort || this.pluginConfig.defaultEffort,
|
|
362
449
|
model: config.model || persisted?.model || this.pluginConfig.defaultModel,
|
|
450
|
+
sandboxMode: config.sandboxMode ?? persisted?.sandboxMode,
|
|
363
451
|
...config,
|
|
364
452
|
...(resumeId ? { resumeSessionId: resumeId } : {}),
|
|
365
453
|
};
|
|
@@ -371,6 +459,10 @@ export class SessionManager {
|
|
|
371
459
|
// Starts a local proxy server that converts Anthropic → OpenAI format
|
|
372
460
|
// and forwards to the OpenClaw gateway. Zero config required.
|
|
373
461
|
const engine = fullConfig.engine || persisted?.engine || 'claude';
|
|
462
|
+
// Write the resolved engine back so downstream consumers of the managed
|
|
463
|
+
// config (agy resume-id lookups, _persistSession's registry entry) see the
|
|
464
|
+
// real engine even when it came from the persisted registry.
|
|
465
|
+
fullConfig.engine = engine;
|
|
374
466
|
// Circuit breaker — reject early if engine is in backoff
|
|
375
467
|
this._circuitBreaker.check(engine);
|
|
376
468
|
if (engine === 'claude' && fullConfig.resolvedModel && !fullConfig.baseUrl) {
|
|
@@ -403,7 +495,8 @@ export class SessionManager {
|
|
|
403
495
|
created: persisted?.originalCreated || new Date().toISOString(),
|
|
404
496
|
lastActivity: Date.now(),
|
|
405
497
|
cwd: fullConfig.cwd,
|
|
406
|
-
claudeSessionId:
|
|
498
|
+
claudeSessionId: this._sessionResumeId(engine, session),
|
|
499
|
+
skipPersistence: skipPersist,
|
|
407
500
|
};
|
|
408
501
|
this.sessions.set(name, managed);
|
|
409
502
|
// Persist registry after session is live (skip for ephemeral sessions
|
|
@@ -478,22 +571,23 @@ export class SessionManager {
|
|
|
478
571
|
};
|
|
479
572
|
}
|
|
480
573
|
const result = await managed.session.send(message, sendOpts);
|
|
481
|
-
// Update session ID if available (skip disk persist
|
|
482
|
-
// sessions that were started with skipPersistence)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
574
|
+
// Update the resume-capable session ID if available (skip disk persist
|
|
575
|
+
// for ephemeral sessions that were started with skipPersistence)
|
|
576
|
+
const resumableId = this._managedResumeId(managed);
|
|
577
|
+
if (resumableId) {
|
|
578
|
+
managed.claudeSessionId = resumableId;
|
|
579
|
+
if (!managed.skipPersistence) {
|
|
486
580
|
this._persistSession(name, managed);
|
|
487
581
|
}
|
|
488
582
|
}
|
|
489
583
|
if ('text' in result) {
|
|
490
584
|
return {
|
|
491
585
|
output: result.text,
|
|
492
|
-
sessionId: managed
|
|
586
|
+
sessionId: this._managedResumeId(managed),
|
|
493
587
|
events: [],
|
|
494
588
|
};
|
|
495
589
|
}
|
|
496
|
-
return { output: '', sessionId: managed
|
|
590
|
+
return { output: '', sessionId: this._managedResumeId(managed), events: [] };
|
|
497
591
|
}
|
|
498
592
|
finally {
|
|
499
593
|
releaseChain();
|
|
@@ -580,9 +674,12 @@ export class SessionManager {
|
|
|
580
674
|
if (managed.session.isBusy) {
|
|
581
675
|
throw new Error(`Session '${name}' is currently processing a message. Wait for it to finish before switching model.`);
|
|
582
676
|
}
|
|
583
|
-
|
|
584
|
-
|
|
677
|
+
// An agy session with no harvested conversation yet has no history to
|
|
678
|
+
// preserve — restart it fresh instead of rejecting the switch.
|
|
679
|
+
const sessionId = this._managedResumeId(managed);
|
|
680
|
+
if (!sessionId && managed.config.engine !== 'agy') {
|
|
585
681
|
throw new Error(`Session '${name}' has no claude session ID — cannot resume after restart`);
|
|
682
|
+
}
|
|
586
683
|
// Validate model — must be a known alias or contain a recognisable pattern
|
|
587
684
|
const resolvedModel = this._resolveModel(model, managed.config.modelOverrides);
|
|
588
685
|
const knownPatterns = ['claude-', 'gemini-', 'gpt-', 'anthropic/', 'google/', 'openai/'];
|
|
@@ -598,14 +695,14 @@ export class SessionManager {
|
|
|
598
695
|
...oldConfig,
|
|
599
696
|
name,
|
|
600
697
|
model,
|
|
601
|
-
resumeSessionId: sessionId,
|
|
698
|
+
...(sessionId ? { resumeSessionId: sessionId } : {}),
|
|
602
699
|
});
|
|
603
700
|
}
|
|
604
701
|
catch (err) {
|
|
605
702
|
// Rollback: restart with original config
|
|
606
703
|
this.logger.error(`switchModel failed for '${name}', attempting rollback:`, err);
|
|
607
704
|
try {
|
|
608
|
-
await this.startSession({ ...oldConfig, name, resumeSessionId: sessionId });
|
|
705
|
+
await this.startSession({ ...oldConfig, name, ...(sessionId ? { resumeSessionId: sessionId } : {}) });
|
|
609
706
|
}
|
|
610
707
|
catch (rollbackErr) {
|
|
611
708
|
this.logger.error(`Rollback also failed for '${name}':`, rollbackErr);
|
|
@@ -631,9 +728,12 @@ export class SessionManager {
|
|
|
631
728
|
if (managed.session.isBusy) {
|
|
632
729
|
throw new Error(`Session '${name}' is currently processing a message. Wait for it to finish before updating tools.`);
|
|
633
730
|
}
|
|
634
|
-
|
|
635
|
-
|
|
731
|
+
// An agy session with no harvested conversation yet has no history to
|
|
732
|
+
// preserve — restart it fresh instead of rejecting the update.
|
|
733
|
+
const sessionId = this._managedResumeId(managed);
|
|
734
|
+
if (!sessionId && managed.config.engine !== 'agy') {
|
|
636
735
|
throw new Error(`Session '${name}' has no claude session ID — cannot resume after restart`);
|
|
736
|
+
}
|
|
637
737
|
const oldConfig = { ...managed.config };
|
|
638
738
|
let newAllowed = opts.allowedTools;
|
|
639
739
|
let newDisallowed = opts.disallowedTools;
|
|
@@ -661,13 +761,13 @@ export class SessionManager {
|
|
|
661
761
|
name,
|
|
662
762
|
allowedTools: newAllowed,
|
|
663
763
|
disallowedTools: newDisallowed,
|
|
664
|
-
resumeSessionId: sessionId,
|
|
764
|
+
...(sessionId ? { resumeSessionId: sessionId } : {}),
|
|
665
765
|
});
|
|
666
766
|
}
|
|
667
767
|
catch (err) {
|
|
668
768
|
this.logger.error(`updateTools failed for '${name}', attempting rollback:`, err);
|
|
669
769
|
try {
|
|
670
|
-
await this.startSession({ ...oldConfig, name, resumeSessionId: sessionId });
|
|
770
|
+
await this.startSession({ ...oldConfig, name, ...(sessionId ? { resumeSessionId: sessionId } : {}) });
|
|
671
771
|
}
|
|
672
772
|
catch (rollbackErr) {
|
|
673
773
|
this.logger.error(`Rollback also failed for '${name}':`, rollbackErr);
|
|
@@ -819,7 +919,7 @@ export class SessionManager {
|
|
|
819
919
|
output: deliveryResult.delivered
|
|
820
920
|
? `Message delivered to ${teammate}`
|
|
821
921
|
: `Message queued for ${teammate} (session is busy)`,
|
|
822
|
-
sessionId: managed
|
|
922
|
+
sessionId: this._managedResumeId(managed),
|
|
823
923
|
events: [],
|
|
824
924
|
};
|
|
825
925
|
}
|
|
@@ -1341,15 +1441,22 @@ export class SessionManager {
|
|
|
1341
1441
|
}
|
|
1342
1442
|
// ─── Private ───────────────────────────────────────────────────────────
|
|
1343
1443
|
_persistSession(name, managed) {
|
|
1344
|
-
|
|
1444
|
+
const resumeSessionId = this._managedResumeId(managed);
|
|
1445
|
+
if (!resumeSessionId) {
|
|
1446
|
+
if (managed.config.engine === 'agy' && this.persistedSessions.delete(name)) {
|
|
1447
|
+
this._debouncedSave();
|
|
1448
|
+
}
|
|
1345
1449
|
return;
|
|
1450
|
+
}
|
|
1451
|
+
managed.claudeSessionId = resumeSessionId;
|
|
1346
1452
|
const existing = this.persistedSessions.get(name);
|
|
1347
1453
|
this.persistedSessions.set(name, {
|
|
1348
1454
|
name,
|
|
1349
|
-
claudeSessionId:
|
|
1455
|
+
claudeSessionId: resumeSessionId,
|
|
1350
1456
|
cwd: managed.cwd,
|
|
1351
1457
|
model: managed.config.resolvedModel || managed.config.model,
|
|
1352
1458
|
engine: managed.config.engine,
|
|
1459
|
+
sandboxMode: managed.config.sandboxMode,
|
|
1353
1460
|
originalCreated: existing?.originalCreated || managed.created,
|
|
1354
1461
|
lastResumed: new Date().toISOString(),
|
|
1355
1462
|
lastActivity: managed.lastActivity,
|
|
@@ -1428,6 +1535,7 @@ export class SessionManager {
|
|
|
1428
1535
|
/(?:^|[/\s])claude(?:[\s/]|$)/, // claude CLI
|
|
1429
1536
|
/(?:^|[/\s])codex(?:[\s/]|$)/, // codex CLI
|
|
1430
1537
|
/(?:^|[/\s])gemini(?:[\s/]|$)/, // gemini CLI
|
|
1538
|
+
/(?:^|[/\s])agy(?:[\s/]|$)/, // agy CLI (Google Antigravity)
|
|
1431
1539
|
/(?:^|[/\s])cursor-agent(?:[\s/]|$)/, // cursor-agent CLI
|
|
1432
1540
|
/(?:^|[/\s])opencode(?:[\s/]|$)/, // opencode CLI (sst/opencode)
|
|
1433
1541
|
/(?:^|\/)agent(?:[\s/]|$)/, // 'agent' only as executable/after a slash (not ssh-agent)
|
|
@@ -1551,9 +1659,12 @@ export class SessionManager {
|
|
|
1551
1659
|
}
|
|
1552
1660
|
_toSessionInfo(name, managed) {
|
|
1553
1661
|
const stats = managed.session.getStats();
|
|
1662
|
+
const resumeSessionId = this._managedResumeId(managed);
|
|
1663
|
+
if (resumeSessionId)
|
|
1664
|
+
managed.claudeSessionId = resumeSessionId;
|
|
1554
1665
|
return {
|
|
1555
1666
|
name,
|
|
1556
|
-
claudeSessionId:
|
|
1667
|
+
claudeSessionId: resumeSessionId,
|
|
1557
1668
|
created: managed.created,
|
|
1558
1669
|
cwd: managed.cwd,
|
|
1559
1670
|
model: managed.config.resolvedModel || managed.config.model,
|
|
@@ -1566,6 +1677,32 @@ export class SessionManager {
|
|
|
1566
1677
|
return overrides[alias];
|
|
1567
1678
|
return resolveAlias(alias);
|
|
1568
1679
|
}
|
|
1680
|
+
_managedResumeId(managed) {
|
|
1681
|
+
return (this._sessionResumeId(managed.config.engine, managed.session) ||
|
|
1682
|
+
this._storedResumeId(managed.config.engine, managed.claudeSessionId));
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Return only IDs that can actually resume the engine. Agy and Codex expose
|
|
1686
|
+
* harvested conversation/thread IDs; their BaseOneShot sessionId values are
|
|
1687
|
+
* synthetic wrapper identifiers and must never be persisted for resume.
|
|
1688
|
+
*/
|
|
1689
|
+
_sessionResumeId(engine, session) {
|
|
1690
|
+
if (engine === 'agy') {
|
|
1691
|
+
const conversationId = session.conversationId;
|
|
1692
|
+
return isAgyConversationId(conversationId) ? conversationId : undefined;
|
|
1693
|
+
}
|
|
1694
|
+
if (engine === 'codex') {
|
|
1695
|
+
return session.threadId;
|
|
1696
|
+
}
|
|
1697
|
+
return session.sessionId;
|
|
1698
|
+
}
|
|
1699
|
+
_storedResumeId(engine, id) {
|
|
1700
|
+
if (engine === 'agy')
|
|
1701
|
+
return isAgyConversationId(id) ? id : undefined;
|
|
1702
|
+
if (engine === 'codex')
|
|
1703
|
+
return id && !/^codex-\d+-/.test(id) ? id : undefined;
|
|
1704
|
+
return id;
|
|
1705
|
+
}
|
|
1569
1706
|
_listMdFiles(dir) {
|
|
1570
1707
|
if (!fs.existsSync(dir))
|
|
1571
1708
|
return [];
|
|
@@ -1582,6 +1719,8 @@ export class SessionManager {
|
|
|
1582
1719
|
switch (engine) {
|
|
1583
1720
|
case 'gemini':
|
|
1584
1721
|
return new PersistentGeminiSession(config, process.env.GEMINI_BIN);
|
|
1722
|
+
case 'agy':
|
|
1723
|
+
return new PersistentAgySession(config, process.env.AGY_BIN);
|
|
1585
1724
|
case 'codex':
|
|
1586
1725
|
return new PersistentCodexSession(config, process.env.CODEX_BIN);
|
|
1587
1726
|
case 'codex-app':
|
|
@@ -2065,6 +2204,14 @@ export class SessionManager {
|
|
|
2065
2204
|
// concurrent autoloopStart recreating the same id (or autoloopChat using a
|
|
2066
2205
|
// dispatcher mid-shutdown) during the async delete window.
|
|
2067
2206
|
_deletingAutoloops = new Set();
|
|
2207
|
+
/**
|
|
2208
|
+
* Runs whose Planner is mid-startup. The delete fence was one-directional:
|
|
2209
|
+
* a start could not race a delete, but a delete COULD race a start — it would
|
|
2210
|
+
* resolve `true`, drop the registry row, and leave the still-starting Planner
|
|
2211
|
+
* session orphaned (no run to stop it, no entry to find it by). Deleting a run
|
|
2212
|
+
* that is still coming up is rejected instead.
|
|
2213
|
+
*/
|
|
2214
|
+
_startingAutoloops = new Set();
|
|
2068
2215
|
/**
|
|
2069
2216
|
* Start a v2 autoloop in chat mode. Creates the Planner persistent session,
|
|
2070
2217
|
* returns the run handle. Coder/Reviewer are NOT started until S3's
|
|
@@ -2077,6 +2224,15 @@ export class SessionManager {
|
|
|
2077
2224
|
if (this._deletingAutoloops.has(opts.runId)) {
|
|
2078
2225
|
throw new Error(`Autoloop with id '${opts.runId}' is being deleted`);
|
|
2079
2226
|
}
|
|
2227
|
+
const plannerEngine = validateAutoloopRole('planner', opts.plannerEngine, opts.plannerCustomEngine);
|
|
2228
|
+
const coderEngine = validateAutoloopRole('coder', opts.coderEngine, opts.coderCustomEngine);
|
|
2229
|
+
const reviewerEngine = validateAutoloopRole('reviewer', opts.reviewerEngine, opts.reviewerCustomEngine);
|
|
2230
|
+
for (const role of ['planner', 'coder', 'reviewer']) {
|
|
2231
|
+
const sessionName = `autoloop-${opts.runId}-${role}`;
|
|
2232
|
+
if (this.sessions.has(sessionName) || this._pendingSessions.has(sessionName)) {
|
|
2233
|
+
throw new Error(`Autoloop session name '${sessionName}' is already in use`);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2080
2236
|
const ledgerDir = path.join(opts.workspace, 'tasks', opts.runId);
|
|
2081
2237
|
if (!fs.existsSync(ledgerDir)) {
|
|
2082
2238
|
fs.mkdirSync(ledgerDir, { recursive: true });
|
|
@@ -2092,7 +2248,15 @@ export class SessionManager {
|
|
|
2092
2248
|
runId: opts.runId,
|
|
2093
2249
|
workspace: opts.workspace,
|
|
2094
2250
|
plannerPromptPath: opts.plannerPromptPath,
|
|
2251
|
+
plannerEngine,
|
|
2095
2252
|
plannerModel: opts.plannerModel,
|
|
2253
|
+
plannerCustomEngine: opts.plannerCustomEngine,
|
|
2254
|
+
coderEngine,
|
|
2255
|
+
coderModel: opts.coderModel,
|
|
2256
|
+
coderCustomEngine: opts.coderCustomEngine,
|
|
2257
|
+
reviewerEngine,
|
|
2258
|
+
reviewerModel: opts.reviewerModel,
|
|
2259
|
+
reviewerCustomEngine: opts.reviewerCustomEngine,
|
|
2096
2260
|
sendTimeoutMs: opts.sendTimeoutMs,
|
|
2097
2261
|
logger: this.logger,
|
|
2098
2262
|
pushPolicyRef: pushPolicy,
|
|
@@ -2101,6 +2265,26 @@ export class SessionManager {
|
|
|
2101
2265
|
await dispatcherRef?.spawnSubagents(args);
|
|
2102
2266
|
runnerRef?.markSubagentsSpawned();
|
|
2103
2267
|
},
|
|
2268
|
+
onRoleSelectionChanged: async (selection) => {
|
|
2269
|
+
try {
|
|
2270
|
+
upsertAutoloopRegistry(DEFAULT_AUTOLOOP_REGISTRY, {
|
|
2271
|
+
run_id: runId,
|
|
2272
|
+
workspace: opts.workspace,
|
|
2273
|
+
ledger_dir: ledgerDir,
|
|
2274
|
+
started_at: runnerRef?.state.started_at ?? new Date().toISOString(),
|
|
2275
|
+
planner_session: dispatcherRef?.sessionNames.planner ?? `autoloop-${runId}-planner`,
|
|
2276
|
+
planner_engine: plannerEngine,
|
|
2277
|
+
planner_model: opts.plannerModel,
|
|
2278
|
+
coder_engine: selection.coder.engine,
|
|
2279
|
+
coder_model: selection.coder.model,
|
|
2280
|
+
reviewer_engine: selection.reviewer.engine,
|
|
2281
|
+
reviewer_model: selection.reviewer.model,
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
catch (err) {
|
|
2285
|
+
this.logger.warn?.(`[autoloop/${runId}] registry update after spawn failed: ${err.message}`);
|
|
2286
|
+
}
|
|
2287
|
+
},
|
|
2104
2288
|
};
|
|
2105
2289
|
const dispatcher = new ClaudeAgentDispatcher(dispatcherConfig);
|
|
2106
2290
|
dispatcherRef = dispatcher;
|
|
@@ -2137,17 +2321,40 @@ export class SessionManager {
|
|
|
2137
2321
|
ledgerDir,
|
|
2138
2322
|
pushPolicy,
|
|
2139
2323
|
});
|
|
2140
|
-
|
|
2324
|
+
this._startingAutoloops.add(opts.runId);
|
|
2325
|
+
try {
|
|
2326
|
+
await runner.start();
|
|
2327
|
+
}
|
|
2328
|
+
catch (err) {
|
|
2329
|
+
this.autoloops.delete(opts.runId);
|
|
2330
|
+
try {
|
|
2331
|
+
await dispatcher.shutdown('start-failed', { purge: true });
|
|
2332
|
+
}
|
|
2333
|
+
catch (cleanupErr) {
|
|
2334
|
+
this.logger.warn?.(`[autoloop/${runId}] cleanup after failed start failed: ${cleanupErr.message}`);
|
|
2335
|
+
}
|
|
2336
|
+
runner.stop();
|
|
2337
|
+
throw err;
|
|
2338
|
+
}
|
|
2339
|
+
finally {
|
|
2340
|
+
this._startingAutoloops.delete(opts.runId);
|
|
2341
|
+
}
|
|
2141
2342
|
// Record into the cross-process registry so the dashboard / another
|
|
2142
2343
|
// SessionManager instance can list this run even after it ends. Best
|
|
2143
2344
|
// effort — registry failure should not block the run.
|
|
2144
2345
|
try {
|
|
2145
|
-
|
|
2346
|
+
upsertAutoloopRegistry(DEFAULT_AUTOLOOP_REGISTRY, {
|
|
2146
2347
|
run_id: opts.runId,
|
|
2147
2348
|
workspace: opts.workspace,
|
|
2148
2349
|
ledger_dir: ledgerDir,
|
|
2149
2350
|
started_at: runner.state.started_at,
|
|
2150
2351
|
planner_session: dispatcher.sessionNames.planner,
|
|
2352
|
+
planner_engine: plannerEngine,
|
|
2353
|
+
planner_model: opts.plannerModel,
|
|
2354
|
+
coder_engine: coderEngine,
|
|
2355
|
+
coder_model: opts.coderModel,
|
|
2356
|
+
reviewer_engine: reviewerEngine,
|
|
2357
|
+
reviewer_model: opts.reviewerModel,
|
|
2151
2358
|
});
|
|
2152
2359
|
}
|
|
2153
2360
|
catch (err) {
|
|
@@ -2269,27 +2476,33 @@ export class SessionManager {
|
|
|
2269
2476
|
* is still served via /autoloop/<id>/chat_history so the dashboard can
|
|
2270
2477
|
* replay the conversation visually.
|
|
2271
2478
|
*/
|
|
2272
|
-
async autoloopResume(runId) {
|
|
2479
|
+
async autoloopResume(runId, opts = {}) {
|
|
2273
2480
|
const existing = this.autoloops.get(runId);
|
|
2274
2481
|
if (existing)
|
|
2275
2482
|
return existing.runner.state;
|
|
2276
2483
|
const entry = listAutoloopsFromRegistry().find((e) => e.run_id === runId);
|
|
2277
2484
|
if (!entry)
|
|
2278
2485
|
throw new Error(`Autoloop run '${runId}' not found in registry`);
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
catch (err) {
|
|
2288
|
-
this.logger.warn?.(`[autoloop/${runId}] registry scrub during resume failed: ${err.message}`);
|
|
2289
|
-
}
|
|
2486
|
+
// Validate the full restart configuration before touching the registry.
|
|
2487
|
+
// Old rows omit these fields and intentionally recover the legacy Claude defaults.
|
|
2488
|
+
const plannerEngine = validateAutoloopRole('planner', entry.planner_engine, opts.plannerCustomEngine);
|
|
2489
|
+
const coderEngine = validateAutoloopRole('coder', entry.coder_engine, opts.coderCustomEngine);
|
|
2490
|
+
const reviewerEngine = validateAutoloopRole('reviewer', entry.reviewer_engine, opts.reviewerCustomEngine);
|
|
2491
|
+
// The registry is append-only and newest entry wins. Leave the prior row
|
|
2492
|
+
// untouched while starting so a transient failure cannot erase or restore
|
|
2493
|
+
// stale cross-process state. A successful start appends the replacement.
|
|
2290
2494
|
return (await this.autoloopStart({
|
|
2291
2495
|
runId: entry.run_id,
|
|
2292
2496
|
workspace: entry.workspace,
|
|
2497
|
+
plannerEngine,
|
|
2498
|
+
plannerModel: entry.planner_model,
|
|
2499
|
+
plannerCustomEngine: opts.plannerCustomEngine,
|
|
2500
|
+
coderEngine,
|
|
2501
|
+
coderModel: entry.coder_model,
|
|
2502
|
+
coderCustomEngine: opts.coderCustomEngine,
|
|
2503
|
+
reviewerEngine,
|
|
2504
|
+
reviewerModel: entry.reviewer_model,
|
|
2505
|
+
reviewerCustomEngine: opts.reviewerCustomEngine,
|
|
2293
2506
|
})).state;
|
|
2294
2507
|
}
|
|
2295
2508
|
/**
|
|
@@ -2302,6 +2515,12 @@ export class SessionManager {
|
|
|
2302
2515
|
* Returns true if anything was removed (in-memory entry OR registry row).
|
|
2303
2516
|
*/
|
|
2304
2517
|
async autoloopDelete(runId) {
|
|
2518
|
+
// Refuse to tear down a run that is still coming up: its Planner session is
|
|
2519
|
+
// mid-startSession, so deleting now would drop the registry row and orphan a
|
|
2520
|
+
// session that finishes starting a moment later.
|
|
2521
|
+
if (this._startingAutoloops.has(runId)) {
|
|
2522
|
+
throw new Error(`Autoloop with id '${runId}' is still starting`);
|
|
2523
|
+
}
|
|
2305
2524
|
// Fence the async teardown so a concurrent start/chat can't race on this id.
|
|
2306
2525
|
this._deletingAutoloops.add(runId);
|
|
2307
2526
|
try {
|