@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.
- package/README.md +4 -4
- package/dist/src/acp-server.d.ts +11 -1
- package/dist/src/acp-server.js +68 -14
- package/dist/src/acp-server.js.map +1 -1
- package/dist/src/autoloop/dispatcher.js +9 -1
- package/dist/src/autoloop/dispatcher.js.map +1 -1
- package/dist/src/base-oneshot-session.js +2 -25
- package/dist/src/base-oneshot-session.js.map +1 -1
- package/dist/src/engine-spawn.d.ts +38 -0
- package/dist/src/engine-spawn.js +223 -0
- package/dist/src/engine-spawn.js.map +1 -0
- package/dist/src/index.js +10 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/persistent-agy-session.js +2 -2
- package/dist/src/persistent-agy-session.js.map +1 -1
- package/dist/src/persistent-codex-app-session.js +2 -2
- package/dist/src/persistent-codex-app-session.js.map +1 -1
- package/dist/src/persistent-codex-session.js +14 -7
- package/dist/src/persistent-codex-session.js.map +1 -1
- package/dist/src/persistent-cursor-session.js +2 -2
- package/dist/src/persistent-cursor-session.js.map +1 -1
- package/dist/src/persistent-custom-session.js +3 -3
- package/dist/src/persistent-custom-session.js.map +1 -1
- package/dist/src/persistent-gemini-session.js +2 -2
- package/dist/src/persistent-gemini-session.js.map +1 -1
- package/dist/src/persistent-grok-session.js +2 -2
- package/dist/src/persistent-grok-session.js.map +1 -1
- package/dist/src/persistent-opencode-session.js +10 -8
- package/dist/src/persistent-opencode-session.js.map +1 -1
- package/dist/src/persistent-session.d.ts +2 -0
- package/dist/src/persistent-session.js +28 -3
- package/dist/src/persistent-session.js.map +1 -1
- package/dist/src/run-ledger.d.ts +6 -0
- package/dist/src/run-ledger.js.map +1 -1
- package/dist/src/session-manager.d.ts +7 -0
- package/dist/src/session-manager.js +106 -49
- package/dist/src/session-manager.js.map +1 -1
- package/dist/src/types.d.ts +24 -0
- package/package.json +3 -1
- package/skills/references/acp.md +29 -11
- package/skills/references/autoloop.md +5 -0
- package/skills/references/claude-cli-tracking.md +3 -1
- package/skills/references/mcp.md +19 -0
- package/skills/references/multi-engine.md +8 -6
- package/skills/references/observability.md +2 -1
- package/skills/references/sessions.md +11 -0
- 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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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.
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
3236
|
-
|
|
3237
|
-
this.
|
|
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.
|
|
3345
|
+
this._forgetPersisted(name);
|
|
3289
3346
|
pruned = true;
|
|
3290
3347
|
}
|
|
3291
3348
|
}
|
|
3292
3349
|
if (pruned)
|
|
3293
|
-
|
|
3350
|
+
this._debouncedSave();
|
|
3294
3351
|
}
|
|
3295
3352
|
}
|
|
3296
3353
|
//# sourceMappingURL=session-manager.js.map
|