@enderfga/claw-orchestrator 7.6.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -4
  2. package/dist/src/acp-server.d.ts +11 -1
  3. package/dist/src/acp-server.js +68 -14
  4. package/dist/src/acp-server.js.map +1 -1
  5. package/dist/src/autoloop/dispatcher.js +9 -1
  6. package/dist/src/autoloop/dispatcher.js.map +1 -1
  7. package/dist/src/base-oneshot-session.js +2 -25
  8. package/dist/src/base-oneshot-session.js.map +1 -1
  9. package/dist/src/engine-spawn.d.ts +38 -0
  10. package/dist/src/engine-spawn.js +223 -0
  11. package/dist/src/engine-spawn.js.map +1 -0
  12. package/dist/src/index.js +10 -0
  13. package/dist/src/index.js.map +1 -1
  14. package/dist/src/persistent-agy-session.js +2 -2
  15. package/dist/src/persistent-agy-session.js.map +1 -1
  16. package/dist/src/persistent-codex-app-session.js +2 -2
  17. package/dist/src/persistent-codex-app-session.js.map +1 -1
  18. package/dist/src/persistent-codex-session.js +14 -7
  19. package/dist/src/persistent-codex-session.js.map +1 -1
  20. package/dist/src/persistent-cursor-session.js +2 -2
  21. package/dist/src/persistent-cursor-session.js.map +1 -1
  22. package/dist/src/persistent-custom-session.js +3 -3
  23. package/dist/src/persistent-custom-session.js.map +1 -1
  24. package/dist/src/persistent-gemini-session.js +2 -2
  25. package/dist/src/persistent-gemini-session.js.map +1 -1
  26. package/dist/src/persistent-grok-session.js +2 -2
  27. package/dist/src/persistent-grok-session.js.map +1 -1
  28. package/dist/src/persistent-opencode-session.js +10 -8
  29. package/dist/src/persistent-opencode-session.js.map +1 -1
  30. package/dist/src/persistent-session.d.ts +2 -0
  31. package/dist/src/persistent-session.js +28 -3
  32. package/dist/src/persistent-session.js.map +1 -1
  33. package/dist/src/run-ledger.d.ts +6 -0
  34. package/dist/src/run-ledger.js.map +1 -1
  35. package/dist/src/session-manager.d.ts +7 -0
  36. package/dist/src/session-manager.js +106 -49
  37. package/dist/src/session-manager.js.map +1 -1
  38. package/dist/src/types.d.ts +24 -0
  39. package/package.json +3 -1
  40. package/skills/references/acp.md +29 -11
  41. package/skills/references/autoloop.md +5 -0
  42. package/skills/references/claude-cli-tracking.md +3 -1
  43. package/skills/references/mcp.md +19 -0
  44. package/skills/references/multi-engine.md +8 -6
  45. package/skills/references/observability.md +2 -1
  46. package/skills/references/sessions.md +11 -0
  47. package/skills/references/tools.md +2 -0
@@ -10,6 +10,7 @@ import * as os from 'node:os';
10
10
  import { execFile, execFileSync } from 'node:child_process';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { promisify } from 'node:util';
13
+ import { execEngine } from './engine-spawn.js';
13
14
  const execFileAsync = promisify(execFile);
14
15
  import * as http from 'node:http';
15
16
  import { createRequire } from 'node:module';
@@ -35,7 +36,8 @@ function getPluginVersion() {
35
36
  return 'unknown';
36
37
  }
37
38
  // ─── Persistence ─────────────────────────────────────────────────────────────
38
- 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');
39
41
  const PERSIST_FILE = path.join(PERSIST_DIR, 'claude-sessions.json');
40
42
  function loadPersistedSessions() {
41
43
  try {
@@ -52,45 +54,50 @@ function loadPersistedSessions() {
52
54
  return new Map();
53
55
  }
54
56
  }
55
- // Atomic write: write to .tmp then rename to avoid corrupt reads on crash
56
- 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');
57
73
  try {
58
74
  fs.mkdirSync(PERSIST_DIR, { recursive: true });
59
- const arr = Array.from(sessions.values());
60
- const tmp = PERSIST_FILE + '.tmp';
61
- fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
62
- 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;
63
95
  }
64
96
  catch (err) {
65
- (logger || createConsoleLogger('SessionManager')).warn('Failed to persist sessions:', err.message);
97
+ log.warn('Failed to persist sessions:', err.message);
98
+ return undefined;
66
99
  }
67
100
  }
