@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.
Files changed (59) hide show
  1. package/README.md +6 -5
  2. package/configs/autoloop-coder-prompt.md +4 -4
  3. package/configs/autoloop-planner-prompt.md +10 -10
  4. package/configs/autoloop-reviewer-prompt.md +3 -4
  5. package/dist/bin/cli.js +1 -1
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/src/agy-conversation.d.ts +2 -0
  8. package/dist/src/agy-conversation.js +10 -0
  9. package/dist/src/agy-conversation.js.map +1 -0
  10. package/dist/src/autoloop/dispatcher.d.ts +59 -10
  11. package/dist/src/autoloop/dispatcher.js +261 -40
  12. package/dist/src/autoloop/dispatcher.js.map +1 -1
  13. package/dist/src/autoloop/planner-tools.d.ts +4 -3
  14. package/dist/src/autoloop/planner-tools.js +35 -2
  15. package/dist/src/autoloop/planner-tools.js.map +1 -1
  16. package/dist/src/autoloop/types.d.ts +2 -0
  17. package/dist/src/autoloop/types.js.map +1 -1
  18. package/dist/src/dashboard/index.html +185 -94
  19. package/dist/src/embedded-server.js +67 -9
  20. package/dist/src/embedded-server.js.map +1 -1
  21. package/dist/src/index.d.ts +1 -0
  22. package/dist/src/index.js +98 -61
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/models.js +68 -3
  25. package/dist/src/models.js.map +1 -1
  26. package/dist/src/persistent-agy-session.d.ts +60 -0
  27. package/dist/src/persistent-agy-session.js +228 -0
  28. package/dist/src/persistent-agy-session.js.map +1 -0
  29. package/dist/src/persistent-codex-session.js +3 -0
  30. package/dist/src/persistent-codex-session.js.map +1 -1
  31. package/dist/src/persistent-cursor-session.js +9 -6
  32. package/dist/src/persistent-cursor-session.js.map +1 -1
  33. package/dist/src/persistent-custom-session.js +18 -32
  34. package/dist/src/persistent-custom-session.js.map +1 -1
  35. package/dist/src/persistent-gemini-session.d.ts +4 -0
  36. package/dist/src/persistent-gemini-session.js +58 -7
  37. package/dist/src/persistent-gemini-session.js.map +1 -1
  38. package/dist/src/persistent-opencode-session.js +37 -7
  39. package/dist/src/persistent-opencode-session.js.map +1 -1
  40. package/dist/src/persistent-session.js +8 -9
  41. package/dist/src/persistent-session.js.map +1 -1
  42. package/dist/src/sanitize.d.ts +23 -0
  43. package/dist/src/sanitize.js +57 -0
  44. package/dist/src/sanitize.js.map +1 -0
  45. package/dist/src/session-manager.d.ts +49 -2
  46. package/dist/src/session-manager.js +254 -35
  47. package/dist/src/session-manager.js.map +1 -1
  48. package/dist/src/types.d.ts +8 -4
  49. package/dist/src/types.js +2 -0
  50. package/dist/src/types.js.map +1 -1
  51. package/package.json +1 -1
  52. package/skills/SKILL.md +17 -14
  53. package/skills/references/autoloop.md +50 -6
  54. package/skills/references/claude-cli-tracking.md +3 -1
  55. package/skills/references/cli.md +4 -3
  56. package/skills/references/mcp.md +1 -1
  57. package/skills/references/multi-engine.md +53 -8
  58. package/skills/references/sessions.md +2 -2
  59. 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): Promise<AutoloopState>;
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: session.sessionId,
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 for ephemeral
482
- // sessions that were started with skipPersistence)
483
- if (managed.session.sessionId) {
484
- managed.claudeSessionId = managed.session.sessionId;
485
- if (this.persistedSessions.has(name)) {
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.claudeSessionId,
586
+ sessionId: this._managedResumeId(managed),
493
587
  events: [],
494
588
  };
495
589
  }
496
- return { output: '', sessionId: managed.claudeSessionId, events: [] };
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
- const sessionId = managed.claudeSessionId || managed.session.sessionId;
584
- if (!sessionId)
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
- const sessionId = managed.claudeSessionId || managed.session.sessionId;
635
- if (!sessionId)
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.claudeSessionId,
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
- if (!managed.claudeSessionId)
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: managed.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: managed.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
- await runner.start();
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
- appendAutoloopRegistry(DEFAULT_AUTOLOOP_REGISTRY, {
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
- // Scrub the prior registry row before autoloopStart appends a new one.
2280
- // Without this, every resume leaves a duplicate JSONL line; listAutoloops
2281
- // dedups on read (newest wins), but the file grows monotonically and the
2282
- // postmortem ledger becomes hard to scan. Best-effort — we don't block
2283
- // the resume on a registry write failure.
2284
- try {
2285
- removeAutoloopFromRegistry(DEFAULT_AUTOLOOP_REGISTRY, runId);
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 {