@enderfga/claw-orchestrator 7.6.2 → 7.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 (33) hide show
  1. package/README.md +4 -4
  2. package/dist/src/acp-server.d.ts +28 -6
  3. package/dist/src/acp-server.js +108 -19
  4. package/dist/src/acp-server.js.map +1 -1
  5. package/dist/src/index.js +10 -0
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/kernel/nodes/autoloop.d.ts +2 -0
  8. package/dist/src/kernel/nodes/autoloop.js +5 -0
  9. package/dist/src/kernel/nodes/autoloop.js.map +1 -1
  10. package/dist/src/persistent-codex-app-session.d.ts +2 -0
  11. package/dist/src/persistent-codex-app-session.js +11 -0
  12. package/dist/src/persistent-codex-app-session.js.map +1 -1
  13. package/dist/src/persistent-codex-session.d.ts +9 -0
  14. package/dist/src/persistent-codex-session.js +24 -0
  15. package/dist/src/persistent-codex-session.js.map +1 -1
  16. package/dist/src/persistent-session.d.ts +2 -0
  17. package/dist/src/persistent-session.js +26 -1
  18. package/dist/src/persistent-session.js.map +1 -1
  19. package/dist/src/run-ledger.d.ts +6 -0
  20. package/dist/src/run-ledger.js.map +1 -1
  21. package/dist/src/session-manager.d.ts +13 -0
  22. package/dist/src/session-manager.js +164 -47
  23. package/dist/src/session-manager.js.map +1 -1
  24. package/dist/src/types.d.ts +37 -0
  25. package/package.json +1 -1
  26. package/skills/references/acp.md +36 -11
  27. package/skills/references/autoloop.md +7 -1
  28. package/skills/references/claude-cli-tracking.md +3 -1
  29. package/skills/references/mcp.md +19 -0
  30. package/skills/references/multi-engine.md +8 -3
  31. package/skills/references/observability.md +2 -1
  32. package/skills/references/sessions.md +11 -0
  33. package/skills/references/tools.md +6 -2
@@ -36,7 +36,8 @@ function getPluginVersion() {
36
36
  return 'unknown';
37
37
  }
38
38
  // ─── Persistence ─────────────────────────────────────────────────────────────
39
- const PERSIST_DIR = path.join(os.homedir(), '.openclaw');
39
+ // CLAWO_SESSIONS_DIR relocates the file, so tests never touch the real one.
40
+ const PERSIST_DIR = process.env.CLAWO_SESSIONS_DIR || path.join(os.homedir(), '.openclaw');
40
41
  const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
