@enderfga/claw-orchestrator 3.7.1 → 4.0.3

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 (115) hide show
  1. package/README.md +45 -3
  2. package/configs/autoloop-coder-prompt.md +65 -28
  3. package/configs/autoloop-planner-prompt.md +103 -42
  4. package/configs/autoloop-reviewer-prompt.md +58 -30
  5. package/dist/bin/cli.js +30 -7
  6. package/dist/bin/cli.js.map +1 -1
  7. package/dist/src/autoloop/dispatcher.js +16 -2
  8. package/dist/src/autoloop/dispatcher.js.map +1 -1
  9. package/dist/src/autoloop/messages.d.ts +0 -2
  10. package/dist/src/autoloop/messages.js +0 -2
  11. package/dist/src/autoloop/messages.js.map +1 -1
  12. package/dist/src/autoloop/planner-tools.d.ts +11 -6
  13. package/dist/src/autoloop/planner-tools.js +24 -7
  14. package/dist/src/autoloop/planner-tools.js.map +1 -1
  15. package/dist/src/autoloop/runner.d.ts +1 -2
  16. package/dist/src/autoloop/runner.js +1 -2
  17. package/dist/src/autoloop/runner.js.map +1 -1
  18. package/dist/src/autoloop/types.d.ts +0 -2
  19. package/dist/src/autoloop/types.js +0 -2
  20. package/dist/src/autoloop/types.js.map +1 -1
  21. package/dist/src/council.js +1 -0
  22. package/dist/src/council.js.map +1 -1
  23. package/dist/src/dashboard/index.html +1066 -12
  24. package/dist/src/embedded-server.d.ts +7 -0
  25. package/dist/src/embedded-server.js +359 -17
  26. package/dist/src/embedded-server.js.map +1 -1
  27. package/dist/src/index.js +222 -1
  28. package/dist/src/index.js.map +1 -1
  29. package/dist/src/session-manager.d.ts +98 -1
  30. package/dist/src/session-manager.js +340 -5
  31. package/dist/src/session-manager.js.map +1 -1
  32. package/dist/src/ultraapp/build-events.d.ts +46 -0
  33. package/dist/src/ultraapp/build-events.js +6 -0
  34. package/dist/src/ultraapp/build-events.js.map +1 -0
  35. package/dist/src/ultraapp/build.d.ts +39 -0
  36. package/dist/src/ultraapp/build.js +111 -0
  37. package/dist/src/ultraapp/build.js.map +1 -0
  38. package/dist/src/ultraapp/conventions.d.ts +8 -0
  39. package/dist/src/ultraapp/conventions.js +248 -0
  40. package/dist/src/ultraapp/conventions.js.map +1 -0
  41. package/dist/src/ultraapp/council-adapter.d.ts +49 -0
  42. package/dist/src/ultraapp/council-adapter.js +152 -0
  43. package/dist/src/ultraapp/council-adapter.js.map +1 -0
  44. package/dist/src/ultraapp/deploy.d.ts +45 -0
  45. package/dist/src/ultraapp/deploy.js +82 -0
  46. package/dist/src/ultraapp/deploy.js.map +1 -0
  47. package/dist/src/ultraapp/diff-apply.d.ts +15 -0
  48. package/dist/src/ultraapp/diff-apply.js +48 -0
  49. package/dist/src/ultraapp/diff-apply.js.map +1 -0
  50. package/dist/src/ultraapp/docker.d.ts +57 -0
  51. package/dist/src/ultraapp/docker.js +83 -0
  52. package/dist/src/ultraapp/docker.js.map +1 -0
  53. package/dist/src/ultraapp/feedback-classifier.d.ts +24 -0
  54. package/dist/src/ultraapp/feedback-classifier.js +85 -0
  55. package/dist/src/ultraapp/feedback-classifier.js.map +1 -0
  56. package/dist/src/ultraapp/files.d.ts +24 -0
  57. package/dist/src/ultraapp/files.js +79 -0
  58. package/dist/src/ultraapp/files.js.map +1 -0
  59. package/dist/src/ultraapp/fix-on-failure-session.d.ts +23 -0
  60. package/dist/src/ultraapp/fix-on-failure-session.js +48 -0
  61. package/dist/src/ultraapp/fix-on-failure-session.js.map +1 -0
  62. package/dist/src/ultraapp/fix-on-failure.d.ts +39 -0
  63. package/dist/src/ultraapp/fix-on-failure.js +69 -0
  64. package/dist/src/ultraapp/fix-on-failure.js.map +1 -0
  65. package/dist/src/ultraapp/host-strategy.d.ts +57 -0
  66. package/dist/src/ultraapp/host-strategy.js +205 -0
  67. package/dist/src/ultraapp/host-strategy.js.map +1 -0
  68. package/dist/src/ultraapp/interview-parser.d.ts +41 -0
  69. package/dist/src/ultraapp/interview-parser.js +83 -0
  70. package/dist/src/ultraapp/interview-parser.js.map +1 -0
  71. package/dist/src/ultraapp/interview-tools.d.ts +16 -0
  72. package/dist/src/ultraapp/interview-tools.js +36 -0
  73. package/dist/src/ultraapp/interview-tools.js.map +1 -0
  74. package/dist/src/ultraapp/json-patch.d.ts +6 -0
  75. package/dist/src/ultraapp/json-patch.js +78 -0
  76. package/dist/src/ultraapp/json-patch.js.map +1 -0
  77. package/dist/src/ultraapp/lifecycle.d.ts +38 -0
  78. package/dist/src/ultraapp/lifecycle.js +31 -0
  79. package/dist/src/ultraapp/lifecycle.js.map +1 -0
  80. package/dist/src/ultraapp/manager.d.ts +150 -0
  81. package/dist/src/ultraapp/manager.js +684 -0
  82. package/dist/src/ultraapp/manager.js.map +1 -0
  83. package/dist/src/ultraapp/narrator-prompt.d.ts +10 -0
  84. package/dist/src/ultraapp/narrator-prompt.js +54 -0
  85. package/dist/src/ultraapp/narrator-prompt.js.map +1 -0
  86. package/dist/src/ultraapp/narrator.d.ts +49 -0
  87. package/dist/src/ultraapp/narrator.js +83 -0
  88. package/dist/src/ultraapp/narrator.js.map +1 -0
  89. package/dist/src/ultraapp/patcher.d.ts +35 -0
  90. package/dist/src/ultraapp/patcher.js +159 -0
  91. package/dist/src/ultraapp/patcher.js.map +1 -0
  92. package/dist/src/ultraapp/router.d.ts +39 -0
  93. package/dist/src/ultraapp/router.js +139 -0
  94. package/dist/src/ultraapp/router.js.map +1 -0
  95. package/dist/src/ultraapp/spec-delta.d.ts +18 -0
  96. package/dist/src/ultraapp/spec-delta.js +35 -0
  97. package/dist/src/ultraapp/spec-delta.js.map +1 -0
  98. package/dist/src/ultraapp/spec.d.ts +82 -0
  99. package/dist/src/ultraapp/spec.js +125 -0
  100. package/dist/src/ultraapp/spec.js.map +1 -0
  101. package/dist/src/ultraapp/store.d.ts +67 -0
  102. package/dist/src/ultraapp/store.js +177 -0
  103. package/dist/src/ultraapp/store.js.map +1 -0
  104. package/dist/src/ultraapp/versions.d.ts +50 -0
  105. package/dist/src/ultraapp/versions.js +65 -0
  106. package/dist/src/ultraapp/versions.js.map +1 -0
  107. package/openclaw.plugin.json +15 -1
  108. package/package.json +3 -1
  109. package/skills/SKILL.md +2 -2
  110. package/skills/references/autoloop.md +7 -4
  111. package/skills/references/dashboard.md +153 -0
  112. package/skills/references/mcp.md +2 -2
  113. package/skills/references/tools.md +183 -0
  114. package/skills/references/ultraapp.md +203 -0
  115. package/skills/ultraapp/SKILL.md +112 -0
