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 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
  [![npm version](https://img.shields.io/npm/v/crewswarm)](https://www.npmjs.com/package/crewswarm)
6
- [![Tests](https://img.shields.io/badge/tests-929%20passed-brightgreen)]()
6
+ [![Tests](https://img.shields.io/badge/tests-957%20passed-brightgreen)]()
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
8
  [![Node.js](https://img.shields.io/badge/Node.js-20%2B-green)](https://nodejs.org)
9
9
  [![Website](https://img.shields.io/badge/website-crewswarm.ai-blue)](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 929 tests
164
+ npm test # Run 957 tests
165
165
  crew exec "Build X" # Send task via CLI
166
166
  ```
167
167
 
@@ -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) {
@@ -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 = "";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewswarm",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Local-first multi-agent orchestration platform β€” coordinate AI coding agents, LLMs, and tools from a single dashboard",
5
5
  "type": "module",
6
6
  "license": "MIT",