41
42
  function loadPersistedSessions() {
42
43
  try {
@@ -53,45 +54,50 @@ function loadPersistedSessions() {
53
54
  return new Map();
54
55
  }
55
56
  }
56
- // Atomic write: write to .tmp then rename to avoid corrupt reads on crash
57
- function savePersistedSessions(sessions, logger) {
57
+ /**
58
+ * Write this process's changes into the shared session file.
59
+ *
60
+ * Several processes share the file — the server, the OpenClaw plugin, every
61
+ * `clawo-mcp` and `clawo acp` a host starts. Each used to write its whole
62
+ * in-memory map back, so the last writer erased whatever the others had added
63
+ * since it loaded: a long-lived session's resume record could vanish because an
64
+ * unrelated process exited. Now only the names this process changed (`dirty`,
65
+ * set or deleted) are merged into what is on disk, under a lock, and the merged
66
+ * map is returned so the caller sees the other processes' sessions too.
67
+ *
68
+ * Returns undefined when the lock stays busy; `dirty` is left as it was, so
69
+ * the changes go out with the next save instead of being lost.
70
+ */
71
+ function savePersistedSessions(mine, dirty, logger) {
72
+ const log = logger || createConsoleLogger('SessionManager');
58
73
  try {
59
74
  fs.mkdirSync(PERSIST_DIR, { recursive: true });
60
- const arr = Array.from(sessions.values());
61
- const tmp = PERSIST_FILE + '.tmp';
62
- fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
63
- fs.renameSync(tmp, PERSIST_FILE);
75
+ const result = withFileLock(`${PERSIST_FILE}.lock`, () => {
76
+ const merged = loadPersistedSessions();
77
+ for (const name of dirty) {
78
+ const entry = mine.get(name);
79
+ if (entry)
80
+ merged.set(name, entry);
81
+ else
82
+ merged.delete(name);
83
+ }
84
+ const tmp = `${PERSIST_FILE}.${process.pid}.tmp`;
85
+ fs.writeFileSync(tmp, JSON.stringify(Array.from(merged.values()), null, 2));
86
+ fs.renameSync(tmp, PERSIST_FILE);
87
+ return merged;
88
+ });
89
+ if (!result.ok) {
90
+ log.warn('Session file busy; changes kept for the next save');
91
+ return undefined;
92
+ }
93
+ dirty.clear();
94
+ return result.value;
64
95
  }
65
96
  catch (err) {
66
- (logger || createConsoleLogger('SessionManager')).warn('Failed to persist sessions:', err.message);
97
+ log.warn('Failed to persist sessions:', err.message);
98
+ return undefined;
67
99
  }
68
100
  }
69
- // Async version for hot-path (sendMessage, TTL cleanup)
70
- function savePersistedSessionsAsync(sessions, logger) {
71
- const log = logger || createConsoleLogger('SessionManager');
72
- const arr = Array.from(sessions.values());
73
- const tmp = PERSIST_FILE + '.tmp';
74
- fs.mkdir(PERSIST_DIR, { recursive: true }, (mkdirErr) => {
75
- if (mkdirErr) {
76
- log.error('Failed to create persist dir:', mkdirErr.message);
77
- return;
78
- }
79
- fs.writeFile(tmp, JSON.stringify(arr, null, 2), (writeErr) => {
80
- if (writeErr) {
81
- log.error('Failed to write session file:', writeErr.message);
82
- return;
83
- }
84
- fs.rename(tmp, PERSIST_FILE, (renameErr) => {
85
- if (renameErr) {
86
- log.error('Failed to rename session file:', renameErr.message);
87
- // Clean up orphan tmp file
88
- fs.unlink(tmp, () => { });
89
- }
90
- });
91
- });
92
- });
93
- }
94
- // Debounce helper — coalesces rapid writes into one
95
101
  function makeDebounced(fn, ms) {
96
102
  let timer = null;
97
103
  return () => {
@@ -117,6 +123,7 @@ import { runContract } from './verify/runner.js';
117
123
  import { evidenceDir, listEvidence, readEvidence, writeEvidence } from './verify/evidence.js';
118
124
  import { annotateVerdicts, appendRunRow, readRunLedger, summarizeRuns, } from './run-ledger.js';
119
125
  import { checkBudget, isBudgetExceeded } from './budget.js';
126
+ import { withFileLock } from './kernel/file-lock.js';
120
127
  import { InboxManager } from './inbox-manager.js';
121
128
  import { sanitizeCwd, validateName } from './validation.js';
122
129
  import { PersistentClaudeSession } from './persistent-session.js';
@@ -327,6 +334,13 @@ function validateAutoloopEffort(role, effort) {
327
334
  * engine emits a result event of its own shape, so anything that is not an
328
335
  * array of objects naming a tool is ignored rather than trusted.
329
336
  */
337
+ /** Images an engine attached to its result event as `generated_images`. */
338
+ function readGeneratedImages(evt) {
339
+ const raw = evt?.generated_images;
340
+ if (!Array.isArray(raw))
341
+ return [];
342
+ return raw.filter((i) => !!i && typeof i === 'object' && typeof i.path === 'string');
343
+ }
330
344
  function readPermissionDenials(evt) {
331
345
  const raw = evt?.permission_denials;
332
346
  if (!Array.isArray(raw))
@@ -392,7 +406,7 @@ export class SessionManager {
392
406
  // Clean up orphaned child processes from a previous unclean exit
393
407
  this._cleanupOrphanedPids();
394
408
  // Debounced async writer — at most one write per 5 seconds on hot paths
395
- this._debouncedSave = makeDebounced(() => savePersistedSessionsAsync(this.persistedSessions, this.logger), DEBOUNCED_SAVE_MS);
409
+ this._debouncedSave = makeDebounced(() => this._savePersisted(), DEBOUNCED_SAVE_MS);
396
410
  // Start TTL cleanup timer
397
411
  this.cleanupTimer = setInterval(() => this._cleanupIdleSessions(), CLEANUP_INTERVAL_MS);
398
412
  }
@@ -445,6 +459,10 @@ export class SessionManager {
445
459
  // ─── Session Lifecycle ─────────────────────────────────────────────────
446
460
  async startSession(config) {
447
461
  const name = config.name || `session-${Date.now()}`;
462
+ const compactAt = config.autoCompactPercent;
463
+ if (compactAt !== undefined && !(Number.isFinite(compactAt) && compactAt >= 1 && compactAt <= 99)) {
464
+ throw new Error('autoCompactPercent must be a number from 1 to 99');
465
+ }
448
466
  // Check pending first — a concurrent caller may have already started creation
449
467
  const pending = this._pendingSessions.get(name);
450
468
  if (pending)
@@ -518,6 +536,8 @@ export class SessionManager {
518
536
  effort: config.effort || this.pluginConfig.defaultEffort,
519
537
  model: config.model || persisted?.model || this.pluginConfig.defaultModel,
520
538
  sandboxMode: config.sandboxMode ?? persisted?.sandboxMode,
539
+ autoCompactPercent: config.autoCompactPercent ?? persisted?.autoCompactPercent,
540
+ chrome: config.chrome ?? persisted?.chrome,
521
541
  ...config,
522
542
  ...(resumeId ? { resumeSessionId: resumeId } : {}),
523
543
  };
@@ -617,6 +637,20 @@ export class SessionManager {
617
637
  session: name,
618
638
  engine: managed.config.engine || 'claude',
619
639
  });
640
+ // Compact before the send rather than after the previous reply, so the
641
+ // caller is never kept waiting for it, and inside the send chain, so no
642
+ // other send can interleave. Best effort: a failed or unsupported compact
643
+ // must not cost the caller their turn.
644
+ const compactAt = managed.config.autoCompactPercent;
645
+ if (compactAt && managed.session.getStats().contextPercent >= compactAt) {
646
+ try {
647
+ await managed.session.compact();
648
+ this.logger.info(`[SessionManager] ${name}: context at or above ${compactAt}% — compacted before send`);
649
+ }
650
+ catch (err) {
651
+ this.logger.warn(`[SessionManager] ${name}: auto-compact failed: ${err.message}`);
652
+ }
653
+ }
620
654
  const sendOpts = {
621
655
  waitForComplete: true,
622
656
  timeout: options.timeout || TURN_TIMEOUT_MS,
@@ -687,6 +721,7 @@ export class SessionManager {
687
721
  // through. The result event was dropped at this line, and it is the
688
722
  // only record of a blocked call: the turn itself still reports success.
689
723
  const permissionDenials = readPermissionDenials(evt);
724
+ const images = readGeneratedImages(evt);
690
725
  // The record holds what the caller said, never the replayed history in
691
726
  // front of it — a second handoff would otherwise nest one inside the other.
692
727
  managed.transcript ??= newTranscript();
@@ -701,6 +736,7 @@ export class SessionManager {
701
736
  error: turnError,
702
737
  events: [],
703
738
  ...(permissionDenials.length ? { permissionDenials } : {}),
739
+ ...(images.length ? { images } : {}),
704
740
  };
705
741
  }
706
742
  return { output: '', sessionId: this._managedResumeId(managed), events: [] };
@@ -757,6 +793,7 @@ export class SessionManager {
757
793
  sandboxMode: source.config.sandboxMode,
758
794
  effort: source.config.effort,
759
795
  maxBudgetUsd: source.config.maxBudgetUsd,
796
+ autoCompactPercent: source.config.autoCompactPercent,
760
797
  systemPrompt: source.config.systemPrompt,
761
798
  appendSystemPrompt: source.config.appendSystemPrompt,
762
799
  addDir: source.config.addDir,
@@ -813,6 +850,7 @@ export class SessionManager {
813
850
  toolCalls: 0,
814
851
  toolErrors: 0,
815
852
  costUsd: 0,
853
+ refusalFallbacks: 0,
816
854
  };
817
855
  try {
818
856
  const st = managed.session.getStats();
@@ -825,6 +863,7 @@ export class SessionManager {
825
863
  toolCalls: st.toolCalls || 0,
826
864
  toolErrors: st.toolErrors || 0,
827
865
  costUsd: this._spentUsd(managed),
866
+ refusalFallbacks: st.refusalFallbacks || 0,
828
867
  };
829
868
  }
830
869
  catch {
@@ -865,12 +904,21 @@ export class SessionManager {
865
904
  ok: !error && (after.turns > before.turns ? after.turnsSucceeded > before.turnsSucceeded : true),
866
905
  };
867
906
  // Fall back to the engine's own reported model, so a session started
868
- // without an explicit `model` still records what actually answered.
869
- const model = managed.config.resolvedModel || managed.config.model || this._reportedModel(managed);
907
+ // without an explicit `model` still records what actually answered. An
908
+ // alias (`opus[1m]`) is the CLI's to resolve, so what it reports wins there.
909
+ const configured = managed.config.resolvedModel || managed.config.model;
910
+ const bare = configured?.replace(/\[1m\]$/i, '');
911
+ const isAlias = !!bare && resolveAlias(bare) !== bare;
912
+ const model = (isAlias ? this._reportedModel(managed) : undefined) || configured || this._reportedModel(managed);
870
913
  if (model)
871
914
  row.model = model;
872
915
  if (error)
873
916
  row.error = error.slice(0, 500);
917
+ if (after.refusalFallbacks > before.refusalFallbacks) {
918
+ const fallback = managed.session.getStats().lastRefusalFallback;
919
+ if (fallback)
920
+ row.refusalFallback = fallback;
921
+ }
874
922
  if (parent)
875
923
  row.parent = parent;
876
924
  if (dims.nodeKind)
@@ -1103,8 +1151,8 @@ export class SessionManager {
1103
1151
  // Callers that want the session resumable (autoloop terminate that
1104
1152
  // should still allow /autoloop/<id>/resume to reattach the Planner's
1105
1153
  // Claude conversation) pass keepPersisted: true.
1106
- this.persistedSessions.delete(name);
1107
- savePersistedSessions(this.persistedSessions, this.logger);
1154
+ this._forgetPersisted(name);
1155
+ this._savePersisted();
1108
1156
  }
1109
1157
  }
1110
1158
  hasSession(name) {
@@ -1505,7 +1553,7 @@ export class SessionManager {
1505
1553
  this._proxyPort = null;
1506
1554
  }
1507
1555
  // Persist final state (TTL-expired sessions already removed by cleanup)
1508
- savePersistedSessions(this.persistedSessions, this.logger);
1556
+ this._savePersisted();
1509
1557
  }
1510
1558
  // ─── Codex /goal helpers (codex-app engine only) ─────────────────────
1511
1559
  /**
@@ -1940,16 +1988,32 @@ export class SessionManager {
1940
1988
  });
1941
1989
  }
1942
1990
  // ─── Private ───────────────────────────────────────────────────────────
1991
+ /** Names this process has set or deleted since its last save; see savePersistedSessions. */
1992
+ _persistDirty = new Set();
1993
+ _forgetPersisted(name) {
1994
+ this.persistedSessions.delete(name);
1995
+ this._persistDirty.add(name);
1996
+ }
1997
+ /** Merge this process's changes into the shared file and adopt the merged view. */
1998
+ _savePersisted() {
1999
+ if (this._persistDirty.size === 0)
2000
+ return;
2001
+ const merged = savePersistedSessions(this.persistedSessions, this._persistDirty, this.logger);
2002
+ if (merged)
2003
+ this.persistedSessions = merged;
2004
+ }
1943
2005
  _persistSession(name, managed) {
1944
2006
  const resumeSessionId = this._managedResumeId(managed);
1945
2007
  if (!resumeSessionId) {
1946
- if (managed.config.engine === 'agy' && this.persistedSessions.delete(name)) {
2008
+ if (managed.config.engine === 'agy' && this.persistedSessions.has(name)) {
2009
+ this._forgetPersisted(name);
1947
2010
  this._debouncedSave();
1948
2011
  }
1949
2012
  return;
1950
2013
  }
1951
2014
  managed.claudeSessionId = resumeSessionId;
1952
2015
  const existing = this.persistedSessions.get(name);
2016
+ this._persistDirty.add(name);
1953
2017
  this.persistedSessions.set(name, {
1954
2018
  name,
1955
2019
  claudeSessionId: resumeSessionId,
@@ -1957,6 +2021,8 @@ export class SessionManager {
1957
2021
  model: managed.config.resolvedModel || managed.config.model,
1958
2022
  engine: managed.config.engine,
1959
2023
  sandboxMode: managed.config.sandboxMode,
2024
+ autoCompactPercent: managed.config.autoCompactPercent,
2025
+ chrome: managed.config.chrome,
1960
2026
  originalCreated: existing?.originalCreated || managed.created,
1961
2027
  lastResumed: new Date().toISOString(),
1962
2028
  lastActivity: managed.lastActivity,
@@ -2804,6 +2870,7 @@ export class SessionManager {
2804
2870
  // this inside the startup try makes an append failure follow the same
2805
2871
  // cleanup path as any other failed resume.
2806
2872
  opts._commitTimeoutMigration?.();
2873
+ await this._recoverCommittedCoderIteration(opts.workspace, ledgerDir, runId, runner, dispatcher);
2807
2874
  }
2808
2875
  catch (err) {
2809
2876
  try {
@@ -2819,6 +2886,57 @@ export class SessionManager {
2819
2886
  }
2820
2887
  return { runner, dispatcher, ledgerDir, pushPolicy };
2821
2888
  }
2889
+ /**
2890
+ * If the durable ledger has a committed Coder iteration with no Reviewer
2891
+ * verdict, and HEAD still names that iter, deliver it to Reviewer once.
2892
+ * Git failure or a subject mismatch leaves this resume as a Planner-only boot.
2893
+ */
2894
+ async _recoverCommittedCoderIteration(workspace, ledgerDir, runId, runner, dispatcher) {
2895
+ const iterRoot = path.join(ledgerDir, 'iter');
2896
+ if (!fs.existsSync(iterRoot))
2897
+ return;
2898
+ const iters = fs
2899
+ .readdirSync(iterRoot)
2900
+ .filter((name) => /^\d+$/.test(name))
2901
+ .map((name) => Number(name));
2902
+ if (iters.length === 0)
2903
+ return;
2904
+ const n = Math.max(...iters);
2905
+ const artifacts = ['directive.json', 'eval_output.json', 'coder_summary.txt', 'diff.patch'];
2906
+ const iterDir = path.join(iterRoot, String(n));
2907
+ if (artifacts.some((name) => !fs.existsSync(path.join(iterDir, name))))
2908
+ return;
2909
+ if (fs.existsSync(path.join(iterDir, 'verdict.json')))
2910
+ return;
2911
+ for (let i = 0; i < n; i++) {
2912
+ if (!fs.existsSync(path.join(iterRoot, String(i), 'verdict.json')))
2913
+ return;
2914
+ }
2915
+ let subject;
2916
+ try {
2917
+ subject = execFileSync('git', ['-C', workspace, 'log', '-1', '--format=%s'], {
2918
+ encoding: 'utf8',
2919
+ }).trim();
2920
+ }
2921
+ catch {
2922
+ return;
2923
+ }
2924
+ if (!subject.startsWith(`autoloop/iter-${n}:`))
2925
+ return;
2926
+ this.logger.info?.(`[autoloop/${runId}] recovering committed iter ${n} into Reviewer`);
2927
+ await dispatcher.spawnSubagents();
2928
+ runner.markSubagentsSpawned();
2929
+ runner.state.iter = n;
2930
+ void runner
2931
+ .send(AutoloopMsg.reviewRequest(n, {
2932
+ iter: n,
2933
+ ledger_path: ledgerDir,
2934
+ prior_metrics: runner.state.metric_history.slice(-10),
2935
+ }))
2936
+ .catch((err) => {
2937
+ this.logger.warn?.(`[autoloop/${runId}] recovered review_request failed: ${err instanceof Error ? err.message : String(err)}`);
2938
+ });
2939
+ }
2822
2940
  /**
2823
2941
  * Start a v2 autoloop in chat mode. Creates the Planner persistent session,
2824
2942
  * returns the run handle. Coder/Reviewer are NOT started until S3's
@@ -3233,10 +3351,9 @@ export class SessionManager {
3233
3351
  catch {
3234
3352
  /* session not in memory — fine */
3235
3353
  }
3236
- this.persistedSessions.delete(`autoloop-${runId}-planner`);
3237
- this.persistedSessions.delete(`autoloop-${runId}-coder`);
3238
- this.persistedSessions.delete(`autoloop-${runId}-reviewer`);
3239
- savePersistedSessions(this.persistedSessions, this.logger);
3354
+ for (const role of ['planner', 'coder', 'reviewer'])
3355
+ this._forgetPersisted(`autoloop-${runId}-${role}`);
3356
+ this._savePersisted();
3240
3357
  }
3241
3358
  // No registry to scrub: the run record IS the registry, and removing it is
3242
3359
  // the delete. The ledger directory under tasks/<runId>/ is deliberately left
@@ -3286,12 +3403,12 @@ export class SessionManager {
3286
3403
  let pruned = false;
3287
3404
  for (const [name, entry] of this.persistedSessions) {
3288
3405
  if (now - entry.lastActivity > PERSIST_DISK_TTL_MS) {
3289
- this.persistedSessions.delete(name);
3406
+ this._forgetPersisted(name);
3290
3407
  pruned = true;
3291
3408
  }
3292
3409
  }
3293
3410
  if (pruned)
3294
- savePersistedSessionsAsync(this.persistedSessions);
3411
+ this._debouncedSave();
3295
3412
  }
3296
3413
  }
3297
3414
  //# sourceMappingURL=session-manager.js.map