68
- // Async version for hot-path (sendMessage, TTL cleanup)
69
- function savePersistedSessionsAsync(sessions, logger) {
70
- const log = logger || createConsoleLogger('SessionManager');
71
- const arr = Array.from(sessions.values());
72
- const tmp = PERSIST_FILE + '.tmp';
73
- fs.mkdir(PERSIST_DIR, { recursive: true }, (mkdirErr) => {
74
- if (mkdirErr) {
75
- log.error('Failed to create persist dir:', mkdirErr.message);
76
- return;
77
- }
78
- fs.writeFile(tmp, JSON.stringify(arr, null, 2), (writeErr) => {
79
- if (writeErr) {
80
- log.error('Failed to write session file:', writeErr.message);
81
- return;
82
- }
83
- fs.rename(tmp, PERSIST_FILE, (renameErr) => {
84
- if (renameErr) {
85
- log.error('Failed to rename session file:', renameErr.message);
86
- // Clean up orphan tmp file
87
- fs.unlink(tmp, () => { });
88
- }
89
- });
90
- });
91
- });
92
- }
93
- // Debounce helper — coalesces rapid writes into one
94
101
  function makeDebounced(fn, ms) {
95
102
  let timer = null;
96
103
  return () => {
@@ -116,6 +123,7 @@ import { runContract } from './verify/runner.js';
116
123
  import { evidenceDir, listEvidence, readEvidence, writeEvidence } from './verify/evidence.js';
117
124
  import { annotateVerdicts, appendRunRow, readRunLedger, summarizeRuns, } from './run-ledger.js';
118
125
  import { checkBudget, isBudgetExceeded } from './budget.js';
126
+ import { withFileLock } from './kernel/file-lock.js';
119
127
  import { InboxManager } from './inbox-manager.js';
120
128
  import { sanitizeCwd, validateName } from './validation.js';
121
129
  import { PersistentClaudeSession } from './persistent-session.js';
@@ -391,7 +399,7 @@ export class SessionManager {
391
399
  // Clean up orphaned child processes from a previous unclean exit
392
400
  this._cleanupOrphanedPids();
393
401
  // Debounced async writer — at most one write per 5 seconds on hot paths
394
- this._debouncedSave = makeDebounced(() => savePersistedSessionsAsync(this.persistedSessions, this.logger), DEBOUNCED_SAVE_MS);
402
+ this._debouncedSave = makeDebounced(() => this._savePersisted(), DEBOUNCED_SAVE_MS);
395
403
  // Start TTL cleanup timer
396
404
  this.cleanupTimer = setInterval(() => this._cleanupIdleSessions(), CLEANUP_INTERVAL_MS);
397
405
  }
@@ -444,6 +452,10 @@ export class SessionManager {
444
452
  // ─── Session Lifecycle ─────────────────────────────────────────────────
445
453
  async startSession(config) {
446
454
  const name = config.name || `session-${Date.now()}`;
455
+ const compactAt = config.autoCompactPercent;
456
+ if (compactAt !== undefined && !(Number.isFinite(compactAt) && compactAt >= 1 && compactAt <= 99)) {
457
+ throw new Error('autoCompactPercent must be a number from 1 to 99');
458
+ }
447
459
  // Check pending first — a concurrent caller may have already started creation
448
460
  const pending = this._pendingSessions.get(name);
449
461
  if (pending)
@@ -517,6 +529,8 @@ export class SessionManager {
517
529
  effort: config.effort || this.pluginConfig.defaultEffort,
518
530
  model: config.model || persisted?.model || this.pluginConfig.defaultModel,
519
531
  sandboxMode: config.sandboxMode ?? persisted?.sandboxMode,
532
+ autoCompactPercent: config.autoCompactPercent ?? persisted?.autoCompactPercent,
533
+ chrome: config.chrome ?? persisted?.chrome,
520
534
  ...config,
521
535
  ...(resumeId ? { resumeSessionId: resumeId } : {}),
522
536
  };
@@ -616,6 +630,20 @@ export class SessionManager {
616
630
  session: name,
617
631
  engine: managed.config.engine || 'claude',
618
632
  });
633
+ // Compact before the send rather than after the previous reply, so the
634
+ // caller is never kept waiting for it, and inside the send chain, so no
635
+ // other send can interleave. Best effort: a failed or unsupported compact
636
+ // must not cost the caller their turn.
637
+ const compactAt = managed.config.autoCompactPercent;
638
+ if (compactAt && managed.session.getStats().contextPercent >= compactAt) {
639
+ try {
640
+ await managed.session.compact();
641
+ this.logger.info(`[SessionManager] ${name}: context at or above ${compactAt}% — compacted before send`);
642
+ }
643
+ catch (err) {
644
+ this.logger.warn(`[SessionManager] ${name}: auto-compact failed: ${err.message}`);
645
+ }
646
+ }
619
647
  const sendOpts = {
620
648
  waitForComplete: true,
621
649
  timeout: options.timeout || TURN_TIMEOUT_MS,
@@ -756,6 +784,7 @@ export class SessionManager {
756
784
  sandboxMode: source.config.sandboxMode,
757
785
  effort: source.config.effort,
758
786
  maxBudgetUsd: source.config.maxBudgetUsd,
787
+ autoCompactPercent: source.config.autoCompactPercent,
759
788
  systemPrompt: source.config.systemPrompt,
760
789
  appendSystemPrompt: source.config.appendSystemPrompt,
761
790
  addDir: source.config.addDir,
@@ -812,6 +841,7 @@ export class SessionManager {
812
841
  toolCalls: 0,
813
842
  toolErrors: 0,
814
843
  costUsd: 0,
844
+ refusalFallbacks: 0,
815
845
  };
816
846
  try {
817
847
  const st = managed.session.getStats();
@@ -824,6 +854,7 @@ export class SessionManager {
824
854
  toolCalls: st.toolCalls || 0,
825
855
  toolErrors: st.toolErrors || 0,
826
856
  costUsd: this._spentUsd(managed),
857
+ refusalFallbacks: st.refusalFallbacks || 0,
827
858
  };
828
859
  }
829
860
  catch {
@@ -864,12 +895,21 @@ export class SessionManager {
864
895
  ok: !error && (after.turns > before.turns ? after.turnsSucceeded > before.turnsSucceeded : true),
865
896
  };
866
897
  // Fall back to the engine's own reported model, so a session started
867
- // without an explicit `model` still records what actually answered.
868
- const model = managed.config.resolvedModel || managed.config.model || this._reportedModel(managed);
898
+ // without an explicit `model` still records what actually answered. An
899
+ // alias (`opus[1m]`) is the CLI's to resolve, so what it reports wins there.
900
+ const configured = managed.config.resolvedModel || managed.config.model;
901
+ const bare = configured?.replace(/\[1m\]$/i, '');
902
+ const isAlias = !!bare && resolveAlias(bare) !== bare;
903
+ const model = (isAlias ? this._reportedModel(managed) : undefined) || configured || this._reportedModel(managed);
869
904
  if (model)
870
905
  row.model = model;
871
906
  if (error)
872
907
  row.error = error.slice(0, 500);
908
+ if (after.refusalFallbacks > before.refusalFallbacks) {
909
+ const fallback = managed.session.getStats().lastRefusalFallback;
910
+ if (fallback)
911
+ row.refusalFallback = fallback;
912
+ }
873
913
  if (parent)
874
914
  row.parent = parent;
875
915
  if (dims.nodeKind)
@@ -1102,8 +1142,8 @@ export class SessionManager {
1102
1142
  // Callers that want the session resumable (autoloop terminate that
1103
1143
  // should still allow /autoloop/<id>/resume to reattach the Planner's
1104
1144
  // Claude conversation) pass keepPersisted: true.
1105
- this.persistedSessions.delete(name);
1106
- savePersistedSessions(this.persistedSessions, this.logger);
1145
+ this._forgetPersisted(name);
1146
+ this._savePersisted();
1107
1147
  }
1108
1148
  }
1109
1149
  hasSession(name) {
@@ -1504,7 +1544,7 @@ export class SessionManager {
1504
1544
  this._proxyPort = null;
1505
1545
  }
1506
1546
  // Persist final state (TTL-expired sessions already removed by cleanup)
1507
- savePersistedSessions(this.persistedSessions, this.logger);
1547
+ this._savePersisted();
1508
1548
  }
1509
1549
  // ─── Codex /goal helpers (codex-app engine only) ─────────────────────
1510
1550
  /**
@@ -1745,7 +1785,7 @@ export class SessionManager {
1745
1785
  if (opts.model)
1746
1786
  args.push('--model', opts.model);
1747
1787
  args.push(opts.message);
1748
- const { stdout } = await execFileAsync(this._codexBin(), args, {
1788
+ const { stdout } = await execEngine(this._codexBin(), args, {
1749
1789
  cwd: opts.cwd ? path.resolve(opts.cwd) : undefined,
1750
1790
  maxBuffer: 32 * 1024 * 1024,
1751
1791
  timeout: opts.timeout || 300_000,
@@ -1794,7 +1834,7 @@ export class SessionManager {
1794
1834
  args.push('-c', `model="${opts.model}"`);
1795
1835
  if (opts.prompt)
1796
1836
  args.push(opts.prompt);
1797
- const { stdout, stderr } = await execFileAsync(this._codexBin(), args, {
1837
+ const { stdout, stderr } = await execEngine(this._codexBin(), args, {
1798
1838
  cwd: opts.cwd ? path.resolve(opts.cwd) : undefined,
1799
1839
  maxBuffer: 16 * 1024 * 1024,
1800
1840
  timeout: opts.timeout || 600_000,
@@ -1939,16 +1979,32 @@ export class SessionManager {
1939
1979
  });
1940
1980
  }
1941
1981
  // ─── Private ───────────────────────────────────────────────────────────
1982
+ /** Names this process has set or deleted since its last save; see savePersistedSessions. */
1983
+ _persistDirty = new Set();
1984
+ _forgetPersisted(name) {
1985
+ this.persistedSessions.delete(name);
1986
+ this._persistDirty.add(name);
1987
+ }
1988
+ /** Merge this process's changes into the shared file and adopt the merged view. */
1989
+ _savePersisted() {
1990
+ if (this._persistDirty.size === 0)
1991
+ return;
1992
+ const merged = savePersistedSessions(this.persistedSessions, this._persistDirty, this.logger);
1993
+ if (merged)
1994
+ this.persistedSessions = merged;
1995
+ }
1942
1996
  _persistSession(name, managed) {
1943
1997
  const resumeSessionId = this._managedResumeId(managed);
1944
1998
  if (!resumeSessionId) {
1945
- if (managed.config.engine === 'agy' && this.persistedSessions.delete(name)) {
1999
+ if (managed.config.engine === 'agy' && this.persistedSessions.has(name)) {
2000
+ this._forgetPersisted(name);
1946
2001
  this._debouncedSave();
1947
2002
  }
1948
2003
  return;
1949
2004
  }
1950
2005
  managed.claudeSessionId = resumeSessionId;
1951
2006
  const existing = this.persistedSessions.get(name);
2007
+ this._persistDirty.add(name);
1952
2008
  this.persistedSessions.set(name, {
1953
2009
  name,
1954
2010
  claudeSessionId: resumeSessionId,
@@ -1956,6 +2012,8 @@ export class SessionManager {
1956
2012
  model: managed.config.resolvedModel || managed.config.model,
1957
2013
  engine: managed.config.engine,
1958
2014
  sandboxMode: managed.config.sandboxMode,
2015
+ autoCompactPercent: managed.config.autoCompactPercent,
2016
+ chrome: managed.config.chrome,
1959
2017
  originalCreated: existing?.originalCreated || managed.created,
1960
2018
  lastResumed: new Date().toISOString(),
1961
2019
  lastActivity: managed.lastActivity,
@@ -3232,10 +3290,9 @@ export class SessionManager {
3232
3290
  catch {
3233
3291
  /* session not in memory — fine */
3234
3292
  }
3235
- this.persistedSessions.delete(`autoloop-${runId}-planner`);
3236
- this.persistedSessions.delete(`autoloop-${runId}-coder`);
3237
- this.persistedSessions.delete(`autoloop-${runId}-reviewer`);
3238
- savePersistedSessions(this.persistedSessions, this.logger);
3293
+ for (const role of ['planner', 'coder', 'reviewer'])
3294
+ this._forgetPersisted(`autoloop-${runId}-${role}`);
3295
+ this._savePersisted();
3239
3296
  }
3240
3297
  // No registry to scrub: the run record IS the registry, and removing it is
3241
3298
  // the delete. The ledger directory under tasks/<runId>/ is deliberately left
@@ -3285,12 +3342,12 @@ export class SessionManager {
3285
3342
  let pruned = false;
3286
3343
  for (const [name, entry] of this.persistedSessions) {
3287
3344
  if (now - entry.lastActivity > PERSIST_DISK_TTL_MS) {
3288
- this.persistedSessions.delete(name);
3345
+ this._forgetPersisted(name);
3289
3346
  pruned = true;
3290
3347
  }
3291
3348
  }
3292
3349
  if (pruned)
3293
- savePersistedSessionsAsync(this.persistedSessions);
3350
+ this._debouncedSave();
3294
3351
  }
3295
3352
  }
3296
3353
  //# sourceMappingURL=session-manager.js.map