@@ -120,7 +120,124 @@ import { ClaudeAgentDispatcher } from './autoloop/dispatcher.js';
120
120
  import { DEFAULT_PUSH_POLICY } from './autoloop/types.js';
121
121
  import { Msg as AutoloopMsg } from './autoloop/messages.js';
122
122
  import { appendPushLog, notifyUserFallbackChain } from './autoloop/notify.js';
123
+ import { UltraappManager } from './ultraapp/manager.js';
124
+ import { UltraappStore, defaultStoreRoot } from './ultraapp/store.js';
123
125
  import { PERSIST_DISK_TTL_MS, DEBOUNCED_SAVE_MS, CLEANUP_INTERVAL_MS, TURN_TIMEOUT_MS, GREP_HISTORY_FETCH, RESULT_TTL_MS, ULTRAPLAN_TIMEOUT_MS, ULTRAREVIEW_POLL_INTERVAL_MS, STOP_SIGKILL_DELAY_MS, SESSION_EVENT, DEFAULT_HISTORY_LIMIT, } from './constants.js';
126
+ // ─── Disk enumeration (cross-process visibility) ────────────────────────────
127
+ //
128
+ // When the dashboard's standalone clawo-serve and the OpenClaw plugin run as
129
+ // separate processes, each has its own in-memory map of active runs. To make
130
+ // past runs visible across processes we read what's persisted on disk:
131
+ // - Council: transcripts at ~/.openclaw/council-logs/council-*.md
132
+ // - Autoloop: registry at ~/.claw-orchestrator/autoloop-registry.jsonl
133
+ const DEFAULT_COUNCIL_LOG_DIR = path.join(os.homedir(), '.openclaw', 'council-logs');
134
+ const DEFAULT_AUTOLOOP_REGISTRY = path.join(os.homedir(), '.claw-orchestrator', 'autoloop-registry.jsonl');
135
+ /** Append-only registry write. Safe under concurrent writers — append is atomic for short lines. */
136
+ export function appendAutoloopRegistry(file, entry) {
137
+ fs.mkdirSync(path.dirname(file), { recursive: true });
138
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
139
+ }
140
+ /**
141
+ * Read the registry, dedup by run_id (newest entry wins), drop entries whose
142
+ * ledger_dir no longer exists on disk (cleanup of moved/deleted workspaces).
143
+ * Returns entries newest-first.
144
+ */
145
+ export function listAutoloopsFromRegistry(file = DEFAULT_AUTOLOOP_REGISTRY) {
146
+ if (!fs.existsSync(file))
147
+ return [];
148
+ const lines = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
149
+ const seen = new Set();
150
+ const out = [];
151
+ // Walk in reverse so the latest entry for a given run_id wins.
152
+ for (const line of [...lines].reverse()) {
153
+ try {
154
+ const e = JSON.parse(line);
155
+ if (seen.has(e.run_id))
156
+ continue;
157
+ seen.add(e.run_id);
158
+ if (!fs.existsSync(e.ledger_dir))
159
+ continue; // stale entry, ledger gone
160
+ out.push(e);
161
+ }
162
+ catch {
163
+ // malformed line; skip
164
+ }
165
+ }
166
+ return out; // already newest-first because we reversed
167
+ }
168
+ /**
169
+ * Rewrite the registry with every line for the given run_id filtered out.
170
+ * Used by autoloopDelete to scrub a run from cross-process visibility.
171
+ * No-op if the file does not exist. Returns the number of lines removed.
172
+ */
173
+ export function removeAutoloopFromRegistry(file, runId) {
174
+ if (!fs.existsSync(file))
175
+ return 0;
176
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
177
+ let removed = 0;
178
+ const kept = [];
179
+ for (const line of lines) {
180
+ if (!line) {
181
+ kept.push(line);
182
+ continue;
183
+ }
184
+ try {
185
+ const e = JSON.parse(line);
186
+ if (e.run_id === runId) {
187
+ removed += 1;
188
+ continue;
189
+ }
190
+ }
191
+ catch {
192
+ // malformed line — keep it, we only filter recognizable entries
193
+ }
194
+ kept.push(line);
195
+ }
196
+ if (removed === 0)
197
+ return 0;
198
+ const tmp = `${file}.${process.pid}.tmp`;
199
+ fs.writeFileSync(tmp, kept.join('\n'));
200
+ fs.renameSync(tmp, file);
201
+ return removed;
202
+ }
203
+ /**
204
+ * Enumerate council sessions from on-disk transcripts. Called by
205
+ * SessionManager.councilList() to surface runs that the current process didn't
206
+ * spawn itself (e.g. runs started in another process whose transcripts have
207
+ * already been flushed to ~/.openclaw/council-logs/).
208
+ *
209
+ * Format parsed (matches src/council.ts saveTranscript):
210
+ * - **ID**: <session.id>
211
+ * - **Time**: <iso>
212
+ * - **Task**: <text>
213
+ * - **Status**: <consensus|max_rounds|...>
214
+ *
215
+ * Legacy transcripts written before the ID field was added fall back to a
216
+ * filename-derived id (basename without .md). That's stable across reruns
217
+ * even if uncomfortable as a display id.
218
+ */
219
+ export function listCouncilsFromDisk(logDir = DEFAULT_COUNCIL_LOG_DIR) {
220
+ if (!fs.existsSync(logDir))
221
+ return [];
222
+ const out = [];
223
+ for (const entry of fs.readdirSync(logDir)) {
224
+ if (!entry.startsWith('council-') || !entry.endsWith('.md'))
225
+ continue;
226
+ let head;
227
+ try {
228
+ head = fs.readFileSync(path.join(logDir, entry), 'utf-8').slice(0, 2000);
229
+ }
230
+ catch {
231
+ continue;
232
+ }
233
+ const id = /^-\s+\*\*ID\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || entry.replace(/\.md$/, '');
234
+ const task = /^-\s+\*\*Task\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || '(no task recorded)';
235
+ const startTime = /^-\s+\*\*Time\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || '';
236
+ const status = /^-\s+\*\*Status\*\*:\s*([^\n]+)/m.exec(head)?.[1]?.trim() || 'unknown';
237
+ out.push({ id, task, status, startTime });
238
+ }
239
+ return out;
240
+ }
124
241
  // ─── SessionManager ──────────────────────────────────────────────────────────
