crewswarm 0.9.1 β 0.9.2
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 +2 -2
- package/apps/dashboard/dist/index.html.gz +0 -0
- package/apps/dashboard/index.html +70 -0
- package/apps/dashboard/src/app.js +5 -0
- package/apps/dashboard/src/tabs/settings-tab.js +58 -0
- package/crew-lead.mjs +14 -1
- package/lib/bridges/tmux-bridge.mjs +200 -0
- package/lib/cli-process-tracker.mjs +2 -1
- package/lib/crew-lead/http-server.mjs +285 -0
- package/lib/crew-lead/wave-dispatcher.mjs +38 -0
- package/lib/engines/runners.mjs +21 -0
- package/lib/runtime/config.mjs +7 -0
- package/lib/sessions/session-manager.mjs +287 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**The only multi-engine AI coding platform.** Switch between Claude Code, Cursor, Gemini, Codex, and OpenCode mid-conversation. Parallel agents. Persistent sessions. No vendor lock-in.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/crewswarm)
|
|
6
|
-
[]()
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://nodejs.org)
|
|
9
9
|
[](https://crewswarm.ai)
|
|
@@ -161,7 +161,7 @@ crewswarm # Start all services
|
|
|
161
161
|
crewswarm pm-loop # Run autonomous PM loop
|
|
162
162
|
npm run doctor # Preflight check
|
|
163
163
|
npm run restart-all # Restart the stack
|
|
164
|
-
npm test # Run
|
|
164
|
+
npm test # Run 957 tests
|
|
165
165
|
crew exec "Build X" # Send task via CLI
|
|
166
166
|
```
|
|
167
167
|
|
|
Binary file
|
|
@@ -4402,6 +4402,76 @@
|
|
|
4402
4402
|
></div>
|
|
4403
4403
|
</div>
|
|
4404
4404
|
|
|
4405
|
+
<div class="card" style="margin-top: 16px">
|
|
4406
|
+
<div
|
|
4407
|
+
style="
|
|
4408
|
+
display: flex;
|
|
4409
|
+
align-items: center;
|
|
4410
|
+
justify-content: space-between;
|
|
4411
|
+
flex-wrap: wrap;
|
|
4412
|
+
gap: 12px;
|
|
4413
|
+
"
|
|
4414
|
+
>
|
|
4415
|
+
<div>
|
|
4416
|
+
<div class="card-title" style="margin-bottom: 2px">
|
|
4417
|
+
π tmux-bridge Sessions
|
|
4418
|
+
</div>
|
|
4419
|
+
<div
|
|
4420
|
+
style="
|
|
4421
|
+
font-size: 11px;
|
|
4422
|
+
color: var(--text-3);
|
|
4423
|
+
line-height: 1.5;
|
|
4424
|
+
"
|
|
4425
|
+
>
|
|
4426
|
+
Enable persistent tmux sessions that survive across pipeline
|
|
4427
|
+
waves. Agents can hand off live execution context (running
|
|
4428
|
+
servers, env vars, cwd) to the next wave instead of
|
|
4429
|
+
cold-starting. Requires
|
|
4430
|
+
<code
|
|
4431
|
+
style="
|
|
4432
|
+
background: var(--bg-1);
|
|
4433
|
+
padding: 1px 4px;
|
|
4434
|
+
border-radius: 3px;
|
|
4435
|
+
"
|
|
4436
|
+
>tmux</code
|
|
4437
|
+
>
|
|
4438
|
+
+
|
|
4439
|
+
<code
|
|
4440
|
+
style="
|
|
4441
|
+
background: var(--bg-1);
|
|
4442
|
+
padding: 1px 4px;
|
|
4443
|
+
border-radius: 3px;
|
|
4444
|
+
"
|
|
4445
|
+
>smux</code
|
|
4446
|
+
>
|
|
4447
|
+
installed. One writer per session (lock enforced).
|
|
4448
|
+
</div>
|
|
4449
|
+
</div>
|
|
4450
|
+
<button
|
|
4451
|
+
id="tmuxBridgeBtn"
|
|
4452
|
+
data-action="toggleTmuxBridge"
|
|
4453
|
+
style="
|
|
4454
|
+
font-size: 12px;
|
|
4455
|
+
font-weight: 700;
|
|
4456
|
+
padding: 8px 18px;
|
|
4457
|
+
border-radius: 8px;
|
|
4458
|
+
cursor: pointer;
|
|
4459
|
+
border: 1px solid var(--border);
|
|
4460
|
+
background: var(--surface-2);
|
|
4461
|
+
color: var(--text-2);
|
|
4462
|
+
white-space: nowrap;
|
|
4463
|
+
min-width: 80px;
|
|
4464
|
+
"
|
|
4465
|
+
>
|
|
4466
|
+
Loadingβ¦
|
|
4467
|
+
</button>
|
|
4468
|
+
</div>
|
|
4469
|
+
<div
|
|
4470
|
+
id="tmuxBridgeStatus"
|
|
4471
|
+
style="margin-top: 8px; font-size: 12px; color: var(--text-3)"
|
|
4472
|
+
></div>
|
|
4473
|
+
</div>
|
|
4474
|
+
|
|
4405
4475
|
<div class="card" style="margin-top: 16px">
|
|
4406
4476
|
<div
|
|
4407
4477
|
style="
|
|
@@ -181,6 +181,8 @@ import {
|
|
|
181
181
|
loadLoopBrain,
|
|
182
182
|
saveLoopBrain,
|
|
183
183
|
loadEnvAdvanced,
|
|
184
|
+
loadTmuxBridge,
|
|
185
|
+
toggleTmuxBridge,
|
|
184
186
|
} from "./tabs/settings-tab.js";
|
|
185
187
|
import {
|
|
186
188
|
initCommsTab,
|
|
@@ -1739,6 +1741,7 @@ function showSettingsTab(tab) {
|
|
|
1739
1741
|
loadGlobalFallback();
|
|
1740
1742
|
loadConfigLockStatus();
|
|
1741
1743
|
loadCursorWaves();
|
|
1744
|
+
loadTmuxBridge();
|
|
1742
1745
|
loadAutonomousMentions();
|
|
1743
1746
|
loadClaudeCode();
|
|
1744
1747
|
loadCodexExecutor();
|
|
@@ -2181,6 +2184,7 @@ const ACTION_REGISTRY = {
|
|
|
2181
2184
|
saveGlobalFallback,
|
|
2182
2185
|
toggleBgConsciousness,
|
|
2183
2186
|
toggleCursorWaves,
|
|
2187
|
+
toggleTmuxBridge,
|
|
2184
2188
|
toggleAutonomousMentions,
|
|
2185
2189
|
toggleClaudeCode,
|
|
2186
2190
|
toggleCodexExecutor,
|
|
@@ -2772,6 +2776,7 @@ Object.assign(window, {
|
|
|
2772
2776
|
toggleAddSkill,
|
|
2773
2777
|
toggleBgConsciousness,
|
|
2774
2778
|
toggleCursorWaves,
|
|
2779
|
+
toggleTmuxBridge,
|
|
2775
2780
|
toggleClaudeCode,
|
|
2776
2781
|
toggleEmojiPicker,
|
|
2777
2782
|
updateSkillAuthFields,
|
|
@@ -290,6 +290,36 @@ export async function toggleCursorWaves() {
|
|
|
290
290
|
} catch(e) { showNotification('Failed: ' + e.message, 'error'); }
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
export async function loadTmuxBridge() {
|
|
294
|
+
const btn = document.getElementById('tmuxBridgeBtn');
|
|
295
|
+
const status = document.getElementById('tmuxBridgeStatus');
|
|
296
|
+
try {
|
|
297
|
+
const d = await getJSON('/api/settings/tmux-bridge');
|
|
298
|
+
const on = d.enabled;
|
|
299
|
+
if (btn) {
|
|
300
|
+
btn.textContent = on ? 'π ON' : 'β« OFF';
|
|
301
|
+
btn.style.background = on ? 'rgba(52,211,153,0.15)' : 'var(--surface-2)';
|
|
302
|
+
btn.style.borderColor = on ? 'rgba(52,211,153,0.3)' : 'var(--border)';
|
|
303
|
+
btn.style.color = on ? 'var(--green)' : 'var(--text-2)';
|
|
304
|
+
}
|
|
305
|
+
if (status) status.textContent = on
|
|
306
|
+
? 'Active β agents can share persistent tmux sessions across pipeline waves. Requires tmux + smux.'
|
|
307
|
+
: 'Off β agents use standard cold-start execution (no session persistence).';
|
|
308
|
+
} catch(e) {
|
|
309
|
+
if (btn) btn.textContent = 'Error';
|
|
310
|
+
if (status) status.textContent = 'Could not load: ' + e.message;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function toggleTmuxBridge() {
|
|
315
|
+
try {
|
|
316
|
+
const current = await getJSON('/api/settings/tmux-bridge');
|
|
317
|
+
const d = await postJSON('/api/settings/tmux-bridge', { enabled: !current.enabled });
|
|
318
|
+
showNotification('tmux-bridge ' + (d.enabled ? 'ENABLED π' : 'DISABLED'));
|
|
319
|
+
loadTmuxBridge();
|
|
320
|
+
} catch(e) { showNotification('Failed: ' + e.message, 'error'); }
|
|
321
|
+
}
|
|
322
|
+
|
|
293
323
|
export async function loadAutonomousMentions() {
|
|
294
324
|
const btn = document.getElementById('autonomousMentionsBtn');
|
|
295
325
|
const status = document.getElementById('autonomousMentionsStatus');
|
|
@@ -691,6 +721,34 @@ const ENV_GROUPS = [
|
|
|
691
721
|
{ key: 'SHARED_MEMORY_DIR', hint: 'Directory for shared memory files', default: '~/.crewswarm/memory' },
|
|
692
722
|
],
|
|
693
723
|
},
|
|
724
|
+
{
|
|
725
|
+
label: 'crew-cli β Streaming & Hooks',
|
|
726
|
+
note: 'Controls for crew-cli streaming output, tool hooks, and session token limits.',
|
|
727
|
+
vars: [
|
|
728
|
+
{ key: 'CREW_NO_STREAM', hint: 'Disable streaming output β tokens arrive after full response (true/false)', default: 'false' },
|
|
729
|
+
{ key: 'CREW_HOOKS_FILE', hint: 'Path to hooks.json for PreToolUse/PostToolUse hooks', default: '.crew/hooks.json' },
|
|
730
|
+
{ key: 'CREW_MAX_SESSION_TOKENS', hint: 'Max estimated tokens per session before oldest turns are trimmed', default: '100000' },
|
|
731
|
+
],
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
label: 'crew-cli β Codebase Index & RAG',
|
|
735
|
+
note: 'Codebase embedding index auto-builds on startup. Injects relevant file context into every worker prompt.',
|
|
736
|
+
vars: [
|
|
737
|
+
{ key: 'CREW_RAG_MODE', hint: 'RAG mode: auto (use index when ready, else keyword), semantic, keyword, import-graph, off', default: 'auto' },
|
|
738
|
+
{ key: 'CREW_EMBEDDING_PROVIDER', hint: 'Embedding provider: local (zero-cost), openai (best), gemini (free tier)', default: 'local' },
|
|
739
|
+
{ key: 'CREW_RAG_WORKER_BUDGET', hint: 'Max tokens of RAG context injected per worker (approximate)', default: '4000' },
|
|
740
|
+
{ key: 'CREW_RAG_MAX_FILES', hint: 'Max code files to index (larger repos should increase this)', default: '2000' },
|
|
741
|
+
{ key: 'CREW_RAG_BATCH_SIZE', hint: 'Files per embedding batch (higher = faster but more API calls)', default: '20' },
|
|
742
|
+
],
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
label: 'crew-cli β Checkpointing',
|
|
746
|
+
note: 'Automatic git checkpoints during pipeline execution for easy rollback.',
|
|
747
|
+
vars: [
|
|
748
|
+
{ key: 'CREW_AUTO_CHECKPOINT', hint: 'Enable auto-commit at task boundaries (true/false)', default: 'true' },
|
|
749
|
+
{ key: 'CREW_CHECKPOINT_INTERVAL_MS', hint: 'Periodic git stash snapshot interval during long tasks (ms, 0=off)', default: '60000' },
|
|
750
|
+
],
|
|
751
|
+
},
|
|
694
752
|
{
|
|
695
753
|
label: 'PM Loop',
|
|
696
754
|
vars: [
|
package/crew-lead.mjs
CHANGED
|
@@ -33,8 +33,10 @@ import {
|
|
|
33
33
|
DISPATCH_TIMEOUT_MS,
|
|
34
34
|
DISPATCH_CLAIMED_TIMEOUT_MS,
|
|
35
35
|
loadCursorWavesEnabled,
|
|
36
|
-
loadClaudeCodeEnabled
|
|
36
|
+
loadClaudeCodeEnabled,
|
|
37
|
+
loadTmuxBridgeEnabled
|
|
37
38
|
} from "./lib/runtime/config.mjs";
|
|
39
|
+
import { _reset as resetTmuxBridge } from "./lib/bridges/tmux-bridge.mjs";
|
|
38
40
|
import {
|
|
39
41
|
CREWSWARM_TOOL_NAMES,
|
|
40
42
|
readAgentTools,
|
|
@@ -157,6 +159,7 @@ function broadcastSSE(payload) {
|
|
|
157
159
|
|
|
158
160
|
let _cursorWavesEnabled = loadCursorWavesEnabled();
|
|
159
161
|
let _claudeCodeEnabled = loadClaudeCodeEnabled();
|
|
162
|
+
let _tmuxBridgeEnabled = loadTmuxBridgeEnabled();
|
|
160
163
|
|
|
161
164
|
const BG_CONSCIOUSNESS_INTERVAL_MS = Number(process.env.CREWSWARM_BG_CONSCIOUSNESS_INTERVAL_MS) || 15 * 60 * 1000;
|
|
162
165
|
let BG_CONSCIOUSNESS_MODEL = (() => {
|
|
@@ -501,6 +504,15 @@ const bgConsciousnessRef = {
|
|
|
501
504
|
};
|
|
502
505
|
const cursorWavesRef = { get enabled() { return _cursorWavesEnabled; }, set enabled(v) { _cursorWavesEnabled = v; } };
|
|
503
506
|
const claudeCodeRef = { get enabled() { return _claudeCodeEnabled; }, set enabled(v) { _claudeCodeEnabled = v; } };
|
|
507
|
+
const tmuxBridgeRef = {
|
|
508
|
+
get enabled() { return _tmuxBridgeEnabled; },
|
|
509
|
+
set enabled(v) {
|
|
510
|
+
_tmuxBridgeEnabled = v;
|
|
511
|
+
// Sync env var and reset detection cache so tmux-bridge module picks up runtime changes
|
|
512
|
+
process.env.CREWSWARM_TMUX_BRIDGE = v ? "1" : "0";
|
|
513
|
+
resetTmuxBridge();
|
|
514
|
+
},
|
|
515
|
+
};
|
|
504
516
|
|
|
505
517
|
// connectRT is initialized after RT_URL/RT_TOKEN β use a mutable ref so HTTP server can call it
|
|
506
518
|
let _connectRT = () => { throw new Error("connectRT not initialized yet"); };
|
|
@@ -544,6 +556,7 @@ initHttpServer({
|
|
|
544
556
|
bgConsciousnessIntervalMs: BG_CONSCIOUSNESS_INTERVAL_MS,
|
|
545
557
|
cursorWavesRef,
|
|
546
558
|
claudeCodeRef,
|
|
559
|
+
tmuxBridgeRef,
|
|
547
560
|
});
|
|
548
561
|
createAndStartServer(PORT);
|
|
549
562
|
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tmux-bridge adapter β thin wrapper around smux's tmux-bridge CLI
|
|
3
|
+
*
|
|
4
|
+
* Provides agent-to-agent pane communication when running inside a tmux session
|
|
5
|
+
* with smux installed. All functions degrade to no-ops when unavailable.
|
|
6
|
+
*
|
|
7
|
+
* Opt-in: requires $TMUX set, `tmux-bridge` on PATH, and
|
|
8
|
+
* CREWSWARM_TMUX_BRIDGE=1 env var (or tmuxBridge: true in system config).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
function which(bin) {
|
|
14
|
+
try { execSync(`which ${bin}`, { stdio: "ignore" }); return true; } catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ββ Detection & caching βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
18
|
+
|
|
19
|
+
let _available = null;
|
|
20
|
+
let _ownPaneId = null;
|
|
21
|
+
const _resolveCache = new Map(); // label β { paneId, ts }
|
|
22
|
+
const RESOLVE_TTL_MS = 30_000;
|
|
23
|
+
const TMUX_BRIDGE_BIN = process.env.SMUX_BRIDGE_BIN || "tmux-bridge";
|
|
24
|
+
|
|
25
|
+
function exec(args, { timeout = 5000 } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync(TMUX_BRIDGE_BIN, args, {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
timeout,
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
}).trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if tmux-bridge is available and opted-in.
|
|
39
|
+
* Result is cached for the process lifetime.
|
|
40
|
+
*/
|
|
41
|
+
export function detect() {
|
|
42
|
+
if (_available !== null) return _available;
|
|
43
|
+
|
|
44
|
+
// Must be inside a tmux session
|
|
45
|
+
if (!process.env.TMUX) {
|
|
46
|
+
_available = false;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Must have tmux-bridge binary
|
|
51
|
+
if (!which(TMUX_BRIDGE_BIN)) {
|
|
52
|
+
_available = false;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Must be opted-in via env var or config
|
|
57
|
+
const envFlag = process.env.CREWSWARM_TMUX_BRIDGE;
|
|
58
|
+
if (!envFlag || envFlag === "0" || envFlag === "false") {
|
|
59
|
+
_available = false;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_available = true;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get this process's own tmux pane ID.
|
|
69
|
+
* @returns {string|null} e.g. "%3"
|
|
70
|
+
*/
|
|
71
|
+
export function id() {
|
|
72
|
+
if (!detect()) return null;
|
|
73
|
+
if (_ownPaneId) return _ownPaneId;
|
|
74
|
+
_ownPaneId = exec(["id"]);
|
|
75
|
+
return _ownPaneId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Label a pane with an agent ID so other agents can discover it.
|
|
80
|
+
* @param {string} agentId - CrewSwarm agent ID (e.g. "crew-coder")
|
|
81
|
+
* @param {string} [paneId] - Target pane ID. Defaults to own pane.
|
|
82
|
+
* @returns {boolean} success
|
|
83
|
+
*/
|
|
84
|
+
export function label(agentId, paneId) {
|
|
85
|
+
if (!detect()) return false;
|
|
86
|
+
const target = paneId || id();
|
|
87
|
+
if (!target) return false;
|
|
88
|
+
const result = exec(["name", target, agentId]);
|
|
89
|
+
if (result !== null) {
|
|
90
|
+
// Update cache
|
|
91
|
+
_resolveCache.set(agentId, { paneId: target, ts: Date.now() });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve an agent ID to a tmux pane ID.
|
|
99
|
+
* @param {string} agentId
|
|
100
|
+
* @returns {string|null} pane ID or null
|
|
101
|
+
*/
|
|
102
|
+
export function resolve(agentId) {
|
|
103
|
+
if (!detect()) return null;
|
|
104
|
+
|
|
105
|
+
// Check cache
|
|
106
|
+
const cached = _resolveCache.get(agentId);
|
|
107
|
+
if (cached && (Date.now() - cached.ts) < RESOLVE_TTL_MS) {
|
|
108
|
+
return cached.paneId;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const paneId = exec(["resolve", agentId]);
|
|
112
|
+
if (paneId) {
|
|
113
|
+
_resolveCache.set(agentId, { paneId, ts: Date.now() });
|
|
114
|
+
}
|
|
115
|
+
return paneId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Read the last N lines from an agent's pane.
|
|
120
|
+
* Also satisfies the read-guard requirement for subsequent sends.
|
|
121
|
+
* @param {string} agentId
|
|
122
|
+
* @param {number} [lines=50]
|
|
123
|
+
* @returns {string|null} pane content or null
|
|
124
|
+
*/
|
|
125
|
+
export function read(agentId, lines = 50) {
|
|
126
|
+
if (!detect()) return null;
|
|
127
|
+
const paneId = resolve(agentId);
|
|
128
|
+
if (!paneId) return null;
|
|
129
|
+
const output = exec(["read", paneId, String(lines)]);
|
|
130
|
+
return output;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Send text to an agent's pane followed by Enter.
|
|
135
|
+
* Reads the pane before each write command to satisfy tmux-bridge's read-guard
|
|
136
|
+
* (the guard is consumed on every type/keys call).
|
|
137
|
+
* @param {string} agentId
|
|
138
|
+
* @param {string} text
|
|
139
|
+
* @returns {boolean} success
|
|
140
|
+
*/
|
|
141
|
+
export function send(agentId, text) {
|
|
142
|
+
if (!detect()) return false;
|
|
143
|
+
const paneId = resolve(agentId);
|
|
144
|
+
if (!paneId) return false;
|
|
145
|
+
|
|
146
|
+
// Read-guard is consumed on each type/keys call, so read before each one
|
|
147
|
+
exec(["read", paneId, "1"]);
|
|
148
|
+
const typeResult = exec(["type", paneId, text]);
|
|
149
|
+
if (typeResult === null) return false;
|
|
150
|
+
|
|
151
|
+
exec(["read", paneId, "1"]);
|
|
152
|
+
const keyResult = exec(["keys", paneId, "Enter"]);
|
|
153
|
+
return keyResult !== null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* List all tmux panes with their labels and metadata.
|
|
158
|
+
* @returns {Array<{paneId: string, label: string, raw: string}>}
|
|
159
|
+
*/
|
|
160
|
+
export function list() {
|
|
161
|
+
if (!detect()) return [];
|
|
162
|
+
const raw = exec(["list"]);
|
|
163
|
+
if (!raw) return [];
|
|
164
|
+
|
|
165
|
+
// Parse tmux-bridge list output:
|
|
166
|
+
// TARGET SESSION:WIN SIZE PROCESS LABEL CWD
|
|
167
|
+
// %0 crewtest:0 120x29 -zsh crew-coder ~/CrewSwarm
|
|
168
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
169
|
+
// Skip header row (starts with "TARGET")
|
|
170
|
+
return lines
|
|
171
|
+
.filter(line => line.trimStart().startsWith("%"))
|
|
172
|
+
.map(line => {
|
|
173
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
174
|
+
return {
|
|
175
|
+
paneId: parts[0] || "",
|
|
176
|
+
session: parts[1] || "",
|
|
177
|
+
size: parts[2] || "",
|
|
178
|
+
process: parts[3] || "",
|
|
179
|
+
label: (parts[4] && parts[4] !== "-") ? parts[4] : "",
|
|
180
|
+
cwd: parts[5] || "",
|
|
181
|
+
raw: line,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clear the resolve cache (useful after pane layout changes).
|
|
188
|
+
*/
|
|
189
|
+
export function clearCache() {
|
|
190
|
+
_resolveCache.clear();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Reset detection state (for testing).
|
|
195
|
+
*/
|
|
196
|
+
export function _reset() {
|
|
197
|
+
_available = null;
|
|
198
|
+
_ownPaneId = null;
|
|
199
|
+
clearCache();
|
|
200
|
+
}
|
|
@@ -160,7 +160,8 @@ export function getActiveProcesses() {
|
|
|
160
160
|
idleFor: now - proc.lastActivity,
|
|
161
161
|
outputLines: proc.outputLines,
|
|
162
162
|
chatId: proc.chatId,
|
|
163
|
-
sessionId: proc.sessionId
|
|
163
|
+
sessionId: proc.sessionId,
|
|
164
|
+
tmuxSessionId: proc.tmuxSessionId || null
|
|
164
165
|
}));
|
|
165
166
|
}
|
|
166
167
|
|
|
@@ -1087,6 +1087,7 @@ export function createAndStartServer(PORT) {
|
|
|
1087
1087
|
bgConsciousnessIntervalMs,
|
|
1088
1088
|
cursorWavesRef,
|
|
1089
1089
|
claudeCodeRef,
|
|
1090
|
+
tmuxBridgeRef,
|
|
1090
1091
|
} = _deps;
|
|
1091
1092
|
|
|
1092
1093
|
// Debug: verify critical deps
|
|
@@ -4580,6 +4581,46 @@ export function createAndStartServer(PORT) {
|
|
|
4580
4581
|
}
|
|
4581
4582
|
}
|
|
4582
4583
|
|
|
4584
|
+
// GET/POST /api/settings/tmux-bridge β toggle tmux-bridge session layer at runtime
|
|
4585
|
+
if (url.pathname === "/api/settings/tmux-bridge") {
|
|
4586
|
+
if (!checkBearer(req)) {
|
|
4587
|
+
json(res, 401, { ok: false, error: "Unauthorized" });
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
if (req.method === "GET") {
|
|
4591
|
+
json(res, 200, { ok: true, enabled: tmuxBridgeRef?.enabled ?? false });
|
|
4592
|
+
return;
|
|
4593
|
+
}
|
|
4594
|
+
if (req.method === "POST") {
|
|
4595
|
+
const body = await readBody(req);
|
|
4596
|
+
const enable =
|
|
4597
|
+
typeof body.enabled === "boolean"
|
|
4598
|
+
? body.enabled
|
|
4599
|
+
: !(tmuxBridgeRef?.enabled ?? false);
|
|
4600
|
+
if (tmuxBridgeRef) tmuxBridgeRef.enabled = enable;
|
|
4601
|
+
try {
|
|
4602
|
+
const cfgPath = path.join(
|
|
4603
|
+
os.homedir(),
|
|
4604
|
+
".crewswarm",
|
|
4605
|
+
"crewswarm.json",
|
|
4606
|
+
);
|
|
4607
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
4608
|
+
cfg.tmuxBridge = enable;
|
|
4609
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
4610
|
+
} catch (e) {
|
|
4611
|
+
console.warn(
|
|
4612
|
+
"[crew-lead] Could not persist tmuxBridge:",
|
|
4613
|
+
e.message,
|
|
4614
|
+
);
|
|
4615
|
+
}
|
|
4616
|
+
console.log(
|
|
4617
|
+
`[crew-lead] tmux-bridge ${enable ? "ENABLED" : "DISABLED"} via dashboard`,
|
|
4618
|
+
);
|
|
4619
|
+
json(res, 200, { ok: true, enabled: tmuxBridgeRef?.enabled ?? enable });
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4583
4624
|
// GET/POST /api/settings/global-fallback β set/get global OpenCode fallback model
|
|
4584
4625
|
if (url.pathname === "/api/settings/global-fallback") {
|
|
4585
4626
|
if (!checkBearer(req)) {
|
|
@@ -4680,6 +4721,250 @@ export function createAndStartServer(PORT) {
|
|
|
4680
4721
|
}
|
|
4681
4722
|
}
|
|
4682
4723
|
|
|
4724
|
+
// ββ Missing settings endpoints (bulk implementation) βββββββββββββββββββββ
|
|
4725
|
+
|
|
4726
|
+
// Helper: read/write crewswarm.json for simple boolean/string settings
|
|
4727
|
+
const cfgPath = path.join(os.homedir(), ".crewswarm", "crewswarm.json");
|
|
4728
|
+
function readCfg() {
|
|
4729
|
+
try { return JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch { return {}; }
|
|
4730
|
+
}
|
|
4731
|
+
function writeCfg(cfg) {
|
|
4732
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
4733
|
+
}
|
|
4734
|
+
function whichBin(bin) {
|
|
4735
|
+
try {
|
|
4736
|
+
const r = spawn("which", [bin], { stdio: "ignore" });
|
|
4737
|
+
// spawn is async but we can check if the binary exists via fs
|
|
4738
|
+
const paths = (process.env.PATH || "").split(":");
|
|
4739
|
+
return paths.some(p => { try { fs.accessSync(path.join(p, bin), fs.constants.X_OK); return true; } catch { return false; } });
|
|
4740
|
+
} catch { return false; }
|
|
4741
|
+
}
|
|
4742
|
+
|
|
4743
|
+
// GET/POST /api/settings/autonomous-mentions
|
|
4744
|
+
if (url.pathname === "/api/settings/autonomous-mentions") {
|
|
4745
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4746
|
+
if (req.method === "GET") {
|
|
4747
|
+
const cfg = readCfg();
|
|
4748
|
+
const enabled = cfg.settings?.autonomousMentionsEnabled !== false;
|
|
4749
|
+
json(res, 200, { ok: true, enabled });
|
|
4750
|
+
return;
|
|
4751
|
+
}
|
|
4752
|
+
if (req.method === "POST") {
|
|
4753
|
+
const body = await readBody(req);
|
|
4754
|
+
const cfg = readCfg();
|
|
4755
|
+
if (!cfg.settings) cfg.settings = {};
|
|
4756
|
+
cfg.settings.autonomousMentionsEnabled = typeof body.enabled === "boolean" ? body.enabled : true;
|
|
4757
|
+
writeCfg(cfg);
|
|
4758
|
+
console.log(`[crew-lead] Autonomous mentions ${cfg.settings.autonomousMentionsEnabled ? "ENABLED" : "DISABLED"} via dashboard`);
|
|
4759
|
+
json(res, 200, { ok: true, enabled: cfg.settings.autonomousMentionsEnabled });
|
|
4760
|
+
return;
|
|
4761
|
+
}
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
// GET/POST /api/settings/codex
|
|
4765
|
+
if (url.pathname === "/api/settings/codex") {
|
|
4766
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4767
|
+
if (req.method === "GET") {
|
|
4768
|
+
const cfg = readCfg();
|
|
4769
|
+
const enabled = cfg.codexEnabled === true || /^1|true|yes$/i.test(String(process.env.CREWSWARM_CODEX_ENABLED || ""));
|
|
4770
|
+
json(res, 200, { ok: true, enabled });
|
|
4771
|
+
return;
|
|
4772
|
+
}
|
|
4773
|
+
if (req.method === "POST") {
|
|
4774
|
+
const body = await readBody(req);
|
|
4775
|
+
const cfg = readCfg();
|
|
4776
|
+
const enable = typeof body.enabled === "boolean" ? body.enabled : !cfg.codexEnabled;
|
|
4777
|
+
cfg.codexEnabled = enable;
|
|
4778
|
+
writeCfg(cfg);
|
|
4779
|
+
console.log(`[crew-lead] Codex executor ${enable ? "ENABLED" : "DISABLED"} via dashboard`);
|
|
4780
|
+
json(res, 200, { ok: true, enabled: enable });
|
|
4781
|
+
return;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
// GET/POST /api/settings/gemini-cli
|
|
4786
|
+
if (url.pathname === "/api/settings/gemini-cli") {
|
|
4787
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4788
|
+
if (req.method === "GET") {
|
|
4789
|
+
const cfg = readCfg();
|
|
4790
|
+
const enabled = cfg.geminiCliEnabled === true || /^1|true|yes$/i.test(String(process.env.CREWSWARM_GEMINI_CLI_ENABLED || ""));
|
|
4791
|
+
const installed = whichBin("gemini");
|
|
4792
|
+
json(res, 200, { ok: true, enabled, installed });
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (req.method === "POST") {
|
|
4796
|
+
const body = await readBody(req);
|
|
4797
|
+
const cfg = readCfg();
|
|
4798
|
+
const enable = typeof body.enabled === "boolean" ? body.enabled : !cfg.geminiCliEnabled;
|
|
4799
|
+
cfg.geminiCliEnabled = enable;
|
|
4800
|
+
writeCfg(cfg);
|
|
4801
|
+
console.log(`[crew-lead] Gemini CLI ${enable ? "ENABLED" : "DISABLED"} via dashboard`);
|
|
4802
|
+
json(res, 200, { ok: true, enabled: enable, installed: whichBin("gemini") });
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
|
|
4807
|
+
// GET/POST /api/settings/crew-cli
|
|
4808
|
+
if (url.pathname === "/api/settings/crew-cli") {
|
|
4809
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4810
|
+
if (req.method === "GET") {
|
|
4811
|
+
const cfg = readCfg();
|
|
4812
|
+
const enabled = cfg.crewCliEnabled === true || /^1|true|yes$/i.test(String(process.env.CREWSWARM_CREW_CLI_ENABLED || ""));
|
|
4813
|
+
json(res, 200, { ok: true, enabled });
|
|
4814
|
+
return;
|
|
4815
|
+
}
|
|
4816
|
+
if (req.method === "POST") {
|
|
4817
|
+
const body = await readBody(req);
|
|
4818
|
+
const cfg = readCfg();
|
|
4819
|
+
const enable = typeof body.enabled === "boolean" ? body.enabled : !cfg.crewCliEnabled;
|
|
4820
|
+
cfg.crewCliEnabled = enable;
|
|
4821
|
+
writeCfg(cfg);
|
|
4822
|
+
console.log(`[crew-lead] Crew CLI ${enable ? "ENABLED" : "DISABLED"} via dashboard`);
|
|
4823
|
+
json(res, 200, { ok: true, enabled: enable });
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
|
|
4828
|
+
// GET/POST /api/settings/opencode
|
|
4829
|
+
if (url.pathname === "/api/settings/opencode") {
|
|
4830
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4831
|
+
if (req.method === "GET") {
|
|
4832
|
+
const cfg = readCfg();
|
|
4833
|
+
const enabled = cfg.opencodeEnabled === true || /^1|true|yes$/i.test(String(process.env.CREWSWARM_OPENCODE_ENABLED || ""));
|
|
4834
|
+
const installed = whichBin("opencode");
|
|
4835
|
+
json(res, 200, { ok: true, enabled, installed });
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
if (req.method === "POST") {
|
|
4839
|
+
const body = await readBody(req);
|
|
4840
|
+
const cfg = readCfg();
|
|
4841
|
+
const enable = typeof body.enabled === "boolean" ? body.enabled : !cfg.opencodeEnabled;
|
|
4842
|
+
cfg.opencodeEnabled = enable;
|
|
4843
|
+
writeCfg(cfg);
|
|
4844
|
+
console.log(`[crew-lead] OpenCode ${enable ? "ENABLED" : "DISABLED"} via dashboard`);
|
|
4845
|
+
json(res, 200, { ok: true, enabled: enable, installed: whichBin("opencode") });
|
|
4846
|
+
return;
|
|
4847
|
+
}
|
|
4848
|
+
}
|
|
4849
|
+
|
|
4850
|
+
// GET/POST /api/settings/global-oc-loop
|
|
4851
|
+
if (url.pathname === "/api/settings/global-oc-loop") {
|
|
4852
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4853
|
+
if (req.method === "GET") {
|
|
4854
|
+
const cfg = readCfg();
|
|
4855
|
+
const enabled = cfg.engineLoop === true || /^1|true|yes$/i.test(String(process.env.CREWSWARM_ENGINE_LOOP || ""));
|
|
4856
|
+
const maxRounds = cfg.engineLoopMaxRounds ?? parseInt(process.env.CREWSWARM_ENGINE_LOOP_MAX_ROUNDS || "10", 10);
|
|
4857
|
+
json(res, 200, { ok: true, enabled, maxRounds });
|
|
4858
|
+
return;
|
|
4859
|
+
}
|
|
4860
|
+
if (req.method === "POST") {
|
|
4861
|
+
const body = await readBody(req);
|
|
4862
|
+
const cfg = readCfg();
|
|
4863
|
+
if (typeof body.enabled === "boolean") cfg.engineLoop = body.enabled;
|
|
4864
|
+
if (body.maxRounds !== undefined) cfg.engineLoopMaxRounds = parseInt(body.maxRounds, 10) || 10;
|
|
4865
|
+
writeCfg(cfg);
|
|
4866
|
+
console.log(`[crew-lead] Engine loop: enabled=${cfg.engineLoop ?? false}, maxRounds=${cfg.engineLoopMaxRounds ?? 10}`);
|
|
4867
|
+
json(res, 200, { ok: true, enabled: cfg.engineLoop ?? false, maxRounds: cfg.engineLoopMaxRounds ?? 10 });
|
|
4868
|
+
return;
|
|
4869
|
+
}
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
// GET/POST /api/settings/passthrough-notify
|
|
4873
|
+
if (url.pathname === "/api/settings/passthrough-notify") {
|
|
4874
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4875
|
+
if (req.method === "GET") {
|
|
4876
|
+
const cfg = readCfg();
|
|
4877
|
+
json(res, 200, { ok: true, value: cfg.passthroughNotify || "both" });
|
|
4878
|
+
return;
|
|
4879
|
+
}
|
|
4880
|
+
if (req.method === "POST") {
|
|
4881
|
+
const body = await readBody(req);
|
|
4882
|
+
const cfg = readCfg();
|
|
4883
|
+
cfg.passthroughNotify = body.value || "both";
|
|
4884
|
+
writeCfg(cfg);
|
|
4885
|
+
console.log(`[crew-lead] Passthrough notify β ${cfg.passthroughNotify}`);
|
|
4886
|
+
json(res, 200, { ok: true, value: cfg.passthroughNotify });
|
|
4887
|
+
return;
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
|
|
4891
|
+
// GET/POST /api/settings/loop-brain
|
|
4892
|
+
if (url.pathname === "/api/settings/loop-brain") {
|
|
4893
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4894
|
+
if (req.method === "GET") {
|
|
4895
|
+
const cfg = readCfg();
|
|
4896
|
+
json(res, 200, { ok: true, loopBrain: cfg.loopBrain || "" });
|
|
4897
|
+
return;
|
|
4898
|
+
}
|
|
4899
|
+
if (req.method === "POST") {
|
|
4900
|
+
const body = await readBody(req);
|
|
4901
|
+
const cfg = readCfg();
|
|
4902
|
+
cfg.loopBrain = body.loopBrain || "";
|
|
4903
|
+
writeCfg(cfg);
|
|
4904
|
+
console.log(`[crew-lead] Loop brain β ${cfg.loopBrain || "(cleared)"}`);
|
|
4905
|
+
json(res, 200, { ok: true, loopBrain: cfg.loopBrain });
|
|
4906
|
+
return;
|
|
4907
|
+
}
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4910
|
+
// GET /api/settings/openclaw-status
|
|
4911
|
+
if (url.pathname === "/api/settings/openclaw-status" && req.method === "GET") {
|
|
4912
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4913
|
+
const cfg = readCfg();
|
|
4914
|
+
const installed = !!(cfg.openClaw?.enabled || cfg.openclaw?.gatewayUrl || process.env.OPENCLAW_GATEWAY_URL);
|
|
4915
|
+
json(res, 200, { ok: true, installed });
|
|
4916
|
+
return;
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4919
|
+
// GET/POST /api/settings/rt-token
|
|
4920
|
+
if (url.pathname === "/api/settings/rt-token") {
|
|
4921
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4922
|
+
if (req.method === "GET") {
|
|
4923
|
+
const cfg = readCfg();
|
|
4924
|
+
const token = cfg.rtToken || process.env.CREWSWARM_RT_AUTH_TOKEN || "";
|
|
4925
|
+
json(res, 200, { ok: true, token: token ? true : false });
|
|
4926
|
+
return;
|
|
4927
|
+
}
|
|
4928
|
+
if (req.method === "POST") {
|
|
4929
|
+
const body = await readBody(req);
|
|
4930
|
+
if (!body.token) { json(res, 400, { ok: false, error: "No token provided" }); return; }
|
|
4931
|
+
const cfg = readCfg();
|
|
4932
|
+
cfg.rtToken = body.token;
|
|
4933
|
+
writeCfg(cfg);
|
|
4934
|
+
console.log("[crew-lead] RT token saved via dashboard");
|
|
4935
|
+
json(res, 200, { ok: true, saved: true });
|
|
4936
|
+
return;
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
// GET /api/config/lock-status, POST /api/config/lock, POST /api/config/unlock
|
|
4941
|
+
if (url.pathname === "/api/config/lock-status" && req.method === "GET") {
|
|
4942
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4943
|
+
const lockFile = path.join(os.homedir(), ".crewswarm", ".config.lock");
|
|
4944
|
+
json(res, 200, { ok: true, locked: fs.existsSync(lockFile) });
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
if (url.pathname === "/api/config/lock" && req.method === "POST") {
|
|
4948
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4949
|
+
const lockFile = path.join(os.homedir(), ".crewswarm", ".config.lock");
|
|
4950
|
+
try {
|
|
4951
|
+
fs.writeFileSync(lockFile, new Date().toISOString());
|
|
4952
|
+
console.log("[crew-lead] Config LOCKED via dashboard");
|
|
4953
|
+
json(res, 200, { ok: true, locked: true });
|
|
4954
|
+
} catch (e) { json(res, 500, { ok: false, error: e.message }); }
|
|
4955
|
+
return;
|
|
4956
|
+
}
|
|
4957
|
+
if (url.pathname === "/api/config/unlock" && req.method === "POST") {
|
|
4958
|
+
if (!checkBearer(req)) { json(res, 401, { ok: false, error: "Unauthorized" }); return; }
|
|
4959
|
+
const lockFile = path.join(os.homedir(), ".crewswarm", ".config.lock");
|
|
4960
|
+
try {
|
|
4961
|
+
fs.unlinkSync(lockFile);
|
|
4962
|
+
console.log("[crew-lead] Config UNLOCKED via dashboard");
|
|
4963
|
+
} catch {}
|
|
4964
|
+
json(res, 200, { ok: true, locked: false });
|
|
4965
|
+
return;
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4683
4968
|
// POST /api/spending/reset β reset today's spending counters
|
|
4684
4969
|
if (url.pathname === "/api/spending/reset" && req.method === "POST") {
|
|
4685
4970
|
if (!checkBearer(req)) {
|
|
@@ -11,6 +11,8 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { getStatePath, getConfigPath } from "../runtime/paths.mjs";
|
|
12
12
|
import { normalizeProjectDir } from "../runtime/project-dir.mjs";
|
|
13
13
|
import { loadProjectMessages } from "../chat/project-messages.mjs";
|
|
14
|
+
import * as tmuxBridge from "../bridges/tmux-bridge.mjs";
|
|
15
|
+
import * as sessionManager from "../sessions/session-manager.mjs";
|
|
14
16
|
|
|
15
17
|
let _deps = {};
|
|
16
18
|
|
|
@@ -425,6 +427,11 @@ export function dispatchPipelineWave(pipelineId) {
|
|
|
425
427
|
...(step.verify ? { verify: step.verify } : {}),
|
|
426
428
|
...(step.done ? { done: step.done } : {}),
|
|
427
429
|
};
|
|
430
|
+
// Build session handoff metadata for tmux-bridge
|
|
431
|
+
const sessionMeta = {};
|
|
432
|
+
if (step.session) sessionMeta.session = step.session;
|
|
433
|
+
else if (pipeline._tmuxSessionId) sessionMeta.session = `handoff:${pipeline._tmuxSessionId}`;
|
|
434
|
+
|
|
428
435
|
const taskId = dispatchTask(step.agent, stepSpec, sessionId, {
|
|
429
436
|
pipelineId,
|
|
430
437
|
waveIndex: currentWave,
|
|
@@ -434,6 +441,7 @@ export function dispatchPipelineWave(pipelineId) {
|
|
|
434
441
|
originThreadId: pipeline.originThreadId,
|
|
435
442
|
originMessageId: pipeline.originMessageId,
|
|
436
443
|
triggeredBy: pipeline.triggeredBy || "pipeline",
|
|
444
|
+
...sessionMeta,
|
|
437
445
|
});
|
|
438
446
|
if (taskId && taskId !== true) pipeline.pendingTaskIds.add(taskId);
|
|
439
447
|
}
|
|
@@ -762,6 +770,36 @@ export function dispatchTask(agent, task, sessionId = "owner", pipelineMeta = nu
|
|
|
762
770
|
if (pipelineMeta?.mentionedBy) extraFlags.mentionedBy = pipelineMeta.mentionedBy;
|
|
763
771
|
if (pipelineMeta?.autonomous !== undefined) extraFlags.autonomous = pipelineMeta.autonomous;
|
|
764
772
|
|
|
773
|
+
// ββ tmux session handoff ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
774
|
+
// If pipelineMeta carries a tmux session, hand it off to this agent
|
|
775
|
+
// or create a new one if session: "persist" is set.
|
|
776
|
+
if (tmuxBridge.detect()) {
|
|
777
|
+
const sessionSpec = pipelineMeta?.session;
|
|
778
|
+
if (typeof sessionSpec === "string" && sessionSpec.startsWith("handoff:")) {
|
|
779
|
+
const existingSessionId = sessionSpec.slice("handoff:".length);
|
|
780
|
+
const prevOwner = sessionManager.getSession(existingSessionId)?.owner;
|
|
781
|
+
if (prevOwner) {
|
|
782
|
+
sessionManager.handoff(existingSessionId, prevOwner, agent);
|
|
783
|
+
}
|
|
784
|
+
const meta = sessionManager.getSession(existingSessionId);
|
|
785
|
+
if (meta?.paneId) extraFlags.tmuxSessionId = meta.paneId;
|
|
786
|
+
} else if (sessionSpec === "persist") {
|
|
787
|
+
const newSessionId = sessionManager.create({
|
|
788
|
+
workspaceId: pipelineMeta?.pipelineId || "default",
|
|
789
|
+
agentId: agent,
|
|
790
|
+
cwd: pipelineMeta?.projectDir || undefined,
|
|
791
|
+
});
|
|
792
|
+
if (newSessionId) {
|
|
793
|
+
const meta = sessionManager.getSession(newSessionId);
|
|
794
|
+
if (meta?.paneId) extraFlags.tmuxSessionId = meta.paneId;
|
|
795
|
+
// Store session ID on pipeline meta so next wave can handoff
|
|
796
|
+
if (pipelineMeta) pipelineMeta._tmuxSessionId = newSessionId;
|
|
797
|
+
}
|
|
798
|
+
} else if (pipelineMeta?.tmuxSessionId) {
|
|
799
|
+
extraFlags.tmuxSessionId = pipelineMeta.tmuxSessionId;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
765
803
|
// Log enrichment for verification
|
|
766
804
|
const engineFlags = Object.keys(extraFlags).filter(k => k.startsWith('use') || k.includes('Model') || k === 'engine');
|
|
767
805
|
if (engineFlags.length > 0) {
|
package/lib/engines/runners.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { initEngineRegistry, selectEngine as registrySelectEngine, getEngineById
|
|
|
21
21
|
import { runCrewCLITask } from "./crew-cli.mjs";
|
|
22
22
|
import { normalizeProjectDir } from "../runtime/project-dir.mjs";
|
|
23
23
|
import { resolveCursorLaunchSpec } from "./cursor-launcher.mjs";
|
|
24
|
+
import * as tmuxBridge from "../bridges/tmux-bridge.mjs";
|
|
24
25
|
|
|
25
26
|
function which(bin) {
|
|
26
27
|
try { execSync(`which ${bin}`, { stdio: "ignore" }); return true; } catch { return false; }
|
|
@@ -357,6 +358,11 @@ export async function runGeminiCliTask(prompt, payload = {}) {
|
|
|
357
358
|
stdio: ["ignore", "pipe", "pipe"],
|
|
358
359
|
});
|
|
359
360
|
|
|
361
|
+
// Label tmux pane with agent ID for cross-agent discovery
|
|
362
|
+
if (payload?.tmuxSessionId && tmuxBridge.detect()) {
|
|
363
|
+
try { tmuxBridge.label(agentId, payload.tmuxSessionId); } catch {}
|
|
364
|
+
}
|
|
365
|
+
|
|
360
366
|
let lineBuffer = "";
|
|
361
367
|
let accumulatedText = "";
|
|
362
368
|
let orphanStream = "";
|
|
@@ -683,6 +689,11 @@ export async function runCursorCliTask(prompt, payload = {}) {
|
|
|
683
689
|
stdio: ["ignore", "pipe", "pipe"],
|
|
684
690
|
});
|
|
685
691
|
|
|
692
|
+
// Label tmux pane with agent ID for cross-agent discovery
|
|
693
|
+
if (payload?.tmuxSessionId && tmuxBridge.detect()) {
|
|
694
|
+
try { tmuxBridge.label(agentId, payload.tmuxSessionId); } catch {}
|
|
695
|
+
}
|
|
696
|
+
|
|
686
697
|
let lineBuffer = "";
|
|
687
698
|
let accumulatedText = "";
|
|
688
699
|
let lastCursorAssistantNorm = "";
|
|
@@ -915,6 +926,11 @@ export async function runCodexTask(prompt, payload = {}) {
|
|
|
915
926
|
stdio: ["ignore", "pipe", "pipe"],
|
|
916
927
|
});
|
|
917
928
|
|
|
929
|
+
// Label tmux pane with agent ID for cross-agent discovery
|
|
930
|
+
if (payload?.tmuxSessionId && tmuxBridge.detect()) {
|
|
931
|
+
try { tmuxBridge.label(agentId, payload.tmuxSessionId); } catch {}
|
|
932
|
+
}
|
|
933
|
+
|
|
918
934
|
let lineBuffer = "";
|
|
919
935
|
let accumulatedText = "";
|
|
920
936
|
/** Non-JSON lines (stderr, errors, usage) β previously swallowed by catch {} */
|
|
@@ -1207,6 +1223,11 @@ export async function runClaudeCodeTask(prompt, payload = {}) {
|
|
|
1207
1223
|
stdio: ["ignore", "pipe", "pipe"], // Changed from "pipe" to "ignore" for stdin since we use args
|
|
1208
1224
|
});
|
|
1209
1225
|
|
|
1226
|
+
// Label tmux pane with agent ID for cross-agent discovery
|
|
1227
|
+
if (payload?.tmuxSessionId && tmuxBridge.detect()) {
|
|
1228
|
+
try { tmuxBridge.label(agentId, payload.tmuxSessionId); } catch {}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1210
1231
|
let lineBuffer = "";
|
|
1211
1232
|
let accumulatedText = "";
|
|
1212
1233
|
let stderrText = "";
|
package/lib/runtime/config.mjs
CHANGED
|
@@ -290,6 +290,13 @@ export function loadClaudeCodeEnabled() {
|
|
|
290
290
|
if (typeof cfg.claudeCode === "boolean") return cfg.claudeCode;
|
|
291
291
|
return false;
|
|
292
292
|
}
|
|
293
|
+
|
|
294
|
+
export function loadTmuxBridgeEnabled() {
|
|
295
|
+
if (process.env.CREWSWARM_TMUX_BRIDGE) return /^1|true|yes$/i.test(String(process.env.CREWSWARM_TMUX_BRIDGE));
|
|
296
|
+
const cfg = loadSystemConfig();
|
|
297
|
+
if (typeof cfg.tmuxBridge === "boolean") return cfg.tmuxBridge;
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
293
300
|
// ββ Configuration Parsers (Migrated from registry.mjs) βββββββββββββββββββ
|
|
294
301
|
export function resolveConfig() {
|
|
295
302
|
const paths = [CREWSWARM_CONFIG_PATH, path.join(LEGACY_STATE_DIR, "openclaw.json")];
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager β persistent tmux sessions as first-class execution resources
|
|
3
|
+
*
|
|
4
|
+
* Manages session lifecycle: create, attach, exec, lock, handoff, terminate.
|
|
5
|
+
* One writer per session (lock enforcement). Transcripts logged for auditability.
|
|
6
|
+
*
|
|
7
|
+
* Sessions are stored as metadata files under ~/.crewswarm/state/sessions/.
|
|
8
|
+
* The actual tmux sessions are managed via tmux CLI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { getStatePath } from "../runtime/paths.mjs";
|
|
16
|
+
import * as tmuxBridge from "../bridges/tmux-bridge.mjs";
|
|
17
|
+
|
|
18
|
+
const SESSION_DIR = getStatePath("sessions");
|
|
19
|
+
const TRANSCRIPT_DIR = getStatePath("sessions", "transcripts");
|
|
20
|
+
|
|
21
|
+
try { fs.mkdirSync(SESSION_DIR, { recursive: true }); } catch {}
|
|
22
|
+
try { fs.mkdirSync(TRANSCRIPT_DIR, { recursive: true }); } catch {}
|
|
23
|
+
|
|
24
|
+
// ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
25
|
+
|
|
26
|
+
function sessionMetaPath(sessionId) {
|
|
27
|
+
return path.join(SESSION_DIR, `${sessionId}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function transcriptPath(sessionId) {
|
|
31
|
+
return path.join(TRANSCRIPT_DIR, `${sessionId}.jsonl`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadMeta(sessionId) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(fs.readFileSync(sessionMetaPath(sessionId), "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveMeta(sessionId, meta) {
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(sessionMetaPath(sessionId), JSON.stringify(meta, null, 2));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error(`[session-manager] Failed to save meta for ${sessionId}: ${e.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendTranscript(sessionId, entry) {
|
|
51
|
+
try {
|
|
52
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
|
|
53
|
+
fs.appendFileSync(transcriptPath(sessionId), line + "\n");
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tmuxExec(cmd, timeout = 5000) {
|
|
58
|
+
try {
|
|
59
|
+
return execSync(cmd, { encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a new persistent tmux session for agent work.
|
|
69
|
+
* @param {object} opts
|
|
70
|
+
* @param {string} opts.workspaceId - Logical workspace name
|
|
71
|
+
* @param {string} opts.agentId - Owning agent
|
|
72
|
+
* @param {string} [opts.cwd] - Working directory
|
|
73
|
+
* @param {Record<string, string>} [opts.env] - Extra env vars
|
|
74
|
+
* @returns {string|null} sessionId or null on failure
|
|
75
|
+
*/
|
|
76
|
+
export function create({ workspaceId, agentId, cwd, env } = {}) {
|
|
77
|
+
if (!tmuxBridge.detect()) return null;
|
|
78
|
+
|
|
79
|
+
const sessionId = `cs-${workspaceId}-${randomUUID().slice(0, 8)}`;
|
|
80
|
+
const sessionName = sessionId;
|
|
81
|
+
|
|
82
|
+
// Create a new tmux session (detached)
|
|
83
|
+
const envStr = env
|
|
84
|
+
? Object.entries(env).map(([k, v]) => `-e ${k}=${v}`).join(" ")
|
|
85
|
+
: "";
|
|
86
|
+
const cwdFlag = cwd ? `-c "${cwd}"` : "";
|
|
87
|
+
const result = tmuxExec(`tmux new-session -d -s "${sessionName}" ${cwdFlag} ${envStr}`);
|
|
88
|
+
if (result === null) {
|
|
89
|
+
console.error(`[session-manager] Failed to create tmux session: ${sessionName}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Label the session's first pane with the agent ID
|
|
94
|
+
const paneId = tmuxExec(`tmux list-panes -t "${sessionName}" -F "#{pane_id}" | head -1`);
|
|
95
|
+
if (paneId) {
|
|
96
|
+
tmuxBridge.label(agentId, paneId);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const meta = {
|
|
100
|
+
sessionId,
|
|
101
|
+
sessionName,
|
|
102
|
+
workspaceId,
|
|
103
|
+
owner: agentId,
|
|
104
|
+
lockedBy: agentId,
|
|
105
|
+
paneId: paneId || null,
|
|
106
|
+
cwd: cwd || null,
|
|
107
|
+
env: env || null,
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
status: "active",
|
|
110
|
+
};
|
|
111
|
+
saveMeta(sessionId, meta);
|
|
112
|
+
appendTranscript(sessionId, { action: "created", agent: agentId, cwd });
|
|
113
|
+
|
|
114
|
+
console.log(`[session-manager] Created session ${sessionId} for ${agentId} (pane=${paneId})`);
|
|
115
|
+
return sessionId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Attach an agent to an existing session (for handoff or observation).
|
|
120
|
+
* @param {string} sessionId
|
|
121
|
+
* @param {string} agentId
|
|
122
|
+
* @returns {{ paneId: string, sessionName: string }|null}
|
|
123
|
+
*/
|
|
124
|
+
export function attach(sessionId, agentId) {
|
|
125
|
+
const meta = loadMeta(sessionId);
|
|
126
|
+
if (!meta || meta.status !== "active") return null;
|
|
127
|
+
|
|
128
|
+
appendTranscript(sessionId, { action: "attached", agent: agentId });
|
|
129
|
+
console.log(`[session-manager] ${agentId} attached to session ${sessionId}`);
|
|
130
|
+
|
|
131
|
+
return { paneId: meta.paneId, sessionName: meta.sessionName };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Execute a command in a session's tmux pane.
|
|
136
|
+
* Only the lock owner can execute.
|
|
137
|
+
* @param {string} sessionId
|
|
138
|
+
* @param {string} command
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} opts.actorId - Agent executing the command
|
|
141
|
+
* @param {number} [opts.timeout=30000] - Timeout in ms
|
|
142
|
+
* @returns {{ output: string }|null}
|
|
143
|
+
*/
|
|
144
|
+
export function exec(sessionId, command, { actorId, timeout = 30000 } = {}) {
|
|
145
|
+
const meta = loadMeta(sessionId);
|
|
146
|
+
if (!meta || meta.status !== "active") return null;
|
|
147
|
+
|
|
148
|
+
// Enforce lock
|
|
149
|
+
if (meta.lockedBy && meta.lockedBy !== actorId) {
|
|
150
|
+
console.warn(`[session-manager] ${actorId} cannot exec in ${sessionId} β locked by ${meta.lockedBy}`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const paneId = meta.paneId;
|
|
155
|
+
if (!paneId) return null;
|
|
156
|
+
|
|
157
|
+
// Send keys to the pane
|
|
158
|
+
tmuxExec(`tmux send-keys -t "${paneId}" "${command.replace(/"/g, '\\"')}" Enter`, timeout);
|
|
159
|
+
appendTranscript(sessionId, { action: "exec", agent: actorId, command: command.slice(0, 500) });
|
|
160
|
+
|
|
161
|
+
// Read back output after a short delay
|
|
162
|
+
const output = tmuxBridge.read(meta.owner, 50);
|
|
163
|
+
return { output: output || "" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Lock a session for exclusive write access.
|
|
168
|
+
* @param {string} sessionId
|
|
169
|
+
* @param {string} ownerId - Agent requesting the lock
|
|
170
|
+
* @returns {boolean} true if lock acquired
|
|
171
|
+
*/
|
|
172
|
+
export function lock(sessionId, ownerId) {
|
|
173
|
+
const meta = loadMeta(sessionId);
|
|
174
|
+
if (!meta || meta.status !== "active") return false;
|
|
175
|
+
|
|
176
|
+
if (meta.lockedBy && meta.lockedBy !== ownerId) {
|
|
177
|
+
console.warn(`[session-manager] Lock denied for ${ownerId} on ${sessionId} β held by ${meta.lockedBy}`);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
meta.lockedBy = ownerId;
|
|
182
|
+
meta.lockedAt = new Date().toISOString();
|
|
183
|
+
saveMeta(sessionId, meta);
|
|
184
|
+
appendTranscript(sessionId, { action: "locked", agent: ownerId });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Unlock a session.
|
|
190
|
+
* @param {string} sessionId
|
|
191
|
+
* @param {string} ownerId - Must match current lock holder
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
export function unlock(sessionId, ownerId) {
|
|
195
|
+
const meta = loadMeta(sessionId);
|
|
196
|
+
if (!meta) return false;
|
|
197
|
+
|
|
198
|
+
if (meta.lockedBy && meta.lockedBy !== ownerId) {
|
|
199
|
+
console.warn(`[session-manager] Unlock denied for ${ownerId} on ${sessionId} β held by ${meta.lockedBy}`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
meta.lockedBy = null;
|
|
204
|
+
meta.lockedAt = null;
|
|
205
|
+
saveMeta(sessionId, meta);
|
|
206
|
+
appendTranscript(sessionId, { action: "unlocked", agent: ownerId });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Hand off a session from one agent to another.
|
|
212
|
+
* Transfers lock ownership and re-labels the pane.
|
|
213
|
+
* @param {string} sessionId
|
|
214
|
+
* @param {string} fromAgent
|
|
215
|
+
* @param {string} toAgent
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
export function handoff(sessionId, fromAgent, toAgent) {
|
|
219
|
+
const meta = loadMeta(sessionId);
|
|
220
|
+
if (!meta || meta.status !== "active") return false;
|
|
221
|
+
|
|
222
|
+
// Only the current lock holder (or unlocked session) can hand off
|
|
223
|
+
if (meta.lockedBy && meta.lockedBy !== fromAgent) {
|
|
224
|
+
console.warn(`[session-manager] Handoff denied: ${sessionId} locked by ${meta.lockedBy}, not ${fromAgent}`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
meta.owner = toAgent;
|
|
229
|
+
meta.lockedBy = toAgent;
|
|
230
|
+
meta.lockedAt = new Date().toISOString();
|
|
231
|
+
saveMeta(sessionId, meta);
|
|
232
|
+
|
|
233
|
+
// Re-label pane for the new agent
|
|
234
|
+
if (meta.paneId) {
|
|
235
|
+
tmuxBridge.label(toAgent, meta.paneId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
appendTranscript(sessionId, { action: "handoff", from: fromAgent, to: toAgent });
|
|
239
|
+
console.log(`[session-manager] Session ${sessionId} handed off: ${fromAgent} β ${toAgent}`);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Terminate a session and clean up its tmux pane.
|
|
245
|
+
* @param {string} sessionId
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
export function terminate(sessionId) {
|
|
249
|
+
const meta = loadMeta(sessionId);
|
|
250
|
+
if (!meta) return false;
|
|
251
|
+
|
|
252
|
+
// Kill the tmux session
|
|
253
|
+
if (meta.sessionName) {
|
|
254
|
+
tmuxExec(`tmux kill-session -t "${meta.sessionName}"`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
meta.status = "terminated";
|
|
258
|
+
meta.terminatedAt = new Date().toISOString();
|
|
259
|
+
saveMeta(sessionId, meta);
|
|
260
|
+
appendTranscript(sessionId, { action: "terminated" });
|
|
261
|
+
console.log(`[session-manager] Session ${sessionId} terminated`);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get metadata for a session.
|
|
267
|
+
* @param {string} sessionId
|
|
268
|
+
* @returns {object|null}
|
|
269
|
+
*/
|
|
270
|
+
export function getSession(sessionId) {
|
|
271
|
+
return loadMeta(sessionId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* List all active sessions.
|
|
276
|
+
* @returns {Array<object>}
|
|
277
|
+
*/
|
|
278
|
+
export function listSessions() {
|
|
279
|
+
try {
|
|
280
|
+
const files = fs.readdirSync(SESSION_DIR).filter(f => f.endsWith(".json"));
|
|
281
|
+
return files
|
|
282
|
+
.map(f => loadMeta(f.replace(".json", "")))
|
|
283
|
+
.filter(m => m && m.status === "active");
|
|
284
|
+
} catch {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
}
|
package/package.json
CHANGED