125
242
  export class SessionManager {
126
243
  sessions = new Map();
@@ -135,6 +252,9 @@ export class SessionManager {
135
252
  _circuitBreaker = new CircuitBreaker();
136
253
  _inbox = new InboxManager();
137
254
  logger;
255
+ _ultraappManager = null;
256
+ _ultraappRouter = null;
257
+ _ultraappRuntimeMode = 'host';
138
258
  constructor(config, logger) {
139
259
  this.logger = logger || createConsoleLogger('SessionManager');
140
260
  this.pluginConfig = {
@@ -158,6 +278,48 @@ export class SessionManager {
158
278
  // Start TTL cleanup timer
159
279
  this.cleanupTimer = setInterval(() => this._cleanupIdleSessions(), CLEANUP_INTERVAL_MS);
160
280
  }
281
+ /**
282
+ * Lazily-constructed ultraapp manager. The ultraapp manager itself uses
283
+ * `this` as its session-manager dependency; building it lazily avoids any
284
+ * circular initialisation concerns.
285
+ */
286
+ getUltraappManager() {
287
+ if (!this._ultraappManager) {
288
+ this._ultraappManager = new UltraappManager({
289
+ store: new UltraappStore(defaultStoreRoot()),
290
+ sessionManager: this,
291
+ router: this._ultraappRouter ?? undefined,
292
+ runtimeMode: this._ultraappRuntimeMode,
293
+ });
294
+ }
295
+ return this._ultraappManager;
296
+ }
297
+ /**
298
+ * Inject a started UltraappRouter so deploy + lifecycle wiring becomes
299
+ * available. Must be called BEFORE the first `getUltraappManager()` call —
300
+ * the manager is constructed lazily and reads the router reference at that
301
+ * point. Production: bin/cli.ts wires this. Tests: leave unset to keep
302
+ * v0.2-style "build-complete is resting state" behaviour.
303
+ */
304
+ setUltraappRouter(router) {
305
+ if (this._ultraappManager) {
306
+ throw new Error('setUltraappRouter must be called before getUltraappManager');
307
+ }
308
+ this._ultraappRouter = router;
309
+ }
310
+ /**
311
+ * Pick the ultraapp runtime mode. 'host' (default) spawns the generated
312
+ * app as a regular Node process — works anywhere Node works, no Docker
313
+ * required. 'docker' uses `docker build` + `docker run` for isolation,
314
+ * intended for shared production hosts. Must be called before the first
315
+ * `getUltraappManager()` call.
316
+ */
317
+ setUltraappRuntimeMode(mode) {
318
+ if (this._ultraappManager) {
319
+ throw new Error('setUltraappRuntimeMode must be called before getUltraappManager');
320
+ }
321
+ this._ultraappRuntimeMode = mode;
322
+ }
161
323
  // ─── Session Lifecycle ─────────────────────────────────────────────────
162
324
  async startSession(config) {
163
325
  const name = config.name || `session-${Date.now()}`;
@@ -1040,7 +1202,51 @@ export class SessionManager {
1040
1202
  const dir = path.dirname(SessionManager.PID_FILE);
1041
1203
  if (!fs.existsSync(dir))
1042
1204
  fs.mkdirSync(dir, { recursive: true });
1043
- fs.writeFileSync(SessionManager.PID_FILE, JSON.stringify(Object.fromEntries(this._activePids)));
1205
+ // The PID file is host-shared: any SessionManager (gateway, dashboard,
1206
+ // standalone test runner) writes here. We MUST NOT overwrite entries
1207
+ // owned by another live SessionManager process — that would erase its
1208
+ // record of pids it spawned, and its next cleanup pass might decide
1209
+ // they're orphans and kill them. Read-merge-write keyed by ownerPid.
1210
+ let existing = {};
1211
+ try {
1212
+ existing = JSON.parse(fs.readFileSync(SessionManager.PID_FILE, 'utf8'));
1213
+ }
1214
+ catch {
1215
+ /* missing or malformed — start fresh */
1216
+ }
1217
+ const merged = {};
1218
+ const now = new Date().toISOString();
1219
+ // Keep entries from OTHER LIVE owners untouched. Entries whose
1220
+ // ownerPid is a dead process are stale bookkeeping — drop them so
1221
+ // the file doesn't grow unboundedly across server restarts. The
1222
+ // child processes those entries used to track were already reaped
1223
+ // by _cleanupOrphanedPids (which runs at SessionManager init,
1224
+ // before the first save).
1225
+ for (const [name, raw] of Object.entries(existing)) {
1226
+ if (typeof raw === 'number')
1227
+ continue; // legacy format — drop on first save
1228
+ const entry = raw;
1229
+ if (typeof entry.pid !== 'number' || typeof entry.ownerPid !== 'number')
1230
+ continue;
1231
+ if (entry.ownerPid === process.pid)
1232
+ continue; // ours; we're about to rewrite
1233
+ try {
1234
+ process.kill(entry.ownerPid, 0);
1235
+ }
1236
+ catch {
1237
+ continue; // owner dead — stale entry, drop it
1238
+ }
1239
+ merged[name] = {
1240
+ pid: entry.pid,
1241
+ ownerPid: entry.ownerPid,
1242
+ since: entry.since ?? now,
1243
+ };
1244
+ }
1245
+ // Add OUR current entries
1246
+ for (const [name, pid] of this._activePids) {
1247
+ merged[name] = { pid, ownerPid: process.pid, since: now };
1248
+ }
1249
+ fs.writeFileSync(SessionManager.PID_FILE, JSON.stringify(merged));
1044
1250
  }
1045
1251
  catch {
1046
1252
  /* best effort */
@@ -1077,7 +1283,49 @@ export class SessionManager {
1077
1283
  if (!fs.existsSync(SessionManager.PID_FILE))
1078
1284
  return;
1079
1285
  const data = JSON.parse(fs.readFileSync(SessionManager.PID_FILE, 'utf8'));
1080
- for (const [name, pid] of Object.entries(data)) {
1286
+ for (const [name, raw] of Object.entries(data)) {
1287
+ // Resolve entry shape: legacy = number; current = { pid, ownerPid, since }.
1288
+ let pid;
1289
+ let ownerPid;
1290
+ if (typeof raw === 'number') {
1291
+ pid = raw;
1292
+ ownerPid = null; // unknown owner — treat conservatively (skip kill)
1293
+ }
1294
+ else if (raw && typeof raw === 'object') {
1295
+ const e = raw;
1296
+ if (typeof e.pid !== 'number')
1297
+ continue;
1298
+ pid = e.pid;
1299
+ ownerPid = typeof e.ownerPid === 'number' ? e.ownerPid : null;
1300
+ }
1301
+ else {
1302
+ continue;
1303
+ }
1304
+ // Cross-process safety: if this PID has a known owner SessionManager
1305
+ // and that owner is still alive, the child is NOT an orphan — it's
1306
+ // owned by another live manager. Only kill if owner is dead or unknown
1307
+ // AND the conservative legacy-format path has been ruled out.
1308
+ if (ownerPid !== null && ownerPid !== process.pid) {
1309
+ let ownerAlive = false;
1310
+ try {
1311
+ process.kill(ownerPid, 0);
1312
+ ownerAlive = true;
1313
+ }
1314
+ catch {
1315
+ /* owner dead */
1316
+ }
1317
+ if (ownerAlive) {
1318
+ this.logger.info(`PID ${pid} (session: ${name}) owned by live SessionManager pid=${ownerPid} — skipping`);
1319
+ continue;
1320
+ }
1321
+ }
1322
+ else if (ownerPid === null) {
1323
+ // Legacy format with no owner info — too risky to kill if a host
1324
+ // shares the file across managers. Skip; the entry will be cleaned
1325
+ // up on the next save (read-merge-write drops legacy format).
1326
+ this.logger.info(`PID ${pid} (session: ${name}) is legacy-format (no ownerPid) — skipping kill`);
1327
+ continue;
1328
+ }
1081
1329
  try {
1082
1330
  process.kill(pid, 0); // check if alive
1083
1331
  // Alive — but verify it's actually a coding CLI, not a recycled PID
@@ -1230,11 +1478,32 @@ export class SessionManager {
1230
1478
  const council = this.councils.get(id);
1231
1479
  return council?.getSession();
1232
1480
  }
1233
- /** List all council sessions known to this manager (running, terminal, awaiting-user). */
1481
+ /**
1482
+ * List all council sessions visible to this process.
1483
+ *
1484
+ * Includes (a) in-memory sessions managed by this SessionManager and (b)
1485
+ * sessions reconstructed from on-disk transcripts at ~/.openclaw/council-logs/.
1486
+ * The disk path lets the dashboard see runs started in OTHER processes
1487
+ * (e.g. plugin-managed runs visible to a standalone clawo-serve dashboard).
1488
+ * Dedup by id; in-memory wins. Sorted by startTime descending so the newest
1489
+ * appears at the top of the sidebar.
1490
+ */
1234
1491
  councilList() {
1235
- return Array.from(this.councils.values())
1492
+ const inMemory = Array.from(this.councils.values())
1236
1493
  .map((c) => c.getSession())
1237
1494
  .filter((s) => s !== null && s !== undefined);
1495
+ const inMemIds = new Set(inMemory.map((s) => s.id));
1496
+ const fromDisk = listCouncilsFromDisk()
1497
+ .filter((r) => !inMemIds.has(r.id))
1498
+ .map((r) => ({
1499
+ id: r.id,
1500
+ task: r.task,
1501
+ status: r.status,
1502
+ startTime: r.startTime,
1503
+ responses: [],
1504
+ config: { agents: [], maxRounds: 0, projectDir: '' },
1505
+ }));
1506
+ return [...inMemory, ...fromDisk].sort((a, b) => (b.startTime || '').localeCompare(a.startTime || ''));
1238
1507
  }
1239
1508
  /** Used by embedded-server to subscribe to a council's event stream. */
1240
1509
  getCouncil(id) {
@@ -1598,6 +1867,21 @@ export class SessionManager {
1598
1867
  pushPolicy,
1599
1868
  });
1600
1869
  await runner.start();
1870
+ // Record into the cross-process registry so the dashboard / another
1871
+ // SessionManager instance can list this run even after it ends. Best
1872
+ // effort — registry failure should not block the run.
1873
+ try {
1874
+ appendAutoloopRegistry(DEFAULT_AUTOLOOP_REGISTRY, {
1875
+ run_id: opts.runId,
1876
+ workspace: opts.workspace,
1877
+ ledger_dir: ledgerDir,
1878
+ started_at: runner.state.started_at,
1879
+ planner_session: dispatcher.sessionNames.planner,
1880
+ });
1881
+ }
1882
+ catch (err) {
1883
+ this.logger.warn?.(`[autoloop/${runId}] registry append failed: ${err.message}`);
1884
+ }
1601
1885
  return {
1602
1886
  runId: opts.runId,
1603
1887
  plannerSession: dispatcher.sessionNames.planner,
@@ -1631,7 +1915,26 @@ export class SessionManager {
1631
1915
  return this.autoloops.get(runId)?.runner.state;
1632
1916
  }
1633
1917
  autoloopList() {
1634
- return Array.from(this.autoloops.values()).map((c) => c.runner.state);
1918
+ const inMemory = Array.from(this.autoloops.values()).map((c) => c.runner.state);
1919
+ const inMemIds = new Set(inMemory.map((s) => s.run_id));
1920
+ const fromDisk = listAutoloopsFromRegistry()
1921
+ .filter((e) => !inMemIds.has(e.run_id))
1922
+ .map((e) => ({
1923
+ run_id: e.run_id,
1924
+ status: 'terminated',
1925
+ iter: 0,
1926
+ subagents_spawned: false,
1927
+ started_at: e.started_at,
1928
+ workspace: e.workspace,
1929
+ ledger_dir: e.ledger_dir,
1930
+ push_log_count: 0,
1931
+ status_reason: 'reconstructed from registry — not in current process memory',
1932
+ consecutive_phase_errors: 0,
1933
+ recent_phase_errors: [],
1934
+ metric_history: [],
1935
+ last_activity_at: 0,
1936
+ }));
1937
+ return [...inMemory, ...fromDisk].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''));
1635
1938
  }
1636
1939
  /**
1637
1940
  * Reset a single subagent on a v2 run. Useful when an agent has drifted
@@ -1655,6 +1958,38 @@ export class SessionManager {
1655
1958
  this.autoloops.delete(runId);
1656
1959
  return true;
1657
1960
  }
1961
+ /**
1962
+ * Delete a run from the system: stop the runner if it's still alive in this
1963
+ * process, then scrub the row from the cross-process registry so it stops
1964
+ * appearing in `autoloop_list` / the dashboard. The ledger directory on disk
1965
+ * is NOT removed — postmortem artifacts (chat history, push log, plan.md)
1966
+ * are kept for the user to inspect or `rm` manually.
1967
+ *
1968
+ * Returns true if anything was removed (in-memory entry OR registry row).
1969
+ */
1970
+ async autoloopDelete(runId) {
1971
+ const ctx = this.autoloops.get(runId);
1972
+ let touched = false;
1973
+ if (ctx) {
1974
+ try {
1975
+ await ctx.runner.send(AutoloopMsg.terminate(ctx.runner.state.iter, { reason: 'user-delete' }));
1976
+ }
1977
+ catch (err) {
1978
+ this.logger.warn?.(`[autoloop/${runId}] terminate during delete failed: ${err.message}`);
1979
+ }
1980
+ this.autoloops.delete(runId);
1981
+ touched = true;
1982
+ }
1983
+ try {
1984
+ const removed = removeAutoloopFromRegistry(DEFAULT_AUTOLOOP_REGISTRY, runId);
1985
+ if (removed > 0)
1986
+ touched = true;
1987
+ }
1988
+ catch (err) {
1989
+ this.logger.warn?.(`[autoloop/${runId}] registry scrub failed: ${err.message}`);
1990
+ }
1991
+ return touched;
1992
+ }
1658
1993
  /** Used by embedded-server to attach SSE listeners. */
1659
1994
  getAutoloop(runId) {
1660
1995
  const ctx = this.autoloops.get(runId);